diff --git a/.dev_scripts/diff_images.py b/.dev_scripts/diff_images.py new file mode 100644 index 0000000000000000000000000000000000000000..5208ed41ecb726c925c9387037dcdbe6c866e995 --- /dev/null +++ b/.dev_scripts/diff_images.py @@ -0,0 +1,32 @@ +import argparse + +import numpy as np +from PIL import Image + + +def read_image_int16(image_path): + image = Image.open(image_path) + return np.array(image).astype(np.int16) + + +def calc_images_mean_L1(image1_path, image2_path): + image1 = read_image_int16(image1_path) + image2 = read_image_int16(image2_path) + assert image1.shape == image2.shape + + mean_L1 = np.abs(image1 - image2).mean() + return mean_L1 + + +def parse_args(): + parser = argparse.ArgumentParser() + parser.add_argument("image1_path") + parser.add_argument("image2_path") + args = parser.parse_args() + return args + + +if __name__ == "__main__": + args = parse_args() + mean_L1 = calc_images_mean_L1(args.image1_path, args.image2_path) + print(mean_L1) diff --git a/.dev_scripts/images/v1_4_astronaut_rides_horse_plms_step50_seed42.png b/.dev_scripts/images/v1_4_astronaut_rides_horse_plms_step50_seed42.png new file mode 100644 index 0000000000000000000000000000000000000000..c91c714c02d134253a10829af8109560df9aaee1 Binary files /dev/null and b/.dev_scripts/images/v1_4_astronaut_rides_horse_plms_step50_seed42.png differ diff --git a/.dev_scripts/sample_command.txt b/.dev_scripts/sample_command.txt new file mode 100644 index 0000000000000000000000000000000000000000..cd1530e8c1d8ff0f073141807a9057f99c2cace3 --- /dev/null +++ b/.dev_scripts/sample_command.txt @@ -0,0 +1 @@ +"a photograph of an astronaut riding a horse" -s50 -S42 diff --git a/.dev_scripts/test_regression_txt2img_dream_v1_4.sh b/.dev_scripts/test_regression_txt2img_dream_v1_4.sh new file mode 100644 index 0000000000000000000000000000000000000000..9326d3c311cd085621eb231ae4b75509d2f78d8c --- /dev/null +++ b/.dev_scripts/test_regression_txt2img_dream_v1_4.sh @@ -0,0 +1,19 @@ +# generate an image +PROMPT_FILE=".dev_scripts/sample_command.txt" +OUT_DIR="outputs/img-samples/test_regression_txt2img_v1_4" +SAMPLES_DIR=${OUT_DIR} +python scripts/dream.py \ + --from_file ${PROMPT_FILE} \ + --outdir ${OUT_DIR} \ + --sampler plms + +# original output by CompVis/stable-diffusion +IMAGE1=".dev_scripts/images/v1_4_astronaut_rides_horse_plms_step50_seed42.png" +# new output +IMAGE2=`ls -A ${SAMPLES_DIR}/*.png | sort | tail -n 1` + +echo "" +echo "comparing the following two images" +echo "IMAGE1: ${IMAGE1}" +echo "IMAGE2: ${IMAGE2}" +python .dev_scripts/diff_images.py ${IMAGE1} ${IMAGE2} diff --git a/.dev_scripts/test_regression_txt2img_v1_4.sh b/.dev_scripts/test_regression_txt2img_v1_4.sh new file mode 100644 index 0000000000000000000000000000000000000000..e7d1b8c3335b1af25528a473d6ade6ac901e0fc0 --- /dev/null +++ b/.dev_scripts/test_regression_txt2img_v1_4.sh @@ -0,0 +1,23 @@ +# generate an image +PROMPT="a photograph of an astronaut riding a horse" +OUT_DIR="outputs/txt2img-samples/test_regression_txt2img_v1_4" +SAMPLES_DIR="outputs/txt2img-samples/test_regression_txt2img_v1_4/samples" +python scripts/orig_scripts/txt2img.py \ + --prompt "${PROMPT}" \ + --outdir ${OUT_DIR} \ + --plms \ + --ddim_steps 50 \ + --n_samples 1 \ + --n_iter 1 \ + --seed 42 + +# original output by CompVis/stable-diffusion +IMAGE1=".dev_scripts/images/v1_4_astronaut_rides_horse_plms_step50_seed42.png" +# new output +IMAGE2=`ls -A ${SAMPLES_DIR}/*.png | sort | tail -n 1` + +echo "" +echo "comparing the following two images" +echo "IMAGE1: ${IMAGE1}" +echo "IMAGE2: ${IMAGE2}" +python .dev_scripts/diff_images.py ${IMAGE1} ${IMAGE2} diff --git a/.dockerignore b/.dockerignore new file mode 100644 index 0000000000000000000000000000000000000000..3f674f978dba031917d566cf2e2cd5e2afce794e --- /dev/null +++ b/.dockerignore @@ -0,0 +1,9 @@ +* +!invokeai +!pyproject.toml +!docker/docker-entrypoint.sh +!LICENSE + +**/node_modules +**/__pycache__ +**/*.egg-info \ No newline at end of file diff --git a/.editorconfig b/.editorconfig new file mode 100644 index 0000000000000000000000000000000000000000..d4b0972edabee92fa954309ade512e37c19523da --- /dev/null +++ b/.editorconfig @@ -0,0 +1,12 @@ +# All files +[*] +charset = utf-8 +end_of_line = lf +indent_size = 2 +indent_style = space +insert_final_newline = true +trim_trailing_whitespace = true + +# Python +[*.py] +indent_size = 4 diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs new file mode 100644 index 0000000000000000000000000000000000000000..c6b833cf58957b7cd24991a7dcaa60155a2bbc10 --- /dev/null +++ b/.git-blame-ignore-revs @@ -0,0 +1,2 @@ +b3dccfaeb636599c02effc377cdd8a87d658256c +218b6d0546b990fc449c876fb99f44b50c4daa35 diff --git a/.gitattributes b/.gitattributes index a6344aac8c09253b3b630fb776ae94478aa0275b..1152283eb832bc954e61e49444bd8e16bff2c7ed 100644 --- a/.gitattributes +++ b/.gitattributes @@ -1,35 +1,20 @@ -*.7z filter=lfs diff=lfs merge=lfs -text -*.arrow filter=lfs diff=lfs merge=lfs -text -*.bin filter=lfs diff=lfs merge=lfs -text -*.bz2 filter=lfs diff=lfs merge=lfs -text -*.ckpt filter=lfs diff=lfs merge=lfs -text -*.ftz filter=lfs diff=lfs merge=lfs -text -*.gz filter=lfs diff=lfs merge=lfs -text -*.h5 filter=lfs diff=lfs merge=lfs -text -*.joblib filter=lfs diff=lfs merge=lfs -text -*.lfs.* filter=lfs diff=lfs merge=lfs -text -*.mlmodel filter=lfs diff=lfs merge=lfs -text -*.model filter=lfs diff=lfs merge=lfs -text -*.msgpack filter=lfs diff=lfs merge=lfs -text -*.npy filter=lfs diff=lfs merge=lfs -text -*.npz filter=lfs diff=lfs merge=lfs -text -*.onnx filter=lfs diff=lfs merge=lfs -text -*.ot filter=lfs diff=lfs merge=lfs -text -*.parquet filter=lfs diff=lfs merge=lfs -text -*.pb filter=lfs diff=lfs merge=lfs -text -*.pickle filter=lfs diff=lfs merge=lfs -text -*.pkl filter=lfs diff=lfs merge=lfs -text -*.pt filter=lfs diff=lfs merge=lfs -text -*.pth filter=lfs diff=lfs merge=lfs -text -*.rar filter=lfs diff=lfs merge=lfs -text -*.safetensors filter=lfs diff=lfs merge=lfs -text -saved_model/**/* filter=lfs diff=lfs merge=lfs -text -*.tar.* filter=lfs diff=lfs merge=lfs -text -*.tar filter=lfs diff=lfs merge=lfs -text -*.tflite filter=lfs diff=lfs merge=lfs -text -*.tgz filter=lfs diff=lfs merge=lfs -text -*.wasm filter=lfs diff=lfs merge=lfs -text -*.xz filter=lfs diff=lfs merge=lfs -text -*.zip filter=lfs diff=lfs merge=lfs -text -*.zst filter=lfs diff=lfs merge=lfs -text -*tfevents* filter=lfs diff=lfs merge=lfs -text +# Auto normalizes line endings on commit so devs don't need to change local settings. +# 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=lfdocs/assets/features/restoration-montage.png filter=lfs diff=lfs merge=lfs -text +docs/assets/features/upscaling-montage.png filter=lfs diff=lfs merge=lfs -text +docs/assets/invoke_ai_banner.png filter=lfs diff=lfs merge=lfs -text +docs/assets/invoke_web_server.png filter=lfs diff=lfs merge=lfs -text +docs/assets/invoke-web-server-1.png filter=lfs diff=lfs merge=lfs -text +docs/assets/invoke-web-server-9.png filter=lfs diff=lfs merge=lfs -text +docs/assets/stable-samples/txt2img/merged-0005.png filter=lfs diff=lfs merge=lfs -text +docs/assets/stable-samples/txt2img/merged-0006.png filter=lfs diff=lfs merge=lfs -text +docs/assets/stable-samples/txt2img/merged-0007.png filter=lfs diff=lfs merge=lfs -text +docs/assets/step7.png filter=lfs diff=lfs merge=lfs -text +docs/assets/truncation_comparison.jpg filter=lfs diff=lfs merge=lfs -text +invokeai/assets/data/imagenet_train_hr_indices.p filter=lfs diff=lfs merge=lfs -text +invokeai/assets/results.gif filter=lfs diff=lfs merge=lfs -text +invokeai/assets/stable-samples/img2img/upscaling-in.png filter=lfs diff=lfs merge=lfs -text +invokeai/assets/stable-samples/img2img/upscaling-out.png filter=lfs diff=lfs merge=lfs -text +invokeai/assets/txt2img-preview.png filter=lfs diff=lfs merge=lfs -text diff --git a/.github/CODEOWNERS b/.github/CODEOWNERS new file mode 100644 index 0000000000000000000000000000000000000000..b979196cc1b5b7ca6313ca8a520ab776795d6f4a --- /dev/null +++ b/.github/CODEOWNERS @@ -0,0 +1,32 @@ +# continuous integration +/.github/workflows/ @lstein @blessedcoolant @hipsterusername @ebr + +# documentation +/docs/ @lstein @blessedcoolant @hipsterusername @Millu +/mkdocs.yml @lstein @blessedcoolant @hipsterusername @Millu + +# nodes +/invokeai/app/ @Kyle0654 @blessedcoolant @psychedelicious @brandonrising @hipsterusername + +# 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 + +# web ui +/invokeai/frontend @blessedcoolant @psychedelicious @lstein @maryhipp @hipsterusername +/invokeai/backend @blessedcoolant @psychedelicious @lstein @maryhipp @hipsterusername + +# generation, model management, postprocessing +/invokeai/backend @damian0815 @lstein @blessedcoolant @gregghelt2 @StAlKeR7779 @brandonrising @ryanjdick @hipsterusername + +# 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 diff --git a/.github/ISSUE_TEMPLATE/BUG_REPORT.yml b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml new file mode 100644 index 0000000000000000000000000000000000000000..26e1579f73a20597c2debaf4ea6471aaf656b4c0 --- /dev/null +++ b/.github/ISSUE_TEMPLATE/BUG_REPORT.yml @@ -0,0 +1,146 @@ +name: 🐞 Bug Report + +description: File a bug report + +title: '[bug]: ' + +labels: ['bug'] + +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this Bug Report! + + - type: checkboxes + attributes: + label: Is there an existing issue for this problem? + description: | + Please [search](https://github.com/invoke-ai/InvokeAI/issues) first to see if an issue already exists for the problem. + options: + - label: I have searched the existing issues + required: true + + - type: markdown + attributes: + value: __Describe your environment__ + + - type: dropdown + id: os_dropdown + attributes: + label: Operating system + description: Your computer's operating system. + multiple: false + options: + - 'Linux' + - 'Windows' + - 'macOS' + - 'other' + validations: + required: true + + - type: dropdown + id: gpu_dropdown + attributes: + label: GPU vendor + description: Your GPU's vendor. + multiple: false + options: + - 'Nvidia (CUDA)' + - 'AMD (ROCm)' + - 'Apple Silicon (MPS)' + - 'None (CPU)' + validations: + required: true + + - type: input + id: gpu_model + attributes: + label: GPU model + description: Your GPU's model. If on Apple Silicon, this is your Mac's chip. Leave blank if on CPU. + placeholder: ex. RTX 2080 Ti, Mac M1 Pro + validations: + required: false + + - type: input + id: vram + attributes: + label: GPU VRAM + description: Your GPU's VRAM. If on Apple Silicon, this is your Mac's unified memory. Leave blank if on CPU. + placeholder: 8GB + validations: + required: false + + - type: input + id: version-number + 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 + validations: + required: true + + - type: input + id: browser-version + attributes: + label: Browser + description: Your web browser and version. + placeholder: ex. Firefox 123.0b3 + validations: + required: true + + - type: textarea + id: python-deps + attributes: + label: Python dependencies + 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. + validations: + required: false + + - type: textarea + id: what-happened + attributes: + label: What happened + description: | + Describe what happened. Include any relevant error messages, stack traces and screenshots here. + placeholder: I clicked button X and then Y happened. + validations: + required: true + + - type: textarea + id: what-you-expected + attributes: + label: What you expected to happen + description: Describe what you expected to happen. + placeholder: I expected Z to happen. + validations: + required: true + + - type: textarea + id: how-to-repro + attributes: + label: How to reproduce the problem + description: List steps to reproduce the problem. + placeholder: Start the app, generate an image with these settings, then click button X. + validations: + required: false + + - type: textarea + id: additional-context + attributes: + label: Additional context + description: Any other context that might help us to understand the problem. + placeholder: Only happens when there is full moon and Friday the 13th on Christmas Eve 🎅🏻 + validations: + required: false + + - type: input + id: discord-username + attributes: + label: Discord username + description: If you are on the Invoke discord and would prefer to be contacted there, please provide your username. + placeholder: supercoolusername123 + validations: + required: false diff --git a/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml new file mode 100644 index 0000000000000000000000000000000000000000..6d43d447f42aa3c5e8c8ce4d445792fe8f39963a --- /dev/null +++ b/.github/ISSUE_TEMPLATE/FEATURE_REQUEST.yml @@ -0,0 +1,53 @@ +name: Feature Request +description: Contribute a idea or request a new feature +title: '[enhancement]: ' +labels: ['enhancement'] +# assignees: +# - lstein +# - tildebyte +body: + - type: markdown + attributes: + value: | + Thanks for taking the time to fill out this feature request! + + - type: checkboxes + attributes: + label: Is there an existing issue for this? + description: | + Please make use of the [search function](https://github.com/invoke-ai/InvokeAI/labels/enhancement) + to see if a similar issue already exists for the feature you want to request + options: + - label: I have searched the existing issues + required: true + + - type: input + id: contact + attributes: + label: Contact Details + description: __OPTIONAL__ How could we get in touch with you if we need more info (besides this issue)? + placeholder: ex. email@example.com, discordname, twitter, ... + validations: + required: false + + - type: textarea + id: whatisexpected + attributes: + label: What should this feature add? + description: Explain the functionality this feature should add. Feature requests should be for single features. Please create multiple requests if you want to request multiple features. + placeholder: | + I'd like a button that creates an image of banana sushi every time I press it. Each image should be different. There should be a toggle next to the button that enables strawberry mode, in which the images are of strawberry sushi instead. + validations: + required: true + + - type: textarea + attributes: + label: Alternatives + description: Describe alternatives you've considered + placeholder: A clear and concise description of any alternative solutions or features you've considered. + + - type: textarea + attributes: + label: Additional Content + description: Add any other context or screenshots about the feature request here. + placeholder: This is a mockup of the design how I imagine it diff --git a/.github/ISSUE_TEMPLATE/config.yml b/.github/ISSUE_TEMPLATE/config.yml new file mode 100644 index 0000000000000000000000000000000000000000..306483bfaaf9cad09f13cd6f074a764c29db181c --- /dev/null +++ b/.github/ISSUE_TEMPLATE/config.yml @@ -0,0 +1,14 @@ +blank_issues_enabled: false +contact_links: + - name: Project-Documentation + url: https://invoke-ai.github.io/InvokeAI/ + about: Should be your first place to go when looking for manuals/FAQs regarding our InvokeAI Toolkit + - name: Discord + url: https://discord.gg/ZmtBAhwWhy + about: Our Discord Community could maybe help you out via live-chat + - name: GitHub Community Support + url: https://github.com/orgs/community/discussions + about: Please ask and answer questions regarding the GitHub Platform here. + - name: GitHub Security Bug Bounty + url: https://bounty.github.com/ + about: Please report security vulnerabilities of the GitHub Platform here. diff --git a/.github/actions/install-frontend-deps/action.yml b/.github/actions/install-frontend-deps/action.yml new file mode 100644 index 0000000000000000000000000000000000000000..6152da80c6a75d67a8dbc8ed807d71dbfab017ab --- /dev/null +++ b/.github/actions/install-frontend-deps/action.yml @@ -0,0 +1,33 @@ +name: install frontend dependencies +description: Installs frontend dependencies with pnpm, with caching +runs: + using: 'composite' + steps: + - name: setup node 18 + uses: actions/setup-node@v4 + with: + node-version: '18' + + - name: setup pnpm + uses: pnpm/action-setup@v4 + with: + version: 8.15.6 + run_install: false + + - name: get pnpm store directory + shell: bash + run: | + echo "STORE_PATH=$(pnpm store path --silent)" >> $GITHUB_ENV + + - name: setup cache + uses: actions/cache@v4 + with: + path: ${{ env.STORE_PATH }} + key: ${{ runner.os }}-pnpm-store-${{ hashFiles('**/pnpm-lock.yaml') }} + restore-keys: | + ${{ runner.os }}-pnpm-store- + + - name: install frontend dependencies + run: pnpm install --prefer-frozen-lockfile + shell: bash + working-directory: invokeai/frontend/web diff --git a/.github/pr_labels.yml b/.github/pr_labels.yml new file mode 100644 index 0000000000000000000000000000000000000000..fdf11a470fbf88f75425dc02ab0d7b271694e86c --- /dev/null +++ b/.github/pr_labels.yml @@ -0,0 +1,59 @@ +root: +- changed-files: + - any-glob-to-any-file: '*' + +python-deps: +- changed-files: + - any-glob-to-any-file: 'pyproject.toml' + +python: +- changed-files: + - all-globs-to-any-file: + - 'invokeai/**' + - '!invokeai/frontend/web/**' + +python-tests: +- changed-files: + - any-glob-to-any-file: 'tests/**' + +ci-cd: +- changed-files: + - any-glob-to-any-file: .github/** + +docker: +- changed-files: + - any-glob-to-any-file: docker/** + +installer: +- changed-files: + - any-glob-to-any-file: installer/** + +docs: +- changed-files: + - any-glob-to-any-file: docs/** + +invocations: +- changed-files: + - any-glob-to-any-file: 'invokeai/app/invocations/**' + +backend: +- changed-files: + - any-glob-to-any-file: 'invokeai/backend/**' + +api: +- changed-files: + - any-glob-to-any-file: 'invokeai/app/api/**' + +services: +- changed-files: + - any-glob-to-any-file: 'invokeai/app/services/**' + +frontend-deps: +- changed-files: + - any-glob-to-any-file: + - '**/*/package.json' + - '**/*/pnpm-lock.yaml' + +frontend: +- changed-files: + - any-glob-to-any-file: 'invokeai/frontend/web/**' diff --git a/.github/pull_request_template.md b/.github/pull_request_template.md new file mode 100644 index 0000000000000000000000000000000000000000..cc78635071b88c7055542645f4c175037e2c579d --- /dev/null +++ b/.github/pull_request_template.md @@ -0,0 +1,22 @@ +## Summary + + + +## Related Issues / Discussions + + + +## QA Instructions + + + +## Merge Plan + + + +## Checklist + +- [ ] _The PR has a short but descriptive title, suitable for a changelog_ +- [ ] _Tests added / updated (if applicable)_ +- [ ] _Documentation added / updated (if applicable)_ +- [ ] _Updated `What's New` copy (if doing a release after this PR)_ diff --git a/.github/stale.yaml b/.github/stale.yaml new file mode 100644 index 0000000000000000000000000000000000000000..b9150235fcc7dcb0baa2ab4294d23f8561a4f143 --- /dev/null +++ b/.github/stale.yaml @@ -0,0 +1,19 @@ +# Number of days of inactivity before an issue becomes stale +daysUntilStale: 28 +# Number of days of inactivity before a stale issue is closed +daysUntilClose: 14 +# Issues with these labels will never be considered stale +exemptLabels: + - pinned + - security +# Label to use when marking an issue as stale +staleLabel: stale +# Comment to post when marking an issue as stale. Set to `false` to disable +markComment: > + This issue has been automatically marked as stale because it has not had + recent activity. It will be closed if no further activity occurs. Please + update the ticket if this is still a problem on the latest release. +# Comment to post when closing a stale issue. Set to `false` to disable +closeComment: > + Due to inactivity, this issue has been automatically closed. If this is + still a problem on the latest release, please recreate the issue. diff --git a/.github/workflows/build-container.yml b/.github/workflows/build-container.yml new file mode 100644 index 0000000000000000000000000000000000000000..a0e5ff14af4d5fdb5a1d48564259cd214bed5f65 --- /dev/null +++ b/.github/workflows/build-container.yml @@ -0,0 +1,109 @@ +name: build container image +on: + push: + branches: + - 'main' + paths: + - 'pyproject.toml' + - '.dockerignore' + - 'invokeai/**' + - 'docker/Dockerfile' + - 'docker/docker-entrypoint.sh' + - 'workflows/build-container.yml' + 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 + packages: write + +jobs: + docker: + if: github.event.pull_request.draft == false + strategy: + fail-fast: false + matrix: + gpu-driver: + - cuda + - cpu + - rocm + runs-on: ubuntu-latest + name: ${{ matrix.gpu-driver }} + env: + # torch/arm64 does not support GPU currently, so arm64 builds + # would not be GPU-accelerated. + # re-enable arm64 if there is sufficient demand. + # PLATFORMS: 'linux/amd64,linux/arm64' + PLATFORMS: 'linux/amd64' + steps: + - 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" + sudo swapoff /mnt/swapfile + sudo rm -rf /mnt/swapfile + echo "----- Free space after cleanup" + df -h + + - name: Checkout + uses: actions/checkout@v4 + + - name: Docker meta + id: meta + uses: docker/metadata-action@v5 + with: + github-token: ${{ secrets.GITHUB_TOKEN }} + images: | + ghcr.io/${{ github.repository }} + tags: | + type=ref,event=branch + type=ref,event=tag + type=pep440,pattern={{version}} + type=pep440,pattern={{major}}.{{minor}} + type=pep440,pattern={{major}} + type=sha,enable=true,prefix=sha-,format=short + flavor: | + 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@v3 + + - name: Set up Docker Buildx + uses: docker/setup-buildx-action@v3 + with: + platforms: ${{ env.PLATFORMS }} + + - name: Login to GitHub Container Registry + if: github.event_name != 'pull_request' + uses: docker/login-action@v3 + with: + registry: ghcr.io + username: ${{ github.repository_owner }} + password: ${{ secrets.GITHUB_TOKEN }} + + - name: Build container + timeout-minutes: 40 + id: docker_build + uses: docker/build-push-action@v6 + with: + context: . + file: docker/Dockerfile + platforms: ${{ env.PLATFORMS }} + 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 }} diff --git a/.github/workflows/build-installer.yml b/.github/workflows/build-installer.yml new file mode 100644 index 0000000000000000000000000000000000000000..b517751960c64c87c082019fd7d1e0129e0c3ed7 --- /dev/null +++ b/.github/workflows/build-installer.yml @@ -0,0 +1,45 @@ +# 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/clean-caches.yml b/.github/workflows/clean-caches.yml new file mode 100644 index 0000000000000000000000000000000000000000..e5acdeab1b9bee7384885df1f6db65b1b8a8ba77 --- /dev/null +++ b/.github/workflows/clean-caches.yml @@ -0,0 +1,34 @@ +name: cleanup caches by a branch +on: + pull_request: + types: + - closed + workflow_dispatch: + +jobs: + cleanup: + runs-on: ubuntu-latest + steps: + - name: Check out code + uses: actions/checkout@v3 + + - name: Cleanup + run: | + gh extension install actions/gh-actions-cache + + REPO=${{ github.repository }} + BRANCH=${{ github.ref }} + + echo "Fetching list of cache key" + cacheKeysForPR=$(gh actions-cache list -R $REPO -B $BRANCH | cut -f 1 ) + + ## Setting this to not fail the workflow while deleting cache keys. + set +e + echo "Deleting caches..." + for cacheKey in $cacheKeysForPR + do + gh actions-cache delete $cacheKey -R $REPO -B $BRANCH --confirm + done + echo "Done" + env: + GH_TOKEN: ${{ secrets.GITHUB_TOKEN }} diff --git a/.github/workflows/close-inactive-issues.yml b/.github/workflows/close-inactive-issues.yml new file mode 100644 index 0000000000000000000000000000000000000000..9636911b2e9740fe5e743069178cdfadefdc6aa4 --- /dev/null +++ b/.github/workflows/close-inactive-issues.yml @@ -0,0 +1,28 @@ +name: Close inactive issues +on: + schedule: + - cron: "00 4 * * *" + +env: + DAYS_BEFORE_ISSUE_STALE: 30 + DAYS_BEFORE_ISSUE_CLOSE: 14 + +jobs: + close-issues: + runs-on: ubuntu-latest + permissions: + issues: write + pull-requests: write + steps: + - uses: actions/stale@v8 + with: + days-before-issue-stale: ${{ env.DAYS_BEFORE_ISSUE_STALE }} + days-before-issue-close: ${{ env.DAYS_BEFORE_ISSUE_CLOSE }} + stale-issue-label: "Inactive Issue" + stale-issue-message: "There has been no activity in this issue for ${{ env.DAYS_BEFORE_ISSUE_STALE }} days. If this issue is still being experienced, please reply with an updated confirmation that the issue is still being experienced with the latest release." + 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 + exempt-issue-labels: "Active Issue" + repo-token: ${{ secrets.GITHUB_TOKEN }} + operations-per-run: 500 diff --git a/.github/workflows/frontend-checks.yml b/.github/workflows/frontend-checks.yml new file mode 100644 index 0000000000000000000000000000000000000000..da19b74ebccede46a8d21b6870290aebcb6bdcf9 --- /dev/null +++ b/.github/workflows/frontend-checks.yml @@ -0,0 +1,80 @@ +# Runs frontend code quality checks. +# +# Checks for changes to frontend files before running the checks. +# If always_run is true, always runs the checks. + +name: 'frontend 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 + +defaults: + run: + working-directory: invokeai/frontend/web + +jobs: + frontend-checks: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + steps: + - uses: actions/checkout@v4 + + - name: check for changed frontend files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + frontend: + - 'invokeai/frontend/web/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + uses: ./.github/actions/install-frontend-deps + + - name: tsc + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm lint:tsc' + shell: bash + + - name: dpdm + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm lint:dpdm' + shell: bash + + - name: eslint + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm lint:eslint' + shell: bash + + - name: prettier + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm lint:prettier' + shell: bash + + - name: knip + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm lint:knip' + shell: bash diff --git a/.github/workflows/frontend-tests.yml b/.github/workflows/frontend-tests.yml new file mode 100644 index 0000000000000000000000000000000000000000..086cff7b3d45cce266d0a227b3023f50f81cbc1f --- /dev/null +++ b/.github/workflows/frontend-tests.yml @@ -0,0 +1,60 @@ +# Runs frontend tests. +# +# Checks for changes to frontend files before running the tests. +# If always_run is true, always runs the tests. + +name: 'frontend tests' + +on: + push: + branches: + - 'main' + pull_request: + types: + - 'ready_for_review' + - 'opened' + - 'synchronize' + merge_group: + workflow_dispatch: + inputs: + always_run: + description: 'Always run the tests' + required: true + type: boolean + default: true + workflow_call: + inputs: + always_run: + description: 'Always run the tests' + required: true + type: boolean + default: true + +defaults: + run: + working-directory: invokeai/frontend/web + +jobs: + frontend-tests: + runs-on: ubuntu-latest + timeout-minutes: 10 # expected run time: <2 min + steps: + - uses: actions/checkout@v4 + + - name: check for changed frontend files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + frontend: + - 'invokeai/frontend/web/**' + + - name: install dependencies + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + uses: ./.github/actions/install-frontend-deps + + - name: vitest + if: ${{ steps.changed-files.outputs.frontend_any_changed == 'true' || inputs.always_run == true }} + run: 'pnpm test:no-watch' + shell: bash diff --git a/.github/workflows/label-pr.yml b/.github/workflows/label-pr.yml new file mode 100644 index 0000000000000000000000000000000000000000..1a98512190ace8612a34d96fc3d4421e3bfa3716 --- /dev/null +++ b/.github/workflows/label-pr.yml @@ -0,0 +1,18 @@ +name: 'label PRs' +on: + - pull_request_target + +jobs: + labeler: + permissions: + contents: read + pull-requests: write + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: label PRs + uses: actions/labeler@v5 + with: + configuration-path: .github/pr_labels.yml diff --git a/.github/workflows/mkdocs-material.yml b/.github/workflows/mkdocs-material.yml new file mode 100644 index 0000000000000000000000000000000000000000..419d87f37bb841962984adf7d1d330815a4728ab --- /dev/null +++ b/.github/workflows/mkdocs-material.yml @@ -0,0 +1,49 @@ +# 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/python-checks.yml b/.github/workflows/python-checks.yml new file mode 100644 index 0000000000000000000000000000000000000000..40d028826b33eede01af92f7d587045195022ff1 --- /dev/null +++ b/.github/workflows/python-checks.yml @@ -0,0 +1,76 @@ +# Runs python code quality checks. +# +# Checks for changes to python files before running the checks. +# If always_run is true, always runs the checks. +# +# TODO: Add mypy or pyright to the checks. + +name: 'python 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: + python-checks: + runs-on: ubuntu-latest + timeout-minutes: 5 # expected run time: <1 min + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: check for changed python files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + python: + - 'pyproject.toml' + - 'invokeai/**' + - '!invokeai/frontend/web/**' + - 'tests/**' + + - name: setup python + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }} + uses: actions/setup-python@v5 + with: + python-version: '3.10' + cache: pip + cache-dependency-path: pyproject.toml + + - name: install ruff + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }} + run: pip install ruff==0.6.0 + shell: bash + + - name: ruff check + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }} + run: ruff 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 . + shell: bash diff --git a/.github/workflows/python-tests.yml b/.github/workflows/python-tests.yml new file mode 100644 index 0000000000000000000000000000000000000000..f22e29e3e9be2d8ad69fe61b778cac2462f10271 --- /dev/null +++ b/.github/workflows/python-tests.yml @@ -0,0 +1,106 @@ +# Runs python tests on a matrix of python versions and platforms. +# +# Checks for changes to python files before running the tests. +# If always_run is true, always runs the tests. + +name: 'python tests' + +on: + push: + branches: + - 'main' + pull_request: + types: + - 'ready_for_review' + - 'opened' + - 'synchronize' + merge_group: + workflow_dispatch: + inputs: + always_run: + description: 'Always run the tests' + required: true + type: boolean + default: true + workflow_call: + inputs: + always_run: + description: 'Always run the tests' + required: true + type: boolean + default: true + +concurrency: + group: ${{ github.workflow }}-${{ github.head_ref || github.run_id }} + cancel-in-progress: true + +jobs: + matrix: + strategy: + matrix: + python-version: + - '3.10' + - '3.11' + 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 + extra-index-url: 'https://download.pytorch.org/whl/cpu' + github-env: $GITHUB_ENV + - platform: macos-default + os: macOS-14 + github-env: $GITHUB_ENV + - platform: windows-cpu + os: windows-2022 + github-env: $env:GITHUB_ENV + name: 'py${{ matrix.python-version }}: ${{ matrix.platform }}' + runs-on: ${{ matrix.os }} + timeout-minutes: 15 # expected run time: 2-6 min, depending on platform + env: + PIP_USE_PEP517: '1' + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: check for changed python files + if: ${{ inputs.always_run != true }} + id: changed-files + uses: tj-actions/changed-files@v42 + with: + files_yaml: | + python: + - 'pyproject.toml' + - 'invokeai/**' + - '!invokeai/frontend/web/**' + - 'tests/**' + + - name: setup python + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }} + uses: actions/setup-python@v5 + with: + 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]" + + - name: run pytest + if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }} + run: pytest diff --git a/.github/workflows/release.yml b/.github/workflows/release.yml new file mode 100644 index 0000000000000000000000000000000000000000..0f09c0b245db2a9305422074b7d00bb08f712da1 --- /dev/null +++ b/.github/workflows/release.yml @@ -0,0 +1,108 @@ +# Main release workflow. Triggered on tag push or manual trigger. +# +# - Runs all code checks and tests +# - Verifies the app version matches the tag version. +# - Builds the installer and build, uploading them as artifacts. +# - Publishes to TestPyPI and PyPI. Both are conditional on the previous steps passing and require a manual approval. +# +# See docs/RELEASE.md for more information on the release process. + +name: release + +on: + push: + tags: + - 'v*' + workflow_dispatch: + +jobs: + check-version: + runs-on: ubuntu-latest + steps: + - name: checkout + uses: actions/checkout@v4 + + - name: check python version + uses: samuelcolvin/check-python-version@v4 + id: check-python-version + with: + version_file_path: invokeai/version/invokeai_version.py + + frontend-checks: + uses: ./.github/workflows/frontend-checks.yml + with: + always_run: true + + frontend-tests: + uses: ./.github/workflows/frontend-tests.yml + with: + always_run: true + + python-checks: + uses: ./.github/workflows/python-checks.yml + with: + always_run: true + + python-tests: + uses: ./.github/workflows/python-tests.yml + with: + always_run: true + + build: + uses: ./.github/workflows/build-installer.yml + + publish-testpypi: + runs-on: ubuntu-latest + timeout-minutes: 5 # expected run time: <1 min + needs: + [ + check-version, + frontend-checks, + frontend-tests, + python-checks, + python-tests, + build, + ] + environment: + name: testpypi + url: https://test.pypi.org/p/invokeai + permissions: + id-token: write + steps: + - name: download distribution from build job + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: publish distribution to TestPyPI + uses: pypa/gh-action-pypi-publish@release/v1 + with: + repository-url: https://test.pypi.org/legacy/ + + publish-pypi: + runs-on: ubuntu-latest + timeout-minutes: 5 # expected run time: <1 min + needs: + [ + check-version, + frontend-checks, + frontend-tests, + python-checks, + python-tests, + build, + ] + environment: + name: pypi + url: https://pypi.org/p/invokeai + permissions: + id-token: write + steps: + - name: download distribution from build job + uses: actions/download-artifact@v4 + with: + name: dist + path: dist/ + + - name: publish distribution to PyPI + uses: pypa/gh-action-pypi-publish@release/v1 diff --git a/.gitignore b/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..29d27d78ed5adc478b2b9a208ca11cc54779d57f --- /dev/null +++ b/.gitignore @@ -0,0 +1,190 @@ +.idea/ + +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# emacs autosave and recovery files +*~ +.#* + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +pip-wheel-metadata/ +share/python-wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.nox/ +.coveragerc +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +cov.xml +*.cover +*.py,cover +.hypothesis/ +.pytest_cache/ +.pytest.ini +cover/ +junit/ +notes/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 +db.sqlite3-journal + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +.pybuilder/ +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# IPython +profile_default/ +ipython_config.py + +# pyenv +# For a library or package, you might want to ignore these files since the code is +# intended to run in multiple environments; otherwise, check them in: +# .python-version +.python-version + +# pipenv +# According to pypa/pipenv#598, it is recommended to include Pipfile.lock in version control. +# However, in case of collaboration, if having platform-specific dependencies or dependencies +# having no cross-platform support, pipenv may install dependencies that don't work, or not +# install all needed dependencies. +#Pipfile.lock + +# poetry +# Similar to Pipfile.lock, it is generally recommended to include poetry.lock in version control. +# This is especially recommended for binary packages to ensure reproducibility, and is more +# commonly ignored for libraries. +# https://python-poetry.org/docs/basic-usage/#commit-your-poetrylock-file-to-version-control +#poetry.lock + +# pdm +# Similar to Pipfile.lock, it is generally recommended to include pdm.lock in version control. +#pdm.lock +# pdm stores project-wide configurations in .pdm.toml, but it is recommended to not include it +# in version control. +# https://pdm.fming.dev/#use-with-ide +.pdm.toml + +# PEP 582; used by e.g. github.com/David-OConnor/pyflow +__pypackages__/ + +# Celery stuff +celerybeat-schedule +celerybeat.pid + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv* +env/ +venv/ +ENV/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# mypy +.mypy_cache/ +.dmypy.json +dmypy.json + +# Pyre type checker +.pyre/ + +# pytype static type analyzer +.pytype/ + +# Cython debug symbols +cython_debug/ + +# PyCharm +# JetBrains specific template is maintained in a separate JetBrains.gitignore that can +# be found at https://github.com/github/gitignore/blob/main/Global/JetBrains.gitignore +# and can be added to the global gitignore or merged into this file. For a more nuclear +# option (not recommended) you can uncomment the following to ignore the entire idea folder. +#.idea/ + +**/__pycache__/ + +# If it's a Mac +.DS_Store + +# Let the frontend manage its own gitignore +!invokeai/frontend/web/* + +# Scratch folder +.scratch/ +.vscode/ + +# source installer files +installer/*zip +installer/install.bat +installer/install.sh +installer/update.bat +installer/update.sh +installer/InvokeAI-Installer/ diff --git a/.gitmodules b/.gitmodules new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/.pre-commit-config.yaml b/.pre-commit-config.yaml new file mode 100644 index 0000000000000000000000000000000000000000..6cff07a959b6c4dccab5475c0d1b351e77d422e7 --- /dev/null +++ b/.pre-commit-config.yaml @@ -0,0 +1,24 @@ +# See https://pre-commit.com/ for usage and config +repos: +- repo: local + hooks: + - id: black + name: black + stages: [commit] + language: system + entry: black + types: [python] + + - id: flake8 + name: flake8 + stages: [commit] + language: system + entry: flake8 + types: [python] + + - id: isort + name: isort + stages: [commit] + language: system + entry: isort + types: [python] \ No newline at end of file diff --git a/.prettierrc.yaml b/.prettierrc.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3d2ce3b880d355d62dc784fbcb7a410ca1f6bcec --- /dev/null +++ b/.prettierrc.yaml @@ -0,0 +1,13 @@ +endOfLine: lf +tabWidth: 2 +useTabs: false +singleQuote: true +quoteProps: as-needed +embeddedLanguageFormatting: auto +overrides: + - files: '*.md' + options: + proseWrap: preserve + printWidth: 80 + parser: markdown + cursorOffset: -1 diff --git a/InvokeAI_Statement_of_Values.md b/InvokeAI_Statement_of_Values.md new file mode 100644 index 0000000000000000000000000000000000000000..162220769a2395ae51c7e4b0599d310fd280ee7c --- /dev/null +++ b/InvokeAI_Statement_of_Values.md @@ -0,0 +1,84 @@ + + +Invoke-AI is a community of software developers, researchers, and user +interface experts who have come together on a voluntary basis to build +software tools which support cutting edge AI text-to-image +applications. This community is open to anyone who wishes to +contribute to the effort and has the skill and time to do so. + +# Our Values + +The InvokeAI team is a diverse community which includes individuals +from various parts of the world and many walks of life. Despite our +differences, we share a number of core values which we ask prospective +contributors to understand and respect. We believe: + +1. That Open Source Software is a positive force in the world. We +create software that can be used, reused, and redistributed, without +restrictions, under a straightforward Open Source license (MIT). We +believe that Open Source benefits society as a whole by increasing the +availability of high quality software to all. + +2. That those who create software should receive proper attribution +for their creative work. While we support the exchange and reuse of +Open Source Software, we feel strongly that the original authors of a +piece of code should receive credit for their contribution, and we +endeavor to do so whenever possible. + +3. That there is moral ambiguity surrounding AI-assisted art. We are +aware of the moral and ethical issues surrounding the release of the +Stable Diffusion model and similar products. We are aware that, due to +the composition of their training sets, current AI-generated image +models are biased against certain ethnic groups, cultural concepts of +beauty, ethnic stereotypes, and gender roles. + + 1. We recognize the potential for harm to these groups that these biases + represent and trust that future AI models will take steps towards + reducing or eliminating the biases noted above, respect and give due + credit to the artists whose work is sourced, and call on developers + and users to favor these models over the older ones as they become + available. + +4. We are deeply committed to ensuring that this technology benefits +everyone, including artists. We see AI art not as a replacement for +the artist, but rather as a tool to empower them. With that +in mind, we are constantly debating how to build systems that put +artists’ needs first: tools which can be readily integrated into an +artist’s existing workflows and practices, enhancing their work and +helping them to push it further. Every decision we take as a team, +which includes several artists, aims to build towards that goal. + +5. That artificial intelligence can be a force for good in the world, +but must be used responsibly. Artificial intelligence technologies +have the potential to improve society, in everything from cancer care, +to customer service, to creative writing. + + 1. While we do not believe that software should arbitrarily limit what + users can do with it, we recognize that when used irresponsibly, AI + has the potential to do much harm. Our Discord server is actively + moderated in order to minimize the potential of harm from + user-contributed images. In addition, we ask users of our software to + refrain from using it in any way that would cause mental, emotional or + physical harm to individuals and vulnerable populations including (but + not limited to) women; minors; ethnic minorities; religious groups; + members of LGBTQIA communities; and people with disabilities or + impairments. + + 2. Note that some of the image generation AI models which the Invoke-AI + toolkit supports carry licensing agreements which impose restrictions + on how the model is used. We ask that our users read and agree to + these terms if they wish to make use of these models. These agreements + are distinct from the MIT license which applies to the InvokeAI + software and source code. + +6. That mutual respect is key to a healthy software development +community. Members of the InvokeAI community are expected to treat +each other with respect, beneficence, and empathy. Each of us has a +different background and a unique set of skills. We strive to help +each other grow and gain new skills, and we apportion expectations in +a way that balances the members' time, skillset, and interest +area. Disputes are resolved by open and honest communication. + +## Signature + +This document has been collectively crafted and approved by the current InvokeAI team members, as of 28 Nov 2022: **lstein** (Lincoln Stein), **blessedcoolant**, **hipsterusername** (Kent Keirsey), **Kyle0654** (Kyle Schouviller), **damian0815**, **mauwii** (Matthias Wild), **Netsvetaev** (Artur Netsvetaev), **psychedelicious**, **tildebyte**, **keturn**, and **ebr** (Eugene Brodsky). Although individuals within the group may hold differing views on particular details and/or their implications, we are all in agreement about its fundamental statements, as well as their significance and importance to this project moving forward. diff --git a/LICENSE b/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..fac28ea6b9eaf841ce1ed073647deca53c5c1290 --- /dev/null +++ b/LICENSE @@ -0,0 +1,176 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + diff --git a/LICENSE-SD1+SD2.txt b/LICENSE-SD1+SD2.txt new file mode 100644 index 0000000000000000000000000000000000000000..1dc1a565488c318c2b0a9f29deb3f60daaf32f3d --- /dev/null +++ b/LICENSE-SD1+SD2.txt @@ -0,0 +1,294 @@ +Copyright (c) 2022 Robin Rombach and Patrick Esser and contributors + +CreativeML Open RAIL-M +dated August 22, 2022 + +Section I: PREAMBLE + +Multimodal generative models are being widely adopted and used, and +have the potential to transform the way artists, among other +individuals, conceive and benefit from AI or ML technologies as a tool +for content creation. + +Notwithstanding the current and potential benefits that these +artifacts can bring to society at large, there are also concerns about +potential misuses of them, either due to their technical limitations +or ethical considerations. + +In short, this license strives for both the open and responsible +downstream use of the accompanying model. When it comes to the open +character, we took inspiration from open source permissive licenses +regarding the grant of IP rights. Referring to the downstream +responsible use, we added use-based restrictions not permitting the +use of the Model in very specific scenarios, in order for the licensor +to be able to enforce the license in case potential misuses of the +Model may occur. At the same time, we strive to promote open and +responsible research on generative models for art and content +generation. + +Even though downstream derivative versions of the model could be +released under different licensing terms, the latter will always have +to include - at minimum - the same use-based restrictions as the ones +in the original license (this license). We believe in the intersection +between open and responsible AI development; thus, this License aims +to strike a balance between both in order to enable responsible +open-science in the field of AI. + +This License governs the use of the model (and its derivatives) and is +informed by the model card associated with the model. + +NOW THEREFORE, You and Licensor agree as follows: + +1. Definitions + +- "License" means the terms and conditions for use, reproduction, and + Distribution as defined in this document. + +- "Data" means a collection of information and/or content extracted + from the dataset used with the Model, including to train, pretrain, + or otherwise evaluate the Model. The Data is not licensed under this + License. + +- "Output" means the results of operating a Model as embodied in + informational content resulting therefrom. + +- "Model" means any accompanying machine-learning based assemblies + (including checkpoints), consisting of learnt weights, parameters + (including optimizer states), corresponding to the model + architecture as embodied in the Complementary Material, that have + been trained or tuned, in whole or in part on the Data, using the + Complementary Material. + +- "Derivatives of the Model" means all modifications to the Model, + works based on the Model, or any other model which is created or + initialized by transfer of patterns of the weights, parameters, + activations or output of the Model, to the other model, in order to + cause the other model to perform similarly to the Model, including - + but not limited to - distillation methods entailing the use of + intermediate data representations or methods based on the generation + of synthetic data by the Model for training the other model. + +- "Complementary Material" means the accompanying source code and + scripts used to define, run, load, benchmark or evaluate the Model, + and used to prepare data for training or evaluation, if any. This + includes any accompanying documentation, tutorials, examples, etc, + if any. + +- "Distribution" means any transmission, reproduction, publication or + other sharing of the Model or Derivatives of the Model to a third + party, including providing the Model as a hosted service made + available by electronic or other remote means - e.g. API-based or + web access. + +- "Licensor" means the copyright owner or entity authorized by the + copyright owner that is granting the License, including the persons + or entities that may have rights in the Model and/or distributing + the Model. + +- "You" (or "Your") means an individual or Legal Entity exercising + permissions granted by this License and/or making use of the Model + for whichever purpose and in any field of use, including usage of + the Model in an end-use application - e.g. chatbot, translator, + image generator. + +- "Third Parties" means individuals or legal entities that are not + under common control with Licensor or You. + +- "Contribution" means any work of authorship, including the original + version of the Model and any modifications or additions to that + Model or Derivatives of the Model thereof, that is intentionally + submitted to Licensor for inclusion in the Model by the copyright + owner or by an individual or Legal Entity authorized to submit on + behalf of the copyright owner. For the purposes of this definition, + "submitted" means any form of electronic, verbal, or written + communication sent to the Licensor or its representatives, including + but not limited to communication on electronic mailing lists, source + code control systems, and issue tracking systems that are managed + by, or on behalf of, the Licensor for the purpose of discussing and + improving the Model, but excluding communication that is + conspicuously marked or otherwise designated in writing by the + copyright owner as "Not a Contribution." + +- "Contributor" means Licensor and any individual or Legal Entity on + behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Model. + +Section II: INTELLECTUAL PROPERTY RIGHTS + +Both copyright and patent grants apply to the Model, Derivatives of +the Model and Complementary Material. The Model and Derivatives of the +Model are subject to additional terms as described in Section III. + +2. Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare, publicly display, publicly +perform, sublicense, and distribute the Complementary Material, the +Model, and Derivatives of the Model. + +3. Grant of Patent License. Subject to the terms and conditions of +this License and where and as applicable, each Contributor hereby +grants to You a perpetual, worldwide, non-exclusive, no-charge, +royalty-free, irrevocable (except as stated in this paragraph) patent +license to make, have made, use, offer to sell, sell, import, and +otherwise transfer the Model and the Complementary Material, where +such license applies only to those patent claims licensable by such +Contributor that are necessarily infringed by their Contribution(s) +alone or by combination of their Contribution(s) with the Model to +which such Contribution(s) was submitted. If You institute patent +litigation against any entity (including a cross-claim or counterclaim +in a lawsuit) alleging that the Model and/or Complementary Material or +a Contribution incorporated within the Model and/or Complementary +Material constitutes direct or contributory patent infringement, then +any patent licenses granted to You under this License for the Model +and/or Work shall terminate as of the date such litigation is asserted +or filed. + +Section III: CONDITIONS OF USAGE, DISTRIBUTION AND REDISTRIBUTION + +4. Distribution and Redistribution. You may host for Third Party +remote access purposes (e.g. software-as-a-service), reproduce and +distribute copies of the Model or Derivatives of the Model thereof in +any medium, with or without modifications, provided that You meet the +following conditions: Use-based restrictions as referenced in +paragraph 5 MUST be included as an enforceable provision by You in any +type of legal agreement (e.g. a license) governing the use and/or +distribution of the Model or Derivatives of the Model, and You shall +give notice to subsequent users You Distribute to, that the Model or +Derivatives of the Model are subject to paragraph 5. This provision +does not apply to the use of Complementary Material. You must give +any Third Party recipients of the Model or Derivatives of the Model a +copy of this License; You must cause any modified files to carry +prominent notices stating that You changed the files; You must retain +all copyright, patent, trademark, and attribution notices excluding +those notices that do not pertain to any part of the Model, +Derivatives of the Model. You may add Your own copyright statement to +Your modifications and may provide additional or different license +terms and conditions - respecting paragraph 4.a. - for use, +reproduction, or Distribution of Your modifications, or for any such +Derivatives of the Model as a whole, provided Your use, reproduction, +and Distribution of the Model otherwise complies with the conditions +stated in this License. + +5. Use-based restrictions. The restrictions set forth in Attachment A +are considered Use-based restrictions. Therefore You cannot use the +Model and the Derivatives of the Model for the specified restricted +uses. You may use the Model subject to this License, including only +for lawful purposes and in accordance with the License. Use may +include creating any content with, finetuning, updating, running, +training, evaluating and/or reparametrizing the Model. You shall +require all of Your users who use the Model or a Derivative of the +Model to comply with the terms of this paragraph (paragraph 5). + +6. The Output You Generate. Except as set forth herein, Licensor +claims no rights in the Output You generate using the Model. You are +accountable for the Output you generate and its subsequent uses. No +use of the output can contravene any provision as stated in the +License. + +Section IV: OTHER PROVISIONS + +7. Updates and Runtime Restrictions. To the maximum extent permitted +by law, Licensor reserves the right to restrict (remotely or +otherwise) usage of the Model in violation of this License, update the +Model through electronic means, or modify the Output of the Model +based on updates. You shall undertake reasonable efforts to use the +latest version of the Model. + +8. Trademarks and related. Nothing in this License permits You to make +use of Licensors’ trademarks, trade names, logos or to otherwise +suggest endorsement or misrepresent the relationship between the +parties; and any rights not expressly granted herein are reserved by +the Licensors. + +9. Disclaimer of Warranty. Unless required by applicable law or agreed +to in writing, Licensor provides the Model and the Complementary +Material (and each Contributor provides its Contributions) on an "AS +IS" BASIS, WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either +express or implied, including, without limitation, any warranties or +conditions of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR +A PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Model, Derivatives of +the Model, and the Complementary Material and assume any risks +associated with Your exercise of permissions under this License. + +10. Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, unless +required by applicable law (such as deliberate and grossly negligent +acts) or agreed to in writing, shall any Contributor be liable to You +for damages, including any direct, indirect, special, incidental, or +consequential damages of any character arising as a result of this +License or out of the use or inability to use the Model and the +Complementary Material (including but not limited to damages for loss +of goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +11. Accepting Warranty or Additional Liability. While redistributing +the Model, Derivatives of the Model and the Complementary Material +thereof, You may choose to offer, and charge a fee for, acceptance of +support, warranty, indemnity, or other liability obligations and/or +rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if +You agree to indemnify, defend, and hold each Contributor harmless for +any liability incurred by, or claims asserted against, such +Contributor by reason of your accepting any such warranty or +additional liability. + +12. If any provision of this License is held to be invalid, illegal or +unenforceable, the remaining provisions shall be unaffected thereby +and remain valid as if such provision had not been set forth herein. + +END OF TERMS AND CONDITIONS + + + + +Attachment A + +Use Restrictions + +You agree not to use the Model or Derivatives of the Model: + +- In any way that violates any applicable national, federal, state, + local or international law or regulation; + +- For the purpose of exploiting, harming or attempting to exploit or + harm minors in any way; + +- To generate or disseminate verifiably false information and/or + content with the purpose of harming others; + +- To generate or disseminate personal identifiable information that + can be used to harm an individual; + +- To defame, disparage or otherwise harass others; + +- For fully automated decision making that adversely impacts an + individual’s legal rights or otherwise creates or modifies a + binding, enforceable obligation; + +pp- For any use intended to or which has the effect of discriminating + against or harming individuals or groups based on online or offline + social behavior or known or predicted personal or personality + characteristics; + +- To exploit any of the vulnerabilities of a specific group of persons + based on their age, social, physical or mental characteristics, in + order to materially distort the behavior of a person pertaining to + that group in a manner that causes or is likely to cause that person + or another person physical or psychological harm; + +- For any use intended to or which has the effect of discriminating + against individuals or groups based on legally protected + characteristics or categories; + +- To provide medical advice and medical results interpretation; + +- To generate or disseminate information for the purpose to be used + for administration of justice, law enforcement, immigration or + asylum processes, such as predicting an individual will commit + fraud/crime commitment (e.g. by text profiling, drawing causal + relationships between assertions made in documents, indiscriminate + and arbitrarily-targeted use). diff --git a/LICENSE-SDXL.txt b/LICENSE-SDXL.txt new file mode 100644 index 0000000000000000000000000000000000000000..05fbe3abb65bfb1844c0538d6855b13fa9760add --- /dev/null +++ b/LICENSE-SDXL.txt @@ -0,0 +1,290 @@ +Copyright (c) 2023 Stability AI +CreativeML Open RAIL++-M License dated July 26, 2023 + +Section I: PREAMBLE + +Multimodal generative models are being widely adopted and used, and +have the potential to transform the way artists, among other +individuals, conceive and benefit from AI or ML technologies as a tool +for content creation. + +Notwithstanding the current and potential benefits that these +artifacts can bring to society at large, there are also concerns about +potential misuses of them, either due to their technical limitations +or ethical considerations. + +In short, this license strives for both the open and responsible +downstream use of the accompanying model. When it comes to the open +character, we took inspiration from open source permissive licenses +regarding the grant of IP rights. Referring to the downstream +responsible use, we added use-based restrictions not permitting the +use of the model in very specific scenarios, in order for the licensor +to be able to enforce the license in case potential misuses of the +Model may occur. At the same time, we strive to promote open and +responsible research on generative models for art and content +generation. + +Even though downstream derivative versions of the model could be +released under different licensing terms, the latter will always have +to include - at minimum - the same use-based restrictions as the ones +in the original license (this license). We believe in the intersection +between open and responsible AI development; thus, this agreement aims +to strike a balance between both in order to enable responsible +open-science in the field of AI. + +This CreativeML Open RAIL++-M License governs the use of the model +(and its derivatives) and is informed by the model card associated +with the model. + +NOW THEREFORE, You and Licensor agree as follows: + +Definitions + +"License" means the terms and conditions for use, reproduction, and +Distribution as defined in this document. + +"Data" means a collection of information and/or content extracted from +the dataset used with the Model, including to train, pretrain, or +otherwise evaluate the Model. The Data is not licensed under this +License. + +"Output" means the results of operating a Model as embodied in +informational content resulting therefrom. + +"Model" means any accompanying machine-learning based assemblies +(including checkpoints), consisting of learnt weights, parameters +(including optimizer states), corresponding to the model architecture +as embodied in the Complementary Material, that have been trained or +tuned, in whole or in part on the Data, using the Complementary +Material. + +"Derivatives of the Model" means all modifications to the Model, works +based on the Model, or any other model which is created or initialized +by transfer of patterns of the weights, parameters, activations or +output of the Model, to the other model, in order to cause the other +model to perform similarly to the Model, including - but not limited +to - distillation methods entailing the use of intermediate data +representations or methods based on the generation of synthetic data +by the Model for training the other model. + +"Complementary Material" means the accompanying source code and +scripts used to define, run, load, benchmark or evaluate the Model, +and used to prepare data for training or evaluation, if any. This +includes any accompanying documentation, tutorials, examples, etc, if +any. + +"Distribution" means any transmission, reproduction, publication or +other sharing of the Model or Derivatives of the Model to a third +party, including providing the Model as a hosted service made +available by electronic or other remote means - e.g. API-based or web +access. + +"Licensor" means the copyright owner or entity authorized by the +copyright owner that is granting the License, including the persons or +entities that may have rights in the Model and/or distributing the +Model. + +"You" (or "Your") means an individual or Legal Entity exercising +permissions granted by this License and/or making use of the Model for +whichever purpose and in any field of use, including usage of the +Model in an end-use application - e.g. chatbot, translator, image +generator. + +"Third Parties" means individuals or legal entities that are not under +common control with Licensor or You. + +"Contribution" means any work of authorship, including the original +version of the Model and any modifications or additions to that Model +or Derivatives of the Model thereof, that is intentionally submitted +to Licensor for inclusion in the Model by the copyright owner or by an +individual or Legal Entity authorized to submit on behalf of the +copyright owner. For the purposes of this definition, "submitted" +means any form of electronic, verbal, or written communication sent to +the Licensor or its representatives, including but not limited to +communication on electronic mailing lists, source code control +systems, and issue tracking systems that are managed by, or on behalf +of, the Licensor for the purpose of discussing and improving the +Model, but excluding communication that is conspicuously marked or +otherwise designated in writing by the copyright owner as "Not a +Contribution." + +"Contributor" means Licensor and any individual or Legal Entity on +behalf of whom a Contribution has been received by Licensor and +subsequently incorporated within the Model. + +Section II: INTELLECTUAL PROPERTY RIGHTS + +Both copyright and patent grants apply to the Model, Derivatives of +the Model and Complementary Material. The Model and Derivatives of the +Model are subject to additional terms as described in + +Section III. + +Grant of Copyright License. Subject to the terms and conditions of +this License, each Contributor hereby grants to You a perpetual, +worldwide, non-exclusive, no-charge, royalty-free, irrevocable +copyright license to reproduce, prepare, publicly display, publicly +perform, sublicense, and distribute the Complementary Material, the +Model, and Derivatives of the Model. + +Grant of Patent License. Subject to the terms and conditions of this +License and where and as applicable, each Contributor hereby grants to +You a perpetual, worldwide, non-exclusive, no-charge, royalty-free, +irrevocable (except as stated in this paragraph) patent license to +make, have made, use, offer to sell, sell, import, and otherwise +transfer the Model and the Complementary Material, where such license +applies only to those patent claims licensable by such Contributor +that are necessarily infringed by their Contribution(s) alone or by +combination of their Contribution(s) with the Model to which such +Contribution(s) was submitted. If You institute patent litigation +against any entity (including a cross-claim or counterclaim in a +lawsuit) alleging that the Model and/or Complementary Material or a +Contribution incorporated within the Model and/or Complementary +Material constitutes direct or contributory patent infringement, then +any patent licenses granted to You under this License for the Model +and/or Work shall terminate as of the date such litigation is asserted +or filed. + +Section III: CONDITIONS OF USAGE, DISTRIBUTION AND REDISTRIBUTION + +Distribution and Redistribution. You may host for Third Party remote +access purposes (e.g. software-as-a-service), reproduce and distribute +copies of the Model or Derivatives of the Model thereof in any medium, +with or without modifications, provided that You meet the following +conditions: Use-based restrictions as referenced in paragraph 5 MUST +be included as an enforceable provision by You in any type of legal +agreement (e.g. a license) governing the use and/or distribution of +the Model or Derivatives of the Model, and You shall give notice to +subsequent users You Distribute to, that the Model or Derivatives of +the Model are subject to paragraph 5. This provision does not apply to +the use of Complementary Material. You must give any Third Party +recipients of the Model or Derivatives of the Model a copy of this +License; You must cause any modified files to carry prominent notices +stating that You changed the files; You must retain all copyright, +patent, trademark, and attribution notices excluding those notices +that do not pertain to any part of the Model, Derivatives of the +Model. You may add Your own copyright statement to Your modifications +and may provide additional or different license terms and conditions - +respecting paragraph 4.a. - for use, reproduction, or Distribution of +Your modifications, or for any such Derivatives of the Model as a +whole, provided Your use, reproduction, and Distribution of the Model +otherwise complies with the conditions stated in this License. + +Use-based restrictions. The restrictions set forth in Attachment A are +considered Use-based restrictions. Therefore You cannot use the Model +and the Derivatives of the Model for the specified restricted +uses. You may use the Model subject to this License, including only +for lawful purposes and in accordance with the License. Use may +include creating any content with, finetuning, updating, running, +training, evaluating and/or reparametrizing the Model. You shall +require all of Your users who use the Model or a Derivative of the +Model to comply with the terms of this paragraph (paragraph 5). + +The Output You Generate. Except as set forth herein, Licensor claims +no rights in the Output You generate using the Model. You are +accountable for the Output you generate and its subsequent uses. No +use of the output can contravene any provision as stated in the +License. + +Section IV: OTHER PROVISIONS + +Updates and Runtime Restrictions. To the maximum extent permitted by +law, Licensor reserves the right to restrict (remotely or otherwise) +usage of the Model in violation of this License. + +Trademarks and related. Nothing in this License permits You to make +use of Licensors’ trademarks, trade names, logos or to otherwise +suggest endorsement or misrepresent the relationship between the +parties; and any rights not expressly granted herein are reserved by +the Licensors. + +Disclaimer of Warranty. Unless required by applicable law or agreed to +in writing, Licensor provides the Model and the Complementary Material +(and each Contributor provides its Contributions) on an "AS IS" BASIS, +WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or +implied, including, without limitation, any warranties or conditions +of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A +PARTICULAR PURPOSE. You are solely responsible for determining the +appropriateness of using or redistributing the Model, Derivatives of +the Model, and the Complementary Material and assume any risks +associated with Your exercise of permissions under this License. + +Limitation of Liability. In no event and under no legal theory, +whether in tort (including negligence), contract, or otherwise, unless +required by applicable law (such as deliberate and grossly negligent +acts) or agreed to in writing, shall any Contributor be liable to You +for damages, including any direct, indirect, special, incidental, or +consequential damages of any character arising as a result of this +License or out of the use or inability to use the Model and the +Complementary Material (including but not limited to damages for loss +of goodwill, work stoppage, computer failure or malfunction, or any +and all other commercial damages or losses), even if such Contributor +has been advised of the possibility of such damages. + +Accepting Warranty or Additional Liability. While redistributing the +Model, Derivatives of the Model and the Complementary Material +thereof, You may choose to offer, and charge a fee for, acceptance of +support, warranty, indemnity, or other liability obligations and/or +rights consistent with this License. However, in accepting such +obligations, You may act only on Your own behalf and on Your sole +responsibility, not on behalf of any other Contributor, and only if +You agree to indemnify, defend, and hold each Contributor harmless for +any liability incurred by, or claims asserted against, such +Contributor by reason of your accepting any such warranty or +additional liability. + +If any provision of this License is held to be invalid, illegal or +unenforceable, the remaining provisions shall be unaffected thereby +and remain valid as if such provision had not been set forth herein. + + +END OF TERMS AND CONDITIONS + +Attachment A + +Use Restrictions + +You agree not to use the Model or Derivatives of the Model: + +* In any way that violates any applicable national, federal, state, +local or international law or regulation; + +* For the purpose of exploiting, harming or attempting to exploit or +harm minors in any way; + +* To generate or disseminate verifiably false information and/or + content with the purpose of harming others; + +* To generate or disseminate personal identifiable information that + can be used to harm an individual; + +* To defame, disparage or otherwise harass others; + +* For fully automated decision making that adversely impacts an + individual’s legal rights or otherwise creates or modifies a + binding, enforceable obligation; + +* For any use intended to or which has the effect of discriminating + against or harming individuals or groups based on online or offline + social behavior or known or predicted personal or personality + characteristics; + +* To exploit any of the vulnerabilities of a specific group of persons + based on their age, social, physical or mental characteristics, in + order to materially distort the behavior of a person pertaining to + that group in a manner that causes or is likely to cause that person + or another person physical or psychological harm; + +* For any use intended to or which has the effect of discriminating + against individuals or groups based on legally protected + characteristics or categories; + +* To provide medical advice and medical results interpretation; + +* To generate or disseminate information for the purpose to be used + for administration of justice, law enforcement, immigration or + asylum processes, such as predicting an individual will commit + fraud/crime commitment (e.g. by text profiling, drawing causal + relationships between assertions made in documents, indiscriminate + and arbitrarily-targeted use). + diff --git a/Makefile b/Makefile new file mode 100644 index 0000000000000000000000000000000000000000..8621d16d48e21cb50605d5a4aaaa8573c64d453c --- /dev/null +++ b/Makefile @@ -0,0 +1,82 @@ +# simple Makefile with scripts that are otherwise hard to remember +# to use, run from the repo root `make ` + +default: help + +help: + @echo Developer commands: + @echo + @echo "ruff Run ruff, fixing any safely-fixable errors and formatting" + @echo "ruff-unsafe Run ruff, fixing all fixable errors and formatting" + @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-dev Run the frontend in developer mode on localhost:5173" + @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 "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 Serve the mkdocs site with live reload" + +# Runs ruff, fixing any safely-fixable errors and formatting +ruff: + ruff check . --fix + ruff format . + +# Runs ruff, fixing all errors it can fix and formatting +ruff-unsafe: + ruff check . --fix --unsafe-fixes + ruff format . + +# Runs mypy, using the config in pyproject.toml +mypy: + mypy scripts/invokeai-web.py + +# Runs mypy, ignoring the config in pyproject.toml but still ignoring missing (untyped) imports +# (many files are ignored by the config, so this is useful for checking all files) +mypy-all: + mypy scripts/invokeai-web.py --config-file= --ignore-missing-imports + +# Run the unit tests +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 + cd invokeai/frontend/web && pnpm install + +# Build the frontend +frontend-build: + cd invokeai/frontend/web && pnpm build + +# Run the frontend in dev mode +frontend-dev: + cd invokeai/frontend/web && pnpm dev + +frontend-typegen: + cd invokeai/frontend/web && python ../../../scripts/generate_openapi_schema.py | pnpm typegen + +# Installer zip file +installer-zip: + cd installer && ./create_installer.sh + +# Tag the release +tag-release: + cd installer && ./tag_release.sh + +# Generate the OpenAPI Schema for the app +openapi: + python scripts/generate_openapi_schema.py + +# Serve the mkdocs site w/ live reload +.PHONY: docs +docs: + mkdocs serve \ No newline at end of file diff --git a/README.md b/README.md new file mode 100644 index 0000000000000000000000000000000000000000..4a8d6eb4d0df7163789aa334e26b57f1891cd0e5 --- /dev/null +++ b/README.md @@ -0,0 +1,157 @@ +
+ +![project hero](https://github.com/invoke-ai/InvokeAI/assets/31807370/6e3728c7-e90e-4711-905c-3b55844ff5be) + +# 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. + +Invoke is available in two editions: + +| **Community Edition** | **Professional Edition** | +|----------------------------------------------------------------------------------------------------------------------------|-----------------------------------------------------------------------------------------------------| +| **For users looking for a locally installed, self-hosted and self-managed service** | **For users or teams looking for a cloud-hosted, fully managed service** | +| - Free to use under a commercially-friendly license | - Monthly subscription fee with three different plan levels | +| - Download and install on compatible hardware | - Offers additional benefits, including multi-user support, improved model training, and more | +| - Includes all core studio features: generate, refine, iterate on images, and build workflows | - Hosted in the cloud for easy, secure model access and scalability | +| Quick Start -> [Installation and Updates][installation docs] | More Information -> [www.invoke.com/pricing](https://www.invoke.com/pricing) | + + +![Highlighted Features - Canvas and Workflows](https://github.com/invoke-ai/InvokeAI/assets/31807370/708f7a82-084f-4860-bfbe-e2588c53548d) + +# Documentation +| **Quick Links** | +|----------------------------------------------------------------------------------------------------------------------------| +| [Installation and Updates][installation docs] - [Documentation and Tutorials][docs home] - [Bug Reports][github issues] - [Contributing][contributing docs] | + + + +## Quick Start + +1. Download and unzip the installer from the bottom of the [latest release][latest release link]. +2. Run the installer script. + + - **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`. + +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. + +More detail, including hardware requirements and manual install instructions, are available in the [installation documentation][installation docs]. + +## Docker Container + +We publish official container images in Github Container Registry: https://github.com/invoke-ai/InvokeAI/pkgs/container/invokeai. Both CUDA and ROCm images are available. Check the above link for relevant tags. + +> [!IMPORTANT] +> Ensure that Docker is set up to use the GPU. Refer to [NVIDIA][nvidia docker docs] or [AMD][amd docker docs] documentation. + +### Generate! + +Run the container, modifying the command as necessary: + +```bash +docker run --runtime=nvidia --gpus=all --publish 9090:9090 ghcr.io/invoke-ai/invokeai +``` + +Then open `http://localhost:9090` and install some models using the Model Manager tab to begin generating. + +For ROCm, add `--device /dev/kfd --device /dev/dri` to the `docker run` command. + +### Persist your data + +You will likely want to persist your workspace outside of the container. Use the `--volume /home/myuser/invokeai:/invokeai` flag to mount some local directory (using its **absolute** path) to the `/invokeai` path inside the container. Your generated images and models will reside there. You can use this directory with other InvokeAI installations, or switch between runtime directories as needed. + +### DIY + +Build your own image and customize the environment to match your needs using our `docker-compose` stack. See [README.md](./docker/README.md) in the [docker](./docker) directory. + +## Troubleshooting, FAQ and Support + +Please review our [FAQ][faq] for solutions to common installation problems and other issues. + +For more help, please join our [Discord][discord link]. + +## Features + +Full details on features can be found in [our documentation][features docs]. + +### Web Server & UI + +Invoke runs a locally hosted web server & React UI with an industry-leading user experience. + +### Unified Canvas + +The Unified Canvas is a fully integrated canvas implementation with support for all core generation capabilities, in/out-painting, brush tools, and more. This creative tool unlocks the capability for artists to create with AI as a creative collaborator, and can be used to augment AI-generated imagery, sketches, photography, renders, and more. + +### 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. + +### 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. + +### Other features + +- Support for both ckpt and diffusers models +- SD1.5, SD2.0, SDXL, and FLUX support +- Upscaling Tools +- Embedding Manager & Support +- Model Manager & Support +- Workflow creation & management +- Node-Based Architecture + +## 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. + +Get started with contributing by reading our [contribution documentation][contributing docs], joining the [#dev-chat] or the GitHub discussion board. + +We hope you enjoy using Invoke as much as we enjoy creating it, and we hope you will elect to become part of our community. + +## Thanks + +Invoke is a combined effort of [passionate and talented people from across the world][contributors]. We thank them for their time, hard work and effort. + +Original portions of the software are Copyright © 2024 by respective contributors. + +[features docs]: https://invoke-ai.github.io/InvokeAI/features/database/ +[faq]: https://invoke-ai.github.io/InvokeAI/faq/ +[contributors]: https://invoke-ai.github.io/InvokeAI/contributing/contributors/ +[invoke.com]: https://www.invoke.com/about +[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/ +[#dev-chat]: https://discord.com/channels/1020123559063990373/1049495067846524939 +[contributing docs]: https://invoke-ai.github.io/InvokeAI/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 +[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=invoke-ai%2FInvokeAI +[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 commit to main badge]: https://flat.badgen.net/github/last-commit/invoke-ai/InvokeAI/main?icon=github&color=yellow&label=last%20dev%20commit&cache=900 +[latest commit to main link]: https://github.com/invoke-ai/InvokeAI/commits/main +[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/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/Stable_Diffusion_v1_Model_Card.md b/Stable_Diffusion_v1_Model_Card.md new file mode 100644 index 0000000000000000000000000000000000000000..4ebebc8b83b14f7ab726342b09dd53ef99d1e337 --- /dev/null +++ b/Stable_Diffusion_v1_Model_Card.md @@ -0,0 +1,140 @@ +# Stable Diffusion v1 Model Card +This model card focuses on the model associated with the Stable Diffusion model, available [here](https://github.com/CompVis/stable-diffusion). + +## Model Details +- **Developed by:** Robin Rombach, Patrick Esser +- **Model type:** Diffusion-based text-to-image generation model +- **Language(s):** English +- **License:** [Proprietary](LICENSE) +- **Model Description:** This is a model that can be used to generate and modify images based on text prompts. It is a [Latent Diffusion Model](https://arxiv.org/abs/2112.10752) that uses a fixed, pretrained text encoder ([CLIP ViT-L/14](https://arxiv.org/abs/2103.00020)) as suggested in the [Imagen paper](https://arxiv.org/abs/2205.11487). +- **Resources for more information:** [GitHub Repository](https://github.com/CompVis/stable-diffusion), [Paper](https://arxiv.org/abs/2112.10752). +- **Cite as:** + + @InProceedings{Rombach_2022_CVPR, + author = {Rombach, Robin and Blattmann, Andreas and Lorenz, Dominik and Esser, Patrick and Ommer, Bj\"orn}, + title = {High-Resolution Image Synthesis With Latent Diffusion Models}, + booktitle = {Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR)}, + month = {June}, + year = {2022}, + pages = {10684-10695} + } + +# Uses + +## Direct Use +The model is intended for research purposes only. Possible research areas and +tasks include + +- Safe deployment of models which have the potential to generate harmful content. +- Probing and understanding the limitations and biases of generative models. +- Generation of artworks and use in design and other artistic processes. +- Applications in educational or creative tools. +- Research on generative models. + +Excluded uses are described below. + + ### Misuse, Malicious Use, and Out-of-Scope Use +_Note: This section is taken from the [DALLE-MINI model card](https://huggingface.co/dalle-mini/dalle-mini), but applies in the same way to Stable Diffusion v1_. + + +The model should not be used to intentionally create or disseminate images that create hostile or alienating environments for people. This includes generating images that people would foreseeably find disturbing, distressing, or offensive; or content that propagates historical or current stereotypes. +#### Out-of-Scope Use +The model was not trained to be factual or true representations of people or events, and therefore using the model to generate such content is out-of-scope for the abilities of this model. +#### Misuse and Malicious Use +Using the model to generate content that is cruel to individuals is a misuse of this model. This includes, but is not limited to: + +- Generating demeaning, dehumanizing, or otherwise harmful representations of people or their environments, cultures, religions, etc. +- Intentionally promoting or propagating discriminatory content or harmful stereotypes. +- Impersonating individuals without their consent. +- Sexual content without consent of the people who might see it. +- Mis- and disinformation +- Representations of egregious violence and gore +- Sharing of copyrighted or licensed material in violation of its terms of use. +- Sharing content that is an alteration of copyrighted or licensed material in violation of its terms of use. + +## Limitations and Bias + +### Limitations + +- The model does not achieve perfect photorealism +- The model cannot render legible text +- The model does not perform well on more difficult tasks which involve compositionality, such as rendering an image corresponding to “A red cube on top of a blue sphere” +- Faces and people in general may not be generated properly. +- The model was trained mainly with English captions and will not work as well in other languages. +- The autoencoding part of the model is lossy +- The model was trained on a large-scale dataset + [LAION-5B](https://laion.ai/blog/laion-5b/) which contains adult material + and is not fit for product use without additional safety mechanisms and + considerations. + +### Bias +While the capabilities of image generation models are impressive, they can also reinforce or exacerbate social biases. +Stable Diffusion v1 was trained on subsets of [LAION-2B(en)](https://laion.ai/blog/laion-5b/), +which consists of images that are primarily limited to English descriptions. +Texts and images from communities and cultures that use other languages are likely to be insufficiently accounted for. +This affects the overall output of the model, as white and western cultures are often set as the default. Further, the +ability of the model to generate content with non-English prompts is significantly worse than with English-language prompts. + + +## Training + +**Training Data** +The model developers used the following dataset for training the model: + +- LAION-2B (en) and subsets thereof (see next section) + +**Training Procedure** +Stable Diffusion v1 is a latent diffusion model which combines an autoencoder with a diffusion model that is trained in the latent space of the autoencoder. During training, + +- Images are encoded through an encoder, which turns images into latent representations. The autoencoder uses a relative downsampling factor of 8 and maps images of shape H x W x 3 to latents of shape H/f x W/f x 4 +- Text prompts are encoded through a ViT-L/14 text-encoder. +- The non-pooled output of the text encoder is fed into the UNet backbone of the latent diffusion model via cross-attention. +- The loss is a reconstruction objective between the noise that was added to the latent and the prediction made by the UNet. + +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). + + +- **Hardware:** 32 x 8 x A100 GPUs +- **Optimizer:** AdamW +- **Gradient Accumulations**: 2 +- **Batch:** 32 x 8 x 2 x 4 = 2048 +- **Learning rate:** warmup to 0.0001 for 10,000 steps and then kept constant + +## Evaluation Results +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: + +![pareto](assets/v1-variants-scores.jpg) + +Evaluated using 50 PLMS steps and 10000 random prompts from the COCO2017 validation set, evaluated at 512x512 resolution. Not optimized for FID scores. +## Environmental Impact + +**Stable Diffusion v1** **Estimated Emissions** +Based on that information, we estimate the following CO2 emissions using the [Machine Learning Impact calculator](https://mlco2.github.io/impact#compute) presented in [Lacoste et al. (2019)](https://arxiv.org/abs/1910.09700). The hardware, runtime, cloud provider, and compute region were utilized to estimate the carbon impact. + +- **Hardware Type:** A100 PCIe 40GB +- **Hours used:** 150000 +- **Cloud Provider:** AWS +- **Compute Region:** US-east +- **Carbon Emitted (Power consumption x Time x Carbon produced based on location of power grid):** 11250 kg CO2 eq. +## Citation + @InProceedings{Rombach_2022_CVPR, + author = {Rombach, Robin and Blattmann, Andreas and Lorenz, Dominik and Esser, Patrick and Ommer, Bj\"orn}, + title = {High-Resolution Image Synthesis With Latent Diffusion Models}, + booktitle = {Proceedings of the IEEE/CVF Conference on Computer Vision and Pattern Recognition (CVPR)}, + month = {June}, + year = {2022}, + pages = {10684-10695} + } + +*This model card was written by: Robin Rombach and Patrick Esser and is based on the [DALL-E Mini model card](https://huggingface.co/dalle-mini/dalle-mini).* + diff --git a/coverage/.gitignore b/coverage/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..86d0cb2726c6c7c179b99520c452dd1b68e7a813 --- /dev/null +++ b/coverage/.gitignore @@ -0,0 +1,4 @@ +# Ignore everything in this directory +* +# Except this file +!.gitignore \ No newline at end of file diff --git a/docker/.env.sample b/docker/.env.sample new file mode 100644 index 0000000000000000000000000000000000000000..eef690a8086fd2cf6bc2615fd0025f193fdb3ec0 --- /dev/null +++ b/docker/.env.sample @@ -0,0 +1,27 @@ +## Make a copy of this file named `.env` and fill in the values below. +## Any environment variables supported by InvokeAI can be specified here, +## in addition to the examples below. + +## INVOKEAI_ROOT is the path *on the host system* where Invoke will store its data. +## It is mounted into the container and allows both containerized and non-containerized usage of Invoke. +# Usually this is the only variable you need to set. It can be relative or absolute. +# INVOKEAI_ROOT=~/invokeai + +## HOST_INVOKEAI_ROOT and CONTAINER_INVOKEAI_ROOT can be used to control the on-host +## and in-container paths separately, if needed. +## HOST_INVOKEAI_ROOT is the path on the docker host's filesystem where Invoke will store data. +## If relative, it will be relative to the docker directory in which the docker-compose.yml file is located +## CONTAINER_INVOKEAI_ROOT is the path within the container where Invoke will expect to find the runtime directory. +## It MUST be absolute. There is usually no need to change this. +# HOST_INVOKEAI_ROOT=../../invokeai-data +# CONTAINER_INVOKEAI_ROOT=/invokeai + +## INVOKEAI_PORT is the port on which the InvokeAI web interface will be available +# INVOKEAI_PORT=9090 + +## GPU_DRIVER can be set to either `cuda` or `rocm` to enable GPU support in the container accordingly. +# GPU_DRIVER=cuda #| rocm + +## 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 new file mode 100644 index 0000000000000000000000000000000000000000..2e9c22e5b2edf0150dc80d514c710c8699a35dc5 --- /dev/null +++ b/docker/Dockerfile @@ -0,0 +1,124 @@ +# syntax=docker/dockerfile:1.4 + +## Builder stage + +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/rocm6.1"; \ + else \ + extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cu124"; \ + 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 +ENV PNPM_HOME="/pnpm" +ENV PATH="$PNPM_HOME:$PATH" +RUN corepack use pnpm@8.x +RUN corepack enable + +WORKDIR /build +COPY invokeai/frontend/web/ ./ +RUN --mount=type=cache,target=/pnpm/store \ + pnpm install --frozen-lockfile +RUN npx vite build + +#### Runtime stage --------------------------------------- + +FROM library/ubuntu:23.04 AS runtime + +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 + +# 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} + +# 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 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"] diff --git a/docker/README.md b/docker/README.md new file mode 100644 index 0000000000000000000000000000000000000000..1a8f807392927f2d969f1545922cdbad3997b072 --- /dev/null +++ b/docker/README.md @@ -0,0 +1,117 @@ +# Invoke in Docker + +First things first: + +- 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. + +## Quickstart + +No `docker compose`, no persistence, single command, using the official images: + +**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 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 probably won't work. Update to a recent version. +3. Ensure docker daemon is able to access the GPU. + - [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. + +### 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 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`. Navigate to the Model Manager tab and install some models before generating. + +### Use a GPU + +- Linux is *recommended* for GPU support in Docker. +- 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/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 before running `./run.sh`. + +## Customize + +Check the `.env.sample` file. It contains some environment variables for running in Docker. Copy it, name it `.env`, and fill it in with your own values. Next time you run `run.sh`, your custom values will be used. + +You can also set these values in `docker-compose.yml` directly, but `.env` will help avoid conflicts when code is updated. + +Values are optional, but setting `INVOKEAI_ROOT` is highly recommended. The default is `~/invokeai`. Example: + +```bash +INVOKEAI_ROOT=/Volumes/WorkDrive/invokeai +HUGGINGFACE_TOKEN=the_actual_token +CONTAINER_UID=1000 +GPU_DRIVER=cuda +``` + +Any environment variables supported by InvokeAI can be set here. See the [Configuration docs](https://invoke-ai.github.io/InvokeAI/features/CONFIGURATION/) for further detail. + +--- + +[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 new file mode 100644 index 0000000000000000000000000000000000000000..0c2b6fbd789f9597c7e21603115a6c38f3e72182 --- /dev/null +++ b/docker/docker-compose.yml @@ -0,0 +1,54 @@ +# Copyright (c) 2023 Eugene Brodsky https://github.com/ebr + +x-invokeai: &invokeai + image: "ghcr.io/invoke-ai/invokeai:latest" + build: + context: .. + dockerfile: docker/Dockerfile + + # Create a .env file in the same directory as this docker-compose.yml file + # and populate it with environment variables. See .env.sample + env_file: + - .env + + # variables without a default will automatically inherit from the host environment + environment: + # if set, CONTAINER_INVOKEAI_ROOT will override the Invoke runtime directory location *inside* the container + - INVOKEAI_ROOT=${CONTAINER_INVOKEAI_ROOT:-/invokeai} + - HF_HOME + ports: + - "${INVOKEAI_PORT:-9090}:${INVOKEAI_PORT:-9090}" + volumes: + - type: bind + source: ${HOST_INVOKEAI_ROOT:-${INVOKEAI_ROOT:-~/invokeai}} + target: ${CONTAINER_INVOKEAI_ROOT:-/invokeai} + bind: + create_host_path: true + - ${HF_HOME:-~/.cache/huggingface}:${HF_HOME:-/invokeai/.cache/huggingface} + tty: true + stdin_open: true + + +services: + invokeai-cuda: + <<: *invokeai + deploy: + resources: + reservations: + devices: + - driver: nvidia + count: 1 + capabilities: [gpu] + + invokeai-cpu: + <<: *invokeai + profiles: + - cpu + + invokeai-rocm: + <<: *invokeai + devices: + - /dev/kfd:/dev/kfd + - /dev/dri:/dev/dri + profiles: + - rocm diff --git a/docker/docker-entrypoint.sh b/docker/docker-entrypoint.sh new file mode 100644 index 0000000000000000000000000000000000000000..686bd9630ffd6c0e34d2f99b7099dbd6567b09a4 --- /dev/null +++ b/docker/docker-entrypoint.sh @@ -0,0 +1,41 @@ +#!/bin/bash +set -e -o pipefail + +### Container entrypoint +# Runs the CMD as defined by the Dockerfile or passed to `docker run` +# Can be used to configure the runtime dir +# Bypass by using ENTRYPOINT or `--entrypoint` + +### Set INVOKEAI_ROOT pointing to a valid runtime directory +# Otherwise configure the runtime dir first. + +### Set the CONTAINER_UID envvar to match your user. +# Ensures files created in the container are owned by you: +# docker run --rm -it -v /some/path:/invokeai -e CONTAINER_UID=$(id -u) +# Default UID: 1000 chosen due to popularity on Linux systems. Possibly 501 on MacOS. + +USER_ID=${CONTAINER_UID:-1000} +USER=ubuntu +usermod -u ${USER_ID} ${USER} 1>/dev/null + +### 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 +fi + +mkdir -p "${INVOKEAI_ROOT}" +chown --recursive ${USER} "${INVOKEAI_ROOT}" || true +cd "${INVOKEAI_ROOT}" + +# Run the CMD as the Container User (not root). +exec gosu ${USER} "$@" diff --git a/docker/run.sh b/docker/run.sh new file mode 100644 index 0000000000000000000000000000000000000000..1040e865bf1082fba83c9d53fd6a5c418e29ceb0 --- /dev/null +++ b/docker/run.sh @@ -0,0 +1,36 @@ +#!/usr/bin/env bash +set -e -o pipefail + +run() { + local scriptdir=$(dirname "${BASH_SOURCE[0]}") + cd "$scriptdir" || exit 1 + + 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)" + + # default to 'cuda' profile + [[ -z "$profile" ]] && profile="cuda" + + local service_name="invokeai-$profile" + + if [[ ! -z "$build_args" ]]; then + printf "%s\n" "docker compose build args:" + printf "%s\n" "$build_args" + fi + + docker compose build $build_args $service_name + unset build_args + + printf "%s\n" "starting service $service_name" + docker compose --profile "$profile" up -d "$service_name" + docker compose logs -f +} + +run diff --git a/docker/runpod-readme.md b/docker/runpod-readme.md new file mode 100644 index 0000000000000000000000000000000000000000..c464480d46db0d941b96a0733f228a74172abc6f --- /dev/null +++ b/docker/runpod-readme.md @@ -0,0 +1,60 @@ +# InvokeAI - A Stable Diffusion Toolkit + +Stable Diffusion distribution by InvokeAI: https://github.com/invoke-ai + +The Docker image tracks the `main` branch of the InvokeAI project, which means it includes the latest features, but may contain some bugs. + +Your working directory is mounted under the `/workspace` path inside the pod. The models are in `/workspace/invokeai/models`, and outputs are in `/workspace/invokeai/outputs`. + +> **Only the /workspace directory will persist between pod restarts!** + +> **If you _terminate_ (not just _stop_) the pod, the /workspace will be lost.** + +## Quickstart + +1. Launch a pod from this template. **It will take about 5-10 minutes to run through the initial setup**. Be patient. +1. Wait for the application to load. + - TIP: you know it's ready when the CPU usage goes idle + - You can also check the logs for a line that says "_Point your browser at..._" +1. Open the Invoke AI web UI: click the `Connect` => `connect over HTTP` button. +1. Generate some art! + +## Other things you can do + +At any point you may edit the pod configuration and set an arbitrary Docker command. For example, you could run a command to downloads some models using `curl`, or fetch some images and place them into your outputs to continue a working session. + +If you need to run *multiple commands*, define them in the Docker Command field like this: + +`bash -c "cd ${INVOKEAI_ROOT}/outputs; wormhole receive 2-foo-bar; invoke.py --web --host 0.0.0.0"` + +### Copying your data in and out of the pod + +This image includes a couple of handy tools to help you get the data into the pod (such as your custom models or embeddings), and out of the pod (such as downloading your outputs). Here are your options for getting your data in and out of the pod: + +- **SSH server**: + 1. Make sure to create and set your Public Key in the RunPod settings (follow the official instructions) + 1. Add an exposed port 22 (TCP) in the pod settings! + 1. When your pod restarts, you will see a new entry in the `Connect` dialog. Use this SSH server to `scp` or `sftp` your files as necessary, or SSH into the pod using the fully fledged SSH server. + +- [**Magic Wormhole**](https://magic-wormhole.readthedocs.io/en/latest/welcome.html): + 1. On your computer, `pip install magic-wormhole` (see above instructions for details) + 1. Connect to the command line **using the "light" SSH client** or the browser-based console. _Currently there's a bug where `wormhole` isn't available when connected to "full" SSH server, as described above_. + 1. `wormhole send /workspace/invokeai/outputs` will send the entire `outputs` directory. You can also send individual files. + 1. Once packaged, you will see a `wormhole receive <123-some-words>` command. Copy it + 1. Paste this command into the terminal on your local machine to securely download the payload. + 1. It works the same in reverse: you can `wormhole send` some models from your computer to the pod. Again, save your files somewhere in `/workspace` or they will be lost when the pod is stopped. + +- **RunPod's Cloud Sync feature** may be used to sync the persistent volume to cloud storage. You could, for example, copy the entire `/workspace` to S3, add some custom models to it, and copy it back from S3 when launching new pod configurations. Follow the Cloud Sync instructions. + + +### Disable the NSFW checker + +The NSFW checker is enabled by default. To disable it, edit the pod configuration and set the following command: + +``` +invoke --web --host 0.0.0.0 --no-nsfw_checker +``` + +--- + +Template ©2023 Eugene Brodsky [ebr](https://github.com/ebr) \ No newline at end of file diff --git a/docs/CODE_OF_CONDUCT.md b/docs/CODE_OF_CONDUCT.md new file mode 100644 index 0000000000000000000000000000000000000000..d68cdf98c838f75c66d695c152630c7ee52cb03c --- /dev/null +++ b/docs/CODE_OF_CONDUCT.md @@ -0,0 +1,128 @@ +# 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/RELEASE.md b/docs/RELEASE.md new file mode 100644 index 0000000000000000000000000000000000000000..56d27eb76ce0cd7cd3704d9e54ba892ab972b3c0 --- /dev/null +++ b/docs/RELEASE.md @@ -0,0 +1,173 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..363f3cced3f09b6e3b3d1ec36ce804c554ac2488 Binary files /dev/null and b/docs/assets/Lincoln-and-Parrot-512-transparent.png differ diff --git a/docs/assets/Lincoln-and-Parrot-512.png b/docs/assets/Lincoln-and-Parrot-512.png new file mode 100644 index 0000000000000000000000000000000000000000..acabe0f27c161530e4ec69d8d7a26c1d9cf46863 Binary files /dev/null and b/docs/assets/Lincoln-and-Parrot-512.png differ diff --git a/docs/assets/canvas/biker_granny.png b/docs/assets/canvas/biker_granny.png new file mode 100644 index 0000000000000000000000000000000000000000..70385014da85091bac91fb8b4c06ab378a5e642d Binary files /dev/null and b/docs/assets/canvas/biker_granny.png differ diff --git a/docs/assets/canvas/biker_jacket_granny.png b/docs/assets/canvas/biker_jacket_granny.png new file mode 100644 index 0000000000000000000000000000000000000000..3a46b8a49ce40c2496fdc86324e7c4ffa9dfa20d Binary files /dev/null and b/docs/assets/canvas/biker_jacket_granny.png differ diff --git a/docs/assets/canvas/mask_granny.png b/docs/assets/canvas/mask_granny.png new file mode 100644 index 0000000000000000000000000000000000000000..041a0317c9263111cd475e66d43a2b4af449f619 Binary files /dev/null and b/docs/assets/canvas/mask_granny.png differ diff --git a/docs/assets/canvas/staging_area.png b/docs/assets/canvas/staging_area.png new file mode 100644 index 0000000000000000000000000000000000000000..0e9d4ba0de9ab9100df33d2f905e121302df24c4 Binary files /dev/null and b/docs/assets/canvas/staging_area.png differ diff --git a/docs/assets/canvas_preview.png b/docs/assets/canvas_preview.png new file mode 100644 index 0000000000000000000000000000000000000000..dba4ee2ca29173aed21ccc233189fab15c1abfce Binary files /dev/null and b/docs/assets/canvas_preview.png differ diff --git a/docs/assets/colab_notebook.png b/docs/assets/colab_notebook.png new file mode 100644 index 0000000000000000000000000000000000000000..933664a86f7537ff04ad2c6b5bf5f84c41f929ef Binary files /dev/null and b/docs/assets/colab_notebook.png differ diff --git a/docs/assets/concepts/image1.png b/docs/assets/concepts/image1.png new file mode 100644 index 0000000000000000000000000000000000000000..f8c93efcf93b4c63215296c7dd6a4f48fe2f7eb9 Binary files /dev/null and b/docs/assets/concepts/image1.png differ diff --git a/docs/assets/concepts/image2.png b/docs/assets/concepts/image2.png new file mode 100644 index 0000000000000000000000000000000000000000..a22411492e7c5318bbcf264def8fa3ec010dfc71 Binary files /dev/null and b/docs/assets/concepts/image2.png differ diff --git a/docs/assets/concepts/image3.png b/docs/assets/concepts/image3.png new file mode 100644 index 0000000000000000000000000000000000000000..e2213fc707901fc6c95b601bcf1644b2541da95b Binary files /dev/null and b/docs/assets/concepts/image3.png differ diff --git a/docs/assets/concepts/image4.png b/docs/assets/concepts/image4.png new file mode 100644 index 0000000000000000000000000000000000000000..052479019c19232696203b99b5cbcf786c0a6cfd Binary files /dev/null and b/docs/assets/concepts/image4.png differ diff --git a/docs/assets/concepts/image5.png b/docs/assets/concepts/image5.png new file mode 100644 index 0000000000000000000000000000000000000000..f3a4f764705e72bf89de9a7a9a183d62d34c0858 Binary files /dev/null and b/docs/assets/concepts/image5.png differ diff --git a/docs/assets/contributing/html-detail.png b/docs/assets/contributing/html-detail.png new file mode 100644 index 0000000000000000000000000000000000000000..055218002f7556ce700f8c5534e11960ba3235e7 Binary files /dev/null and b/docs/assets/contributing/html-detail.png differ diff --git a/docs/assets/contributing/html-overview.png b/docs/assets/contributing/html-overview.png new file mode 100644 index 0000000000000000000000000000000000000000..1f288fde118b5781a87063b91b53bd7a014a5091 Binary files /dev/null and b/docs/assets/contributing/html-overview.png differ diff --git a/docs/assets/contributing/resize_invocation.png b/docs/assets/contributing/resize_invocation.png new file mode 100644 index 0000000000000000000000000000000000000000..a78f8eb86a3d13e705ced458f5c9c18d80b6c733 Binary files /dev/null and b/docs/assets/contributing/resize_invocation.png differ diff --git a/docs/assets/contributing/resize_node_editor.png b/docs/assets/contributing/resize_node_editor.png new file mode 100644 index 0000000000000000000000000000000000000000..d121ba1aa6d426ce75b9f5f02f280df1be867bae Binary files /dev/null and b/docs/assets/contributing/resize_node_editor.png differ diff --git a/docs/assets/control-panel-2.png b/docs/assets/control-panel-2.png new file mode 100644 index 0000000000000000000000000000000000000000..d7767d524a4f529667b62b28db16bdcc5bddf8fc Binary files /dev/null and b/docs/assets/control-panel-2.png differ diff --git a/docs/assets/dream-py-demo.png b/docs/assets/dream-py-demo.png new file mode 100644 index 0000000000000000000000000000000000000000..c6945bf07c9fb8dd2e6ab5d5a35ec42f6f4c7acc Binary files /dev/null and b/docs/assets/dream-py-demo.png differ diff --git a/docs/assets/dream_web_server.png b/docs/assets/dream_web_server.png new file mode 100644 index 0000000000000000000000000000000000000000..c8ec9756c59720052a4382ef4f2cdcc445129787 Binary files /dev/null and b/docs/assets/dream_web_server.png differ diff --git a/docs/assets/features/restoration-montage.png b/docs/assets/features/restoration-montage.png new file mode 100644 index 0000000000000000000000000000000000000000..1d166be1602ecf500acfc797ef7109dab11adfd9 --- /dev/null +++ b/docs/assets/features/restoration-montage.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d9c9eb1c9d4f53ca54ee2dbb970f343f07c001c5352330b2064b56a25c13c460 +size 4145059 diff --git a/docs/assets/features/upscale-dialog.png b/docs/assets/features/upscale-dialog.png new file mode 100644 index 0000000000000000000000000000000000000000..fd91f90a65f15f0385e658ac92fe0be7296c5bb9 Binary files /dev/null and b/docs/assets/features/upscale-dialog.png differ diff --git a/docs/assets/features/upscaling-montage.png b/docs/assets/features/upscaling-montage.png new file mode 100644 index 0000000000000000000000000000000000000000..efacc5a2f48a19dc242600ac0d46a860eb26f966 --- /dev/null +++ b/docs/assets/features/upscaling-montage.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:10d0e03fc38c17ec74f9e83a93f377642d303b9a1da55a66dfd54f5a51d0021e +size 8671605 diff --git a/docs/assets/gallery/board_settings.png b/docs/assets/gallery/board_settings.png new file mode 100644 index 0000000000000000000000000000000000000000..44c4ef240bd6e0609ced2d9766fe4d9f1102ed33 Binary files /dev/null and b/docs/assets/gallery/board_settings.png differ diff --git a/docs/assets/gallery/board_tabs.png b/docs/assets/gallery/board_tabs.png new file mode 100644 index 0000000000000000000000000000000000000000..23e5f8a91cf4b7eadaef3eb5912bcd8f6857b9a3 Binary files /dev/null and b/docs/assets/gallery/board_tabs.png differ diff --git a/docs/assets/gallery/board_thumbnails.png b/docs/assets/gallery/board_thumbnails.png new file mode 100644 index 0000000000000000000000000000000000000000..1c739d48546cc5cf6bdde7e66af1184dfa8d4f09 Binary files /dev/null and b/docs/assets/gallery/board_thumbnails.png differ diff --git a/docs/assets/gallery/gallery.png b/docs/assets/gallery/gallery.png new file mode 100644 index 0000000000000000000000000000000000000000..89f2dd1b463eaada3c8ee88474f56427f95b543a Binary files /dev/null and b/docs/assets/gallery/gallery.png differ diff --git a/docs/assets/gallery/image_menu.png b/docs/assets/gallery/image_menu.png new file mode 100644 index 0000000000000000000000000000000000000000..2f10f280acf3d4335c88a34a21c9bc1e15992571 Binary files /dev/null and b/docs/assets/gallery/image_menu.png differ diff --git a/docs/assets/gallery/info_button.png b/docs/assets/gallery/info_button.png new file mode 100644 index 0000000000000000000000000000000000000000..539cd6252e0cc3f095bdcad345ef65ec95e86f46 Binary files /dev/null and b/docs/assets/gallery/info_button.png differ diff --git a/docs/assets/gallery/thumbnail_menu.png b/docs/assets/gallery/thumbnail_menu.png new file mode 100644 index 0000000000000000000000000000000000000000..a56caadbd8e544fc4d5a34a1e6ec814526cc2fe4 Binary files /dev/null and b/docs/assets/gallery/thumbnail_menu.png differ diff --git a/docs/assets/gallery/top_controls.png b/docs/assets/gallery/top_controls.png new file mode 100644 index 0000000000000000000000000000000000000000..c5d3cdc854bf4b7b1fe1990ebaceda2f7c80db07 Binary files /dev/null and b/docs/assets/gallery/top_controls.png differ diff --git a/docs/assets/img2img/000019.1592514025.png b/docs/assets/img2img/000019.1592514025.png new file mode 100644 index 0000000000000000000000000000000000000000..2bc2d63ffa09c625f0d30960483df77a5e53b229 Binary files /dev/null and b/docs/assets/img2img/000019.1592514025.png differ diff --git a/docs/assets/img2img/000019.steps.png b/docs/assets/img2img/000019.steps.png new file mode 100644 index 0000000000000000000000000000000000000000..28899e91111b9381446962a83b1a744a4638f075 Binary files /dev/null and b/docs/assets/img2img/000019.steps.png differ diff --git a/docs/assets/img2img/000030.1592514025.png b/docs/assets/img2img/000030.1592514025.png new file mode 100644 index 0000000000000000000000000000000000000000..0e1641f7eb00ae3842c85232ad2e026942829f6d Binary files /dev/null and b/docs/assets/img2img/000030.1592514025.png differ diff --git a/docs/assets/img2img/000030.step-0.png b/docs/assets/img2img/000030.step-0.png new file mode 100644 index 0000000000000000000000000000000000000000..81beb074ec039a12f92468f6b9402f9b5b5b2dd3 Binary files /dev/null and b/docs/assets/img2img/000030.step-0.png differ diff --git a/docs/assets/img2img/000030.steps.gravity.png b/docs/assets/img2img/000030.steps.gravity.png new file mode 100644 index 0000000000000000000000000000000000000000..2bda935a5f3fbbe324c147feb427dc2d08ff4712 Binary files /dev/null and b/docs/assets/img2img/000030.steps.gravity.png differ diff --git a/docs/assets/img2img/000032.1592514025.png b/docs/assets/img2img/000032.1592514025.png new file mode 100644 index 0000000000000000000000000000000000000000..0ed2106ec48c440c378369dde024896d40e307fd Binary files /dev/null and b/docs/assets/img2img/000032.1592514025.png differ diff --git a/docs/assets/img2img/000032.step-0.png b/docs/assets/img2img/000032.step-0.png new file mode 100644 index 0000000000000000000000000000000000000000..cc2da68ee43f207203046efad6cb26848eebe433 Binary files /dev/null and b/docs/assets/img2img/000032.step-0.png differ diff --git a/docs/assets/img2img/000032.steps.gravity.png b/docs/assets/img2img/000032.steps.gravity.png new file mode 100644 index 0000000000000000000000000000000000000000..79058c1227a3abd1a45326e76716c14feea9756a Binary files /dev/null and b/docs/assets/img2img/000032.steps.gravity.png differ diff --git a/docs/assets/img2img/000034.1592514025.png b/docs/assets/img2img/000034.1592514025.png new file mode 100644 index 0000000000000000000000000000000000000000..43751da5728072aa0e863b4eab1cd324785d5d87 Binary files /dev/null and b/docs/assets/img2img/000034.1592514025.png differ diff --git a/docs/assets/img2img/000034.steps.png b/docs/assets/img2img/000034.steps.png new file mode 100644 index 0000000000000000000000000000000000000000..216213162f58f1d229e1414749e24826603c5cff Binary files /dev/null and b/docs/assets/img2img/000034.steps.png differ diff --git a/docs/assets/img2img/000035.1592514025.png b/docs/assets/img2img/000035.1592514025.png new file mode 100644 index 0000000000000000000000000000000000000000..d298895080bfe21d404c2cd0b08b8d45e8cd80cb Binary files /dev/null and b/docs/assets/img2img/000035.1592514025.png differ diff --git a/docs/assets/img2img/000035.steps.gravity.png b/docs/assets/img2img/000035.steps.gravity.png new file mode 100644 index 0000000000000000000000000000000000000000..122c729e87c7cd226cb8c20e4c846f2f6e9be90a Binary files /dev/null and b/docs/assets/img2img/000035.steps.gravity.png differ diff --git a/docs/assets/img2img/000045.1592514025.png b/docs/assets/img2img/000045.1592514025.png new file mode 100644 index 0000000000000000000000000000000000000000..5e70f1a5bfc302195515d6cda3882a190e8420f2 Binary files /dev/null and b/docs/assets/img2img/000045.1592514025.png differ diff --git a/docs/assets/img2img/000045.steps.gravity.png b/docs/assets/img2img/000045.steps.gravity.png new file mode 100644 index 0000000000000000000000000000000000000000..39e2a9b711186a84e2bc263b7d85a0d0da64b9ec Binary files /dev/null and b/docs/assets/img2img/000045.steps.gravity.png differ diff --git a/docs/assets/img2img/000046.1592514025.png b/docs/assets/img2img/000046.1592514025.png new file mode 100644 index 0000000000000000000000000000000000000000..70d248eb61398a0d34027aeeeb5fb352037d1a86 Binary files /dev/null and b/docs/assets/img2img/000046.1592514025.png differ diff --git a/docs/assets/img2img/000046.steps.gravity.png b/docs/assets/img2img/000046.steps.gravity.png new file mode 100644 index 0000000000000000000000000000000000000000..d801a4870152e514ba41921a59f16ab7288dab9e Binary files /dev/null and b/docs/assets/img2img/000046.steps.gravity.png differ diff --git a/docs/assets/img2img/fire-drawing.png b/docs/assets/img2img/fire-drawing.png new file mode 100644 index 0000000000000000000000000000000000000000..36e2f111fa88d13cac132dfe652cab14b1a89909 Binary files /dev/null and b/docs/assets/img2img/fire-drawing.png differ diff --git a/docs/assets/inpainting/000019.curly.hair.deselected.png b/docs/assets/inpainting/000019.curly.hair.deselected.png new file mode 100644 index 0000000000000000000000000000000000000000..54f2285550c83b012133634b6849340bd9b77337 Binary files /dev/null and b/docs/assets/inpainting/000019.curly.hair.deselected.png differ diff --git a/docs/assets/inpainting/000019.curly.hair.masked.png b/docs/assets/inpainting/000019.curly.hair.masked.png new file mode 100644 index 0000000000000000000000000000000000000000..a221c522f3e456fedbc89af87d2eff71596006f1 Binary files /dev/null and b/docs/assets/inpainting/000019.curly.hair.masked.png differ diff --git a/docs/assets/inpainting/000019.curly.hair.selected.png b/docs/assets/inpainting/000019.curly.hair.selected.png new file mode 100644 index 0000000000000000000000000000000000000000..e25bb4340c52fc4ed476e419b235eeb44de0c1f6 Binary files /dev/null and b/docs/assets/inpainting/000019.curly.hair.selected.png differ diff --git a/docs/assets/inpainting/000024.801380492.png b/docs/assets/inpainting/000024.801380492.png new file mode 100644 index 0000000000000000000000000000000000000000..9c72eb06b8b8ff8007542e394feffd942292465a Binary files /dev/null and b/docs/assets/inpainting/000024.801380492.png differ diff --git a/docs/assets/installer-walkthrough/choose-gpu.png b/docs/assets/installer-walkthrough/choose-gpu.png new file mode 100644 index 0000000000000000000000000000000000000000..3db23ef207b2b9711f83312af521c6046e1a26ea Binary files /dev/null and b/docs/assets/installer-walkthrough/choose-gpu.png differ diff --git a/docs/assets/installer-walkthrough/confirm-directory.png b/docs/assets/installer-walkthrough/confirm-directory.png new file mode 100644 index 0000000000000000000000000000000000000000..bc3099bbb355d50406a972f15dbe50c98fecbe4e Binary files /dev/null and b/docs/assets/installer-walkthrough/confirm-directory.png differ diff --git a/docs/assets/installer-walkthrough/downloading-models.png b/docs/assets/installer-walkthrough/downloading-models.png new file mode 100644 index 0000000000000000000000000000000000000000..975a6e39fe94a10f74602188c55ee65365d12a86 Binary files /dev/null and b/docs/assets/installer-walkthrough/downloading-models.png differ diff --git a/docs/assets/installer-walkthrough/installing-models.png b/docs/assets/installer-walkthrough/installing-models.png new file mode 100644 index 0000000000000000000000000000000000000000..b2f6190bf5cc0617de514245838818dac279bb59 Binary files /dev/null and b/docs/assets/installer-walkthrough/installing-models.png differ diff --git a/docs/assets/installer-walkthrough/settings-form.png b/docs/assets/installer-walkthrough/settings-form.png new file mode 100644 index 0000000000000000000000000000000000000000..84e936ed56b5d572a82a2a08553095f6e385cd4a Binary files /dev/null and b/docs/assets/installer-walkthrough/settings-form.png differ diff --git a/docs/assets/installer-walkthrough/unpacked-zipfile.png b/docs/assets/installer-walkthrough/unpacked-zipfile.png new file mode 100644 index 0000000000000000000000000000000000000000..0e7e5f681f29f83522fca4ad7619d133e22d3957 Binary files /dev/null and b/docs/assets/installer-walkthrough/unpacked-zipfile.png differ diff --git a/docs/assets/installing-models/model-installer-controlnet.png b/docs/assets/installing-models/model-installer-controlnet.png new file mode 100644 index 0000000000000000000000000000000000000000..09dfdb269fb9b535df673424664b1aa53360d621 Binary files /dev/null and b/docs/assets/installing-models/model-installer-controlnet.png differ diff --git a/docs/assets/installing-models/webui-models-1.png b/docs/assets/installing-models/webui-models-1.png new file mode 100644 index 0000000000000000000000000000000000000000..648791ca2700fbc97f2571c45760fd93fe996059 Binary files /dev/null and b/docs/assets/installing-models/webui-models-1.png differ diff --git a/docs/assets/installing-models/webui-models-2.png b/docs/assets/installing-models/webui-models-2.png new file mode 100644 index 0000000000000000000000000000000000000000..e58f5b9752ccf6e3b1bf8cc2a35ded7094153ad9 Binary files /dev/null and b/docs/assets/installing-models/webui-models-2.png differ diff --git a/docs/assets/installing-models/webui-models-3.png b/docs/assets/installing-models/webui-models-3.png new file mode 100644 index 0000000000000000000000000000000000000000..6797a6b90760890d976891a9dd04837b4b24f4d9 Binary files /dev/null and b/docs/assets/installing-models/webui-models-3.png differ diff --git a/docs/assets/installing-models/webui-models-4.png b/docs/assets/installing-models/webui-models-4.png new file mode 100644 index 0000000000000000000000000000000000000000..75aa7ad7f1619f9f98fc0cecd21c9bf86c3a3b33 Binary files /dev/null and b/docs/assets/installing-models/webui-models-4.png differ diff --git a/docs/assets/invoke-control-panel-1.png b/docs/assets/invoke-control-panel-1.png new file mode 100644 index 0000000000000000000000000000000000000000..09046676007848172f2919b4eebbadcc4eb7744c Binary files /dev/null and b/docs/assets/invoke-control-panel-1.png differ diff --git a/docs/assets/invoke-web-server-1.png b/docs/assets/invoke-web-server-1.png new file mode 100644 index 0000000000000000000000000000000000000000..2a13430a64c481fd401b7221c626f69b8344768a --- /dev/null +++ b/docs/assets/invoke-web-server-1.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:f5fc98d931476e048992baf4a47a2164a54d24deef0be63a139c44ded12a9971 +size 5140628 diff --git a/docs/assets/invoke-web-server-2.png b/docs/assets/invoke-web-server-2.png new file mode 100644 index 0000000000000000000000000000000000000000..361c113159c14cc4b5f3e53aac95d9d960610324 Binary files /dev/null and b/docs/assets/invoke-web-server-2.png differ diff --git a/docs/assets/invoke-web-server-3.png b/docs/assets/invoke-web-server-3.png new file mode 100644 index 0000000000000000000000000000000000000000..7d392cfecca796001d3dc1c06d1e38b3074c1fa5 Binary files /dev/null and b/docs/assets/invoke-web-server-3.png differ diff --git a/docs/assets/invoke-web-server-4.png b/docs/assets/invoke-web-server-4.png new file mode 100644 index 0000000000000000000000000000000000000000..2690356b8ac4b1c5ae39ff753cdfe6cdca79cdb4 Binary files /dev/null and b/docs/assets/invoke-web-server-4.png differ diff --git a/docs/assets/invoke-web-server-5.png b/docs/assets/invoke-web-server-5.png new file mode 100644 index 0000000000000000000000000000000000000000..1923e64129486286b0da70a3a69f675bbc7f252f Binary files /dev/null and b/docs/assets/invoke-web-server-5.png differ diff --git a/docs/assets/invoke-web-server-6.png b/docs/assets/invoke-web-server-6.png new file mode 100644 index 0000000000000000000000000000000000000000..0e8f703cb5af500c5c93a69c1bd605c31c72bf3d Binary files /dev/null and b/docs/assets/invoke-web-server-6.png differ diff --git a/docs/assets/invoke-web-server-7.png b/docs/assets/invoke-web-server-7.png new file mode 100644 index 0000000000000000000000000000000000000000..19769dea93ee68648dfdff5a43b4b9529478ff50 Binary files /dev/null and b/docs/assets/invoke-web-server-7.png differ diff --git a/docs/assets/invoke-web-server-8.png b/docs/assets/invoke-web-server-8.png new file mode 100644 index 0000000000000000000000000000000000000000..dc708b1117721f9018426eebb7a94bb0327e6b10 Binary files /dev/null and b/docs/assets/invoke-web-server-8.png differ diff --git a/docs/assets/invoke-web-server-9.png b/docs/assets/invoke-web-server-9.png new file mode 100644 index 0000000000000000000000000000000000000000..048f3217f1bebc0da7fcc95077285653d90e3b63 --- /dev/null +++ b/docs/assets/invoke-web-server-9.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:8af0aeb07ffd6219224c392a9329bd29a013fb0201c0e29b286972721d0dd31e +size 1133704 diff --git a/docs/assets/invoke_ai_banner.png b/docs/assets/invoke_ai_banner.png new file mode 100644 index 0000000000000000000000000000000000000000..47523763369be96a21d0857cdabeab71c77cd033 --- /dev/null +++ b/docs/assets/invoke_ai_banner.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:2d393b3424667e7cd49f267a4d9394759f65500f09924a52fabf515f8e9c95d0 +size 1119273 diff --git a/docs/assets/invoke_web_dark.png b/docs/assets/invoke_web_dark.png new file mode 100644 index 0000000000000000000000000000000000000000..9141ab40f37a6bc9b75f0de8b25f6181c0c05a12 Binary files /dev/null and b/docs/assets/invoke_web_dark.png differ diff --git a/docs/assets/invoke_web_light.png b/docs/assets/invoke_web_light.png new file mode 100644 index 0000000000000000000000000000000000000000..98311ccafd62d426da45f7379ebf3f1b157b2485 Binary files /dev/null and b/docs/assets/invoke_web_light.png differ diff --git a/docs/assets/invoke_web_server.png b/docs/assets/invoke_web_server.png new file mode 100644 index 0000000000000000000000000000000000000000..85f0dc6ff811db1c3fbe88605ad75eca482ad771 --- /dev/null +++ b/docs/assets/invoke_web_server.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:97ea38e602654d57cae2e8fa0009db2e66f756b4a26196d4f287305f5d3ae800 +size 1012376 diff --git a/docs/assets/join-us-on-discord-image.png b/docs/assets/join-us-on-discord-image.png new file mode 100644 index 0000000000000000000000000000000000000000..53e4ee0fe05672d46717dd699fac97373080d140 Binary files /dev/null and b/docs/assets/join-us-on-discord-image.png differ diff --git a/docs/assets/logo.png b/docs/assets/logo.png new file mode 100644 index 0000000000000000000000000000000000000000..b6eb33a6db5c47f02b62f780d07c4a3930c232b2 Binary files /dev/null and b/docs/assets/logo.png differ diff --git a/docs/assets/lora-example-0.png b/docs/assets/lora-example-0.png new file mode 100644 index 0000000000000000000000000000000000000000..f98fa53ca41cf1d594a3c1482722a362b16cb2ad Binary files /dev/null and b/docs/assets/lora-example-0.png differ diff --git a/docs/assets/lora-example-1.png b/docs/assets/lora-example-1.png new file mode 100644 index 0000000000000000000000000000000000000000..29ea46e970b0b8ca47777c1bd05d8bd06b02d5a7 Binary files /dev/null and b/docs/assets/lora-example-1.png differ diff --git a/docs/assets/lora-example-2.png b/docs/assets/lora-example-2.png new file mode 100644 index 0000000000000000000000000000000000000000..40eecdce849255b261e6aa52ef675f1d2ac0ff2b Binary files /dev/null and b/docs/assets/lora-example-2.png differ diff --git a/docs/assets/lora-example-3.png b/docs/assets/lora-example-3.png new file mode 100644 index 0000000000000000000000000000000000000000..be4c505d439ec9a5a463d3c54c22cacac434e293 Binary files /dev/null and b/docs/assets/lora-example-3.png differ diff --git a/docs/assets/negative_prompt_walkthru/step1.png b/docs/assets/negative_prompt_walkthru/step1.png new file mode 100644 index 0000000000000000000000000000000000000000..6f94d7d035abc6cea96a400a3af6fae1eae5e8c3 Binary files /dev/null and b/docs/assets/negative_prompt_walkthru/step1.png differ diff --git a/docs/assets/negative_prompt_walkthru/step2.png b/docs/assets/negative_prompt_walkthru/step2.png new file mode 100644 index 0000000000000000000000000000000000000000..0ff90eca3c15ee33d71290f748c85229825a0d7a Binary files /dev/null and b/docs/assets/negative_prompt_walkthru/step2.png differ diff --git a/docs/assets/negative_prompt_walkthru/step3.png b/docs/assets/negative_prompt_walkthru/step3.png new file mode 100644 index 0000000000000000000000000000000000000000..f6676de3868685b87b550d6c3937c91ba15b629a Binary files /dev/null and b/docs/assets/negative_prompt_walkthru/step3.png differ diff --git a/docs/assets/negative_prompt_walkthru/step4.png b/docs/assets/negative_prompt_walkthru/step4.png new file mode 100644 index 0000000000000000000000000000000000000000..2e73532629d64217a8ccbea1bd8e546223579258 Binary files /dev/null and b/docs/assets/negative_prompt_walkthru/step4.png differ diff --git a/docs/assets/nodes/groupsallscale.png b/docs/assets/nodes/groupsallscale.png new file mode 100644 index 0000000000000000000000000000000000000000..5a12fe9e131fda493035bcdcdac0b79db07c9e10 Binary files /dev/null and b/docs/assets/nodes/groupsallscale.png differ diff --git a/docs/assets/nodes/groupsconditioning.png b/docs/assets/nodes/groupsconditioning.png new file mode 100644 index 0000000000000000000000000000000000000000..baaf2b44e0e3d33edec8de8082668c35cb11cdd8 Binary files /dev/null and b/docs/assets/nodes/groupsconditioning.png differ diff --git a/docs/assets/nodes/groupscontrol.png b/docs/assets/nodes/groupscontrol.png new file mode 100644 index 0000000000000000000000000000000000000000..a38e4e4bbaa800b64399ab2d5ce900e4e51c285a Binary files /dev/null and b/docs/assets/nodes/groupscontrol.png differ diff --git a/docs/assets/nodes/groupsimgvae.png b/docs/assets/nodes/groupsimgvae.png new file mode 100644 index 0000000000000000000000000000000000000000..03ac8d1f4aafa78718d75bceb53c5e5860985f44 Binary files /dev/null and b/docs/assets/nodes/groupsimgvae.png differ diff --git a/docs/assets/nodes/groupsiterate.png b/docs/assets/nodes/groupsiterate.png new file mode 100644 index 0000000000000000000000000000000000000000..50b762099a85c33db073aaf875c25a193506cf8c Binary files /dev/null and b/docs/assets/nodes/groupsiterate.png differ diff --git a/docs/assets/nodes/groupslora.png b/docs/assets/nodes/groupslora.png new file mode 100644 index 0000000000000000000000000000000000000000..74ae8a70736f441a1b9c1adde5919f0d32bb1278 Binary files /dev/null and b/docs/assets/nodes/groupslora.png differ diff --git a/docs/assets/nodes/groupsmultigenseeding.png b/docs/assets/nodes/groupsmultigenseeding.png new file mode 100644 index 0000000000000000000000000000000000000000..dcd64c775813e55e0929613609b7932f472a4d48 Binary files /dev/null and b/docs/assets/nodes/groupsmultigenseeding.png differ diff --git a/docs/assets/nodes/groupsnoise.png b/docs/assets/nodes/groupsnoise.png new file mode 100644 index 0000000000000000000000000000000000000000..d95b7ba3073d77e8b30eeba589af939c24944fb4 Binary files /dev/null and b/docs/assets/nodes/groupsnoise.png differ diff --git a/docs/assets/nodes/linearview.png b/docs/assets/nodes/linearview.png new file mode 100644 index 0000000000000000000000000000000000000000..fb6b3efca0e50c85360455288728f1deaca1b77f Binary files /dev/null and b/docs/assets/nodes/linearview.png differ diff --git a/docs/assets/nodes/nodescontrol.png b/docs/assets/nodes/nodescontrol.png new file mode 100644 index 0000000000000000000000000000000000000000..8b179e43acd06df29fc30850f8ab334f876d91bb Binary files /dev/null and b/docs/assets/nodes/nodescontrol.png differ diff --git a/docs/assets/nodes/nodesi2i.png b/docs/assets/nodes/nodesi2i.png new file mode 100644 index 0000000000000000000000000000000000000000..9908833804246bddb43dc9735cc598a5f561d99f Binary files /dev/null and b/docs/assets/nodes/nodesi2i.png differ diff --git a/docs/assets/nodes/nodest2i.png b/docs/assets/nodes/nodest2i.png new file mode 100644 index 0000000000000000000000000000000000000000..7e882dbf1b6273bdf41180f038ebe16c90b74bf2 Binary files /dev/null and b/docs/assets/nodes/nodest2i.png differ diff --git a/docs/assets/nodes/workflow_library.png b/docs/assets/nodes/workflow_library.png new file mode 100644 index 0000000000000000000000000000000000000000..a17593d3b6b6dfd0d97db896413d84aa15754cf2 Binary files /dev/null and b/docs/assets/nodes/workflow_library.png differ diff --git a/docs/assets/outpainting/curly-outcrop-2.png b/docs/assets/outpainting/curly-outcrop-2.png new file mode 100644 index 0000000000000000000000000000000000000000..595f011f27eb2579fca35cbd06f1a2a81446e49e Binary files /dev/null and b/docs/assets/outpainting/curly-outcrop-2.png differ diff --git a/docs/assets/outpainting/curly-outcrop.png b/docs/assets/outpainting/curly-outcrop.png new file mode 100644 index 0000000000000000000000000000000000000000..ae8d8dacd3d18b223abeab9c5df3ebfe93f3626f Binary files /dev/null and b/docs/assets/outpainting/curly-outcrop.png differ diff --git a/docs/assets/outpainting/curly-outpaint.png b/docs/assets/outpainting/curly-outpaint.png new file mode 100644 index 0000000000000000000000000000000000000000..9f4a2ee431eb344bac12dec7327705f21062fe55 Binary files /dev/null and b/docs/assets/outpainting/curly-outpaint.png differ diff --git a/docs/assets/outpainting/curly.png b/docs/assets/outpainting/curly.png new file mode 100644 index 0000000000000000000000000000000000000000..d9a4cb257ee39d328822f36d1a49a6eb08784e4f Binary files /dev/null and b/docs/assets/outpainting/curly.png 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 new file mode 100644 index 0000000000000000000000000000000000000000..43d780a3439bae1df03abb4670b6c749bdfcf550 Binary files /dev/null and b/docs/assets/prompt-blending/blue-sphere-0.25-red-cube-0.75-hybrid.png 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 new file mode 100644 index 0000000000000000000000000000000000000000..c131ae03f4d183d61f0f34877d8f148d328b0092 Binary files /dev/null and b/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5-hybrid.png 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 new file mode 100644 index 0000000000000000000000000000000000000000..d0cb8e389eee9978631faecab5ba00a28926b43d Binary files /dev/null and b/docs/assets/prompt-blending/blue-sphere-0.5-red-cube-0.5.png 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 new file mode 100644 index 0000000000000000000000000000000000000000..b7ddf6658bfebdf09770634faba3a5e18ad44f5a Binary files /dev/null and b/docs/assets/prompt-blending/blue-sphere-0.75-red-cube-0.25-hybrid.png 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 new file mode 100644 index 0000000000000000000000000000000000000000..3ec14564e61b48e300e3b8c52c1a8bbdbf122a1f Binary files /dev/null and b/docs/assets/prompt-blending/blue-sphere-red-cube-hybrid.png differ diff --git a/docs/assets/prompt_syntax/apricots--1.png b/docs/assets/prompt_syntax/apricots--1.png new file mode 100644 index 0000000000000000000000000000000000000000..0f0c17f08b058240197de8979a31b9159912194e Binary files /dev/null and b/docs/assets/prompt_syntax/apricots--1.png differ diff --git a/docs/assets/prompt_syntax/apricots--2.png b/docs/assets/prompt_syntax/apricots--2.png new file mode 100644 index 0000000000000000000000000000000000000000..5c519b09aed80cf87824d0ec08f1b58d672aefb3 Binary files /dev/null and b/docs/assets/prompt_syntax/apricots--2.png differ diff --git a/docs/assets/prompt_syntax/apricots--3.png b/docs/assets/prompt_syntax/apricots--3.png new file mode 100644 index 0000000000000000000000000000000000000000..c98ffd8b071dda8499a5e65dfdd7e25174c882a6 Binary files /dev/null and b/docs/assets/prompt_syntax/apricots--3.png differ diff --git a/docs/assets/prompt_syntax/apricots-0.png b/docs/assets/prompt_syntax/apricots-0.png new file mode 100644 index 0000000000000000000000000000000000000000..f8ead74db2f6da29501a750f6cd100cfc6dbcccb Binary files /dev/null and b/docs/assets/prompt_syntax/apricots-0.png differ diff --git a/docs/assets/prompt_syntax/apricots-1.png b/docs/assets/prompt_syntax/apricots-1.png new file mode 100644 index 0000000000000000000000000000000000000000..75ff7a24a3ddb003b73b76f434d77bb8a9a7edd7 Binary files /dev/null and b/docs/assets/prompt_syntax/apricots-1.png differ diff --git a/docs/assets/prompt_syntax/apricots-2.png b/docs/assets/prompt_syntax/apricots-2.png new file mode 100644 index 0000000000000000000000000000000000000000..e24ced76375b6572d7920ddc369614d0e4fed126 Binary files /dev/null and b/docs/assets/prompt_syntax/apricots-2.png differ diff --git a/docs/assets/prompt_syntax/apricots-3.png b/docs/assets/prompt_syntax/apricots-3.png new file mode 100644 index 0000000000000000000000000000000000000000..d6edf5073c97afacc573d811273711178e9b3147 Binary files /dev/null and b/docs/assets/prompt_syntax/apricots-3.png differ diff --git a/docs/assets/prompt_syntax/apricots-4.png b/docs/assets/prompt_syntax/apricots-4.png new file mode 100644 index 0000000000000000000000000000000000000000..291c5a1b03ba02369ca27117a00aeed043c2199b Binary files /dev/null and b/docs/assets/prompt_syntax/apricots-4.png differ diff --git a/docs/assets/prompt_syntax/apricots-5.png b/docs/assets/prompt_syntax/apricots-5.png new file mode 100644 index 0000000000000000000000000000000000000000..c71b85783758fd88b9d9cc6e52ad2266c4f38772 Binary files /dev/null and b/docs/assets/prompt_syntax/apricots-5.png differ diff --git a/docs/assets/prompt_syntax/mountain-man.png b/docs/assets/prompt_syntax/mountain-man.png new file mode 100644 index 0000000000000000000000000000000000000000..4bfa5817b8cb376beca599dea7be7c1f96fa392f Binary files /dev/null and b/docs/assets/prompt_syntax/mountain-man.png differ diff --git a/docs/assets/prompt_syntax/mountain-man1.png b/docs/assets/prompt_syntax/mountain-man1.png new file mode 100644 index 0000000000000000000000000000000000000000..5ed98162d357a8719128d7bb00d5fe0694e5d888 Binary files /dev/null and b/docs/assets/prompt_syntax/mountain-man1.png differ diff --git a/docs/assets/prompt_syntax/mountain-man2.png b/docs/assets/prompt_syntax/mountain-man2.png new file mode 100644 index 0000000000000000000000000000000000000000..d4466514ded7a87d2527c628a7d41fb8ae499bc6 Binary files /dev/null and b/docs/assets/prompt_syntax/mountain-man2.png differ diff --git a/docs/assets/prompt_syntax/mountain-man3.png b/docs/assets/prompt_syntax/mountain-man3.png new file mode 100644 index 0000000000000000000000000000000000000000..3196c5ca96f0b15f5bda44b5ded0f7c08a9a7530 Binary files /dev/null and b/docs/assets/prompt_syntax/mountain-man3.png differ diff --git a/docs/assets/prompt_syntax/mountain-man4.png b/docs/assets/prompt_syntax/mountain-man4.png new file mode 100644 index 0000000000000000000000000000000000000000..69522dba232387b5ef1ffb046118c1507d218710 Binary files /dev/null and b/docs/assets/prompt_syntax/mountain-man4.png differ diff --git a/docs/assets/prompt_syntax/mountain1-man.png b/docs/assets/prompt_syntax/mountain1-man.png new file mode 100644 index 0000000000000000000000000000000000000000..b5952d02f93dea1bd083d8aa2e90c2c12819d4e8 Binary files /dev/null and b/docs/assets/prompt_syntax/mountain1-man.png differ diff --git a/docs/assets/prompt_syntax/mountain2-man.png b/docs/assets/prompt_syntax/mountain2-man.png new file mode 100644 index 0000000000000000000000000000000000000000..8ab55ff2a7e04bcf8d331755c90dee3272b1cf49 Binary files /dev/null and b/docs/assets/prompt_syntax/mountain2-man.png differ diff --git a/docs/assets/prompt_syntax/mountain3-man.png b/docs/assets/prompt_syntax/mountain3-man.png new file mode 100644 index 0000000000000000000000000000000000000000..c1024b0a6358b65c06589f4752c7c2d690a14c1a Binary files /dev/null and b/docs/assets/prompt_syntax/mountain3-man.png differ diff --git a/docs/assets/prompt_syntax/sdxl-prompt-concatenated.png b/docs/assets/prompt_syntax/sdxl-prompt-concatenated.png new file mode 100644 index 0000000000000000000000000000000000000000..8d5336da3d6476637dd76d1a6b875f0fa209f758 Binary files /dev/null and b/docs/assets/prompt_syntax/sdxl-prompt-concatenated.png differ diff --git a/docs/assets/prompt_syntax/sdxl-prompt.png b/docs/assets/prompt_syntax/sdxl-prompt.png new file mode 100644 index 0000000000000000000000000000000000000000..b85464c5ad822087b535ef92685905842d466004 Binary files /dev/null and b/docs/assets/prompt_syntax/sdxl-prompt.png differ diff --git a/docs/assets/sdxl-graphs/sdxl-base-example1.json b/docs/assets/sdxl-graphs/sdxl-base-example1.json new file mode 100644 index 0000000000000000000000000000000000000000..af162ba3e918e7b3dcd189677ac14140ab537c96 --- /dev/null +++ b/docs/assets/sdxl-graphs/sdxl-base-example1.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000000000000000000000000000000000000..6935c15fd0ddd228d8d39f4c317365881597452e --- /dev/null +++ b/docs/assets/sdxl-graphs/sdxl-base-refine-example1.json @@ -0,0 +1 @@ +{"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 new file mode 100644 index 0000000000000000000000000000000000000000..6ff1c9065b9cd78cf233a0e4de89bdbbde7a02e4 Binary files /dev/null and b/docs/assets/send-to-icon.png differ diff --git a/docs/assets/stable-samples/img2img/mountains-2.png b/docs/assets/stable-samples/img2img/mountains-2.png new file mode 100644 index 0000000000000000000000000000000000000000..e9f4e708535f0e5b53372a3a39e6aa31dce383fd Binary files /dev/null and b/docs/assets/stable-samples/img2img/mountains-2.png differ diff --git a/docs/assets/stable-samples/img2img/mountains-3.png b/docs/assets/stable-samples/img2img/mountains-3.png new file mode 100644 index 0000000000000000000000000000000000000000..017de3012c2f03e4f87cce21b4d3342713b9ae95 Binary files /dev/null and b/docs/assets/stable-samples/img2img/mountains-3.png differ diff --git a/docs/assets/stable-samples/img2img/sketch-mountains-input.jpg b/docs/assets/stable-samples/img2img/sketch-mountains-input.jpg new file mode 100644 index 0000000000000000000000000000000000000000..79d652b8003bbcd1d0c0ba2d984dbbe299ac5916 Binary files /dev/null and b/docs/assets/stable-samples/img2img/sketch-mountains-input.jpg differ diff --git a/docs/assets/stable-samples/txt2img/merged-0005.png b/docs/assets/stable-samples/txt2img/merged-0005.png new file mode 100644 index 0000000000000000000000000000000000000000..88fcd04df9f576070d97f5655733b7a185352676 --- /dev/null +++ b/docs/assets/stable-samples/txt2img/merged-0005.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:a417aadc1d91b91531ca6bbf89840a36f432d8e9382aaa953610bedce22ff76f +size 2576264 diff --git a/docs/assets/stable-samples/txt2img/merged-0006.png b/docs/assets/stable-samples/txt2img/merged-0006.png new file mode 100644 index 0000000000000000000000000000000000000000..831c2096d8a99cb50323e48e17cc3f4dbba60f8c --- /dev/null +++ b/docs/assets/stable-samples/txt2img/merged-0006.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:1d55ba7d103da275b4612976e93f405fcb593f7e6a6fda31f2e180b41c8e4f59 +size 2638534 diff --git a/docs/assets/stable-samples/txt2img/merged-0007.png b/docs/assets/stable-samples/txt2img/merged-0007.png new file mode 100644 index 0000000000000000000000000000000000000000..05e0f3fd7c8b6db82f15026053687558906e9e30 --- /dev/null +++ b/docs/assets/stable-samples/txt2img/merged-0007.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:920ccf908b7fa5073a7c5cd3f4e109b5e66f7e29517ef5462ca55e931d0b5689 +size 2406799 diff --git a/docs/assets/step1.png b/docs/assets/step1.png new file mode 100644 index 0000000000000000000000000000000000000000..6309f41f20676d735ee1e5eb47575fe7a6c78441 Binary files /dev/null and b/docs/assets/step1.png differ diff --git a/docs/assets/step2.png b/docs/assets/step2.png new file mode 100644 index 0000000000000000000000000000000000000000..06027289b2e7c1afe243a9c2e5d60be2a7b1bc9d Binary files /dev/null and b/docs/assets/step2.png differ diff --git a/docs/assets/step4.png b/docs/assets/step4.png new file mode 100644 index 0000000000000000000000000000000000000000..c24db6b470254f3801c0a3143b25e1bd44146824 Binary files /dev/null and b/docs/assets/step4.png differ diff --git a/docs/assets/step5.png b/docs/assets/step5.png new file mode 100644 index 0000000000000000000000000000000000000000..b4e9b50576c6aa568d8267ad6e03cf5630694488 Binary files /dev/null and b/docs/assets/step5.png differ diff --git a/docs/assets/step6.png b/docs/assets/step6.png new file mode 100644 index 0000000000000000000000000000000000000000..c43140c1aab70da583f363c21db5720954be0fe3 Binary files /dev/null and b/docs/assets/step6.png differ diff --git a/docs/assets/step7.png b/docs/assets/step7.png new file mode 100644 index 0000000000000000000000000000000000000000..b89fcccae3d220253c80fa6c03c4e7172ff76a14 --- /dev/null +++ b/docs/assets/step7.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d7bc52d19f759790430de7e74f6d2b844910a6cb16d47b19b41cf6fcfd128d13 +size 1038269 diff --git a/docs/assets/still-life-inpainted.png b/docs/assets/still-life-inpainted.png new file mode 100644 index 0000000000000000000000000000000000000000..ab8c7bd69a7c3f0ec77252a5a9b00c2af7994a15 Binary files /dev/null and b/docs/assets/still-life-inpainted.png differ diff --git a/docs/assets/still-life-scaled.jpg b/docs/assets/still-life-scaled.jpg new file mode 100644 index 0000000000000000000000000000000000000000..ba9c86be00912a1034f9ab45879cf4858e5802b2 Binary files /dev/null and b/docs/assets/still-life-scaled.jpg differ diff --git a/docs/assets/textual-inversion/ti-frontend.png b/docs/assets/textual-inversion/ti-frontend.png new file mode 100644 index 0000000000000000000000000000000000000000..0500e9b13291d8e5111dabfb72371b133adf8a5a Binary files /dev/null and b/docs/assets/textual-inversion/ti-frontend.png differ diff --git a/docs/assets/troubleshooting/broken-dependency.png b/docs/assets/troubleshooting/broken-dependency.png new file mode 100644 index 0000000000000000000000000000000000000000..28415089204dac366c5fd6b0505e27ede624efcb Binary files /dev/null and b/docs/assets/troubleshooting/broken-dependency.png differ diff --git a/docs/assets/truncation_comparison.jpg b/docs/assets/truncation_comparison.jpg new file mode 100644 index 0000000000000000000000000000000000000000..e71d41d4db854d7d823848ded0e8e8f17f83a6de --- /dev/null +++ b/docs/assets/truncation_comparison.jpg @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9f04280588289cba4bbc22b2604547b38a1c3b885b0cd4eb4f1a097a378f0895 +size 1373625 diff --git a/docs/assets/upscaling.png b/docs/assets/upscaling.png new file mode 100644 index 0000000000000000000000000000000000000000..e58a538e5eec1f591c82844827a2a8adf7b275ca Binary files /dev/null and b/docs/assets/upscaling.png differ diff --git a/docs/assets/v1-variants-scores.jpg b/docs/assets/v1-variants-scores.jpg new file mode 100644 index 0000000000000000000000000000000000000000..9201b985d4520c64cdb0a4e4aee6fc13f035df7e Binary files /dev/null and b/docs/assets/v1-variants-scores.jpg differ diff --git a/docs/assets/variation_walkthru/000001.3357757885.png b/docs/assets/variation_walkthru/000001.3357757885.png new file mode 100644 index 0000000000000000000000000000000000000000..b9aa4a78edff155316012395d2eb548cc6e79dc2 Binary files /dev/null and b/docs/assets/variation_walkthru/000001.3357757885.png differ diff --git a/docs/assets/variation_walkthru/000002.1614299449.png b/docs/assets/variation_walkthru/000002.1614299449.png new file mode 100644 index 0000000000000000000000000000000000000000..0db167ae6c136ee47c533c2a31bc8fba00579475 Binary files /dev/null and b/docs/assets/variation_walkthru/000002.1614299449.png differ diff --git a/docs/assets/variation_walkthru/000002.3647897225.png b/docs/assets/variation_walkthru/000002.3647897225.png new file mode 100644 index 0000000000000000000000000000000000000000..7fe1f29227cfd05ff3a708931b24291c25c4f651 Binary files /dev/null and b/docs/assets/variation_walkthru/000002.3647897225.png differ diff --git a/docs/assets/variation_walkthru/000003.1614299449.png b/docs/assets/variation_walkthru/000003.1614299449.png new file mode 100644 index 0000000000000000000000000000000000000000..b7f6ae76139533bb46d4482ed52879b771853b6a Binary files /dev/null and b/docs/assets/variation_walkthru/000003.1614299449.png differ diff --git a/docs/assets/variation_walkthru/000004.3747154981.png b/docs/assets/variation_walkthru/000004.3747154981.png new file mode 100644 index 0000000000000000000000000000000000000000..e6ac5f3bc98fa969720da530d9c1d056f5679f63 Binary files /dev/null and b/docs/assets/variation_walkthru/000004.3747154981.png differ diff --git a/docs/configuration.md b/docs/configuration.md new file mode 100644 index 0000000000000000000000000000000000000000..3d0378bc9653d3518b173096455504593ec8e836 --- /dev/null +++ b/docs/configuration.md @@ -0,0 +1,183 @@ +--- +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: + show_root_heading: false + members: false + show_docstring_description: false + 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: + +```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. + +[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/contributing/ARCHITECTURE.md b/docs/contributing/ARCHITECTURE.md new file mode 100644 index 0000000000000000000000000000000000000000..d74df94492c9a49caaecc8df9d3f7df5c6e9b540 --- /dev/null +++ b/docs/contributing/ARCHITECTURE.md @@ -0,0 +1,93 @@ +# 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/DOWNLOAD_QUEUE.md b/docs/contributing/DOWNLOAD_QUEUE.md new file mode 100644 index 0000000000000000000000000000000000000000..960180961e90ee7fb721e63e88212216d2c3c481 --- /dev/null +++ b/docs/contributing/DOWNLOAD_QUEUE.md @@ -0,0 +1,334 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..249642492b36acc24717abd6bc04877035d4cc4e --- /dev/null +++ b/docs/contributing/INVOCATIONS.md @@ -0,0 +1,395 @@ +# 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 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. + +```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. + +![resize invocation](../assets/contributing/resize_invocation.png) + +When you launch the frontend UI, you can go to the Node Editor tab and find your +new Invocation ready to be used. + +![resize node editor](../assets/contributing/resize_node_editor.png) + +## 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 new file mode 100644 index 0000000000000000000000000000000000000000..97d840186e7a44287f2bbecdecbe58aaaa6d68e4 --- /dev/null +++ b/docs/contributing/LOCAL_DEVELOPMENT.md @@ -0,0 +1,267 @@ +# Local Development + +If you want to contribute, you will need to set up a [local development environment](./dev-environment.md). + +## 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 automatically. + +##### 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 new file mode 100644 index 0000000000000000000000000000000000000000..52b75d8c39a07dfa3c496cae7758551e6673c1ea --- /dev/null +++ b/docs/contributing/MODEL_MANAGER.md @@ -0,0 +1,1659 @@ +# 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, 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: + +``` +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 new file mode 100644 index 0000000000000000000000000000000000000000..8d823bb4e97f6fc336a1810c85253d64da615577 --- /dev/null +++ b/docs/contributing/TESTS.md @@ -0,0 +1,89 @@ +# 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" + + ![html-overview](../assets/contributing/html-overview.png) + + ![html-detail](../assets/contributing/html-detail.png) diff --git a/docs/contributing/contribution_guides/development.md b/docs/contributing/contribution_guides/development.md new file mode 100644 index 0000000000000000000000000000000000000000..d75632fe17cbecb4f4ccb99817ef1b2c4e1b5eea --- /dev/null +++ b/docs/contributing/contribution_guides/development.md @@ -0,0 +1,49 @@ +# Development + +## **What do I need to know to help?** + +If you are looking to help 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 new file mode 100644 index 0000000000000000000000000000000000000000..36720a8dfab449c184722e2874c385d93d02f7d6 --- /dev/null +++ b/docs/contributing/contribution_guides/documentation.md @@ -0,0 +1,13 @@ +# 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 our [GitHub repository](https://github.com/invoke-ai/InvokeAI). 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 Invoke is a tool for everyone, not just those who have familiarity with generative art. + +## Help & Questions + +Please ping @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) if you have any questions. diff --git a/docs/contributing/contribution_guides/newContributorChecklist.md b/docs/contributing/contribution_guides/newContributorChecklist.md new file mode 100644 index 0000000000000000000000000000000000000000..c890672bf079f3ea533697381c01fe357ce1445d --- /dev/null +++ b/docs/contributing/contribution_guides/newContributorChecklist.md @@ -0,0 +1,77 @@ +# 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](../dev-environment.md) +- [x] Set up your local tooling with [this guide](../LOCAL_DEVELOPMENT.md). Feel free to skip this step if you already have tooling you're comfortable with. +- [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 new file mode 100644 index 0000000000000000000000000000000000000000..5fe60f7af7178a88fe12455b3ae29804f4e31eb4 --- /dev/null +++ b/docs/contributing/contribution_guides/translation.md @@ -0,0 +1,19 @@ +# 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! diff --git a/docs/contributing/contribution_guides/tutorials.md b/docs/contributing/contribution_guides/tutorials.md new file mode 100644 index 0000000000000000000000000000000000000000..c51aa09b7e9cbd41d21157de8cd281c2e53b3f24 --- /dev/null +++ b/docs/contributing/contribution_guides/tutorials.md @@ -0,0 +1,11 @@ +# Tutorials + +Tutorials help new & existing users expand their ability 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. diff --git a/docs/contributing/contributors.md b/docs/contributing/contributors.md new file mode 100644 index 0000000000000000000000000000000000000000..5df11933cbae5f18b8dcc1e5be4fb0faae502680 --- /dev/null +++ b/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 (Lincoln Stein) - Co-maintainer +- @blessedcoolant - Co-maintainer +- @hipsterusername (Kent Keirsey) - Co-maintainer, CEO, Positive Vibes +- @psychedelicious (Spencer Mabrito) - Web Team Leader +- @joshistoast (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 +- @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 +- @glimmerleaf (Devon Hopkins) - Community Wizard +- @gogurt enjoyer - Discord moderator and end user support +- @whosawhatsis - Discord moderator and end user support +- @dwringer - 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 +- @chainchompa (Jennifer Player) - Web Development & Chain-Chomping +- @millu (Millun Atluri) - Community Wizard, Documentation, Node-wrangler, +- @genomancer (Gregg Helt) - Controlnet support +- @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/contributing/dev-environment.md b/docs/contributing/dev-environment.md new file mode 100644 index 0000000000000000000000000000000000000000..bfa7047594f40f204fccf9a8659394ac6be0043e --- /dev/null +++ b/docs/contributing/dev-environment.md @@ -0,0 +1,102 @@ +# Dev Environment + +To make changes to Invoke's backend, frontend, or documentation, you'll need to set up a dev environment. + +If you just want to use Invoke, you should use the [installer][installer link]. + +!!! 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 every time you pull in new changes, or run it in dev mode (which incurs a substantial performance penalty). + +!!! warning + + 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. + +## Setup + +1. Run through the [requirements][requirements link]. +2. [Fork and clone][forking link] the [InvokeAI repo][repo link]. +3. Create an 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. +4. Create a python virtual environment inside the directory you just created: + + ```sh + python3 -m venv .venv --prompt InvokeAI-Dev + ``` + +5. Activate the venv (you'll need to do this every time you want to run the app): + + ```sh + source .venv/bin/activate + ``` + +6. Install the repo as an [editable install][editable install link]: + + ```sh + pip install -e ".[dev,test,xformers]" --use-pep517 --extra-index-url https://download.pytorch.org/whl/cu121 + ``` + + Refer to the [manual installation][manual install link]] instructions for more determining the correct install options. `xformers` is optional, but `dev` and `test` are not. + +7. Install the frontend dev toolchain: + + - [`nodejs`](https://nodejs.org/) (recommend v20 LTS) + - [`pnpm`](https://pnpm.io/8.x/installation) (must be v8 - not v9!) + +8. Do a production build of the frontend: + + ```sh + cd PATH_TO_INVOKEAI_REPO/invokeai/frontend/web + pnpm i + pnpm build + ``` + +9. Start the application: + + ```sh + cd PATH_TO_INVOKEAI_REPO + python scripts/invokeai-web.py + ``` + +10. Access the UI at `localhost:9090`. + +## Updating the UI + +You'll need to run `pnpm build` every time you pull in new changes. Another option is to skip the build and instead run the app in dev mode: + +```sh +pnpm dev +``` + +This starts a dev server at `localhost:5173`, which you will use instead of `localhost:9090`. + +The dev mode is substantially slower than the production build but may be more convenient if you just need to test things out. + +## Documentation + +The documentation is built with `mkdocs`. To preview it locally, you need a additional set of packages installed. + +```sh +# after activating the venv +pip install -e ".[docs]" +``` + +Then, you can start a live docs dev server, which will auto-refresh when you edit the docs: + +```sh +mkdocs serve +``` + +On macOS and Linux, there is a `make` target for this: + +```sh +make docs +``` + +[installer link]: ../installation/installer.md +[forking link]: https://docs.github.com/en/pull-requests/collaborating-with-pull-requests/working-with-forks/fork-a-repo +[requirements link]: ../installation/requirements.md +[repo link]: https://github.com/invoke-ai/InvokeAI +[manual install link]: ../installation/manual.md +[editable install link]: https://pip.pypa.io/en/latest/cli/pip_install/#cmdoption-e diff --git a/docs/contributing/frontend/index.md b/docs/contributing/frontend/index.md new file mode 100644 index 0000000000000000000000000000000000000000..0e0ff55f3dbc65d5424ef895ea4775f479cf4a32 --- /dev/null +++ b/docs/contributing/frontend/index.md @@ -0,0 +1,128 @@ +# Invoke UI + +Invoke's UI is made possible by many contributors and open-source libraries. Thank you! + +## Dev environment + +Follow the [dev environment](../dev-environment.md) 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.md +[State Management]: ./state-management.md diff --git a/docs/contributing/frontend/state-management.md b/docs/contributing/frontend/state-management.md new file mode 100644 index 0000000000000000000000000000000000000000..0990a0489f71695c08a87f1ae7c4de720f94c10c --- /dev/null +++ b/docs/contributing/frontend/state-management.md @@ -0,0 +1,38 @@ +# State Management + +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/contributing/frontend/workflows.md b/docs/contributing/frontend/workflows.md new file mode 100644 index 0000000000000000000000000000000000000000..533419e0702a2078e95320cd8ba1ef4458d88e3d --- /dev/null +++ b/docs/contributing/frontend/workflows.md @@ -0,0 +1,314 @@ +# 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/contributing/index.md b/docs/contributing/index.md new file mode 100644 index 0000000000000000000000000000000000000000..c5d677d9c87063877918a8936fe884417a0827d3 --- /dev/null +++ b/docs/contributing/index.md @@ -0,0 +1,52 @@ +# Contributing + +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](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 @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](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. diff --git a/docs/faq.md b/docs/faq.md new file mode 100644 index 0000000000000000000000000000000000000000..ed504f4c5454ed010b7b7bd84fb17e58d37fb9d8 --- /dev/null +++ b/docs/faq.md @@ -0,0 +1,260 @@ +# 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/installer.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: + +![broken-dependency-screenshot](./assets/troubleshooting/broken-dependency.png){: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][latest release], 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. + +## Checkpoint Models Load Slowly or Use Too Much RAM + +The difference between diffusers models (a folder containing multiple +subfolders) and checkpoint models (a file ending with .safetensors or +.ckpt) is that InvokeAI is able to load diffusers models into memory +incrementally, while checkpoint models must be loaded all at +once. With very large models, or systems with limited RAM, you may +experience slowdowns and other memory-related issues when loading +checkpoint models. + +To solve this, go to the Model Manager tab (the cube), select the +checkpoint model that's giving you trouble, and press the "Convert" +button in the upper right of your browser window. This will convert the +checkpoint into a diffusers model, after which loading should be +faster and less memory-intensive. + +## 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/models.md +[system requirements]: ./installation/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]: ./configuration.md +[access token]: https://huggingface.co/docs/hub/security-tokens#how-to-manage-user-access-tokens diff --git a/docs/features/database.md b/docs/features/database.md new file mode 100644 index 0000000000000000000000000000000000000000..ad2f0e105c16b438915e33c2c90276b29d924932 --- /dev/null +++ b/docs/features/database.md @@ -0,0 +1,31 @@ +--- +title: 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 a 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`: + +```yaml +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 new file mode 100644 index 0000000000000000000000000000000000000000..1c12f59c7a760373d7727b482b91dfdc623634a5 --- /dev/null +++ b/docs/features/gallery.md @@ -0,0 +1,92 @@ +--- +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. + +![image](../assets/gallery/gallery.png) + +### Board Display and Settings + +At the very top of the Gallery Panel are the boards disclosure and settings buttons. + +![image](../assets/gallery/top_controls.png) + +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). + +![image](../assets/gallery/board_thumbnails.png) + +The settings button opens a list of options. + +![image](../assets/gallery/board_settings.png) + +- ***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). + +![image](../assets/gallery/thumbnail_menu.png) + +- ***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. + +![image](../assets/gallery/board_tabs.png) + +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 ![image](../assets/gallery/info_button.png) in any of the image result panels. + +Each image also has a context menu (ctrl+click / right-click). + +![image](../assets/gallery/image_menu.png) + + 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 and 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/help/SAMPLER_CONVERGENCE.md b/docs/help/SAMPLER_CONVERGENCE.md new file mode 100644 index 0000000000000000000000000000000000000000..61fdca7b9cce9d33189e55fe68c1064a2c48d4a9 --- /dev/null +++ b/docs/help/SAMPLER_CONVERGENCE.md @@ -0,0 +1,151 @@ +--- +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` + +![191636411-083c8282-6ed1-4f78-9273-ee87c0a0f1b6-min (1)](https://user-images.githubusercontent.com/50542132/191868725-7f7af991-e254-4c1f-83e7-bed8c9b2d34f.png) + +### *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` + +![191736091-dda76929-00d1-4590-bef4-7314ea4ea419-min (1)](https://user-images.githubusercontent.com/50542132/191868763-b151c69e-0a72-4cf1-a151-5a64edd0c93e.png) + +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` + +![191639011-f81d9d38-0a15-45f0-9442-a5e8d5c25f1f-min (1)](https://user-images.githubusercontent.com/50542132/191868898-98801a62-885f-4ea1-aee8-563503522aa9.png) + +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` + +![191771922-6029a4f5-f707-4684-9011-c6f96e25fe56-min (1)](https://user-images.githubusercontent.com/50542132/191868870-9e3b7d82-b909-429f-893a-13f6ec343454.png) + +`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. + +![Screenshot 2022-09-23 at 02 05 48-min (1)](https://user-images.githubusercontent.com/50542132/191871743-6802f199-0ffd-4986-98c5-df2d8db30d18.png) + +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. + +![191988191-c586b75a-2d7f-4351-b705-83cc1149881a-min (1)](https://user-images.githubusercontent.com/50542132/191992123-7e0759d6-6220-42c4-a961-88c7071c5ee6.png) + +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. + +![K-compare](https://user-images.githubusercontent.com/50542132/192046823-2714cb29-bbf3-4eb1-9213-e27a0963905c.png){ 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 new file mode 100644 index 0000000000000000000000000000000000000000..7182a51d67f38d2a4c119509a88074dbd66c6745 --- /dev/null +++ b/docs/help/diffusion.md @@ -0,0 +1,27 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..617bd6040107f8318602e16ec729c8d599ac77c4 --- /dev/null +++ b/docs/help/gettingStartedWithAI.md @@ -0,0 +1,97 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..16a72bebcbb47fe477c1591e73780f2d3c469913 Binary files /dev/null and b/docs/img/favicon.ico differ diff --git a/docs/img/invoke-symbol-wht-lrg.svg b/docs/img/invoke-symbol-wht-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..17cfdc77da7092de2134bbdf14c88327fc228508 --- /dev/null +++ b/docs/img/invoke-symbol-wht-lrg.svg @@ -0,0 +1,3 @@ + + + diff --git a/docs/index.md b/docs/index.md new file mode 100644 index 0000000000000000000000000000000000000000..b1520ba4fbab01074015dfbac905ca5dfe06aa8a --- /dev/null +++ b/docs/index.md @@ -0,0 +1,75 @@ +--- +title: Invoke +--- + + + + + +
+ +[![project logo](https://github.com/invoke-ai/InvokeAI/assets/31807370/6e3728c7-e90e-4711-905c-3b55844ff5be)](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] +[![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] + +[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 commit to main badge]: https://flat.badgen.net/github/last-commit/invoke-ai/InvokeAI/main?icon=github&color=yellow&label=last%20commit&cache=900 +[latest commit to main link]: https://github.com/invoke-ai/InvokeAI/commits/main +[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 + +
+ +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 + +The [installer script](installation/installer.md) is the easiest way to install and update the application. + +You can also install Invoke as python package [via PyPI](installation/manual.md) or [docker](installation/docker.md). + +See the [installation section](./installation/index.md) for more information. + +## Help + +Please first check the [FAQ](./faq.md) to see if there is a fix for your issue or answer to your question. + +If you still have a problem, [create an issue](https://github.com/invoke-ai/InvokeAI/issues) or ask for help on [Discord](https://discord.gg/ZmtBAhwWhy). + +## 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 . + +## Contributing + +We welcome contributions, big and small. Please review our [contributing guide](contributing/index.md) if you'd like make a contribution. + +This software is a combined effort of [people across the world](contributing/contributors.md). We thank them for their time, hard work and effort! diff --git a/docs/installation/docker.md b/docs/installation/docker.md new file mode 100644 index 0000000000000000000000000000000000000000..a317ef9d5fbbf7c289631ff170277b5c08202848 --- /dev/null +++ b/docs/installation/docker.md @@ -0,0 +1,87 @@ +--- +title: Docker +--- + +!!! warning "macOS users" + + Docker can not access the GPU on macOS, so your generation speeds will be slow. Use the [installer](./installer.md) instead. + +!!! tip "Linux and Windows Users" + + Configure Docker 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 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 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](../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` 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/installation/index.md b/docs/installation/index.md new file mode 100644 index 0000000000000000000000000000000000000000..61ab6e763bf67a0e6771b4df63de8ef191fff361 --- /dev/null +++ b/docs/installation/index.md @@ -0,0 +1,36 @@ +# Installation and Updating Overview + +Before installing, review the [installation requirements](./requirements.md) to ensure your system is set up properly. + +See the [FAQ](../faq.md) for frequently-encountered installation issues. + +If you need more help, join our [discord](https://discord.gg/ZmtBAhwWhy) or [create a GitHub issue](https://github.com/invoke-ai/InvokeAI/issues). + +## Automated Installer & Updates + +✅ The automated [installer](./installer.md) is the best way to install Invoke. + +⬆️ The same installer is also the best way to update Invoke - 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. + +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 Invoke manually via PyPI](./manual.md). + +Updates are managed by reinstalling the latest version through PyPi. + +## Developer Install + +If you want to contribute to InvokeAI, you'll need to set up a [dev environment](../contributing/dev-environment.md). + +## Docker + +Invoke publishes docker images. See the [docker installation guide](./docker.md) for details. + +## Other Installation Guides + +- [PyPatchMatch](./patchmatch.md) +- [Installing Models](./models.md) diff --git a/docs/installation/installer.md b/docs/installation/installer.md new file mode 100644 index 0000000000000000000000000000000000000000..b8a661c05383a117496ffed0fb30fb6a6250fcbc --- /dev/null +++ b/docs/installation/installer.md @@ -0,0 +1,115 @@ +# Automatic Install & Updates + +!!! tip "Use the installer to update" + + Using the installer for updates will not erase any of your data (images, models, boards, etc). It only updates the core libraries used to run Invoke. + + Simply use the same path you installed to originally to update your existing installation. + +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: + +```sh +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. + +## Improved Outpainting with PatchMatch + +PatchMatch is an extra add-on that can improve outpainting. Windows users are in luck - it works out of the box. + +On macOS and Linux, a few extra steps are needed to set it up. See the [PatchMatch installation guide](./patchmatch.md). + +## 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, enter your existing installation path, and the app will update. None of your data (images, models, boards, etc) will be erased. + +!!! 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]: ./requirements.md +[FAQ]: ../faq.md +[install some models]: ./models.md +[configuration docs]: ../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/manual.md b/docs/installation/manual.md new file mode 100644 index 0000000000000000000000000000000000000000..8c8bfcebdeb05583923b6572225c62c5b66298e8 --- /dev/null +++ b/docs/installation/manual.md @@ -0,0 +1,118 @@ +# Manual Install + +!!! warning + + **Python experience is mandatory.** + + If you want to use Invoke locally, you should probably use the [installer](./installer.md). + + If you want to contribute to Invoke, instead follow the [dev environment](../contributing/dev-environment.md) guide. + +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](./requirements.md). + +## Walkthrough + +1. Create a directory to contain your InvokeAI library, configuration files, and models. This is known as the "runtime" or "root" directory, and typically lives in your home directory under the name `invokeai`. + + === "Linux/macOS" + + ```bash + mkdir ~/invokeai + ``` + + === "Windows (PowerShell)" + + ```bash + mkdir $Home/invokeai + ``` + +1. Enter the root 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. + + === "Linux/macOS" + + ```bash + cd ~/invokeai + python3 -m venv .venv --prompt InvokeAI + ``` + + === "Windows (PowerShell)" + + ```bash + cd $Home/invokeai + 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)`, indicating the venv is active. + +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. + + ```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 strictly necessary. PyTorch includes an implementation of the SDP attention algorithm with similar performance for most GPUs. + + ```bash + pip install "InvokeAI[xformers]" --use-pep517 + ``` + +1. Deactivate and reactivate your venv 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/models.md b/docs/installation/models.md new file mode 100644 index 0000000000000000000000000000000000000000..9f2bf52793e0dc88a2f3a3f0103bc1b3c3be9d75 --- /dev/null +++ b/docs/installation/models.md @@ -0,0 +1,52 @@ +# 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. + +## 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]. +- **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]: ../configuration.md#model-marketplace-api-keys diff --git a/docs/installation/patchmatch.md b/docs/installation/patchmatch.md new file mode 100644 index 0000000000000000000000000000000000000000..1ca0ba116ffb159159a69413e9f7e8cfa5acb798 --- /dev/null +++ b/docs/installation/patchmatch.md @@ -0,0 +1,121 @@ +--- +title: Installing PyPatchMatch +--- + +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. + +## 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`, `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 + ``` + +[**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/requirements.md b/docs/installation/requirements.md new file mode 100644 index 0000000000000000000000000000000000000000..21d538b0b575687500df48fc673f48b44879c6a3 --- /dev/null +++ b/docs/installation/requirements.md @@ -0,0 +1,181 @@ +# 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/ +[CUDA Toolkit Downloads]: https://developer.nvidia.com/cuda-downloads diff --git a/docs/nodes/NODES.md b/docs/nodes/NODES.md new file mode 100644 index 0000000000000000000000000000000000000000..b7f7aa82ad4843a196c3ea4d664abff7bf6982cb --- /dev/null +++ b/docs/nodes/NODES.md @@ -0,0 +1,97 @@ +# 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. + +![workflow_library](../assets/nodes/workflow_library.png) + +### 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. + +![linearview](../assets/nodes/linearview.png) + +### 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. + +![groupsnoise](../assets/nodes/groupsnoise.png) + +### 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. + +![groupsconditioning](../assets/nodes/groupsconditioning.png) + +### 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. + +![groupsimgvae](../assets/nodes/groupsimgvae.png) + +### 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. + +![groupsrandseed](../assets/nodes/groupsnoise.png) + +### 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. + +![groupscontrol](../assets/nodes/groupscontrol.png) + +### 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. + +![groupslora](../assets/nodes/groupslora.png) + +### 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. + +![groupsallscale](../assets/nodes/groupsallscale.png) + +### 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. + +![groupsiterate](../assets/nodes/groupsiterate.png) + +### 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. + +![groupsmultigenseeding](../assets/nodes/groupsmultigenseeding.png) + diff --git a/docs/nodes/NODES_MIGRATION_V3_V4.md b/docs/nodes/NODES_MIGRATION_V3_V4.md new file mode 100644 index 0000000000000000000000000000000000000000..aa7b234f8106b53ac2d689af3cf3305b1556d696 --- /dev/null +++ b/docs/nodes/NODES_MIGRATION_V3_V4.md @@ -0,0 +1,148 @@ +# 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 the [invocation API docs](./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 new file mode 100644 index 0000000000000000000000000000000000000000..2d894dc74c9d6a73435efb0744a6d6af540e2640 --- /dev/null +++ b/docs/nodes/comfyToInvoke.md @@ -0,0 +1,80 @@ +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..3b5c8a54bd8b8adc3490f0b50e2b9d1d3cbc78ad --- /dev/null +++ b/docs/nodes/communityNodes.md @@ -0,0 +1,647 @@ +# 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 `.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) + + [Enhance Detail](#enhance-detail) + + [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) + + [Ollama](#ollama-node) + + [One Button Prompt](#one-button-prompt) + + [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:** +
+ +-------------------------------- +### 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 + +-------------------------------- +### 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) + +-------------------------------- +### 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:** + +![ollama node](https://raw.githubusercontent.com/Jonseed/Ollama-Node/a3e7cdc55e394cb89c1ea7ed54e106c212c85e8c/ollama-node-screenshot.png) + +-------------------------------- +### 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** + + + +-------------------------------- +### 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 new file mode 100644 index 0000000000000000000000000000000000000000..7a30c8aeb0f8c36a75129297745166a0904693f3 --- /dev/null +++ b/docs/nodes/contributingNodes.md @@ -0,0 +1,27 @@ +# 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** + +![InvokeAI](https://invoke-ai.github.io/InvokeAI/assets/invoke_ai_banner.png) +``` diff --git a/docs/nodes/defaultNodes.md b/docs/nodes/defaultNodes.md new file mode 100644 index 0000000000000000000000000000000000000000..1c951a77a4ef37c83174a6383a7ec948ca4c4bdc --- /dev/null +++ b/docs/nodes/defaultNodes.md @@ -0,0 +1,108 @@ +# 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. | +| 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 new file mode 100644 index 0000000000000000000000000000000000000000..632212d3c33130f814c2d415ed08c969967d0c45 --- /dev/null +++ b/docs/nodes/detailedNodes/faceTools.md @@ -0,0 +1,154 @@ +# 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/invocation-api.md b/docs/nodes/invocation-api.md new file mode 100644 index 0000000000000000000000000000000000000000..1a624f226735c60cd8133ff80d9602170ec9de8f --- /dev/null +++ b/docs/nodes/invocation-api.md @@ -0,0 +1,66 @@ +# Invocation API + +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 API 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. + + +::: 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/overview.md b/docs/nodes/overview.md new file mode 100644 index 0000000000000000000000000000000000000000..736aba9ef6c056a180e1e9932b1217a6892689a9 --- /dev/null +++ b/docs/nodes/overview.md @@ -0,0 +1,23 @@ +# 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). + +## 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/flake.lock b/flake.lock new file mode 100644 index 0000000000000000000000000000000000000000..dedd56e74f37e188c4a41041820ff7744b8a8887 --- /dev/null +++ b/flake.lock @@ -0,0 +1,25 @@ +{ + "nodes": { + "nixpkgs": { + "locked": { + "lastModified": 1727955264, + "narHash": "sha256-lrd+7mmb5NauRoMa8+J1jFKYVa+rc8aq2qc9+CxPDKc=", + "owner": "NixOS", + "repo": "nixpkgs", + "rev": "71cd616696bd199ef18de62524f3df3ffe8b9333", + "type": "github" + }, + "original": { + "id": "nixpkgs", + "type": "indirect" + } + }, + "root": { + "inputs": { + "nixpkgs": "nixpkgs" + } + } + }, + "root": "root", + "version": 7 +} diff --git a/flake.nix b/flake.nix new file mode 100644 index 0000000000000000000000000000000000000000..07af19e93bf6ba3b6d4d63860ce32303605a70da --- /dev/null +++ b/flake.nix @@ -0,0 +1,91 @@ +# Important note: this flake does not attempt to create a fully isolated, 'pure' +# Python environment for InvokeAI. Instead, it depends on local invocations of +# virtualenv/pip to install the required (binary) packages, most importantly the +# prebuilt binary pytorch packages with CUDA support. +# ML Python packages with CUDA support, like pytorch, are notoriously expensive +# to compile so it's purposefuly not what this flake does. + +{ + description = "An (impure) flake to develop on InvokeAI."; + + outputs = { self, nixpkgs }: + let + system = "x86_64-linux"; + pkgs = import nixpkgs { + inherit system; + config.allowUnfree = true; + }; + + python = pkgs.python310; + + mkShell = { dir, install }: + let + setupScript = pkgs.writeScript "setup-invokai" '' + # This must be sourced using 'source', not executed. + ${python}/bin/python -m venv ${dir} + ${dir}/bin/python -m pip install ${install} + # ${dir}/bin/python -c 'import torch; assert(torch.cuda.is_available())' + source ${dir}/bin/activate + ''; + in + pkgs.mkShell rec { + buildInputs = with pkgs; [ + # Backend: graphics, CUDA. + cudaPackages.cudnn + cudaPackages.cuda_nvrtc + cudatoolkit + pkg-config + libconfig + cmake + blas + freeglut + glib + gperf + procps + libGL + libGLU + linuxPackages.nvidia_x11 + python + (opencv4.override { + enableGtk3 = true; + enableFfmpeg = true; + enableCuda = true; + enableUnfree = true; + }) + stdenv.cc + stdenv.cc.cc.lib + xorg.libX11 + xorg.libXext + xorg.libXi + xorg.libXmu + xorg.libXrandr + xorg.libXv + zlib + + # Pre-commit hooks. + black + + # Frontend. + pnpm_8 + nodejs + ]; + LD_LIBRARY_PATH = pkgs.lib.makeLibraryPath buildInputs; + CUDA_PATH = pkgs.cudatoolkit; + EXTRA_LDFLAGS = "-L${pkgs.linuxPackages.nvidia_x11}/lib"; + shellHook = '' + if [[ -f "${dir}/bin/activate" ]]; then + source "${dir}/bin/activate" + echo "Using Python: $(which python)" + else + echo "Use 'source ${setupScript}' to set up the environment." + fi + ''; + }; + in + { + devShells.${system} = rec { + develop = mkShell { dir = "venv"; install = "-e '.[xformers]' --extra-index-url https://download.pytorch.org/whl/cu118"; }; + default = develop; + }; + }; +} diff --git a/installer/WinLongPathsEnabled.reg b/installer/WinLongPathsEnabled.reg new file mode 100644 index 0000000000000000000000000000000000000000..778782b2724d59ad0caa2bd5ce8c0d38dc74f39b Binary files /dev/null and b/installer/WinLongPathsEnabled.reg differ diff --git a/installer/create_installer.sh b/installer/create_installer.sh new file mode 100644 index 0000000000000000000000000000000000000000..a71b0d9c4179688454b4d5b607a75e389a96d6c4 --- /dev/null +++ b/installer/create_installer.sh @@ -0,0 +1,133 @@ +#!/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 new file mode 100644 index 0000000000000000000000000000000000000000..b06aa97c98b776c022542c958ada04aabe21075b --- /dev/null +++ b/installer/install.bat.in @@ -0,0 +1,128 @@ +@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 new file mode 100644 index 0000000000000000000000000000000000000000..2cbe909c6a5ca8d271f8bd5bf67edfd758796cd0 --- /dev/null +++ b/installer/install.sh.in @@ -0,0 +1,40 @@ +#!/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 2>/dev/null`; 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." + read -p "Press any key to exit" + exit -1 +fi + +echo "For the best user experience we suggest enlarging or maximizing this window now." + +exec $PYTHON ./lib/main.py ${@} +read -p "Press any key to exit" diff --git a/installer/lib/__init__.py b/installer/lib/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/installer/lib/installer.py b/installer/lib/installer.py new file mode 100644 index 0000000000000000000000000000000000000000..df90ba8c616b9af67c9c8b634ca8dae51384538d --- /dev/null +++ b/installer/lib/installer.py @@ -0,0 +1,438 @@ +# 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] + + # Uninstall xformers if it is present; the correct version of it will be reinstalled if needed + _ = pip["uninstall", "-yqq", "xformers"] & FG + + 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) + + +### 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 GpuType, 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 == GpuType.ROCM: + url = "https://download.pytorch.org/whl/rocm6.1" + elif device == GpuType.CPU: + url = "https://download.pytorch.org/whl/cpu" + elif device == GpuType.CUDA: + url = "https://download.pytorch.org/whl/cu124" + optional_modules = "[onnx-cuda]" + elif device == GpuType.CUDA_WITH_XFORMERS: + url = "https://download.pytorch.org/whl/cu124" + optional_modules = "[xformers,onnx-cuda]" + elif OS == "Windows": + if device == GpuType.CUDA: + url = "https://download.pytorch.org/whl/cu124" + optional_modules = "[onnx-cuda]" + elif device == GpuType.CUDA_WITH_XFORMERS: + url = "https://download.pytorch.org/whl/cu124" + 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 new file mode 100644 index 0000000000000000000000000000000000000000..be9dc18f9662c30fd8ea2d0056b33cd0ede6ff7a --- /dev/null +++ b/installer/lib/main.py @@ -0,0 +1,57 @@ +""" +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 new file mode 100644 index 0000000000000000000000000000000000000000..f5b425cdbd3de15db1a8e708f7b3380554fb948c --- /dev/null +++ b/installer/lib/messages.py @@ -0,0 +1,342 @@ +# 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_WITH_XFORMERS = "xformers" + 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[/] RTX 3060 or newer GPU using CUDA", + GpuType.CUDA, + ) + vintage_nvidia = ( + "an [gold1 b]NVIDIA[/] RTX 20xx or older GPU using CUDA+xFormers", + GpuType.CUDA_WITH_XFORMERS, + ) + 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, vintage_nvidia, cpu] + if OS == "Linux": + options = [nvidia, vintage_nvidia, amd, cpu] + elif OS == "Darwin": + options = [cpu] + + if len(options) == 1: + 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/installation/requirements/[/] 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 new file mode 100644 index 0000000000000000000000000000000000000000..ef040c3913ccc96be36801c9e1aafea9a162c066 --- /dev/null +++ b/installer/readme.txt @@ -0,0 +1,52 @@ +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/tag_release.sh b/installer/tag_release.sh new file mode 100644 index 0000000000000000000000000000000000000000..b6d1830902d6f0a20413cf89cca9a5fe0b0924d7 --- /dev/null +++ b/installer/tag_release.sh @@ -0,0 +1,62 @@ +#!/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 does_tag_exist { + git rev-parse --quiet --verify "refs/tags/$1" >/dev/null +} + +function git_show_ref { + git show-ref --dereference $1 --abbrev 7 +} + +function git_show { + git show -s --format='%h %s' $1 +} + +VERSION=$( + cd .. + python3 -c "from invokeai.version import __version__ as version; print(version)" +) +PATCH="" +VERSION="v${VERSION}${PATCH}" + +if does_tag_exist $VERSION; then + echo -e "${BCYAN}${VERSION}${RESET} already exists:" + git_show_ref tags/$VERSION + echo +fi + +echo -e "${BGREEN}HEAD${RESET}:" +git_show +echo + +echo -e "${BGREEN}git remote -v${RESET}:" +git remote -v +echo + +echo -e -n "Create tags ${BCYAN}${VERSION}${RESET} @ ${BGREEN}HEAD${RESET}, ${RED}deleting existing tags on origin remote${RESET}? " +read -e -p 'y/n [n]: ' input +RESPONSE=${input:='n'} +if [ "$RESPONSE" == 'y' ]; then + echo + echo -e "Deleting ${BCYAN}${VERSION}${RESET} tag on origin remote..." + git push origin :refs/tags/$VERSION + + echo -e "Tagging ${BGREEN}HEAD${RESET} with ${BCYAN}${VERSION}${RESET} on locally..." + if ! git tag -fa $VERSION; then + echo "Existing/invalid tag" + exit -1 + fi + + echo -e "Pushing updated tags to origin remote..." + git push origin --tags +fi +exit 0 diff --git a/installer/templates/invoke.bat.in b/installer/templates/invoke.bat.in new file mode 100644 index 0000000000000000000000000000000000000000..774b667c086b48509238ffb479c4b9400a20fe76 --- /dev/null +++ b/installer/templates/invoke.bat.in @@ -0,0 +1,54 @@ +@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 new file mode 100644 index 0000000000000000000000000000000000000000..56dccea32fc6ce185c32c7882f1fc1d63f0f91e8 --- /dev/null +++ b/installer/templates/invoke.sh.in @@ -0,0 +1,87 @@ +#!/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 $(readlink -f "$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/__init__.py b/invokeai/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/__init__.py b/invokeai/app/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py new file mode 100644 index 0000000000000000000000000000000000000000..d2be674e53939f2e19d708e435d551fc9bd6e020 --- /dev/null +++ b/invokeai/app/api/dependencies.py @@ -0,0 +1,158 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) + +import asyncio +from logging import Logger + +import torch + +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.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.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.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.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.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData +from invokeai.backend.util.logging import InvokeAILogger +from invokeai.version.invokeai_version import __version__ + + +# TODO: is there a better way to achieve this? +def check_internet() -> bool: + """ + Return true if the internet is reachable. + It does this by pinging huggingface.co. + """ + import urllib.request + + host = "http://huggingface.co" + try: + urllib.request.urlopen(host, timeout=1) + return True + except Exception: + return False + + +logger = InvokeAILogger.get_logger() + + +class ApiDependencies: + """Contains and initializes all dependencies for the API""" + + invoker: Invoker + + @staticmethod + 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)}") + + output_folder = config.outputs_path + if output_folder is None: + raise ValueError("Output folder is not set") + + image_files = DiskImageFileStorage(f"{output_folder}/images") + + model_images_folder = config.models_path + style_presets_folder = config.style_presets_path + + db = init_db(config=config, logger=logger, image_files=image_files) + + configuration = config + logger = logger + + board_image_records = SqliteBoardImageRecordStorage(db=db) + board_images = BoardImagesService() + board_records = SqliteBoardRecordStorage(db=db) + boards = BoardService() + 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) + ) + conditioning = ObjectSerializerForwardCache( + ObjectSerializerDisk[ConditioningFieldData](output_folder / "conditioning", ephemeral=True) + ) + download_queue_service = DownloadQueueService(app_config=configuration, event_bus=events) + model_images_service = ModelImageFileStorageDisk(model_images_folder / "model_images") + model_manager = ModelManagerService.build_model_manager( + app_config=configuration, + model_record_service=ModelRecordServiceSQL(db=db, logger=logger), + download_queue=download_queue_service, + events=events, + ) + 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") + + services = InvocationServices( + board_image_records=board_image_records, + board_images=board_images, + board_records=board_records, + boards=boards, + bulk_download=bulk_download, + configuration=configuration, + events=events, + image_files=image_files, + image_records=image_records, + images=images, + invocation_cache=invocation_cache, + logger=logger, + model_images=model_images_service, + model_manager=model_manager, + download_queue=download_queue_service, + names=names, + performance_statistics=performance_statistics, + session_processor=session_processor, + session_queue=session_queue, + urls=urls, + workflow_records=workflow_records, + tensors=tensors, + conditioning=conditioning, + style_preset_records=style_preset_records, + style_preset_image_files=style_preset_image_files, + ) + + ApiDependencies.invoker = Invoker(services) + db.clean() + + @staticmethod + def shutdown() -> None: + if ApiDependencies.invoker: + ApiDependencies.invoker.stop() diff --git a/invokeai/app/api/no_cache_staticfiles.py b/invokeai/app/api/no_cache_staticfiles.py new file mode 100644 index 0000000000000000000000000000000000000000..15a53270f1d4306f25992d86ec5a317bfdcfe5d1 --- /dev/null +++ b/invokeai/app/api/no_cache_staticfiles.py @@ -0,0 +1,28 @@ +from typing import Any + +from starlette.responses import Response +from starlette.staticfiles import StaticFiles + + +class NoCacheStaticFiles(StaticFiles): + """ + This class is used to override the default caching behavior of starlette for static files, + ensuring we *never* cache static files. It modifies the file response headers to strictly + never cache the files. + + Static files include the javascript bundles, fonts, locales, and some images. Generated + images are not included, as they are served by a router. + """ + + def __init__(self, *args: Any, **kwargs: Any): + self.cachecontrol = "max-age=0, no-cache, no-store, , must-revalidate" + self.pragma = "no-cache" + self.expires = "0" + super().__init__(*args, **kwargs) + + def file_response(self, *args: Any, **kwargs: Any) -> Response: + resp = super().file_response(*args, **kwargs) + resp.headers.setdefault("Cache-Control", self.cachecontrol) + resp.headers.setdefault("Pragma", self.pragma) + resp.headers.setdefault("Expires", self.expires) + return resp diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py new file mode 100644 index 0000000000000000000000000000000000000000..27c1d37388a8b34ad88a9a3489df7da1e06a7b32 --- /dev/null +++ b/invokeai/app/api/routers/app_info.py @@ -0,0 +1,186 @@ +import typing +from enum import Enum +from importlib.metadata import PackageNotFoundError, version +from pathlib import Path +from platform import python_version +from typing import Optional + +import torch +from fastapi import Body +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.invocations.upscale import ESRGAN_MODELS +from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus +from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch +from invokeai.backend.util.logging import logging +from invokeai.version import __version__ + + +class LogLevel(int, Enum): + NotSet = logging.NOTSET + Debug = logging.DEBUG + Info = logging.INFO + Warning = logging.WARNING + Error = logging.ERROR + 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"]) + + +class AppVersion(BaseModel): + """App Version Response""" + + version: str = Field(description="App version") + + highlights: Optional[list[str]] = Field(default=None, description="Highlights of release") + + +class AppDependencyVersions(BaseModel): + """App depencency Versions Response""" + + 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") + + +class AppConfig(BaseModel): + """App Config Response""" + + 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") + + +@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("/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, + ) + + +@app_router.get("/config", operation_id="get_config", status_code=200, response_model=AppConfig) +async def get_config() -> AppConfig: + infill_methods = ["lama", "tile", "cv2", "color"] # TODO: add mosaic back + if PatchMatch.patchmatch_available(): + infill_methods.append("patchmatch") + + 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) + + nsfw_methods = ["nsfw_checker"] + + watermarking_methods = ["invisible_watermark"] + + return AppConfig( + infill_methods=infill_methods, + upscaling_methods=[upscaler], + nsfw_methods=nsfw_methods, + watermarking_methods=watermarking_methods, + ) + + +@app_router.get( + "/logging", + operation_id="get_log_level", + responses={200: {"description": "The operation was successful"}}, + response_model=LogLevel, +) +async def get_log_level() -> LogLevel: + """Returns the log level""" + return LogLevel(ApiDependencies.invoker.services.logger.level) + + +@app_router.post( + "/logging", + operation_id="set_log_level", + responses={200: {"description": "The operation was successful"}}, + response_model=LogLevel, +) +async def set_log_level( + level: LogLevel = Body(description="New log verbosity level"), +) -> LogLevel: + """Sets the log verbosity level""" + ApiDependencies.invoker.services.logger.setLevel(level) + return LogLevel(ApiDependencies.invoker.services.logger.level) + + +@app_router.delete( + "/invocation_cache", + operation_id="clear_invocation_cache", + responses={200: {"description": "The operation was successful"}}, +) +async def clear_invocation_cache() -> None: + """Clears the invocation cache""" + ApiDependencies.invoker.services.invocation_cache.clear() + + +@app_router.put( + "/invocation_cache/enable", + operation_id="enable_invocation_cache", + responses={200: {"description": "The operation was successful"}}, +) +async def enable_invocation_cache() -> None: + """Clears the invocation cache""" + ApiDependencies.invoker.services.invocation_cache.enable() + + +@app_router.put( + "/invocation_cache/disable", + operation_id="disable_invocation_cache", + responses={200: {"description": "The operation was successful"}}, +) +async def disable_invocation_cache() -> None: + """Clears the invocation cache""" + ApiDependencies.invoker.services.invocation_cache.disable() + + +@app_router.get( + "/invocation_cache/status", + operation_id="get_invocation_cache_status", + responses={200: {"model": InvocationCacheStatus}}, +) +async def get_invocation_cache_status() -> InvocationCacheStatus: + """Clears the invocation cache""" + return ApiDependencies.invoker.services.invocation_cache.get_status() diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py new file mode 100644 index 0000000000000000000000000000000000000000..eb193f65855f4e52e2fe6d982e32bf4d32ce4174 --- /dev/null +++ b/invokeai/app/api/routers/board_images.py @@ -0,0 +1,112 @@ +from fastapi import Body, HTTPException +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from invokeai.app.api.dependencies import ApiDependencies + +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") + + +class RemoveImagesFromBoardResult(BaseModel): + removed_image_names: list[str] = Field(description="The image names that were removed from their board") + + +@board_images_router.post( + "/", + operation_id="add_image_to_board", + responses={ + 201: {"description": "The image was added to a board successfully"}, + }, + status_code=201, +) +async def add_image_to_board( + 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"), +): + """Creates a board_image""" + try: + result = ApiDependencies.invoker.services.board_images.add_image_to_board( + board_id=board_id, image_name=image_name + ) + return result + except Exception: + raise HTTPException(status_code=500, detail="Failed to add image to board") + + +@board_images_router.delete( + "/", + operation_id="remove_image_from_board", + responses={ + 201: {"description": "The image was removed from the board successfully"}, + }, + status_code=201, +) +async def remove_image_from_board( + image_name: str = Body(description="The name of the image to remove", embed=True), +): + """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 + except Exception: + raise HTTPException(status_code=500, detail="Failed to remove image from board") + + +@board_images_router.post( + "/batch", + operation_id="add_images_to_board", + responses={ + 201: {"description": "Images were added to board successfully"}, + }, + status_code=201, + response_model=AddImagesToBoardResult, +) +async def add_images_to_board( + 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""" + try: + added_image_names: list[str] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.board_images.add_image_to_board( + board_id=board_id, image_name=image_name + ) + added_image_names.append(image_name) + except Exception: + pass + return AddImagesToBoardResult(board_id=board_id, added_image_names=added_image_names) + except Exception: + raise HTTPException(status_code=500, detail="Failed to add images to board") + + +@board_images_router.post( + "/batch/delete", + operation_id="remove_images_from_board", + responses={ + 201: {"description": "Images were removed from board successfully"}, + }, + status_code=201, + response_model=RemoveImagesFromBoardResult, +) +async def remove_images_from_board( + 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] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) + removed_image_names.append(image_name) + except Exception: + pass + return RemoveImagesFromBoardResult(removed_image_names=removed_image_names) + 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 new file mode 100644 index 0000000000000000000000000000000000000000..e4654f953514f6df8417f90793379089ae60ccd8 --- /dev/null +++ b/invokeai/app/api/routers/boards.py @@ -0,0 +1,151 @@ +from typing import Optional, Union + +from fastapi import Body, HTTPException, Path, Query +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from invokeai.app.api.dependencies import ApiDependencies +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 invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection + +boards_router = APIRouter(prefix="/v1/boards", tags=["boards"]) + + +class DeleteBoardResult(BaseModel): + board_id: str = Field(description="The id of the board that was deleted.") + deleted_board_images: list[str] = Field( + description="The image names of the board-images relationships that were deleted." + ) + deleted_images: list[str] = Field(description="The names of the images that were deleted.") + + +@boards_router.post( + "/", + operation_id="create_board", + responses={ + 201: {"description": "The board was created successfully"}, + }, + status_code=201, + response_model=BoardDTO, +) +async def create_board( + board_name: str = Query(description="The name of the board to create"), + is_private: bool = Query(default=False, description="Whether the board is private"), +) -> BoardDTO: + """Creates a board""" + try: + result = ApiDependencies.invoker.services.boards.create(board_name=board_name) + return result + except Exception: + raise HTTPException(status_code=500, detail="Failed to create board") + + +@boards_router.get("/{board_id}", operation_id="get_board", response_model=BoardDTO) +async def get_board( + board_id: str = Path(description="The id of board to get"), +) -> BoardDTO: + """Gets a board""" + + 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") + + +@boards_router.patch( + "/{board_id}", + operation_id="update_board", + responses={ + 201: { + "description": "The board was updated successfully", + }, + }, + status_code=201, + response_model=BoardDTO, +) +async def update_board( + 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""" + try: + result = ApiDependencies.invoker.services.boards.update(board_id=board_id, changes=changes) + return result + except Exception: + raise HTTPException(status_code=500, detail="Failed to update board") + + +@boards_router.delete("/{board_id}", operation_id="delete_board", response_model=DeleteBoardResult) +async def delete_board( + 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""" + try: + if include_images is True: + deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( + board_id=board_id + ) + ApiDependencies.invoker.services.images.delete_images_on_board(board_id=board_id) + ApiDependencies.invoker.services.boards.delete(board_id=board_id) + return DeleteBoardResult( + board_id=board_id, + deleted_board_images=[], + deleted_images=deleted_images, + ) + else: + deleted_board_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( + board_id=board_id + ) + ApiDependencies.invoker.services.boards.delete(board_id=board_id) + return DeleteBoardResult( + board_id=board_id, + deleted_board_images=deleted_board_images, + deleted_images=[], + ) + except Exception: + raise HTTPException(status_code=500, detail="Failed to delete board") + + +@boards_router.get( + "/", + operation_id="list_boards", + response_model=Union[OffsetPaginatedResults[BoardDTO], list[BoardDTO]], +) +async def list_boards( + 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""" + if all: + return ApiDependencies.invoker.services.boards.get_all(order_by, direction, include_archived) + elif offset is not None and limit is not None: + return ApiDependencies.invoker.services.boards.get_many(order_by, direction, offset, limit, include_archived) + else: + raise HTTPException( + status_code=400, + detail="Invalid request: Must provide either 'all' or both 'offset' and 'limit'", + ) + + +@boards_router.get( + "/{board_id}/image_names", + operation_id="list_all_board_image_names", + response_model=list[str], +) +async def list_all_board_image_names( + board_id: str = Path(description="The id of the board"), +) -> list[str]: + """Gets a list of images for a board""" + + image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( + board_id, + ) + return image_names diff --git a/invokeai/app/api/routers/download_queue.py b/invokeai/app/api/routers/download_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..2633b28bca28a52725cc1b64138d082c26dcd724 --- /dev/null +++ b/invokeai/app/api/routers/download_queue.py @@ -0,0 +1,110 @@ +# Copyright (c) 2023 Lincoln D. Stein +"""FastAPI route for the download queue.""" + +from typing import List, Optional + +from fastapi import Body, Path, Response +from fastapi.routing import APIRouter +from pydantic.networks import AnyHttpUrl +from starlette.exceptions import HTTPException + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.download import ( + DownloadJob, + UnknownJobIDException, +) + +download_queue_router = APIRouter(prefix="/v1/download_queue", tags=["download_queue"]) + + +@download_queue_router.get( + "/", + operation_id="list_downloads", +) +async def list_downloads() -> List[DownloadJob]: + """Get a list of active and inactive jobs.""" + queue = ApiDependencies.invoker.services.download_queue + return queue.list_jobs() + + +@download_queue_router.patch( + "/", + operation_id="prune_downloads", + responses={ + 204: {"description": "All completed jobs have been pruned"}, + 400: {"description": "Bad request"}, + }, +) +async def prune_downloads() -> Response: + """Prune completed and errored jobs.""" + queue = ApiDependencies.invoker.services.download_queue + queue.prune_jobs() + return Response(status_code=204) + + +@download_queue_router.post( + "/i/", + operation_id="download", +) +async def download( + 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.""" + queue = ApiDependencies.invoker.services.download_queue + return queue.download(source, Path(dest), priority, access_token) + + +@download_queue_router.get( + "/i/{id}", + operation_id="get_download_job", + responses={ + 200: {"description": "Success"}, + 404: {"description": "The requested download JobID could not be found"}, + }, +) +async def get_download_job( + id: int = Path(description="ID of the download job to fetch."), +) -> DownloadJob: + """Get a download job using its ID.""" + try: + job = ApiDependencies.invoker.services.download_queue.id_to_job(id) + return job + except UnknownJobIDException as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@download_queue_router.delete( + "/i/{id}", + operation_id="cancel_download_job", + responses={ + 204: {"description": "Job has been cancelled"}, + 404: {"description": "The requested download JobID could not be found"}, + }, +) +async def cancel_download_job( + id: int = Path(description="ID of the download job to cancel."), +) -> Response: + """Cancel a download job using its ID.""" + try: + queue = ApiDependencies.invoker.services.download_queue + job = queue.id_to_job(id) + queue.cancel_job(job) + return Response(status_code=204) + except UnknownJobIDException as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@download_queue_router.delete( + "/i", + operation_id="cancel_all_download_jobs", + responses={ + 204: {"description": "Download jobs have been cancelled"}, + }, +) +async def cancel_all_download_jobs() -> 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 new file mode 100644 index 0000000000000000000000000000000000000000..14652ea78483b81652beef5ff6c84519dff68277 --- /dev/null +++ b/invokeai/app/api/routers/images.py @@ -0,0 +1,464 @@ +import io +import traceback +from typing import 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 invokeai.app.api.dependencies import ApiDependencies +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.shared.pagination import OffsetPaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection + +images_router = APIRouter(prefix="/v1/images", tags=["images"]) + + +# images are immutable; set a high max-age +IMAGE_MAX_AGE = 31536000 + + +@images_router.post( + "/upload", + operation_id="upload_image", + responses={ + 201: {"description": "The image was uploaded successfully"}, + 415: {"description": "Image upload failed"}, + }, + status_code=201, + response_model=ImageDTO, +) +async def upload_image( + file: UploadFile, + request: Request, + response: Response, + image_category: ImageCategory = Query(description="The category of the image"), + is_intermediate: bool = Query(description="Whether this is an intermediate 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 + ), +) -> ImageDTO: + """Uploads an image""" + 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 + + try: + image_dto = ApiDependencies.invoker.services.images.create( + image=pil_image, + image_origin=ResourceOrigin.EXTERNAL, + image_category=image_category, + session_id=session_id, + board_id=board_id, + metadata=_metadata, + workflow=_workflow, + graph=_graph, + is_intermediate=is_intermediate, + ) + + response.status_code = 201 + response.headers["Location"] = image_dto.image_url + + return image_dto + except Exception: + ApiDependencies.invoker.services.logger.error(traceback.format_exc()) + raise HTTPException(status_code=500, detail="Failed to create image") + + +@images_router.delete("/i/{image_name}", operation_id="delete_image") +async def delete_image( + image_name: str = Path(description="The name of the image to delete"), +) -> None: + """Deletes an image""" + + try: + ApiDependencies.invoker.services.images.delete(image_name) + except Exception: + # TODO: Does this need any exception handling at all? + pass + + +@images_router.delete("/intermediates", operation_id="clear_intermediates") +async def clear_intermediates() -> int: + """Clears 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""" + + try: + return ApiDependencies.invoker.services.images.get_intermediates_count() + except Exception: + raise HTTPException(status_code=500, detail="Failed to get intermediates") + pass + + +@images_router.patch( + "/i/{image_name}", + operation_id="update_image", + response_model=ImageDTO, +) +async def update_image( + 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""" + + try: + return ApiDependencies.invoker.services.images.update(image_name, image_changes) + except Exception: + raise HTTPException(status_code=400, detail="Failed to update image") + + +@images_router.get( + "/i/{image_name}", + operation_id="get_image_dto", + response_model=ImageDTO, +) +async def get_image_dto( + image_name: str = Path(description="The name of image to get"), +) -> ImageDTO: + """Gets an image's DTO""" + + try: + return ApiDependencies.invoker.services.images.get_dto(image_name) + except Exception: + raise HTTPException(status_code=404) + + +@images_router.get( + "/i/{image_name}/metadata", + operation_id="get_image_metadata", + response_model=Optional[MetadataField], +) +async def get_image_metadata( + image_name: str = Path(description="The name of image to get"), +) -> Optional[MetadataField]: + """Gets an image's metadata""" + + try: + return ApiDependencies.invoker.services.images.get_metadata(image_name) + except Exception: + raise HTTPException(status_code=404) + + +class WorkflowAndGraphResponse(BaseModel): + workflow: Optional[str] = Field(description="The workflow used to generate the image, as stringified JSON") + graph: Optional[str] = Field(description="The graph used to generate the image, as stringified JSON") + + +@images_router.get( + "/i/{image_name}/workflow", operation_id="get_image_workflow", response_model=WorkflowAndGraphResponse +) +async def get_image_workflow( + image_name: str = Path(description="The name of image whose workflow to get"), +) -> WorkflowAndGraphResponse: + try: + workflow = ApiDependencies.invoker.services.images.get_workflow(image_name) + graph = ApiDependencies.invoker.services.images.get_graph(image_name) + return WorkflowAndGraphResponse(workflow=workflow, graph=graph) + except Exception: + raise HTTPException(status_code=404) + + +@images_router.get( + "/i/{image_name}/full", + operation_id="get_image_full", + response_class=Response, + responses={ + 200: { + "description": "Return the full-resolution image", + "content": {"image/png": {}}, + }, + 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"), +) -> Response: + """Gets a full-resolution image file""" + + try: + path = ApiDependencies.invoker.services.images.get_path(image_name) + 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) + + +@images_router.get( + "/i/{image_name}/thumbnail", + operation_id="get_image_thumbnail", + response_class=Response, + responses={ + 200: { + "description": "Return the image thumbnail", + "content": {"image/webp": {}}, + }, + 404: {"description": "Image not found"}, + }, +) +async def get_image_thumbnail( + image_name: str = Path(description="The name of thumbnail image file to get"), +) -> Response: + """Gets a thumbnail image file""" + + try: + path = ApiDependencies.invoker.services.images.get_path(image_name, thumbnail=True) + 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: + raise HTTPException(status_code=404) + + +@images_router.get( + "/i/{image_name}/urls", + operation_id="get_image_urls", + response_model=ImageUrlsDTO, +) +async def get_image_urls( + image_name: str = Path(description="The name of the image whose URL to get"), +) -> ImageUrlsDTO: + """Gets an image and thumbnail URL""" + + try: + image_url = ApiDependencies.invoker.services.images.get_url(image_name) + thumbnail_url = ApiDependencies.invoker.services.images.get_url(image_name, thumbnail=True) + return ImageUrlsDTO( + image_name=image_name, + image_url=image_url, + thumbnail_url=thumbnail_url, + ) + except Exception: + raise HTTPException(status_code=404) + + +@images_router.get( + "/", + operation_id="list_image_dtos", + response_model=OffsetPaginatedResults[ImageDTO], +) +async def list_image_dtos( + 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.", + ), + 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""" + + image_dtos = ApiDependencies.invoker.services.images.get_many( + offset, limit, starred_first, order_dir, image_origin, categories, is_intermediate, board_id, search_term + ) + + return image_dtos + + +class DeleteImagesFromListResult(BaseModel): + deleted_images: list[str] + + +@images_router.post("/delete", operation_id="delete_images_from_list", response_model=DeleteImagesFromListResult) +async def delete_images_from_list( + image_names: list[str] = Body(description="The list of names of images to delete", embed=True), +) -> DeleteImagesFromListResult: + try: + deleted_images: list[str] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.images.delete(image_name) + deleted_images.append(image_name) + except Exception: + pass + return DeleteImagesFromListResult(deleted_images=deleted_images) + except Exception: + raise HTTPException(status_code=500, detail="Failed to delete images") + + +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) +async def star_images_in_list( + image_names: list[str] = Body(description="The list of names of images to star", embed=True), +) -> ImagesUpdatedFromListResult: + try: + updated_image_names: list[str] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=True)) + updated_image_names.append(image_name) + except Exception: + pass + return ImagesUpdatedFromListResult(updated_image_names=updated_image_names) + 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) +async def unstar_images_in_list( + image_names: list[str] = Body(description="The list of names of images to unstar", embed=True), +) -> ImagesUpdatedFromListResult: + try: + updated_image_names: list[str] = [] + for image_name in image_names: + try: + ApiDependencies.invoker.services.images.update(image_name, changes=ImageRecordChanges(starred=False)) + updated_image_names.append(image_name) + except Exception: + pass + return ImagesUpdatedFromListResult(updated_image_names=updated_image_names) + except Exception: + raise HTTPException(status_code=500, detail="Failed to unstar images") + + +class ImagesDownloaded(BaseModel): + response: Optional[str] = Field( + default=None, description="The message to display to the user when images begin downloading" + ) + bulk_download_item_name: Optional[str] = Field( + default=None, description="The name of the bulk download item for which events will be emitted" + ) + + +@images_router.post( + "/download", operation_id="download_images_from_list", response_model=ImagesDownloaded, status_code=202 +) +async def download_images_from_list( + background_tasks: BackgroundTasks, + image_names: Optional[list[str]] = Body( + default=None, description="The list of names of images to download", embed=True + ), + board_id: Optional[str] = Body( + default=None, description="The board from which image should be downloaded", embed=True + ), +) -> 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.") + bulk_download_item_id: str = ApiDependencies.invoker.services.bulk_download.generate_item_id(board_id) + + background_tasks.add_task( + ApiDependencies.invoker.services.bulk_download.handler, + image_names, + board_id, + bulk_download_item_id, + ) + return ImagesDownloaded(bulk_download_item_name=bulk_download_item_id + ".zip") + + +@images_router.api_route( + "/download/{bulk_download_item_name}", + methods=["GET"], + operation_id="get_bulk_download_item", + response_class=Response, + responses={ + 200: { + "description": "Return the complete bulk download item", + "content": {"application/zip": {}}, + }, + 404: {"description": "Image not found"}, + }, +) +async def get_bulk_download_item( + 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""" + try: + path = ApiDependencies.invoker.services.bulk_download.get_path(bulk_download_item_name) + + response = FileResponse( + path, + media_type="application/zip", + filename=bulk_download_item_name, + content_disposition_type="inline", + ) + 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 Exception: + raise HTTPException(status_code=404) diff --git a/invokeai/app/api/routers/model_manager.py b/invokeai/app/api/routers/model_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..206e95bb8014080e88b0dbabc1d89e64e901af6d --- /dev/null +++ b/invokeai/app/api/routers/model_manager.py @@ -0,0 +1,976 @@ +# 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 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 +from PIL import Image +from pydantic import AnyHttpUrl, BaseModel, ConfigDict, Field +from starlette.exceptions import HTTPException +from typing_extensions import Annotated + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.config import get_config +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 ( + InvalidModelException, + ModelRecordChanges, + UnknownModelException, +) +from invokeai.app.util.suppress_output import SuppressOutput +from invokeai.backend.model_manager.config import ( + AnyModelConfig, + BaseModelType, + MainCheckpointConfig, + ModelFormat, + ModelType, +) +from invokeai.backend.model_manager.load.model_cache.model_cache_base 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.search import ModelSearch +from invokeai.backend.model_manager.starter_models import ( + STARTER_BUNDLES, + STARTER_MODELS, + StarterModel, + StarterModelWithoutDependencies, +) + +model_manager_router = APIRouter(prefix="/v2/models", tags=["model_manager"]) + +# images are immutable; set a high max-age +IMAGE_MAX_AGE = 31536000 + + +class ModelsList(BaseModel): + """Return list of configs.""" + + models: List[AnyModelConfig] + + 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 + + +############################################################################## +# These are example inputs and outputs that are used in places where Swagger +# is unable to generate a correct example. +############################################################################## +example_model_config = { + "path": "string", + "name": "string", + "base": "sd-1", + "type": "main", + "format": "checkpoint", + "config_path": "string", + "key": "string", + "hash": "string", + "description": "string", + "source": "string", + "converted_at": 0, + "variant": "normal", + "prediction_type": "epsilon", + "repo_variant": "fp16", + "upcast_attention": False, +} + +example_model_input = { + "path": "/path/to/model", + "name": "model_name", + "base": "sd-1", + "type": "main", + "format": "checkpoint", + "config_path": "configs/stable-diffusion/v1-inference.yaml", + "description": "Model description", + "vae": None, + "variant": "normal", +} + +############################################################################## +# ROUTES +############################################################################## + + +@model_manager_router.get( + "/", + operation_id="list_model_records", +) +async def list_model_records( + base_models: Optional[List[BaseModelType]] = Query(default=None, description="Base models to include"), + model_type: Optional[ModelType] = Query(default=None, description="The type of model to get"), + model_name: Optional[str] = Query(default=None, description="Exact match on the name of the model"), + model_format: Optional[ModelFormat] = Query( + default=None, description="Exact match on the format of the model (e.g. 'diffusers')" + ), +) -> ModelsList: + """Get a list of models.""" + record_store = ApiDependencies.invoker.services.model_manager.store + found_models: list[AnyModelConfig] = [] + if base_models: + 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 + ) + ) + else: + found_models.extend( + record_store.search_by_attr(model_type=model_type, model_name=model_name, model_format=model_format) + ) + for model in found_models: + model = add_cover_image_to_model_config(model, ApiDependencies) + return ModelsList(models=found_models) + + +@model_manager_router.get( + "/get_by_attrs", + operation_id="get_model_records_by_attrs", + response_model=AnyModelConfig, +) +async def get_model_records_by_attrs( + name: str = Query(description="The name of the model"), + type: ModelType = Query(description="The type of the model"), + base: BaseModelType = Query(description="The base model of the model"), +) -> AnyModelConfig: + """Gets a model by its attributes. The main use of this route is to provide backwards compatibility with the old + model manager, which identified models by a combination of name, base and type.""" + configs = ApiDependencies.invoker.services.model_manager.store.search_by_attr( + base_model=base, model_type=type, model_name=name + ) + if not configs: + raise HTTPException(status_code=404, detail="No model found with these attributes") + + return configs[0] + + +@model_manager_router.get( + "/i/{key}", + operation_id="get_model_record", + 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 get_model_record( + key: str = Path(description="Key of the model record to fetch."), +) -> AnyModelConfig: + """Get a model record""" + try: + config = ApiDependencies.invoker.services.model_manager.store.get_model(key) + return add_cover_image_to_model_config(config, ApiDependencies) + except UnknownModelException as e: + raise HTTPException(status_code=404, detail=str(e)) + + +class FoundModel(BaseModel): + path: str = Field(description="Path to the model") + is_installed: bool = Field(description="Whether or not the model is already installed") + + +@model_manager_router.get( + "/scan_folder", + operation_id="scan_for_models", + responses={ + 200: {"description": "Directory scanned successfully"}, + 400: {"description": "Invalid directory path"}, + }, + status_code=200, + response_model=List[FoundModel], +) +async def scan_for_models( + scan_path: str = Query(description="Directory path to search for models", default=None), +) -> List[FoundModel]: + path = pathlib.Path(scan_path) + if not scan_path or not path.is_dir(): + raise HTTPException( + status_code=400, + detail=f"The search path '{scan_path}' does not exist or is not directory", + ) + + search = ModelSearch() + try: + found_model_paths = search.search(path) + models_path = ApiDependencies.invoker.services.configuration.models_path + + # If the search path includes the main models directory, we need to exclude core models from the list. + # TODO(MM2): Core models should be handled by the model manager so we can determine if they are installed + # without needing to crawl the filesystem. + core_models_path = pathlib.Path(models_path, "core").resolve() + non_core_model_paths = [p for p in found_model_paths if not p.is_relative_to(core_models_path)] + + installed_models = ApiDependencies.invoker.services.model_manager.store.search_by_attr() + + scan_results: list[FoundModel] = [] + + # Check if the model is installed by comparing paths, appending to the scan result. + for p in non_core_model_paths: + path = str(p) + is_installed = any(str(models_path / m.path) == path for m in installed_models) + found_model = FoundModel(path=path, is_installed=is_installed) + scan_results.append(found_model) + except Exception as e: + raise HTTPException( + status_code=500, + detail=f"An error occurred while searching the directory: {e}", + ) + return scan_results + + +class HuggingFaceModels(BaseModel): + urls: List[AnyHttpUrl] | None = Field(description="URLs for all checkpoint format models in the metadata") + is_diffusers: bool = Field(description="Whether the metadata is for a Diffusers format model") + + +@model_manager_router.get( + "/hugging_face", + operation_id="get_hugging_face_models", + responses={ + 200: {"description": "Hugging Face repo scanned successfully"}, + 400: {"description": "Invalid hugging face repo"}, + }, + status_code=200, + response_model=HuggingFaceModels, +) +async def get_hugging_face_models( + hugging_face_repo: str = Query(description="Hugging face repo to search for models", default=None), +) -> HuggingFaceModels: + try: + metadata = HuggingFaceMetadataFetch().from_id(hugging_face_repo) + except UnknownMetadataException: + raise HTTPException( + status_code=400, + detail="No HuggingFace repository found", + ) + + assert isinstance(metadata, ModelMetadataWithFiles) + + return HuggingFaceModels( + urls=metadata.ckpt_urls, + is_diffusers=metadata.is_diffusers, + ) + + +@model_manager_router.patch( + "/i/{key}", + operation_id="update_model_record", + responses={ + 200: { + "description": "The model was updated successfully", + "content": {"application/json": {"example": example_model_config}}, + }, + 400: {"description": "Bad request"}, + 404: {"description": "The model could not be found"}, + 409: {"description": "There is already a model corresponding to the new name"}, + }, + status_code=200, +) +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)], +) -> 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) + logger.info(f"Updated model: {key}") + except UnknownModelException as e: + raise HTTPException(status_code=404, detail=str(e)) + except ValueError as e: + logger.error(str(e)) + raise HTTPException(status_code=409, detail=str(e)) + return config + + +@model_manager_router.get( + "/i/{key}/image", + operation_id="get_model_image", + responses={ + 200: { + "description": "The model image was fetched successfully", + }, + 400: {"description": "Bad request"}, + 404: {"description": "The model image could not be found"}, + }, + status_code=200, +) +async def get_model_image( + key: str = Path(description="The name of model image file to get"), +) -> FileResponse: + """Gets an image file that previews the model""" + + try: + path = ApiDependencies.invoker.services.model_images.get_path(key) + + response = FileResponse( + path, + media_type="image/png", + filename=key + ".png", + content_disposition_type="inline", + ) + response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}" + return response + except Exception: + raise HTTPException(status_code=404) + + +@model_manager_router.patch( + "/i/{key}/image", + operation_id="update_model_image", + responses={ + 200: { + "description": "The model image was updated successfully", + }, + 400: {"description": "Bad request"}, + }, + status_code=200, +) +async def update_model_image( + key: Annotated[str, Path(description="Unique key of model")], + image: UploadFile, +) -> 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") + + logger = ApiDependencies.invoker.services.logger + model_images = ApiDependencies.invoker.services.model_images + try: + model_images.save(pil_image, key) + logger.info(f"Updated image for model: {key}") + except ValueError as e: + logger.error(str(e)) + raise HTTPException(status_code=409, detail=str(e)) + return + + +@model_manager_router.delete( + "/i/{key}", + operation_id="delete_model", + responses={ + 204: {"description": "Model deleted successfully"}, + 404: {"description": "Model not found"}, + }, + status_code=204, +) +async def delete_model( + key: str = Path(description="Unique key of model to remove from model registry."), +) -> Response: + """ + Delete model record from database. + + The configuration record will be removed. The corresponding weights files will be + deleted as well if they reside within the InvokeAI "models" directory. + """ + logger = ApiDependencies.invoker.services.logger + + try: + installer = ApiDependencies.invoker.services.model_manager.install + installer.delete(key) + logger.info(f"Deleted model: {key}") + return Response(status_code=204) + except UnknownModelException as e: + logger.error(str(e)) + raise HTTPException(status_code=404, detail=str(e)) + + +@model_manager_router.delete( + "/i/{key}/image", + operation_id="delete_model_image", + responses={ + 204: {"description": "Model image deleted successfully"}, + 404: {"description": "Model image not found"}, + }, + status_code=204, +) +async def delete_model_image( + key: str = Path(description="Unique key of model image to remove from model_images directory."), +) -> None: + logger = ApiDependencies.invoker.services.logger + model_images = ApiDependencies.invoker.services.model_images + try: + model_images.delete(key) + logger.info(f"Deleted model image: {key}") + return + except UnknownModelException as e: + logger.error(str(e)) + raise HTTPException(status_code=404, detail=str(e)) + + +@model_manager_router.post( + "/install", + operation_id="install_model", + responses={ + 201: {"description": "The model imported successfully"}, + 415: {"description": "Unrecognized file/folder format"}, + 424: {"description": "The model appeared to import successfully, but could not be found in the model manager"}, + 409: {"description": "There is already a model corresponding to this path or repo_id"}, + }, + status_code=201, +) +async def install_model( + 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), + 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 ", + example={"name": "string", "description": "string"}, + ), +) -> ModelInstallJob: + """Install a model using a string identifier. + + `source` can be any of the following. + + 1. A path on the local filesystem ('C:\\users\\fred\\model.safetensors') + 2. A Url pointing to a single downloadable model file + 3. A HuggingFace repo_id with any of the following formats: + - model/name + - model/name:fp16:vae + - model/name::vae -- use default precision + - model/name:fp16:path/to/model.safetensors + - model/name::path/to/model.safetensors + + `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. + + Models will be downloaded, probed, configured and installed in a + series of background threads. The return object has `status` attribute + that can be used to monitor progress. + + See the documentation for `import_model_record` for more information on + interpreting the job information returned by this route. + """ + logger = ApiDependencies.invoker.services.logger + + try: + installer = ApiDependencies.invoker.services.model_manager.install + result: ModelInstallJob = installer.heuristic_import( + source=source, + config=config, + access_token=access_token, + inplace=bool(inplace), + ) + logger.info(f"Started installation of {source}") + except UnknownModelException as e: + logger.error(str(e)) + raise HTTPException(status_code=424, detail=str(e)) + except InvalidModelException as e: + logger.error(str(e)) + raise HTTPException(status_code=415) + except ValueError as e: + logger.error(str(e)) + raise HTTPException(status_code=409, detail=str(e)) + return result + + +@model_manager_router.get( + "/install/huggingface", + operation_id="install_hugging_face_model", + responses={ + 201: {"description": "The model is being installed"}, + 400: {"description": "Bad request"}, + 409: {"description": "There is already a model corresponding to this path or repo_id"}, + }, + status_code=201, + response_class=HTMLResponse, +) +async def install_hugging_face_model( + source: str = Query(description="HuggingFace repo_id to install"), +) -> HTMLResponse: + """Install a Hugging Face model using a string identifier.""" + + def generate_html(title: str, heading: str, repo_id: str, is_error: bool, message: str | None = "") -> str: + if message: + message = f"

{message}

" + title_class = "error" if is_error else "success" + return f""" + + + + {title} + + + + +
+
+

{heading}

+ {message} +

Repo ID: {repo_id}

+
+
+ + + + """ + + try: + metadata = HuggingFaceMetadataFetch().from_id(source) + assert isinstance(metadata, ModelMetadataWithFiles) + except UnknownMetadataException: + title = "Unable to Install Model" + heading = "No HuggingFace repository found with that repo ID." + message = "Ensure the repo ID is correct and try again." + return HTMLResponse(content=generate_html(title, heading, source, True, message), status_code=400) + + logger = ApiDependencies.invoker.services.logger + + try: + installer = ApiDependencies.invoker.services.model_manager.install + if metadata.is_diffusers: + installer.heuristic_import( + source=source, + inplace=False, + ) + elif metadata.ckpt_urls is not None and len(metadata.ckpt_urls) == 1: + installer.heuristic_import( + source=str(metadata.ckpt_urls[0]), + inplace=False, + ) + else: + title = "Unable to Install Model" + heading = "This HuggingFace repo has multiple models." + message = "Please use the Model Manager to install this model." + return HTMLResponse(content=generate_html(title, heading, source, True, message), status_code=200) + + title = "Model Install Started" + heading = "Your HuggingFace model is installing now." + message = "You can close this tab and check the Model Manager for installation progress." + return HTMLResponse(content=generate_html(title, heading, source, False, message), status_code=201) + except Exception as e: + logger.error(str(e)) + title = "Unable to Install Model" + heading = "There was an problem installing this model." + message = 'Please use the Model Manager directly to install this model. If the issue persists, ask for help on discord.' + return HTMLResponse(content=generate_html(title, heading, source, True, message), status_code=500) + + +@model_manager_router.get( + "/install", + operation_id="list_model_installs", +) +async def list_model_installs() -> List[ModelInstallJob]: + """Return the list of model install jobs. + + Install jobs have a numeric `id`, a `status`, and other fields that provide information on + the nature of the job and its progress. The `status` is one of: + + * "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 + * "completed" -- Installation completed successfully + * "error" -- An error occurred. Details will be in the "error_type" and "error" fields. + * "cancelled" -- Job was cancelled before completion. + + Once completed, information about the model such as its size, base + model and type can be retrieved from the `config_out` field. For multi-file models such as diffusers, + information on individual files can be retrieved from `download_parts`. + + See the example and schema below for more information. + """ + jobs: List[ModelInstallJob] = ApiDependencies.invoker.services.model_manager.install.list_jobs() + return jobs + + +@model_manager_router.get( + "/install/{id}", + operation_id="get_model_install_job", + responses={ + 200: {"description": "Success"}, + 404: {"description": "No such job"}, + }, +) +async def get_model_install_job(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. + """ + try: + result: ModelInstallJob = ApiDependencies.invoker.services.model_manager.install.get_job_by_id(id) + return result + except ValueError as e: + raise HTTPException(status_code=404, detail=str(e)) + + +@model_manager_router.delete( + "/install/{id}", + operation_id="cancel_model_install_job", + responses={ + 201: {"description": "The job was cancelled successfully"}, + 415: {"description": "No such job"}, + }, + status_code=201, +) +async def cancel_model_install_job(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: + job = installer.get_job_by_id(id) + except ValueError as e: + raise HTTPException(status_code=415, detail=str(e)) + installer.cancel_job(job) + + +@model_manager_router.delete( + "/install", + operation_id="prune_model_install_jobs", + responses={ + 204: {"description": "All completed and errored jobs have been pruned"}, + 400: {"description": "Bad request"}, + }, +) +async def prune_model_install_jobs() -> 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) + + +@model_manager_router.put( + "/convert/{key}", + operation_id="convert_model", + responses={ + 200: { + "description": "Model converted successfully", + "content": {"application/json": {"example": example_model_config}}, + }, + 400: {"description": "Bad request"}, + 404: {"description": "Model not found"}, + 409: {"description": "There is already a model registered at this location"}, + }, +) +async def convert_model( + key: str = Path(description="Unique key of the safetensors main model to convert to diffusers format."), +) -> AnyModelConfig: + """ + Permanently convert a model into diffusers format, replacing the safetensors version. + Note that during the conversion process the key and model hash will change. + The return value is the model configuration for the converted model. + """ + model_manager = ApiDependencies.invoker.services.model_manager + loader = model_manager.load + logger = ApiDependencies.invoker.services.logger + store = ApiDependencies.invoker.services.model_manager.store + installer = ApiDependencies.invoker.services.model_manager.install + + try: + model_config = store.get_model(key) + except UnknownModelException as e: + 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.") + + 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: + model_image = ApiDependencies.invoker.services.model_images.get(key) + ApiDependencies.invoker.services.model_images.save(model_image, new_key) + ApiDependencies.invoker.services.model_images.delete(key) + except ModelImageFileNotFoundException: + pass + + # delete the original safetensors file + installer.delete(key) + + # 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) + return new_config + + +class StarterModelResponse(BaseModel): + starter_models: list[StarterModel] + starter_bundles: dict[str, list[StarterModel]] + + +def get_is_installed( + starter_model: StarterModel | StarterModelWithoutDependencies, installed_models: list[AnyModelConfig] +) -> bool: + for model in installed_models: + if model.source == starter_model.source: + return True + 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 + 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() + starter_models = deepcopy(STARTER_MODELS) + starter_bundles = deepcopy(STARTER_BUNDLES) + for model in starter_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 + + for bundle in starter_bundles.values(): + for model in bundle: + 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( + "/model_cache", + operation_id="get_cache_size", + response_model=float, + summary="Get maximum size of model manager RAM or VRAM cache.", +) +async def get_cache_size(cache_type: CacheType = Query(description="The cache type", default=CacheType.RAM)) -> float: + """Return the current RAM or VRAM cache size setting (in GB).""" + cache = ApiDependencies.invoker.services.model_manager.load.ram_cache + value = 0.0 + if cache_type == CacheType.RAM: + value = cache.max_cache_size + elif cache_type == CacheType.VRAM: + value = cache.max_vram_cache_size + return value + + +@model_manager_router.put( + "/model_cache", + operation_id="set_cache_size", + response_model=float, + summary="Set maximum size of model manager RAM or VRAM cache, optionally writing new value out to invokeai.yaml config file.", +) +async def set_cache_size( + value: float = Query(description="The new value for the maximum cache size"), + cache_type: CacheType = Query(description="The cache type", default=CacheType.RAM), + persist: bool = Query(description="Write new value out to invokeai.yaml", default=False), +) -> float: + """Set the current RAM or VRAM cache size setting (in GB). .""" + cache = ApiDependencies.invoker.services.model_manager.load.ram_cache + app_config = get_config() + # Record initial state. + vram_old = app_config.vram + ram_old = app_config.ram + + # Prepare target state. + vram_new = vram_old + ram_new = ram_old + if cache_type == CacheType.RAM: + ram_new = value + elif cache_type == CacheType.VRAM: + vram_new = value + else: + raise ValueError(f"Unexpected {cache_type=}.") + + config_path = app_config.config_file_path + new_config_path = config_path.with_suffix(".yaml.new") + + try: + # Try to apply the target state. + cache.max_vram_cache_size = vram_new + cache.max_cache_size = ram_new + app_config.ram = ram_new + app_config.vram = vram_new + if persist: + app_config.write_file(new_config_path) + shutil.move(new_config_path, config_path) + except Exception as e: + # If there was a failure, restore the initial state. + cache.max_cache_size = ram_old + cache.max_vram_cache_size = vram_old + app_config.ram = ram_old + app_config.vram = vram_old + + raise RuntimeError("Failed to update cache size") from e + return value + + +@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 + + +class HFTokenStatus(str, Enum): + VALID = "valid" + INVALID = "invalid" + UNKNOWN = "unknown" + + +class HFTokenHelper: + @classmethod + def get_status(cls) -> HFTokenStatus: + try: + if huggingface_hub.get_token_permission(huggingface_hub.get_token()): + # Valid token! + return HFTokenStatus.VALID + # No token set + return HFTokenStatus.INVALID + 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() + + +@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( + 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 diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py new file mode 100644 index 0000000000000000000000000000000000000000..7db8c8a77b203e0412f7d2db2134a5eb0df8f99e --- /dev/null +++ b/invokeai/app/api/routers/session_queue.py @@ -0,0 +1,260 @@ +from typing import Optional + +from fastapi import Body, Path, Query +from fastapi.routing import APIRouter +from pydantic import BaseModel + +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, + CancelByBatchIDsResult, + CancelByDestinationResult, + ClearResult, + EnqueueBatchResult, + PruneResult, + SessionQueueCountsByDestination, + SessionQueueItem, + SessionQueueItemDTO, + SessionQueueStatus, +) +from invokeai.app.services.shared.pagination import CursorPaginatedResults + +session_queue_router = APIRouter(prefix="/v1/queue", tags=["queue"]) + + +class SessionQueueAndProcessorStatus(BaseModel): + """The overall status of session queue and processor""" + + queue: SessionQueueStatus + processor: SessionProcessorStatus + + +@session_queue_router.post( + "/{queue_id}/enqueue_batch", + operation_id="enqueue_batch", + responses={ + 201: {"model": EnqueueBatchResult}, + }, +) +async def enqueue_batch( + 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.""" + + return ApiDependencies.invoker.services.session_queue.enqueue_batch(queue_id=queue_id, batch=batch, prepend=prepend) + + +@session_queue_router.get( + "/{queue_id}/list", + operation_id="list_queue_items", + responses={ + 200: {"model": CursorPaginatedResults[SessionQueueItemDTO]}, + }, +) +async def list_queue_items( + 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)""" + + return ApiDependencies.invoker.services.session_queue.list_queue_items( + queue_id=queue_id, limit=limit, status=status, cursor=cursor, priority=priority + ) + + +@session_queue_router.put( + "/{queue_id}/processor/resume", + operation_id="resume", + responses={200: {"model": SessionProcessorStatus}}, +) +async def resume( + queue_id: str = Path(description="The queue id to perform this operation on"), +) -> SessionProcessorStatus: + """Resumes session processor""" + return ApiDependencies.invoker.services.session_processor.resume() + + +@session_queue_router.put( + "/{queue_id}/processor/pause", + operation_id="pause", + responses={200: {"model": SessionProcessorStatus}}, +) +async def Pause( + queue_id: str = Path(description="The queue id to perform this operation on"), +) -> SessionProcessorStatus: + """Pauses session processor""" + return ApiDependencies.invoker.services.session_processor.pause() + + +@session_queue_router.put( + "/{queue_id}/cancel_by_batch_ids", + operation_id="cancel_by_batch_ids", + responses={200: {"model": CancelByBatchIDsResult}}, +) +async def cancel_by_batch_ids( + 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) + + +@session_queue_router.put( + "/{queue_id}/cancel_by_destination", + operation_id="cancel_by_destination", + responses={200: {"model": CancelByBatchIDsResult}}, +) +async def cancel_by_destination( + 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 origin""" + return ApiDependencies.invoker.services.session_queue.cancel_by_destination( + queue_id=queue_id, destination=destination + ) + + +@session_queue_router.put( + "/{queue_id}/clear", + operation_id="clear", + responses={ + 200: {"model": ClearResult}, + }, +) +async def clear( + 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 + + +@session_queue_router.put( + "/{queue_id}/prune", + operation_id="prune", + responses={ + 200: {"model": PruneResult}, + }, +) +async def prune( + 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) + + +@session_queue_router.get( + "/{queue_id}/current", + operation_id="get_current_queue_item", + responses={ + 200: {"model": Optional[SessionQueueItem]}, + }, +) +async def get_current_queue_item( + 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) + + +@session_queue_router.get( + "/{queue_id}/next", + operation_id="get_next_queue_item", + responses={ + 200: {"model": Optional[SessionQueueItem]}, + }, +) +async def get_next_queue_item( + 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) + + +@session_queue_router.get( + "/{queue_id}/status", + operation_id="get_queue_status", + responses={ + 200: {"model": SessionQueueAndProcessorStatus}, + }, +) +async def get_queue_status( + 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) + + +@session_queue_router.get( + "/{queue_id}/b/{batch_id}/status", + operation_id="get_batch_status", + responses={ + 200: {"model": BatchStatus}, + }, +) +async def get_batch_status( + 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) + + +@session_queue_router.get( + "/{queue_id}/i/{item_id}", + operation_id="get_queue_item", + responses={ + 200: {"model": SessionQueueItem}, + }, + response_model_exclude_none=True, +) +async def get_queue_item( + 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) + + +@session_queue_router.put( + "/{queue_id}/i/{item_id}/cancel", + operation_id="cancel_queue_item", + responses={ + 200: {"model": SessionQueueItem}, + }, +) +async def cancel_queue_item( + 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""" + + return ApiDependencies.invoker.services.session_queue.cancel_queue_item(item_id) + + +@session_queue_router.get( + "/{queue_id}/counts_by_destination", + operation_id="counts_by_destination", + responses={200: {"model": SessionQueueCountsByDestination}}, +) +async def counts_by_destination( + 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""" + return ApiDependencies.invoker.services.session_queue.get_counts_by_destination( + queue_id=queue_id, destination=destination + ) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py new file mode 100644 index 0000000000000000000000000000000000000000..dadd89debb355970072db9546c8af0cfadbdfdea --- /dev/null +++ b/invokeai/app/api/routers/style_presets.py @@ -0,0 +1,274 @@ +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.dependencies import ApiDependencies +from invokeai.app.api.routers.model_manager import IMAGE_MAX_AGE +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, + 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") + + +style_presets_router = APIRouter(prefix="/v1/style_presets", tags=["style_presets"]) + + +@style_presets_router.get( + "/i/{style_preset_id}", + operation_id="get_style_preset", + responses={ + 200: {"model": StylePresetRecordWithImage}, + }, +) +async def get_style_preset( + style_preset_id: str = Path(description="The style preset to get"), +) -> StylePresetRecordWithImage: + """Gets a style preset""" + try: + image = ApiDependencies.invoker.services.style_preset_image_files.get_url(style_preset_id) + style_preset = ApiDependencies.invoker.services.style_preset_records.get(style_preset_id) + return StylePresetRecordWithImage(image=image, **style_preset.model_dump()) + except StylePresetNotFoundError: + raise HTTPException(status_code=404, detail="Style preset not found") + + +@style_presets_router.patch( + "/i/{style_preset_id}", + operation_id="update_style_preset", + responses={ + 200: {"model": StylePresetRecordWithImage}, + }, +) +async def update_style_preset( + 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""" + 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 + + 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 + + except pydantic.ValidationError: + raise HTTPException(status_code=400, detail="Invalid preset data") + + preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt) + changes = StylePresetChanges(name=name, preset_data=preset_data, type=type) + + 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( + style_preset_id: str = Path(description="The style preset to delete"), +) -> None: + """Deletes a style preset""" + 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( + 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 + + except pydantic.ValidationError: + raise HTTPException(status_code=400, detail="Invalid preset data") + + preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt) + style_preset = StylePresetWithoutId(name=name, preset_data=preset_data, type=type) + new_style_preset = ApiDependencies.invoker.services.style_preset_records.create(style_preset=style_preset) + + 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() -> list[StylePresetRecordWithImage]: + """Gets a page of style presets""" + style_presets_with_image: list[StylePresetRecordWithImage] = [] + style_presets = ApiDependencies.invoker.services.style_preset_records.get_many() + 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( + style_preset_id: str = Path(description="The id of the style preset image to get"), +) -> FileResponse: + """Gets an image file that previews the model""" + + 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(): + # Create an in-memory stream to store the CSV data + output = io.StringIO() + writer = csv.writer(output) + + # Write the header + writer.writerow(["name", "prompt", "negative_prompt"]) + + style_presets = ApiDependencies.invoker.services.style_preset_records.get_many(type=PresetType.User) + + 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(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) + 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 new file mode 100644 index 0000000000000000000000000000000000000000..2a912dfacf274623d638ca040758fbe4a4202c92 --- /dev/null +++ b/invokeai/app/api/routers/utilities.py @@ -0,0 +1,43 @@ +from typing import Optional, Union + +from dynamicprompts.generators import CombinatorialPromptGenerator, RandomPromptGenerator +from fastapi import Body +from fastapi.routing import APIRouter +from pydantic import BaseModel +from pyparsing import ParseException + +utilities_router = APIRouter(prefix="/v1/utilities", tags=["utilities"]) + + +class DynamicPromptsResponse(BaseModel): + prompts: list[str] + error: Optional[str] = None + + +@utilities_router.post( + "/dynamicprompts", + operation_id="parse_dynamicprompts", + responses={ + 200: {"model": DynamicPromptsResponse}, + }, +) +async def parse_dynamicprompts( + 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"), +) -> DynamicPromptsResponse: + """Creates a batch process""" + max_prompts = min(max_prompts, 10000) + generator: Union[RandomPromptGenerator, CombinatorialPromptGenerator] + try: + error: Optional[str] = None + if combinatorial: + generator = CombinatorialPromptGenerator() + prompts = generator.generate(prompt, max_prompts=max_prompts) + else: + generator = RandomPromptGenerator() + 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) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py new file mode 100644 index 0000000000000000000000000000000000000000..f82f235dd89dd7240dca4c7be2e5268127fbf844 --- /dev/null +++ b/invokeai/app/api/routers/workflows.py @@ -0,0 +1,97 @@ +from typing import Optional + +from fastapi import APIRouter, Body, HTTPException, Path, Query + +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 +from invokeai.app.services.workflow_records.workflow_records_common import ( + Workflow, + WorkflowCategory, + WorkflowNotFoundError, + WorkflowRecordDTO, + WorkflowRecordListItemDTO, + WorkflowRecordOrderBy, + WorkflowWithoutID, +) + +workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"]) + + +@workflows_router.get( + "/i/{workflow_id}", + operation_id="get_workflow", + responses={ + 200: {"model": WorkflowRecordDTO}, + }, +) +async def get_workflow( + workflow_id: str = Path(description="The workflow to get"), +) -> WorkflowRecordDTO: + """Gets a workflow""" + try: + return ApiDependencies.invoker.services.workflow_records.get(workflow_id) + except WorkflowNotFoundError: + raise HTTPException(status_code=404, detail="Workflow not found") + + +@workflows_router.patch( + "/i/{workflow_id}", + operation_id="update_workflow", + responses={ + 200: {"model": WorkflowRecordDTO}, + }, +) +async def update_workflow( + workflow: Workflow = Body(description="The updated workflow", embed=True), +) -> WorkflowRecordDTO: + """Updates a workflow""" + return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow) + + +@workflows_router.delete( + "/i/{workflow_id}", + operation_id="delete_workflow", +) +async def delete_workflow( + workflow_id: str = Path(description="The workflow to delete"), +) -> None: + """Deletes a workflow""" + ApiDependencies.invoker.services.workflow_records.delete(workflow_id) + + +@workflows_router.post( + "/", + operation_id="create_workflow", + responses={ + 200: {"model": WorkflowRecordDTO}, + }, +) +async def create_workflow( + workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True), +) -> WorkflowRecordDTO: + """Creates a workflow""" + return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow) + + +@workflows_router.get( + "/", + operation_id="list_workflows", + responses={ + 200: {"model": PaginatedResults[WorkflowRecordListItemDTO]}, + }, +) +async def list_workflows( + page: int = Query(default=0, description="The page to get"), + 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"), + query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"), +) -> PaginatedResults[WorkflowRecordListItemDTO]: + """Gets a page of workflows""" + return ApiDependencies.invoker.services.workflow_records.get_many( + order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, category=category + ) diff --git a/invokeai/app/api/sockets.py b/invokeai/app/api/sockets.py new file mode 100644 index 0000000000000000000000000000000000000000..188f958c88794d021447ce7fdcf482aefff8f5a3 --- /dev/null +++ b/invokeai/app/api/sockets.py @@ -0,0 +1,125 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) + +from typing import Any + +from fastapi import FastAPI +from pydantic import BaseModel +from socketio import ASGIApp, AsyncServer + +from invokeai.app.services.events.events_common import ( + BatchEnqueuedEvent, + BulkDownloadCompleteEvent, + BulkDownloadErrorEvent, + BulkDownloadEventBase, + BulkDownloadStartedEvent, + DownloadCancelledEvent, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadEventBase, + DownloadProgressEvent, + DownloadStartedEvent, + FastAPIEvent, + InvocationCompleteEvent, + InvocationErrorEvent, + InvocationProgressEvent, + InvocationStartedEvent, + ModelEventBase, + ModelInstallCancelledEvent, + ModelInstallCompleteEvent, + ModelInstallDownloadProgressEvent, + ModelInstallDownloadsCompleteEvent, + ModelInstallErrorEvent, + ModelInstallStartedEvent, + ModelLoadCompleteEvent, + ModelLoadStartedEvent, + QueueClearedEvent, + QueueEventBase, + QueueItemStatusChangedEvent, + register_events, +) + + +class QueueSubscriptionEvent(BaseModel): + """Event data for subscribing to the socket.io queue room. + This is a pydantic model to ensure the data is in the correct format.""" + + queue_id: str + + +class BulkDownloadSubscriptionEvent(BaseModel): + """Event data for subscribing to the socket.io bulk downloads room. + This is a pydantic model to ensure the data is in the correct format.""" + + bulk_download_id: str + + +QUEUE_EVENTS = { + InvocationStartedEvent, + InvocationProgressEvent, + InvocationCompleteEvent, + InvocationErrorEvent, + QueueItemStatusChangedEvent, + BatchEnqueuedEvent, + QueueClearedEvent, +} + +MODEL_EVENTS = { + DownloadCancelledEvent, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, + ModelLoadStartedEvent, + ModelLoadCompleteEvent, + ModelInstallDownloadProgressEvent, + ModelInstallDownloadsCompleteEvent, + ModelInstallStartedEvent, + ModelInstallCompleteEvent, + ModelInstallCancelledEvent, + ModelInstallErrorEvent, +} + +BULK_DOWNLOAD_EVENTS = {BulkDownloadStartedEvent, BulkDownloadCompleteEvent, BulkDownloadErrorEvent} + + +class SocketIO: + _sub_queue = "subscribe_queue" + _unsub_queue = "unsubscribe_queue" + + _sub_bulk_download = "subscribe_bulk_download" + _unsub_bulk_download = "unsubscribe_bulk_download" + + def __init__(self, app: FastAPI): + self._sio = AsyncServer(async_mode="asgi", cors_allowed_origins="*") + self._app = ASGIApp(socketio_server=self._sio, socketio_path="/ws/socket.io") + app.mount("/ws", self._app) + + 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) + self._sio.on(self._unsub_bulk_download, handler=self._handle_unsub_bulk_download) + + register_events(QUEUE_EVENTS, self._handle_queue_event) + register_events(MODEL_EVENTS, self._handle_model_event) + register_events(BULK_DOWNLOAD_EVENTS, self._handle_bulk_image_download_event) + + async def _handle_sub_queue(self, sid: str, data: Any) -> None: + await self._sio.enter_room(sid, QueueSubscriptionEvent(**data).queue_id) + + 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: + 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) + + 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) diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py new file mode 100644 index 0000000000000000000000000000000000000000..bab5121b28596405b40fc6dccb5250dbe799c1a9 --- /dev/null +++ b/invokeai/app/api_app.py @@ -0,0 +1,237 @@ +import asyncio +import logging +import mimetypes +import socket +from contextlib import asynccontextmanager +from pathlib import Path + +import torch +import uvicorn +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, RedirectResponse +from fastapi_events.handlers.local import local_handler +from fastapi_events.middleware import EventHandlerASGIMiddleware +from starlette.middleware.base import BaseHTTPMiddleware, RequestResponseEndpoint +from torch.backends.mps import is_available as is_mps_available + +# 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.api.routers import ( + app_info, + board_images, + boards, + download_queue, + images, + model_manager, + session_queue, + style_presets, + utilities, + workflows, +) +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.devices import TorchDevice +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, loop=loop, logger=logger) + yield + # Shut down threads + ApiDependencies.shutdown() + + +# Create the app +# TODO: create this all in a method so configuration/etc. can be passed in? +app = FastAPI( + title="Invoke - Community Edition", + docs_url=None, + redoc_url=None, + separate_input_output_schemas=False, + lifespan=lifespan, +) + + +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) + + +# Add event handler +event_handler_id: int = id(app) +app.add_middleware( + EventHandlerASGIMiddleware, + handlers=[local_handler], # TODO: consider doing this in services to support different configurations + middleware_id=event_handler_id, +) + +socket_io = SocketIO(app) + +app.add_middleware( + CORSMiddleware, + allow_origins=app_config.allow_origins, + allow_credentials=app_config.allow_credentials, + allow_methods=app_config.allow_methods, + allow_headers=app_config.allow_headers, +) + +app.add_middleware(GZipMiddleware, minimum_size=1000) + + +# Include all routers +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(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.openapi = get_openapi_func(app) + + +@app.get("/docs", include_in_schema=False) +def overridden_swagger() -> HTMLResponse: + return get_swagger_ui_html( + openapi_url=app.openapi_url, # type: ignore [arg-type] # this is always a string + title=f"{app.title} - Swagger UI", + swagger_favicon_url="static/docs/invoke-favicon-docs.svg", + ) + + +@app.get("/redoc", include_in_schema=False) +def overridden_redoc() -> HTMLResponse: + return get_redoc_html( + openapi_url=app.openapi_url, # type: ignore [arg-type] # this is always a string + title=f"{app.title} - Redoc", + redoc_favicon_url="static/docs/invoke-favicon-docs.svg", + ) + + +web_root_path = Path(list(web_dir.__path__)[0]) + +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") +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: + s.settimeout(1) + 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) + + 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/assets/images/caution.png b/invokeai/app/assets/images/caution.png new file mode 100644 index 0000000000000000000000000000000000000000..91d43bf86eaca02243eee8eb8cc1a162f3bf9ca0 Binary files /dev/null and b/invokeai/app/assets/images/caution.png differ diff --git a/invokeai/app/invocations/__init__.py b/invokeai/app/invocations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..cb1caa167ef318117baab0b5e52b2f7cddeb5801 --- /dev/null +++ b/invokeai/app/invocations/__init__.py @@ -0,0 +1,28 @@ +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/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py new file mode 100644 index 0000000000000000000000000000000000000000..ec6322449587d62d47255bda74c5aaa774cb8c3d --- /dev/null +++ b/invokeai/app/invocations/baseinvocation.py @@ -0,0 +1,570 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI team + +from __future__ import annotations + +import inspect +import re +import sys +import warnings +from abc import ABC, abstractmethod +from enum import Enum +from inspect import signature +from typing import ( + TYPE_CHECKING, + Annotated, + Any, + Callable, + ClassVar, + Iterable, + Literal, + Optional, + Type, + TypeVar, + Union, +) + +import semver +from pydantic import BaseModel, ConfigDict, Field, 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, +) +from invokeai.app.services.config.config_default import get_config +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.app.util.metaenum import MetaEnum +from invokeai.app.util.misc import uuid_string +from invokeai.backend.util.logging import InvokeAILogger + +if TYPE_CHECKING: + from invokeai.app.services.invocation_services import InvocationServices + +logger = InvokeAILogger.get_logger() + +CUSTOM_NODE_PACK_SUFFIX = "__invokeai-custom-node" + + +class InvalidVersionError(ValueError): + pass + + +class InvalidFieldError(TypeError): + pass + + +class Classification(str, Enum, metaclass=MetaEnum): + """ + The classification of an Invocation. + - `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. + """ + + Stable = "stable" + Beta = "beta" + Prototype = "prototype" + Deprecated = "deprecated" + Internal = "internal" + + +class UIConfigBase(BaseModel): + """ + Provides additional node configuration to the UI. + 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") + 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: 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( + validate_assignment=True, + json_schema_serialization_defaults_required=True, + ) + + +class BaseInvocationOutput(BaseModel): + """ + Base class for all invocation outputs. + + 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()) + + @staticmethod + def json_schema_extra(schema: dict[str, Any], model_class: Type[BaseInvocationOutput]) -> None: + """Adds various UI-facing attributes to the invocation output's OpenAPI schema.""" + # Because we use a pydantic Literal field with default value for the invocation type, + # it will be typed as optional in the OpenAPI schema. Make it required manually. + if "required" not in schema or not isinstance(schema["required"], list): + schema["required"] = [] + schema["class"] = "output" + schema["required"].extend(["type"]) + + @classmethod + def get_type(cls) -> str: + """Gets the invocation output's type, as provided by the `@invocation_output` decorator.""" + return cls.model_fields["type"].default + + model_config = ConfigDict( + protected_namespaces=(), + validate_assignment=True, + json_schema_serialization_defaults_required=True, + json_schema_extra=json_schema_extra, + ) + + +class RequiredConnectionException(Exception): + """Raised when an field which requires a connection did not receive a value.""" + + def __init__(self, node_id: str, field_name: str): + super().__init__(f"Node {node_id} missing connections for field {field_name}") + + +class MissingInputException(Exception): + """Raised when an field which requires some input, but did not receive a value.""" + + def __init__(self, node_id: str, field_name: str): + super().__init__(f"Node {node_id} missing value or connection for field {field_name}") + + +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.get_invocations())], Field(discriminator="type")] + ) + cls._typeadapter = TypeAdapter(AnyInvocation) + cls._typeadapter_needs_update = False + return cls._typeadapter + + @classmethod + def invalidate_typeadapter(cls) -> None: + """Invalidates the typeadapter, forcing it to be rebuilt on next access. If the invocation allowlist or + denylist is changed, this should be called to ensure the typeadapter is updated and validation respects + the updated allowlist and denylist.""" + cls._typeadapter_needs_update = True + + @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: + """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.""" + 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" + schema["required"].extend(["type", "id"]) + + @abstractmethod + def invoke(self, context: InvocationContext) -> BaseInvocationOutput: + """Invoke with provided context and return outputs.""" + pass + + def invoke_internal(self, context: InvocationContext, services: "InvocationServices") -> BaseInvocationOutput: + """ + 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(): + 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 + + # Here we handle the case where the field is optional in the pydantic class, but required + # in the `invoke()` method. + + orig_default = field.json_schema_extra.get("orig_default", PydanticUndefined) + orig_required = field.json_schema_extra.get("orig_required", True) + input_ = field.json_schema_extra.get("input", None) + if orig_default is not PydanticUndefined and not hasattr(self, field_name): + 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) + elif input_ == Input.Any: + raise MissingInputException(self.model_fields["type"].default, field_name) + + # skip node cache codepath if it's disabled + if services.configuration.node_cache_size == 0: + return self.invoke(context) + + output: BaseInvocationOutput + if self.use_cache: + 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 + else: + services.logger.debug(f'Invocation cache hit for type "{self.get_type()}": {self.id}') + return cached_value + else: + services.logger.debug(f'Skipping invocation cache for "{self.get_type()}": {self.id}') + return self.invoke(context) + + id: str = Field( + default_factory=uuid_string, + description="The id of this instance of an invocation. Must be unique among all instances of invocations.", + json_schema_extra={"field_kind": FieldKind.NodeAttribute}, + ) + 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}, + ) + use_cache: bool = Field( + default=True, + description="Whether or not to use the cache", + json_schema_extra={"field_kind": FieldKind.NodeAttribute}, + ) + + UIConfig: ClassVar[UIConfigBase] + + model_config = ConfigDict( + protected_namespaces=(), + validate_assignment=True, + json_schema_extra=json_schema_extra, + json_schema_serialization_defaults_required=False, + coerce_numbers_to_str=True, + ) + + +TBaseInvocation = TypeVar("TBaseInvocation", bound=BaseInvocation) + + +RESERVED_NODE_ATTRIBUTE_FIELD_NAMES = { + "id", + "is_intermediate", + "use_cache", + "type", + "workflow", +} + +RESERVED_INPUT_FIELD_NAMES = {"metadata", "board"} + +RESERVED_OUTPUT_FIELD_NAMES = {"type"} + + +class _Model(BaseModel): + pass + + +with warnings.catch_warnings(): + warnings.simplefilter("ignore", category=DeprecationWarning) + # Get all pydantic model attrs, methods, etc + RESERVED_PYDANTIC_FIELD_NAMES = {m[0] for m in inspect.getmembers(_Model())} + + +def validate_fields(model_fields: dict[str, FieldInfo], model_type: str) -> None: + """ + Validates the fields of an invocation or invocation output: + - Must not override any pydantic reserved fields + - Must have a type annotation + - Must have a json_schema_extra dict + - Must have field_kind in json_schema_extra + - Field name must not be reserved, according to its field_kind + """ + 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)') + + if not field.annotation: + raise InvalidFieldError(f'Invalid field type "{name}" on "{model_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)' + ) + + field_kind = field.json_schema_extra.get("field_kind", None) + + # must have a field_kind + if not isinstance(field_kind, FieldKind): + raise InvalidFieldError( + f'Invalid field definition for "{name}" on "{model_type}" (maybe it\'s not an InputField or OutputField?)' + ) + + if field_kind is FieldKind.Input 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)') + + 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 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)' + ) + + # node attribute fields *must* be in the reserved list + if ( + field_kind is FieldKind.NodeAttribute + 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)' + ) + + 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") + return None + + +def invocation( + invocation_type: str, + title: Optional[str] = None, + tags: Optional[list[str]] = None, + category: Optional[str] = None, + version: Optional[str] = None, + use_cache: Optional[bool] = True, + classification: Classification = Classification.Stable, +) -> Callable[[Type[TBaseInvocation]], Type[TBaseInvocation]]: + """ + Registers an invocation. + + :param str invocation_type: The type of the invocation. Must be unique among all invocations. + :param Optional[str] title: Adds a title to the invocation. Use if the auto-generated title isn't quite right. Defaults to None. + :param Optional[list[str]] tags: Adds tags to the invocation. Invocations may be searched for by their tags. Defaults to None. + :param Optional[str] category: Adds a category to the invocation. Used to group the invocations in the UI. Defaults to None. + :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. + """ + + def wrapper(cls: Type[TBaseInvocation]) -> Type[TBaseInvocation]: + # Validate invocation types on creation of invocation classes + # TODO: ensure unique? + 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') + + validate_fields(cls.model_fields, invocation_type) + + # Add OpenAPI schema extras + uiconfig: dict[str, Any] = {} + uiconfig["title"] = title + uiconfig["tags"] = tags + uiconfig["category"] = category + uiconfig["classification"] = classification + # The node pack is the module name - will be "invokeai" for built-in nodes + uiconfig["node_pack"] = cls.__module__.split(".")[0] + + 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 + uiconfig["version"] = version + else: + logger.warn(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 + + # Add the invocation type to the model. + + # You'd be tempted to just add the type field and rebuild the model, like this: + # cls.model_fields.update(type=FieldInfo.from_annotated_attribute(Literal[invocation_type], invocation_type)) + # cls.model_rebuild() or cls.model_rebuild(force=True) + + # 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} + ) + + # 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 + + # TODO: how to type this correctly? it's typed as ModelMetaclass, a private class in pydantic + BaseInvocation.register_invocation(cls) # type: ignore + + return cls + + return wrapper + + +TBaseInvocationOutput = TypeVar("TBaseInvocationOutput", bound=BaseInvocationOutput) + + +def invocation_output( + output_type: str, +) -> Callable[[Type[TBaseInvocationOutput]], Type[TBaseInvocationOutput]]: + """ + Adds metadata to an invocation output. + + :param str output_type: The type of the invocation output. Must be unique among all invocation outputs. + """ + + def wrapper(cls: Type[TBaseInvocationOutput]) -> Type[TBaseInvocationOutput]: + # Validate output types on creation of invocation output classes + # TODO: ensure unique? + 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) + + # Add the output type to the model. + + output_type_annotation = Literal[output_type] # type: ignore + output_type_field = Field( + title="type", default=output_type, json_schema_extra={"field_kind": FieldKind.NodeAttribute} + ) + + docstring = cls.__doc__ + cls = create_model( + cls.__qualname__, + __base__=cls, + __module__=cls.__module__, + type=(output_type_annotation, output_type_field), + ) + cls.__doc__ = docstring + + BaseInvocationOutput.register_output(cls) # type: ignore # TODO: how to type this correctly? + + return cls + + return wrapper diff --git a/invokeai/app/invocations/blend_latents.py b/invokeai/app/invocations/blend_latents.py new file mode 100644 index 0000000000000000000000000000000000000000..9238f4b34c55847eba189091faff05491a1b6232 --- /dev/null +++ b/invokeai/app/invocations/blend_latents.py @@ -0,0 +1,98 @@ +from typing import Any, Union + +import numpy as np +import numpy.typing as npt +import torch + +from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation +from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, LatentsField +from invokeai.app.invocations.primitives import LatentsOutput +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.util.devices import TorchDevice + + +@invocation( + "lblend", + title="Blend Latents", + tags=["latents", "blend"], + category="latents", + version="1.0.3", +) +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) + + 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 latents_a.shape != latents_b.shape: + raise Exception("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 + + # https://discuss.huggingface.co/t/memory-usage-by-later-pipeline-stages/23699 + blended_latents = blended_latents.to("cpu") + + TorchDevice.empty_cache() + + name = context.tensors.save(tensor=blended_latents) + return LatentsOutput.build(latents_name=name, latents=blended_latents, seed=self.latents_a.seed) diff --git a/invokeai/app/invocations/canny.py b/invokeai/app/invocations/canny.py new file mode 100644 index 0000000000000000000000000000000000000000..0cdc386e62b1e0e59574379d7ed4954022d891c9 --- /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", + 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/collections.py b/invokeai/app/invocations/collections.py new file mode 100644 index 0000000000000000000000000000000000000000..bd3dedb3f8aa8344adc02166e7181e669a1a161a --- /dev/null +++ b/invokeai/app/invocations/collections.py @@ -0,0 +1,77 @@ +# Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team + + +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 + + +@invocation( + "range", title="Integer Range", tags=["collection", "integer", "range"], category="collections", version="1.0.0" +) +class RangeInvocation(BaseInvocation): + """Creates a range of numbers from start to stop with step""" + + start: int = InputField(default=0, description="The start of the range") + stop: int = InputField(default=10, description="The stop of the range") + step: int = InputField(default=1, description="The step of the range") + + @field_validator("stop") + def stop_gt_start(cls, v: int, info: ValidationInfo): + if "start" in info.data and v <= info.data["start"]: + raise ValueError("stop must be greater than start") + return v + + def invoke(self, context: InvocationContext) -> IntegerCollectionOutput: + return IntegerCollectionOutput(collection=list(range(self.start, self.stop, self.step))) + + +@invocation( + "range_of_size", + title="Integer Range of Size", + tags=["collection", "integer", "size", "range"], + category="collections", + version="1.0.0", +) +class RangeOfSizeInvocation(BaseInvocation): + """Creates a range from start to start + (size * step) incremented by step""" + + start: int = InputField(default=0, description="The start of the range") + size: int = InputField(default=1, gt=0, description="The number of values") + step: int = InputField(default=1, description="The step of the range") + + def invoke(self, context: InvocationContext) -> IntegerCollectionOutput: + return IntegerCollectionOutput( + collection=list(range(self.start, self.start + (self.step * self.size), self.step)) + ) + + +@invocation( + "random_range", + title="Random Range", + tags=["range", "integer", "random", "collection"], + category="collections", + version="1.0.1", + use_cache=False, +) +class RandomRangeInvocation(BaseInvocation): + """Creates a collection of random numbers""" + + low: int = InputField(default=0, description="The inclusive low value") + high: int = InputField(default=np.iinfo(np.int32).max, description="The exclusive high value") + size: int = InputField(default=1, description="The number of values to generate") + seed: int = InputField( + default=0, + ge=0, + le=SEED_MAX, + description="The seed for the RNG (omit for random)", + ) + + def invoke(self, context: InvocationContext) -> IntegerCollectionOutput: + rng = np.random.default_rng(self.seed) + return IntegerCollectionOutput(collection=list(rng.integers(low=self.low, high=self.high, size=self.size))) diff --git a/invokeai/app/invocations/color_map.py b/invokeai/app/invocations/color_map.py new file mode 100644 index 0000000000000000000000000000000000000000..e55584caf52faca4e2a7a4174866c2c9118945d1 --- /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", + 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 new file mode 100644 index 0000000000000000000000000000000000000000..fe8943bfcd96d77c2a93e85e2b49aed34d2dfddb --- /dev/null +++ b/invokeai/app/invocations/compel.py @@ -0,0 +1,522 @@ +from typing import Iterator, List, Optional, Tuple, Union, cast + +import torch +from compel import Compel, ReturnedEmbeddingsType +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, + Input, + InputField, + OutputField, + 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.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.lora_patcher import LoRAPatcher +from invokeai.backend.model_patcher import ModelPatcher +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( + BasicConditioningInfo, + ConditioningFieldData, + SDXLConditioningInfo, +) +from invokeai.backend.util.devices import TorchDevice + +# unconditioned: Optional[torch.Tensor] + + +# class ConditioningAlgo(str, Enum): +# Compose = "compose" +# ComposeEx = "compose_ex" +# PerpNeg = "perp_neg" + + +@invocation( + "compel", + title="Prompt", + tags=["prompt", "compel"], + category="conditioning", + version="1.2.0", +) +class CompelInvocation(BaseInvocation): + """Parse prompt using compel package to conditioning.""" + + prompt: str = InputField( + default="", + description=FieldDescriptions.compel_prompt, + ui_component=UIComponent.Textarea, + ) + clip: CLIPField = InputField( + title="CLIP", + description=FieldDescriptions.clip, + ) + 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) -> 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]]: + for lora in self.clip.loras: + lora_info = context.models.load(lora.lora) + assert isinstance(lora_info.model, LoRAModelRaw) + 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] + + 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 (cached_weights, text_encoder), + tokenizer_info as tokenizer, + LoRAPatcher.apply_lora_patches( + model=text_encoder, + patches=_lora_loader(), + prefix="lora_te_", + 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), + ModelPatcher.apply_ti(tokenizer, text_encoder, ti_list) as ( + patched_tokenizer, + ti_manager, + ), + ): + context.util.signal_progress("Building conditioning") + assert isinstance(text_encoder, CLIPTextModel) + assert isinstance(tokenizer, CLIPTokenizer) + compel = Compel( + tokenizer=patched_tokenizer, + text_encoder=text_encoder, + textual_inversion_manager=ti_manager, + dtype_for_device_getter=TorchDevice.choose_torch_dtype, + truncate_long_prompts=False, + ) + + conjunction = Compel.parse_prompt_string(self.prompt) + + if context.config.get().log_tokenization: + log_tokenization_for_conjunction(conjunction, patched_tokenizer) + + c, _options = compel.build_conditioning_tensor_for_conjunction(conjunction) + + c = c.detach().to("cpu") + + conditioning_data = ConditioningFieldData(conditionings=[BasicConditioningInfo(embeds=c)]) + + conditioning_name = context.conditioning.save(conditioning_data) + return ConditioningOutput( + conditioning=ConditioningField( + conditioning_name=conditioning_name, + mask=self.mask, + ) + ) + + +class SDXLPromptInvocationBase: + """Prompt processor for SDXL models.""" + + def run_clip_compel( + self, + context: InvocationContext, + clip_field: CLIPField, + prompt: str, + get_pooled: bool, + 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 + assert isinstance(cpu_text_encoder, torch.nn.Module) + c = torch.zeros( + ( + 1, + cpu_text_encoder.config.max_position_embeddings, + cpu_text_encoder.config.hidden_size, + ), + dtype=cpu_text_encoder.dtype, + ) + if get_pooled: + c_pooled = torch.zeros( + (1, cpu_text_encoder.config.hidden_size), + dtype=c.dtype, + ) + else: + c_pooled = None + return c, c_pooled + + def _lora_loader() -> Iterator[Tuple[LoRAModelRaw, float]]: + for lora in clip_field.loras: + lora_info = context.models.load(lora.lora) + lora_model = lora_info.model + assert isinstance(lora_model, LoRAModelRaw) + yield (lora_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] + + ti_list = generate_ti_list(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 (cached_weights, text_encoder), + tokenizer_info as tokenizer, + LoRAPatcher.apply_lora_patches( + text_encoder, + patches=_lora_loader(), + prefix=lora_prefix, + 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), + ModelPatcher.apply_ti(tokenizer, text_encoder, ti_list) as ( + patched_tokenizer, + ti_manager, + ), + ): + context.util.signal_progress("Building conditioning") + assert isinstance(text_encoder, (CLIPTextModel, CLIPTextModelWithProjection)) + assert isinstance(tokenizer, CLIPTokenizer) + + text_encoder = cast(CLIPTextModel, text_encoder) + compel = Compel( + tokenizer=patched_tokenizer, + text_encoder=text_encoder, + textual_inversion_manager=ti_manager, + dtype_for_device_getter=TorchDevice.choose_torch_dtype, + truncate_long_prompts=False, # TODO: + returned_embeddings_type=ReturnedEmbeddingsType.PENULTIMATE_HIDDEN_STATES_NON_NORMALIZED, # TODO: clip skip + requires_pooled=get_pooled, + ) + + conjunction = Compel.parse_prompt_string(prompt) + + if context.config.get().log_tokenization: + # TODO: better logging for and syntax + log_tokenization_for_conjunction(conjunction, patched_tokenizer) + + # TODO: ask for optimizations? to not run text_encoder twice + c, _options = compel.build_conditioning_tensor_for_conjunction(conjunction) + if get_pooled: + c_pooled = compel.conditioning_provider.get_pooled_embeddings([prompt]) + else: + c_pooled = None + + del tokenizer + del text_encoder + del tokenizer_info + del text_encoder_info + + c = c.detach().to("cpu") + if c_pooled is not None: + c_pooled = c_pooled.detach().to("cpu") + + return c, c_pooled + + +@invocation( + "sdxl_compel_prompt", + title="SDXL Prompt", + tags=["sdxl", "compel", "prompt"], + category="conditioning", + version="1.2.0", +) +class SDXLCompelPromptInvocation(BaseInvocation, SDXLPromptInvocationBase): + """Parse prompt using compel package to conditioning.""" + + prompt: str = InputField( + default="", + description=FieldDescriptions.compel_prompt, + ui_component=UIComponent.Textarea, + ) + style: str = InputField( + default="", + description=FieldDescriptions.compel_prompt, + ui_component=UIComponent.Textarea, + ) + original_width: int = InputField(default=1024, description="") + original_height: int = InputField(default=1024, description="") + crop_top: int = InputField(default=0, description="") + crop_left: int = InputField(default=0, description="") + target_width: int = InputField(default=1024, description="") + target_height: int = InputField(default=1024, description="") + clip: CLIPField = InputField(description=FieldDescriptions.clip, input=Input.Connection, title="CLIP 1") + clip2: CLIPField = InputField(description=FieldDescriptions.clip, input=Input.Connection, title="CLIP 2") + 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) -> ConditioningOutput: + c1, c1_pooled = self.run_clip_compel(context, self.clip, self.prompt, False, "lora_te1_", zero_on_empty=True) + if self.style.strip() == "": + c2, c2_pooled = self.run_clip_compel( + context, self.clip2, self.prompt, True, "lora_te2_", zero_on_empty=True + ) + else: + c2, c2_pooled = self.run_clip_compel(context, self.clip2, self.style, True, "lora_te2_", zero_on_empty=True) + + original_size = (self.original_height, self.original_width) + crop_coords = (self.crop_top, self.crop_left) + target_size = (self.target_height, self.target_width) + + add_time_ids = torch.tensor([original_size + crop_coords + target_size]) + + # [1, 77, 768], [1, 154, 1280] + if c1.shape[1] < c2.shape[1]: + c1 = torch.cat( + [ + c1, + torch.zeros( + (c1.shape[0], c2.shape[1] - c1.shape[1], c1.shape[2]), + device=c1.device, + dtype=c1.dtype, + ), + ], + dim=1, + ) + + elif c1.shape[1] > c2.shape[1]: + c2 = torch.cat( + [ + c2, + torch.zeros( + (c2.shape[0], c1.shape[1] - c2.shape[1], c2.shape[2]), + device=c2.device, + dtype=c2.dtype, + ), + ], + dim=1, + ) + + assert c2_pooled is not None + conditioning_data = ConditioningFieldData( + conditionings=[ + SDXLConditioningInfo( + embeds=torch.cat([c1, c2], dim=-1), pooled_embeds=c2_pooled, add_time_ids=add_time_ids + ) + ] + ) + + conditioning_name = context.conditioning.save(conditioning_data) + + return ConditioningOutput( + conditioning=ConditioningField( + conditioning_name=conditioning_name, + mask=self.mask, + ) + ) + + +@invocation( + "sdxl_refiner_compel_prompt", + title="SDXL Refiner Prompt", + tags=["sdxl", "compel", "prompt"], + category="conditioning", + version="1.1.1", +) +class SDXLRefinerCompelPromptInvocation(BaseInvocation, SDXLPromptInvocationBase): + """Parse prompt using compel package to conditioning.""" + + style: str = InputField( + default="", + description=FieldDescriptions.compel_prompt, + ui_component=UIComponent.Textarea, + ) # TODO: ? + original_width: int = InputField(default=1024, description="") + original_height: int = InputField(default=1024, description="") + crop_top: int = InputField(default=0, description="") + crop_left: int = InputField(default=0, description="") + aesthetic_score: float = InputField(default=6.0, description=FieldDescriptions.sdxl_aesthetic) + clip2: CLIPField = InputField(description=FieldDescriptions.clip, input=Input.Connection) + + @torch.no_grad() + def invoke(self, context: InvocationContext) -> ConditioningOutput: + # TODO: if there will appear lora for refiner - write proper prefix + c2, c2_pooled = self.run_clip_compel(context, self.clip2, self.style, True, "", zero_on_empty=False) + + original_size = (self.original_height, self.original_width) + crop_coords = (self.crop_top, self.crop_left) + + add_time_ids = torch.tensor([original_size + crop_coords + (self.aesthetic_score,)]) + + assert c2_pooled is not None + conditioning_data = ConditioningFieldData( + conditionings=[SDXLConditioningInfo(embeds=c2, pooled_embeds=c2_pooled, add_time_ids=add_time_ids)] + ) + + conditioning_name = context.conditioning.save(conditioning_data) + + return ConditioningOutput.build(conditioning_name) + + +@invocation_output("clip_skip_output") +class CLIPSkipInvocationOutput(BaseInvocationOutput): + """CLIP skip node output""" + + clip: Optional[CLIPField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP") + + +@invocation( + "clip_skip", + title="CLIP Skip", + tags=["clipskip", "clip", "skip"], + category="conditioning", + version="1.1.0", +) +class CLIPSkipInvocation(BaseInvocation): + """Skip layers in clip text_encoder model.""" + + clip: CLIPField = InputField(description=FieldDescriptions.clip, input=Input.Connection, title="CLIP") + skipped_layers: int = InputField(default=0, ge=0, description=FieldDescriptions.skipped_layers) + + def invoke(self, context: InvocationContext) -> CLIPSkipInvocationOutput: + self.clip.skipped_layers += self.skipped_layers + return CLIPSkipInvocationOutput( + clip=self.clip, + ) + + +def get_max_token_count( + tokenizer: CLIPTokenizer, + prompt: Union[FlattenedPrompt, Blend, Conjunction], + truncate_if_too_long: bool = False, +) -> int: + if type(prompt) is Blend: + blend: Blend = prompt + return max([get_max_token_count(tokenizer, p, truncate_if_too_long) for p in blend.prompts]) + elif type(prompt) is Conjunction: + conjunction: Conjunction = prompt + return sum([get_max_token_count(tokenizer, p, truncate_if_too_long) for p in conjunction.prompts]) + else: + return len(get_tokens_for_prompt_object(tokenizer, prompt, truncate_if_too_long)) + + +def get_tokens_for_prompt_object( + tokenizer: CLIPTokenizer, parsed_prompt: FlattenedPrompt, truncate_if_too_long: bool = True +) -> List[str]: + if type(parsed_prompt) is Blend: + raise ValueError("Blend is not supported here - you need to get tokens for each of its .children") + + text_fragments = [ + ( + x.text + if type(x) is Fragment + else (" ".join([f.text for f in x.original]) if type(x) is CrossAttentionControlSubstitute else str(x)) + ) + for x in parsed_prompt.children + ] + text = " ".join(text_fragments) + tokens: List[str] = tokenizer.tokenize(text) + if truncate_if_too_long: + max_tokens_length = tokenizer.model_max_length - 2 # typically 75 + tokens = tokens[0:max_tokens_length] + return tokens + + +def log_tokenization_for_conjunction( + c: Conjunction, tokenizer: CLIPTokenizer, display_label_prefix: Optional[str] = None +) -> None: + display_label_prefix = display_label_prefix or "" + for i, p in enumerate(c.prompts): + if len(c.prompts) > 1: + this_display_label_prefix = f"{display_label_prefix}(conjunction part {i + 1}, weight={c.weights[i]})" + else: + assert display_label_prefix is not None + this_display_label_prefix = display_label_prefix + log_tokenization_for_prompt_object(p, tokenizer, display_label_prefix=this_display_label_prefix) + + +def log_tokenization_for_prompt_object( + p: Union[Blend, FlattenedPrompt], tokenizer: CLIPTokenizer, display_label_prefix: Optional[str] = None +) -> None: + display_label_prefix = display_label_prefix or "" + if type(p) is Blend: + blend: Blend = p + for i, c in enumerate(blend.prompts): + log_tokenization_for_prompt_object( + c, + tokenizer, + display_label_prefix=f"{display_label_prefix}(blend part {i + 1}, weight={blend.weights[i]})", + ) + elif type(p) is FlattenedPrompt: + flattened_prompt: FlattenedPrompt = p + if flattened_prompt.wants_cross_attention_control: + original_fragments = [] + edited_fragments = [] + for f in flattened_prompt.children: + if type(f) is CrossAttentionControlSubstitute: + original_fragments += f.original + edited_fragments += f.edited + else: + original_fragments.append(f) + edited_fragments.append(f) + + original_text = " ".join([x.text for x in original_fragments]) + log_tokenization_for_text( + original_text, + tokenizer, + display_label=f"{display_label_prefix}(.swap originals)", + ) + edited_text = " ".join([x.text for x in edited_fragments]) + log_tokenization_for_text( + edited_text, + tokenizer, + display_label=f"{display_label_prefix}(.swap replacements)", + ) + else: + text = " ".join([x.text for x in flattened_prompt.children]) + log_tokenization_for_text(text, tokenizer, display_label=display_label_prefix) + + +def log_tokenization_for_text( + text: str, + tokenizer: CLIPTokenizer, + display_label: Optional[str] = None, + truncate_if_too_long: Optional[bool] = False, +) -> None: + """shows how the prompt is tokenized + # usually tokens have '' to indicate end-of-word, + # but for readability it has been replaced with ' ' + """ + tokens = tokenizer.tokenize(text) + tokenized = "" + discarded = "" + usedTokens = 0 + totalTokens = len(tokens) + + for i in range(0, totalTokens): + token = tokens[i].replace("", " ") + # alternate color + s = (usedTokens % 6) + 1 + if truncate_if_too_long and i >= tokenizer.model_max_length: + discarded = discarded + f"\x1b[0;3{s};40m{token}" + else: + tokenized = tokenized + f"\x1b[0;3{s};40m{token}" + usedTokens += 1 + + if usedTokens > 0: + print(f'\n>> [TOKENLOG] Tokens {display_label or ""} ({usedTokens}):') + print(f"{tokenized}\x1b[0m") + + if discarded != "": + print(f"\n>> [TOKENLOG] Tokens Discarded ({totalTokens - usedTokens}):") + print(f"{discarded}\x1b[0m") diff --git a/invokeai/app/invocations/constants.py b/invokeai/app/invocations/constants.py new file mode 100644 index 0000000000000000000000000000000000000000..e97275e4fd8b07d2fc8bb00a320d48a7fb34e475 --- /dev/null +++ b/invokeai/app/invocations/constants.py @@ -0,0 +1,16 @@ +from typing import Literal + +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 +be addressed if future models use a different latent scale factor. Also, note that there may be places where the scale +factor is hard-coded to a literal '8' rather than using this constant. +The ratio of image:latent dimensions is LATENT_SCALE_FACTOR:1, or 8:1. +""" + +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 0000000000000000000000000000000000000000..e01096ecea31f6b15a4447eabf424cd8d2d6eaf1 --- /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", + 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_image_processors.py b/invokeai/app/invocations/controlnet_image_processors.py new file mode 100644 index 0000000000000000000000000000000000000000..65da9b2daf30c51f4b3b887db77825c881878b25 --- /dev/null +++ b/invokeai/app/invocations/controlnet_image_processors.py @@ -0,0 +1,716 @@ +# 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 transformers import pipeline +from transformers.pipelines import DepthEstimationPipeline + +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + Classification, + invocation, + invocation_output, +) +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.depth_anything_pipeline import DepthAnythingPipeline +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 + + +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", + classification=Classification.Deprecated, +) +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", "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_image_processor", + title="Depth Anything Processor", + tags=["controlnet", "depth", "depth anything"], + category="controlnet", + version="1.1.3", + classification=Classification.Deprecated, +) +class DepthAnythingImageProcessorInvocation(ImageProcessorInvocation): + """Generates a depth map based on the Depth Anything algorithm""" + + model_size: DEPTH_ANYTHING_MODEL_SIZES = InputField( + default="small_v2", 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 load_depth_anything(model_path: Path): + depth_anything_pipeline = pipeline(model=str(model_path), task="depth-estimation", local_files_only=True) + assert isinstance(depth_anything_pipeline, DepthEstimationPipeline) + return DepthAnythingPipeline(depth_anything_pipeline) + + with self._context.models.load_remote_model( + source=DEPTH_ANYTHING_MODELS[self.model_size], loader=load_depth_anything + ) as depth_anything_detector: + assert isinstance(depth_anything_detector, DepthAnythingPipeline) + depth_map = depth_anything_detector.generate_depth(image) + + # Resizing to user target specified size + new_height = int(image.size[1] * (self.resolution / image.size[0])) + depth_map = depth_map.resize((self.resolution, new_height)) + + return depth_map + + +@invocation( + "dw_openpose_image_processor", + title="DW Openpose Image Processor", + tags=["controlnet", "dwpose", "openpose"], + category="controlnet", + version="1.1.1", + classification=Classification.Deprecated, +) +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 new file mode 100644 index 0000000000000000000000000000000000000000..15e95f49b015d5820afe90fed28cd823ca6a4dd2 --- /dev/null +++ b/invokeai/app/invocations/create_denoise_mask.py @@ -0,0 +1,81 @@ +from typing import Optional + +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.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 +from invokeai.app.invocations.primitives import DenoiseMaskOutput +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor + + +@invocation( + "create_denoise_mask", + title="Create Denoise Mask", + tags=["mask", "denoise"], + category="latents", + version="1.0.2", +) +class CreateDenoiseMaskInvocation(BaseInvocation): + """Creates mask for denoising model run.""" + + vae: VAEField = InputField(description=FieldDescriptions.vae, input=Input.Connection, ui_order=0) + 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, + ) + + def prep_mask_tensor(self, mask_image: Image.Image) -> torch.Tensor: + if mask_image.mode != "L": + mask_image = mask_image.convert("L") + mask_tensor: torch.Tensor = image_resized_to_grid_as_tensor(mask_image, normalize=False) + if mask_tensor.dim() == 3: + mask_tensor = mask_tensor.unsqueeze(0) + # if shape is not None: + # mask_tensor = tv_resize(mask_tensor, shape, T.InterpolationMode.BILINEAR) + return mask_tensor + + @torch.no_grad() + def invoke(self, context: InvocationContext) -> DenoiseMaskOutput: + if self.image is not None: + 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) + else: + image_tensor = None + + mask = self.prep_mask_tensor( + context.images.get_pil(self.mask.image_name), + ) + + if image_tensor is not None: + vae_info = context.models.load(self.vae.vae) + + 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) + else: + masked_latents_name = None + + mask_name = context.tensors.save(tensor=mask) + + return DenoiseMaskOutput.build( + mask_name=mask_name, + masked_latents_name=masked_latents_name, + gradient=False, + ) diff --git a/invokeai/app/invocations/create_gradient_mask.py b/invokeai/app/invocations/create_gradient_mask.py new file mode 100644 index 0000000000000000000000000000000000000000..d32d3c85212a0d2c38662f6554d7c47ba4389dd8 --- /dev/null +++ b/invokeai/app/invocations/create_gradient_mask.py @@ -0,0 +1,143 @@ +from typing import Literal, Optional + +import numpy as np +import torch +import torchvision.transforms as T +from PIL import Image, ImageFilter +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.fields import ( + DenoiseMaskField, + FieldDescriptions, + ImageField, + Input, + InputField, + OutputField, +) +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.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor + + +@invocation_output("gradient_mask_output") +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. 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." + ) + + +@invocation( + "create_gradient_mask", + title="Create Gradient Mask", + tags=["mask", "denoise"], + category="latents", + version="1.2.0", +) +class CreateGradientMaskInvocation(BaseInvocation): + """Creates mask for denoising model run.""" + + 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 + ) + 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 + ) + image: Optional[ImageField] = InputField( + default=None, + description="OPTIONAL: Only connect for specialized Inpainting models, masked_latents will be generated from the image with the VAE", + title="[OPTIONAL] Image", + ui_order=6, + ) + unet: Optional[UNetField] = InputField( + description="OPTIONAL: If the Unet is a specialized Inpainting model, masked_latents will be generated from the image with the VAE", + default=None, + input=Input.Connection, + title="[OPTIONAL] UNet", + ui_order=5, + ) + vae: Optional[VAEField] = InputField( + default=None, + description="OPTIONAL: Only connect for specialized Inpainting models, masked_latents will be generated from the image with the VAE", + title="[OPTIONAL] VAE", + input=Input.Connection, + 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, + ) + + @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) + + # redistribute blur so that the original edges are 0 and blur outwards to 1 + blur_tensor = (blur_tensor - 0.5) * 2 + blur_tensor[blur_tensor < 0] = 0.0 + + threshold = 1 - self.minimum_denoise + + 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) + + else: + blur_tensor: torch.Tensor = image_resized_to_grid_as_tensor(mask_image, normalize=False) + + mask_name = context.tensors.save(tensor=blur_tensor.unsqueeze(1)) + + # 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) + + 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) + 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() + ) + masked_latents_name = context.tensors.save(tensor=masked_latents) + + return 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), + ) diff --git a/invokeai/app/invocations/crop_latents.py b/invokeai/app/invocations/crop_latents.py new file mode 100644 index 0000000000000000000000000000000000000000..258049fd2c10b2ad5186da8870f7e7dae1348b7c --- /dev/null +++ b/invokeai/app/invocations/crop_latents.py @@ -0,0 +1,61 @@ +from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, LatentsField +from invokeai.app.invocations.primitives import LatentsOutput +from invokeai.app.services.shared.invocation_context import InvocationContext + + +# The Crop Latents node was copied from @skunkworxdark's implementation here: +# https://github.com/skunkworxdark/XYGrid_nodes/blob/74647fa9c1fa57d317a94bd43ca689af7f0aae5e/images_to_grids.py#L1117C1-L1167C80 +@invocation( + "crop_latents", + title="Crop Latents", + tags=["latents", "crop"], + category="latents", + version="1.0.2", +) +# TODO(ryand): Named `CropLatentsCoreInvocation` to prevent a conflict with custom node `CropLatentsInvocation`. +# Currently, if the class names conflict then 'GET /openapi.json' fails. +class CropLatentsCoreInvocation(BaseInvocation): + """Crops a latent-space tensor to a box specified in image-space. The box dimensions and coordinates must be + divisible by the latent scale factor of 8. + """ + + latents: LatentsField = InputField( + description=FieldDescriptions.latents, + input=Input.Connection, + ) + x: int = InputField( + ge=0, + multiple_of=LATENT_SCALE_FACTOR, + description="The left x coordinate (in px) of the crop rectangle in image space. This value will be converted to a dimension in latent space.", + ) + y: int = InputField( + ge=0, + multiple_of=LATENT_SCALE_FACTOR, + description="The top y coordinate (in px) of the crop rectangle in image space. This value will be converted to a dimension in latent space.", + ) + width: int = InputField( + ge=1, + multiple_of=LATENT_SCALE_FACTOR, + description="The width (in px) of the crop rectangle in image space. This value will be converted to a dimension in latent space.", + ) + height: int = InputField( + ge=1, + multiple_of=LATENT_SCALE_FACTOR, + description="The height (in px) of the crop rectangle in image space. This value will be converted to a dimension in latent space.", + ) + + def invoke(self, context: InvocationContext) -> LatentsOutput: + latents = context.tensors.load(self.latents.latents_name) + + x1 = self.x // LATENT_SCALE_FACTOR + y1 = self.y // LATENT_SCALE_FACTOR + x2 = x1 + (self.width // LATENT_SCALE_FACTOR) + y2 = y1 + (self.height // LATENT_SCALE_FACTOR) + + cropped_latents = latents[..., y1:y2, x1:x2] + + name = context.tensors.save(tensor=cropped_latents) + + return LatentsOutput.build(latents_name=name, latents=cropped_latents) diff --git a/invokeai/app/invocations/custom_nodes/README.md b/invokeai/app/invocations/custom_nodes/README.md new file mode 100644 index 0000000000000000000000000000000000000000..d93bb65539c755f6cbf18b27953a71bce295c2ad --- /dev/null +++ b/invokeai/app/invocations/custom_nodes/README.md @@ -0,0 +1,51 @@ +# Custom Nodes / Node Packs + +Copy your node packs to this directory. + +When nodes are added or changed, you must restart the app to see the changes. + +## Directory Structure + +For a node pack to be loaded, it must be placed in a directory alongside this +file. Here's an example 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 +``` + +## Node Pack `__init__.py` + +Each node pack must have an `__init__.py` file that imports its nodes. + +The structure of each node or node pack is otherwise not important. + +Here are examples, based on the example directory structure. + +### `cool_node/__init__.py` + +```py +from .cool_node import CoolInvocation +``` + +### `my_node_pack/__init__.py` + +```py +from .tasty_node import TastyInvocation +from .bodacious_node import BodaciousInvocation +from .extra_nodes.fancy_node import FancyInvocation +``` + +Only nodes imported in the `__init__.py` file are loaded. diff --git a/invokeai/app/invocations/custom_nodes/init.py b/invokeai/app/invocations/custom_nodes/init.py new file mode 100644 index 0000000000000000000000000000000000000000..efddede72fc58b7a930b6ff502a9f1f3fdefa2da --- /dev/null +++ b/invokeai/app/invocations/custom_nodes/init.py @@ -0,0 +1,58 @@ +""" +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 new file mode 100644 index 0000000000000000000000000000000000000000..f7951ccfeb2bbcbf460b68d4adf7b051006fd3d5 --- /dev/null +++ b/invokeai/app/invocations/cv.py @@ -0,0 +1,39 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) + + +import cv2 as cv +import numpy +from PIL import Image, ImageOps + +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 + + +@invocation("cv_inpaint", title="OpenCV Inpaint", tags=["opencv", "inpaint"], category="inpaint", version="1.3.1") +class CvInpaintInvocation(BaseInvocation, WithMetadata, WithBoard): + """Simple inpaint using opencv.""" + + image: ImageField = InputField(description="The image to inpaint") + mask: ImageField = InputField(description="The mask to use when inpainting") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + mask = context.images.get_pil(self.mask.image_name) + + # Convert to cv image/mask + # TODO: consider making these utility functions + cv_image = cv.cvtColor(numpy.array(image.convert("RGB")), cv.COLOR_RGB2BGR) + cv_mask = numpy.array(ImageOps.invert(mask.convert("L"))) + + # Inpaint + cv_inpainted = cv.inpaint(cv_image, cv_mask, 3, cv.INPAINT_TELEA) + + # Convert back to Pillow + # TODO: consider making a utility function + image_inpainted = Image.fromarray(cv.cvtColor(cv_inpainted, cv.COLOR_BGR2RGB)) + + image_dto = context.images.save(image=image_inpainted) + + return ImageOutput.build(image_dto) diff --git a/invokeai/app/invocations/denoise_latents.py b/invokeai/app/invocations/denoise_latents.py new file mode 100644 index 0000000000000000000000000000000000000000..4cbbcf07afc589364c3af24138da86628fd833bb --- /dev/null +++ b/invokeai/app/invocations/denoise_latents.py @@ -0,0 +1,1095 @@ +# 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 + +import torch +import torchvision +import torchvision.transforms as T +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_sde import DPMSolverSDEScheduler +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 +from invokeai.app.invocations.controlnet_image_processors import ControlField +from invokeai.app.invocations.fields import ( + ConditioningField, + DenoiseMaskField, + FieldDescriptions, + Input, + InputField, + LatentsField, + UIType, +) +from invokeai.app.invocations.ip_adapter import IPAdapterField +from invokeai.app.invocations.model import ModelIdentifierField, UNetField +from invokeai.app.invocations.primitives import LatentsOutput +from invokeai.app.invocations.t2i_adapter import T2IAdapterField +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.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.lora_patcher import LoRAPatcher +from invokeai.backend.model_manager import BaseModelType, ModelVariantType +from invokeai.backend.model_patcher import ModelPatcher +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, + T2IAdapterData, +) +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( + BasicConditioningInfo, + IPAdapterConditioningInfo, + IPAdapterData, + Range, + SDXLConditioningInfo, + 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 + + +def get_scheduler( + context: InvocationContext, + scheduler_info: ModelIdentifierField, + scheduler_name: str, + seed: int, +) -> 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 + + if "_backup" in scheduler_config: + scheduler_config = scheduler_config["_backup"] + scheduler_config = { + **scheduler_config, + **scheduler_extra_config, # FIXME + "_backup": scheduler_config, + } + + # make dpmpp_sde reproducable(seed can be passed only in initializer) + if scheduler_class is DPMSolverSDEScheduler: + scheduler_config["noise_sampler_seed"] = seed + + scheduler = scheduler_class.from_config(scheduler_config) + + # hack copied over from generate.py + if not hasattr(scheduler, "uses_inpainting_model"): + scheduler.uses_inpainting_model = lambda: False + assert isinstance(scheduler, Scheduler) + return scheduler + + +@invocation( + "denoise_latents", + title="Denoise Latents", + tags=["latents", "denoise", "txt2img", "t2i", "t2l", "img2img", "i2i", "l2l"], + category="latents", + version="1.5.3", +) +class DenoiseLatentsInvocation(BaseInvocation): + """Denoises noisy latents to decodable images""" + + positive_conditioning: Union[ConditioningField, list[ConditioningField]] = InputField( + description=FieldDescriptions.positive_cond, input=Input.Connection, ui_order=0 + ) + negative_conditioning: Union[ConditioningField, list[ConditioningField]] = InputField( + description=FieldDescriptions.negative_cond, input=Input.Connection, ui_order=1 + ) + noise: Optional[LatentsField] = InputField( + default=None, + description=FieldDescriptions.noise, + input=Input.Connection, + ui_order=3, + ) + steps: int = InputField(default=10, gt=0, description=FieldDescriptions.steps) + cfg_scale: Union[float, List[float]] = InputField( + default=7.5, 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", + ui_order=2, + ) + control: Optional[Union[ControlField, list[ControlField]]] = InputField( + default=None, + input=Input.Connection, + ui_order=5, + ) + ip_adapter: Optional[Union[IPAdapterField, list[IPAdapterField]]] = InputField( + description=FieldDescriptions.ip_adapter, + title="IP-Adapter", + default=None, + input=Input.Connection, + ui_order=6, + ) + t2i_adapter: Optional[Union[T2IAdapterField, list[T2IAdapterField]]] = InputField( + description=FieldDescriptions.t2i_adapter, + title="T2I-Adapter", + default=None, + input=Input.Connection, + ui_order=7, + ) + cfg_rescale_multiplier: float = InputField( + title="CFG Rescale Multiplier", default=0, ge=0, lt=1, description=FieldDescriptions.cfg_rescale_multiplier + ) + latents: Optional[LatentsField] = InputField( + default=None, + description=FieldDescriptions.latents, + input=Input.Connection, + ui_order=4, + ) + denoise_mask: Optional[DenoiseMaskField] = InputField( + default=None, + description=FieldDescriptions.denoise_mask, + input=Input.Connection, + ui_order=8, + ) + + @field_validator("cfg_scale") + def ge_one(cls, v: Union[List[float], float]) -> Union[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 _get_text_embeddings_and_masks( + cond_list: list[ConditioningField], + context: InvocationContext, + device: torch.device, + dtype: torch.dtype, + ) -> tuple[Union[list[BasicConditioningInfo], list[SDXLConditioningInfo]], list[Optional[torch.Tensor]]]: + """Get the text embeddings and masks from the input conditioning fields.""" + text_embeddings: Union[list[BasicConditioningInfo], list[SDXLConditioningInfo]] = [] + text_embeddings_masks: list[Optional[torch.Tensor]] = [] + for cond in cond_list: + cond_data = context.conditioning.load(cond.conditioning_name) + text_embeddings.append(cond_data.conditionings[0].to(device=device, dtype=dtype)) + + mask = cond.mask + if mask is not None: + mask = context.tensors.load(mask.tensor_name) + text_embeddings_masks.append(mask) + + return text_embeddings, text_embeddings_masks + + @staticmethod + def _preprocess_regional_prompt_mask( + 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. + If mask is not None, resizes the mask to the target height and width using 'nearest' interpolation. + + Returns: + torch.Tensor: The processed mask. shape: (1, 1, target_height, target_width). + """ + + if mask is None: + return torch.ones((1, 1, target_height, target_width), dtype=dtype) + + mask = to_standard_float_mask(mask, out_dtype=dtype) + + tf = torchvision.transforms.Resize( + (target_height, target_width), interpolation=torchvision.transforms.InterpolationMode.NEAREST + ) + + # Add a batch dimension to the mask, because torchvision expects shape (batch, channels, h, w). + mask = mask.unsqueeze(0) # Shape: (1, h, w) -> (1, 1, h, w) + resized_mask = tf(mask) + return resized_mask + + @staticmethod + def _concat_regional_text_embeddings( + text_conditionings: Union[list[BasicConditioningInfo], list[SDXLConditioningInfo]], + masks: Optional[list[Optional[torch.Tensor]]], + latent_height: int, + latent_width: int, + dtype: torch.dtype, + ) -> tuple[Union[BasicConditioningInfo, SDXLConditioningInfo], Optional[TextConditioningRegions]]: + """Concatenate regional text embeddings into a single embedding and track the region masks accordingly.""" + if masks is None: + masks = [None] * len(text_conditionings) + assert len(text_conditionings) == len(masks) + + is_sdxl = type(text_conditionings[0]) is SDXLConditioningInfo + + all_masks_are_none = all(mask is None for mask in masks) + + text_embedding = [] + pooled_embedding = None + add_time_ids = None + cur_text_embedding_len = 0 + processed_masks = [] + embedding_ranges = [] + + for prompt_idx, text_embedding_info in enumerate(text_conditionings): + mask = masks[prompt_idx] + + if is_sdxl: + # We choose a random SDXLConditioningInfo's pooled_embeds and add_time_ids here, with a preference for + # prompts without a mask. We prefer prompts without a mask, because they are more likely to contain + # global prompt information. In an ideal case, there should be exactly one global prompt without a + # mask, but we don't enforce this. + + # HACK(ryand): The fact that we have to choose a single pooled_embedding and add_time_ids here is a + # fundamental interface issue. The SDXL Compel nodes are not designed to be used in the way that we use + # them for regional prompting. Ideally, the DenoiseLatents invocation should accept a single + # pooled_embeds tensor and a list of standard text embeds with region masks. This change would be a + # pretty major breaking change to a popular node, so for now we use this hack. + if pooled_embedding is None or mask is None: + pooled_embedding = text_embedding_info.pooled_embeds + if add_time_ids is None or mask is None: + add_time_ids = text_embedding_info.add_time_ids + + text_embedding.append(text_embedding_info.embeds) + if not all_masks_are_none: + embedding_ranges.append( + Range( + start=cur_text_embedding_len, end=cur_text_embedding_len + text_embedding_info.embeds.shape[1] + ) + ) + processed_masks.append( + DenoiseLatentsInvocation._preprocess_regional_prompt_mask( + mask, latent_height, latent_width, dtype=dtype + ) + ) + + cur_text_embedding_len += text_embedding_info.embeds.shape[1] + + text_embedding = torch.cat(text_embedding, dim=1) + assert len(text_embedding.shape) == 3 # batch_size, seq_len, token_len + + regions = None + if not all_masks_are_none: + regions = TextConditioningRegions( + masks=torch.cat(processed_masks, dim=1), + ranges=embedding_ranges, + ) + + if is_sdxl: + return ( + SDXLConditioningInfo(embeds=text_embedding, pooled_embeds=pooled_embedding, add_time_ids=add_time_ids), + regions, + ) + return BasicConditioningInfo(embeds=text_embedding), regions + + @staticmethod + def get_conditioning_data( + context: InvocationContext, + 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 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 = negative_conditioning_field + if not isinstance(uncond_list, list): + uncond_list = [uncond_list] + + 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 = DenoiseLatentsInvocation._get_text_embeddings_and_masks( + uncond_list, context, device, dtype + ) + + 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=dtype, + ) + 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=dtype, + ) + + 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=cfg_scale, + guidance_rescale_multiplier=cfg_rescale_multiplier, + ) + return conditioning_data + + @staticmethod + def create_pipeline( + unet: UNet2DConditionModel, + scheduler: Scheduler, + ) -> StableDiffusionGeneratorPipeline: + class FakeVae: + class FakeVaeConfig: + def __init__(self) -> None: + self.block_out_channels = [0] + + def __init__(self) -> None: + self.config = FakeVae.FakeVaeConfig() + + return StableDiffusionGeneratorPipeline( + vae=FakeVae(), # TODO: oh... + text_encoder=None, + tokenizer=None, + unet=unet, + scheduler=scheduler, + safety_checker=None, + feature_extractor=None, + requires_safety_checker=False, + ) + + @staticmethod + def prep_control_data( + context: InvocationContext, + control_input: ControlField | list[ControlField] | None, + latents_shape: List[int], + exit_stack: ExitStack, + do_classifier_free_guidance: bool = True, + ) -> 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): + control_list = control_input + elif control_input is None: + control_list = [] + else: + raise ValueError(f"Unexpected control_input type: {type(control_input)}") + + if len(control_list) == 0: + return None + + # 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_image_field = control_info.image + input_image = context.images.get_pil(control_image_field.image_name) + # self.image.image_type, self.image.image_name + # FIXME: still need to test with different widths, heights, devices, dtypes + # and add in batch_size, num_images_per_prompt? + # and do real check for classifier_free_guidance? + # prepare_control_image should return torch.Tensor of shape(batch_size, 3, height, width) + control_image = prepare_control_image( + image=input_image, + do_classifier_free_guidance=do_classifier_free_guidance, + width=control_width_resize, + height=control_height_resize, + # batch_size=batch_size * num_images_per_prompt, + # num_images_per_prompt=num_images_per_prompt, + device=control_model.device, + dtype=control_model.dtype, + control_mode=control_info.control_mode, + resize_mode=control_info.resize_mode, + ) + control_item = ControlNetData( + model=control_model, + image_tensor=control_image, + 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, + # any resizing needed should currently be happening in prepare_control_image(), + # but adding resize_mode to ControlNetData in case needed in the future + resize_mode=control_info.resize_mode, + ) + controlnet_data.append(control_item) + # MultiControlNetModel has been refactored out, just need list[ControlNetData] + + 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, + ip_adapters: List[IPAdapterField], + ) -> List[Tuple[torch.Tensor, torch.Tensor]]: + """Run the IPAdapter CLIPVisionModel, returning image prompt embeddings.""" + 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, mode="RGB") for image in single_ipa_image_fields + ] + with image_encoder_model_info 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( + single_ipa_images, image_encoder_model + ) + image_prompts.append((image_prompt_embeds, uncond_image_prompt_embeds)) + + return image_prompts + + def prep_ip_adapter_data( + self, + context: InvocationContext, + ip_adapters: List[IPAdapterField], + image_prompts: List[Tuple[torch.Tensor, torch.Tensor]], + exit_stack: ExitStack, + latent_height: int, + latent_width: int, + dtype: torch.dtype, + ) -> Optional[List[IPAdapterData]]: + """If IP-Adapter is enabled, then this function loads the requisite models and adds the image prompt conditioning data.""" + ip_adapter_data_list = [] + for single_ip_adapter, (image_prompt_embeds, uncond_image_prompt_embeds) in zip( + ip_adapters, image_prompts, strict=True + ): + ip_adapter_model = exit_stack.enter_context(context.models.load(single_ip_adapter.ip_adapter_model)) + + mask_field = single_ip_adapter.mask + mask = context.tensors.load(mask_field.tensor_name) if mask_field is not None else None + mask = self._preprocess_regional_prompt_mask(mask, latent_height, latent_width, dtype=dtype) + + ip_adapter_data_list.append( + IPAdapterData( + ip_adapter_model=ip_adapter_model, + weight=single_ip_adapter.weight, + target_blocks=single_ip_adapter.target_blocks, + begin_step_percent=single_ip_adapter.begin_step_percent, + end_step_percent=single_ip_adapter.end_step_percent, + ip_adapter_conditioning=IPAdapterConditioningInfo(image_prompt_embeds, uncond_image_prompt_embeds), + mask=mask, + ) + ) + + return ip_adapter_data_list if len(ip_adapter_data_list) > 0 else None + + def run_t2i_adapters( + self, + context: InvocationContext, + t2i_adapter: Optional[Union[T2IAdapterField, list[T2IAdapterField]]], + latents_shape: list[int], + do_classifier_free_guidance: bool, + ) -> Optional[list[T2IAdapterData]]: + if t2i_adapter is None: + return None + + # Handle the possibility that t2i_adapter could be a list or a single T2IAdapterField. + if isinstance(t2i_adapter, T2IAdapterField): + t2i_adapter = [t2i_adapter] + + if len(t2i_adapter) == 0: + return None + + 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, 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: + total_downscale_factor = t2i_adapter_model.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=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, + 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: + for idx, value in enumerate(adapter_state): + adapter_state[idx] = torch.cat([value] * 2, dim=0) + + t2i_adapter_data.append( + T2IAdapterData( + adapter_state=adapter_state, + weight=t2i_adapter_field.weight, + begin_step_percent=t2i_adapter_field.begin_step_percent, + end_step_percent=t2i_adapter_field.end_step_percent, + ) + ) + + return t2i_adapter_data + + # original idea by https://github.com/AmericanPresidentJimmyCarter + # TODO: research more for second order schedulers timesteps + @staticmethod + def init_scheduler( + scheduler: Union[Scheduler, ConfigMixin], + device: torch.device, + steps: int, + denoising_start: float, + denoising_end: float, + seed: int, + ) -> 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") + timesteps = scheduler.timesteps.to(device=device) + else: + scheduler.set_timesteps(steps, device=device) + timesteps = scheduler.timesteps + + # skip greater order timesteps + _timesteps = timesteps[:: scheduler.order] + + # get start timestep index + t_start_val = int(round(scheduler.config["num_train_timesteps"] * (1 - denoising_start))) + t_start_idx = len(list(filter(lambda ts: ts >= t_start_val, _timesteps))) + + # get end timestep index + t_end_val = int(round(scheduler.config["num_train_timesteps"] * (1 - denoising_end))) + t_end_idx = len(list(filter(lambda ts: ts >= t_end_val, _timesteps[t_start_idx:]))) + + # apply order to indexes + t_start_idx *= scheduler.order + t_end_idx *= scheduler.order + + init_timestep = timesteps[t_start_idx : t_start_idx + 1] + timesteps = timesteps[t_start_idx : t_start_idx + t_end_idx] + + scheduler_step_kwargs: Dict[str, Any] = {} + scheduler_step_signature = inspect.signature(scheduler.step) + if "generator" in scheduler_step_signature.parameters: + # At some point, someone decided that schedulers that accept a generator should use the original seed with + # all bits flipped. I don't know the original rationale for this, but now we must keep it like this for + # reproducibility. + # + # These Invoke-supported schedulers accept a generator as of 2024-06-04: + # - DDIMScheduler + # - DDPMScheduler + # - DPMSolverMultistepScheduler + # - EulerAncestralDiscreteScheduler + # - EulerDiscreteScheduler + # - KDPM2AncestralDiscreteScheduler + # - LCMScheduler + # - TCDScheduler + scheduler_step_kwargs.update({"generator": torch.Generator(device=device).manual_seed(seed ^ 0xFFFFFFFF)}) + if isinstance(scheduler, TCDScheduler): + scheduler_step_kwargs.update({"eta": 1.0}) + + return timesteps, init_timestep, scheduler_step_kwargs + + def prep_inpaint_mask( + self, context: InvocationContext, latents: torch.Tensor + ) -> Tuple[Optional[torch.Tensor], Optional[torch.Tensor], bool]: + if self.denoise_mask is None: + return None, None, False + + mask = context.tensors.load(self.denoise_mask.mask_name) + mask = tv_resize(mask, latents.shape[-2:], T.InterpolationMode.BILINEAR, antialias=False) + if self.denoise_mask.masked_latents_name is not None: + masked_latents = context.tensors.load(self.denoise_mask.masked_latents_name) + else: + masked_latents = torch.where(mask < 0.5, 0.0, latents) + + return mask, masked_latents, self.denoise_mask.gradient + + @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. + + 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. + + 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 ValueError("'latents' or 'noise' must be provided!") + + 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 + + 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, + ) + + 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, + ) + + # 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) + + ### 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 inpecting 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) + + unet_info = context.models.load(self.unet.unet) + assert isinstance(unet_info.model, UNet2DConditionModel) + with ( + unet_info.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: + 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. + t2i_adapter_data = self.run_t2i_adapters( + context, + self.t2i_adapter, + latents.shape, + do_classifier_free_guidance=True, + ) + + ip_adapters: List[IPAdapterField] = [] + if self.ip_adapter is not None: + # ip_adapter could be a list or a single IPAdapterField. Normalize to a list here. + if isinstance(self.ip_adapter, list): + ip_adapters = self.ip_adapter + else: + ip_adapters = [self.ip_adapter] + + # If there are IP adapters, the following line runs the adapters' CLIPVision image encoders to return + # a series of image conditioning embeddings. This is being done here rather than in the + # big model context below in order to use less VRAM on low-VRAM systems. + # 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 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]]: + for lora in self.unet.loras: + lora_info = context.models.load(lora.lora) + assert isinstance(lora_info.model, LoRAModelRaw) + 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 (cached_weights, unet), + ModelPatcher.apply_freeu(unet, self.unet.freeu_config), + 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. + LoRAPatcher.apply_lora_patches( + model=unet, + patches=_lora_loader(), + prefix="lora_unet_", + cached_weights=cached_weights, + ), + ): + assert isinstance(unet, UNet2DConditionModel) + latents = latents.to(device=unet.device, dtype=unet.dtype) + if noise is not None: + noise = noise.to(device=unet.device, dtype=unet.dtype) + if mask is not None: + mask = mask.to(device=unet.device, dtype=unet.dtype) + if masked_latents is not None: + masked_latents = masked_latents.to(device=unet.device, dtype=unet.dtype) + + scheduler = get_scheduler( + context=context, + scheduler_info=self.unet.scheduler, + scheduler_name=self.scheduler, + seed=seed, + ) + + pipeline = self.create_pipeline(unet, scheduler) + + _, _, latent_height, latent_width = latents.shape + conditioning_data = self.get_conditioning_data( + context=context, + positive_conditioning_field=self.positive_conditioning, + negative_conditioning_field=self.negative_conditioning, + device=unet.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, + # do_classifier_free_guidance=(self.cfg_scale >= 1.0)) + do_classifier_free_guidance=True, + exit_stack=exit_stack, + ) + + ip_adapter_data = self.prep_ip_adapter_data( + context=context, + ip_adapters=ip_adapters, + image_prompts=image_prompts, + exit_stack=exit_stack, + latent_height=latent_height, + latent_width=latent_width, + dtype=unet.dtype, + ) + + timesteps, init_timestep, scheduler_step_kwargs = self.init_scheduler( + scheduler, + device=unet.device, + steps=self.steps, + denoising_start=self.denoising_start, + denoising_end=self.denoising_end, + seed=seed, + ) + + result_latents = pipeline.latents_from_embeddings( + latents=latents, + timesteps=timesteps, + init_timestep=init_timestep, + noise=noise, + seed=seed, + mask=mask, + masked_latents=masked_latents, + is_gradient_mask=gradient_mask, + scheduler_step_kwargs=scheduler_step_kwargs, + conditioning_data=conditioning_data, + control_data=controlnet_data, + ip_adapter_data=ip_adapter_data, + t2i_adapter_data=t2i_adapter_data, + callback=step_callback, + ) + + # https://discuss.huggingface.co/t/memory-usage-by-later-pipeline-stages/23699 + result_latents = result_latents.to("cpu") + 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/depth_anything.py b/invokeai/app/invocations/depth_anything.py new file mode 100644 index 0000000000000000000000000000000000000000..af79413ce01a3cf9dc9e89bd7781f1d0d5666744 --- /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", + 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 0000000000000000000000000000000000000000..aa963cceb1dc3a40342365d899ca6786ed1e1986 --- /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 DWOpenposeDetector2 + + +@invocation( + "dw_openpose_detection", + title="DW Openpose Detection", + tags=["controlnet", "dwpose", "openpose"], + category="controlnet", + 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(DWOpenposeDetector2.get_model_url_det()) + onnx_pose_path = context.models.download_and_cache_model(DWOpenposeDetector2.get_model_url_pose()) + + loaded_session_det = context.models.load_local_model( + onnx_det_path, DWOpenposeDetector2.create_onnx_inference_session + ) + loaded_session_pose = context.models.load_local_model( + onnx_pose_path, DWOpenposeDetector2.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 = DWOpenposeDetector2(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/facetools.py b/invokeai/app/invocations/facetools.py new file mode 100644 index 0000000000000000000000000000000000000000..987f1b1e4069ac2ddd1824377608f6cf7bdb9cd7 --- /dev/null +++ b/invokeai/app/invocations/facetools.py @@ -0,0 +1,681 @@ +import math +import re +from pathlib import Path +from typing import Optional, TypedDict + +import cv2 +import numpy as np +from mediapipe.python.solutions.face_mesh import FaceMesh # type: ignore[import] +from PIL import Image, ImageDraw, ImageFilter, ImageFont, ImageOps +from PIL.Image import Image as ImageType +from pydantic import field_validator + +import invokeai.assets.fonts as font_assets +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + invocation, + invocation_output, +) +from invokeai.app.invocations.fields import ImageField, InputField, OutputField, WithBoard, WithMetadata +from invokeai.app.invocations.primitives import ImageOutput +from invokeai.app.services.image_records.image_records_common import ImageCategory +from invokeai.app.services.shared.invocation_context import InvocationContext + + +@invocation_output("face_mask_output") +class FaceMaskOutput(ImageOutput): + """Base class for FaceMask output""" + + mask: ImageField = OutputField(description="The output mask") + + +@invocation_output("face_off_output") +class FaceOffOutput(ImageOutput): + """Base class for FaceOff Output""" + + mask: ImageField = OutputField(description="The output mask") + x: int = OutputField(description="The x coordinate of the bounding box's left side") + y: int = OutputField(description="The y coordinate of the bounding box's top side") + + +class FaceResultData(TypedDict): + image: ImageType + mask: ImageType + x_center: float + y_center: float + mesh_width: int + mesh_height: int + chunk_x_offset: int + chunk_y_offset: int + + +class FaceResultDataWithId(FaceResultData): + face_id: int + + +class ExtractFaceData(TypedDict): + bounded_image: ImageType + bounded_mask: ImageType + x_min: int + y_min: int + x_max: int + y_max: int + + +class FaceMaskResult(TypedDict): + image: ImageType + mask: ImageType + + +def create_white_image(w: int, h: int) -> ImageType: + return Image.new("L", (w, h), color=255) + + +def create_black_image(w: int, h: int) -> ImageType: + return Image.new("L", (w, h), color=0) + + +FONT_SIZE = 32 +FONT_STROKE_WIDTH = 4 + + +def coalesce_faces(face1: FaceResultData, face2: FaceResultData) -> FaceResultData: + face1_x_offset = face1["chunk_x_offset"] - min(face1["chunk_x_offset"], face2["chunk_x_offset"]) + face2_x_offset = face2["chunk_x_offset"] - min(face1["chunk_x_offset"], face2["chunk_x_offset"]) + face1_y_offset = face1["chunk_y_offset"] - min(face1["chunk_y_offset"], face2["chunk_y_offset"]) + face2_y_offset = face2["chunk_y_offset"] - min(face1["chunk_y_offset"], face2["chunk_y_offset"]) + + new_im_width = ( + max(face1["image"].width, face2["image"].width) + + max(face1["chunk_x_offset"], face2["chunk_x_offset"]) + - min(face1["chunk_x_offset"], face2["chunk_x_offset"]) + ) + new_im_height = ( + max(face1["image"].height, face2["image"].height) + + max(face1["chunk_y_offset"], face2["chunk_y_offset"]) + - min(face1["chunk_y_offset"], face2["chunk_y_offset"]) + ) + pil_image = Image.new(mode=face1["image"].mode, size=(new_im_width, new_im_height)) + pil_image.paste(face1["image"], (face1_x_offset, face1_y_offset)) + pil_image.paste(face2["image"], (face2_x_offset, face2_y_offset)) + + # Mask images are always from the origin + new_mask_im_width = max(face1["mask"].width, face2["mask"].width) + new_mask_im_height = max(face1["mask"].height, face2["mask"].height) + mask_pil = create_white_image(new_mask_im_width, new_mask_im_height) + black_image = create_black_image(face1["mask"].width, face1["mask"].height) + mask_pil.paste(black_image, (0, 0), ImageOps.invert(face1["mask"])) + black_image = create_black_image(face2["mask"].width, face2["mask"].height) + mask_pil.paste(black_image, (0, 0), ImageOps.invert(face2["mask"])) + + new_face = FaceResultData( + image=pil_image, + mask=mask_pil, + x_center=max(face1["x_center"], face2["x_center"]), + y_center=max(face1["y_center"], face2["y_center"]), + mesh_width=max(face1["mesh_width"], face2["mesh_width"]), + mesh_height=max(face1["mesh_height"], face2["mesh_height"]), + chunk_x_offset=max(face1["chunk_x_offset"], face2["chunk_x_offset"]), + chunk_y_offset=max(face2["chunk_y_offset"], face2["chunk_y_offset"]), + ) + return new_face + + +def prepare_faces_list( + face_result_list: list[FaceResultData], +) -> list[FaceResultDataWithId]: + """Deduplicates a list of faces, adding IDs to them.""" + deduped_faces: list[FaceResultData] = [] + + if len(face_result_list) == 0: + return [] + + for candidate in face_result_list: + should_add = True + candidate_x_center = candidate["x_center"] + candidate_y_center = candidate["y_center"] + for idx, face in enumerate(deduped_faces): + face_center_x = face["x_center"] + face_center_y = face["y_center"] + face_radius_w = face["mesh_width"] / 2 + face_radius_h = face["mesh_height"] / 2 + # Determine if the center of the candidate_face is inside the ellipse of the added face + # p < 1 -> Inside + # p = 1 -> Exactly on the ellipse + # p > 1 -> Outside + p = (math.pow((candidate_x_center - face_center_x), 2) / math.pow(face_radius_w, 2)) + ( + math.pow((candidate_y_center - face_center_y), 2) / math.pow(face_radius_h, 2) + ) + + if p < 1: # Inside of the already-added face's radius + deduped_faces[idx] = coalesce_faces(face, candidate) + should_add = False + break + + if should_add is True: + deduped_faces.append(candidate) + + sorted_faces = sorted(deduped_faces, key=lambda x: x["y_center"]) + sorted_faces = sorted(sorted_faces, key=lambda x: x["x_center"]) + + # add face_id for reference + sorted_faces_with_ids: list[FaceResultDataWithId] = [] + face_id_counter = 0 + for face in sorted_faces: + sorted_faces_with_ids.append( + FaceResultDataWithId( + **face, + face_id=face_id_counter, + ) + ) + face_id_counter += 1 + + return sorted_faces_with_ids + + +def generate_face_box_mask( + context: InvocationContext, + minimum_confidence: float, + x_offset: float, + y_offset: float, + pil_image: ImageType, + chunk_x_offset: int = 0, + chunk_y_offset: int = 0, + draw_mesh: bool = True, +) -> list[FaceResultData]: + result = [] + mask_pil = None + + # Convert the PIL image to a NumPy array. + np_image = np.array(pil_image, dtype=np.uint8) + + # Check if the input image has four channels (RGBA). + if np_image.shape[2] == 4: + # Convert RGBA to RGB by removing the alpha channel. + np_image = np_image[:, :, :3] + + # Create a FaceMesh object for face landmark detection and mesh generation. + face_mesh = FaceMesh( + max_num_faces=999, + min_detection_confidence=minimum_confidence, + min_tracking_confidence=minimum_confidence, + ) + + # Detect the face landmarks and mesh in the input image. + results = face_mesh.process(np_image) + + # Check if any face is detected. + if results.multi_face_landmarks: # type: ignore # this are via protobuf and not typed + # Search for the face_id in the detected faces. + for _face_id, face_landmarks in enumerate(results.multi_face_landmarks): # type: ignore #this are via protobuf and not typed + # Get the bounding box of the face mesh. + x_coordinates = [landmark.x for landmark in face_landmarks.landmark] + y_coordinates = [landmark.y for landmark in face_landmarks.landmark] + x_min, x_max = min(x_coordinates), max(x_coordinates) + y_min, y_max = min(y_coordinates), max(y_coordinates) + + # Calculate the width and height of the face mesh. + mesh_width = int((x_max - x_min) * np_image.shape[1]) + mesh_height = int((y_max - y_min) * np_image.shape[0]) + + # Get the center of the face. + x_center = np.mean([landmark.x * np_image.shape[1] for landmark in face_landmarks.landmark]) + y_center = np.mean([landmark.y * np_image.shape[0] for landmark in face_landmarks.landmark]) + + face_landmark_points = np.array( + [ + [landmark.x * np_image.shape[1], landmark.y * np_image.shape[0]] + for landmark in face_landmarks.landmark + ] + ) + + # Apply the scaling offsets to the face landmark points with a multiplier. + scale_multiplier = 0.2 + x_center = np.mean(face_landmark_points[:, 0]) + y_center = np.mean(face_landmark_points[:, 1]) + + if draw_mesh: + x_scaled = face_landmark_points[:, 0] + scale_multiplier * x_offset * ( + face_landmark_points[:, 0] - x_center + ) + y_scaled = face_landmark_points[:, 1] + scale_multiplier * y_offset * ( + face_landmark_points[:, 1] - y_center + ) + + convex_hull = cv2.convexHull(np.column_stack((x_scaled, y_scaled)).astype(np.int32)) + + # Generate a binary face mask using the face mesh. + mask_image = np.ones(np_image.shape[:2], dtype=np.uint8) * 255 + cv2.fillConvexPoly(mask_image, convex_hull, 0) + + # Convert the binary mask image to a PIL Image. + init_mask_pil = Image.fromarray(mask_image, mode="L") + w, h = init_mask_pil.size + mask_pil = create_white_image(w + chunk_x_offset, h + chunk_y_offset) + mask_pil.paste(init_mask_pil, (chunk_x_offset, chunk_y_offset)) + + x_center = float(x_center) + y_center = float(y_center) + face = FaceResultData( + image=pil_image, + mask=mask_pil or create_white_image(*pil_image.size), + x_center=x_center + chunk_x_offset, + y_center=y_center + chunk_y_offset, + mesh_width=mesh_width, + mesh_height=mesh_height, + chunk_x_offset=chunk_x_offset, + chunk_y_offset=chunk_y_offset, + ) + + result.append(face) + + return result + + +def extract_face( + context: InvocationContext, + image: ImageType, + face: FaceResultData, + padding: int, +) -> ExtractFaceData: + mask = face["mask"] + center_x = face["x_center"] + center_y = face["y_center"] + mesh_width = face["mesh_width"] + mesh_height = face["mesh_height"] + + # Determine the minimum size of the square crop + min_size = min(mask.width, mask.height) + + # Calculate the crop boundaries for the output image and mask. + mesh_width += 128 + padding # add pixels to account for mask variance + mesh_height += 128 + padding # add pixels to account for mask variance + crop_size = min( + max(mesh_width, mesh_height, 128), min_size + ) # Choose the smaller of the two (given value or face mask size) + if crop_size > 128: + crop_size = (crop_size + 7) // 8 * 8 # Ensure crop side is multiple of 8 + + # Calculate the actual crop boundaries within the bounds of the original image. + x_min = int(center_x - crop_size / 2) + y_min = int(center_y - crop_size / 2) + x_max = int(center_x + crop_size / 2) + y_max = int(center_y + crop_size / 2) + + # Adjust the crop boundaries to stay within the original image's dimensions + if x_min < 0: + context.logger.warning("FaceTools --> -X-axis padding reached image edge.") + x_max -= x_min + x_min = 0 + elif x_max > mask.width: + context.logger.warning("FaceTools --> +X-axis padding reached image edge.") + x_min -= x_max - mask.width + x_max = mask.width + + if y_min < 0: + context.logger.warning("FaceTools --> +Y-axis padding reached image edge.") + y_max -= y_min + y_min = 0 + elif y_max > mask.height: + context.logger.warning("FaceTools --> -Y-axis padding reached image edge.") + y_min -= y_max - mask.height + y_max = mask.height + + # Ensure the crop is square and adjust the boundaries if needed + if x_max - x_min != crop_size: + context.logger.warning("FaceTools --> Limiting x-axis padding to constrain bounding box to a square.") + diff = crop_size - (x_max - x_min) + x_min -= diff // 2 + x_max += diff - diff // 2 + + if y_max - y_min != crop_size: + context.logger.warning("FaceTools --> Limiting y-axis padding to constrain bounding box to a square.") + diff = crop_size - (y_max - y_min) + y_min -= diff // 2 + y_max += diff - diff // 2 + + context.logger.info(f"FaceTools --> Calculated bounding box (8 multiple): {crop_size}") + + # Crop the output image to the specified size with the center of the face mesh as the center. + mask = mask.crop((x_min, y_min, x_max, y_max)) + bounded_image = image.crop((x_min, y_min, x_max, y_max)) + + # blur mask edge by small radius + mask = mask.filter(ImageFilter.GaussianBlur(radius=2)) + + return ExtractFaceData( + bounded_image=bounded_image, + bounded_mask=mask, + x_min=x_min, + y_min=y_min, + x_max=x_max, + y_max=y_max, + ) + + +def get_faces_list( + context: InvocationContext, + image: ImageType, + should_chunk: bool, + minimum_confidence: float, + x_offset: float, + y_offset: float, + draw_mesh: bool = True, +) -> list[FaceResultDataWithId]: + result = [] + + # Generate the face box mask and get the center of the face. + if not should_chunk: + context.logger.info("FaceTools --> Attempting full image face detection.") + result = generate_face_box_mask( + context=context, + minimum_confidence=minimum_confidence, + x_offset=x_offset, + y_offset=y_offset, + pil_image=image, + chunk_x_offset=0, + chunk_y_offset=0, + draw_mesh=draw_mesh, + ) + if should_chunk or len(result) == 0: + context.logger.info("FaceTools --> Chunking image (chunk toggled on, or no face found in full image).") + width, height = image.size + image_chunks = [] + x_offsets = [] + y_offsets = [] + result = [] + + # If width == height, there's nothing more we can do... otherwise... + if width > height: + # Landscape - slice the image horizontally + fx = 0.0 + steps = int(width * 2 / height) + 1 + increment = (width - height) / (steps - 1) + while fx <= (width - height): + x = int(fx) + image_chunks.append(image.crop((x, 0, x + height, height))) + x_offsets.append(x) + y_offsets.append(0) + fx += increment + context.logger.info(f"FaceTools --> Chunk starting at x = {x}") + elif height > width: + # Portrait - slice the image vertically + fy = 0.0 + steps = int(height * 2 / width) + 1 + increment = (height - width) / (steps - 1) + while fy <= (height - width): + y = int(fy) + image_chunks.append(image.crop((0, y, width, y + width))) + x_offsets.append(0) + y_offsets.append(y) + fy += increment + context.logger.info(f"FaceTools --> Chunk starting at y = {y}") + + for idx in range(len(image_chunks)): + context.logger.info(f"FaceTools --> Evaluating faces in chunk {idx}") + result = result + generate_face_box_mask( + context=context, + minimum_confidence=minimum_confidence, + x_offset=x_offset, + y_offset=y_offset, + pil_image=image_chunks[idx], + chunk_x_offset=x_offsets[idx], + chunk_y_offset=y_offsets[idx], + draw_mesh=draw_mesh, + ) + + if len(result) == 0: + # Give up + context.logger.warning( + "FaceTools --> No face detected in chunked input image. Passing through original image." + ) + + all_faces = prepare_faces_list(result) + + return all_faces + + +@invocation("face_off", title="FaceOff", tags=["image", "faceoff", "face", "mask"], category="image", version="1.2.2") +class FaceOffInvocation(BaseInvocation, WithMetadata): + """Bound, extract, and mask a face from an image using MediaPipe detection""" + + image: ImageField = InputField(description="Image for face detection") + face_id: int = InputField( + default=0, + ge=0, + description="The face ID to process, numbered from 0. Multiple faces not supported. Find a face's ID with FaceIdentifier node.", + ) + minimum_confidence: float = InputField( + default=0.5, description="Minimum confidence for face detection (lower if detection is failing)" + ) + x_offset: float = InputField(default=0.0, description="X-axis offset of the mask") + y_offset: float = InputField(default=0.0, description="Y-axis offset of the mask") + padding: int = InputField(default=0, description="All-axis padding around the mask in pixels") + chunk: bool = InputField( + default=False, + description="Whether to bypass full image face detection and default to image chunking. Chunking will occur if no faces are found in the full image.", + ) + + def faceoff(self, context: InvocationContext, image: ImageType) -> Optional[ExtractFaceData]: + all_faces = get_faces_list( + context=context, + image=image, + should_chunk=self.chunk, + minimum_confidence=self.minimum_confidence, + x_offset=self.x_offset, + y_offset=self.y_offset, + draw_mesh=True, + ) + + if len(all_faces) == 0: + context.logger.warning("FaceOff --> No faces detected. Passing through original image.") + return None + + if self.face_id > len(all_faces) - 1: + context.logger.warning( + f"FaceOff --> Face ID {self.face_id} is outside of the number of faces detected ({len(all_faces)}). Passing through original image." + ) + return None + + face_data = extract_face(context=context, image=image, face=all_faces[self.face_id], padding=self.padding) + # Convert the input image to RGBA mode to ensure it has an alpha channel. + face_data["bounded_image"] = face_data["bounded_image"].convert("RGBA") + + return face_data + + def invoke(self, context: InvocationContext) -> FaceOffOutput: + image = context.images.get_pil(self.image.image_name) + result = self.faceoff(context=context, image=image) + + if result is None: + result_image = image + result_mask = create_white_image(*image.size) + x = 0 + y = 0 + else: + result_image = result["bounded_image"] + result_mask = result["bounded_mask"] + x = result["x_min"] + y = result["y_min"] + + image_dto = context.images.save(image=result_image) + + mask_dto = context.images.save(image=result_mask, image_category=ImageCategory.MASK) + + output = FaceOffOutput( + image=ImageField(image_name=image_dto.image_name), + width=image_dto.width, + height=image_dto.height, + mask=ImageField(image_name=mask_dto.image_name), + x=x, + y=y, + ) + + return output + + +@invocation("face_mask_detection", title="FaceMask", tags=["image", "face", "mask"], category="image", version="1.2.2") +class FaceMaskInvocation(BaseInvocation, WithMetadata): + """Face mask creation using mediapipe face detection""" + + image: ImageField = InputField(description="Image to face detect") + face_ids: str = InputField( + default="", + description="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: float = InputField( + default=0.5, description="Minimum confidence for face detection (lower if detection is failing)" + ) + x_offset: float = InputField(default=0.0, description="Offset for the X-axis of the face mask") + y_offset: float = InputField(default=0.0, description="Offset for the Y-axis of the face mask") + chunk: bool = InputField( + default=False, + description="Whether to bypass full image face detection and default to image chunking. Chunking will occur if no faces are found in the full image.", + ) + invert_mask: bool = InputField(default=False, description="Toggle to invert the mask") + + @field_validator("face_ids") + def validate_comma_separated_ints(cls, v) -> str: + comma_separated_ints_regex = re.compile(r"^\d*(,\d+)*$") + if comma_separated_ints_regex.match(v) is None: + raise ValueError('Face IDs must be a comma-separated list of integers (e.g. "1,2,3")') + return v + + def facemask(self, context: InvocationContext, image: ImageType) -> FaceMaskResult: + all_faces = get_faces_list( + context=context, + image=image, + should_chunk=self.chunk, + minimum_confidence=self.minimum_confidence, + x_offset=self.x_offset, + y_offset=self.y_offset, + draw_mesh=True, + ) + + mask_pil = create_white_image(*image.size) + + id_range = list(range(0, len(all_faces))) + ids_to_extract = id_range + if self.face_ids != "": + parsed_face_ids = [int(id) for id in self.face_ids.split(",")] + # get requested face_ids that are in range + intersected_face_ids = set(parsed_face_ids) & set(id_range) + + if len(intersected_face_ids) == 0: + id_range_str = ",".join([str(id) for id in id_range]) + context.logger.warning( + f"Face IDs must be in range of detected faces - requested {self.face_ids}, detected {id_range_str}. Passing through original image." + ) + return FaceMaskResult( + image=image, # original image + mask=mask_pil, # white mask + ) + + ids_to_extract = list(intersected_face_ids) + + for face_id in ids_to_extract: + face_data = extract_face(context=context, image=image, face=all_faces[face_id], padding=0) + face_mask_pil = face_data["bounded_mask"] + x_min = face_data["x_min"] + y_min = face_data["y_min"] + x_max = face_data["x_max"] + y_max = face_data["y_max"] + + mask_pil.paste( + create_black_image(x_max - x_min, y_max - y_min), + box=(x_min, y_min), + mask=ImageOps.invert(face_mask_pil), + ) + + if self.invert_mask: + mask_pil = ImageOps.invert(mask_pil) + + # Create an RGBA image with transparency + image = image.convert("RGBA") + + return FaceMaskResult( + image=image, + mask=mask_pil, + ) + + def invoke(self, context: InvocationContext) -> FaceMaskOutput: + image = context.images.get_pil(self.image.image_name) + result = self.facemask(context=context, image=image) + + image_dto = context.images.save(image=result["image"]) + + mask_dto = context.images.save(image=result["mask"], image_category=ImageCategory.MASK) + + output = FaceMaskOutput( + image=ImageField(image_name=image_dto.image_name), + width=image_dto.width, + height=image_dto.height, + mask=ImageField(image_name=mask_dto.image_name), + ) + + return output + + +@invocation( + "face_identifier", title="FaceIdentifier", tags=["image", "face", "identifier"], category="image", 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.""" + + image: ImageField = InputField(description="Image to face detect") + minimum_confidence: float = InputField( + default=0.5, description="Minimum confidence for face detection (lower if detection is failing)" + ) + chunk: bool = InputField( + default=False, + description="Whether to bypass full image face detection and default to image chunking. Chunking will occur if no faces are found in the full image.", + ) + + def faceidentifier(self, context: InvocationContext, image: ImageType) -> ImageType: + image = image.copy() + + all_faces = get_faces_list( + context=context, + image=image, + should_chunk=self.chunk, + minimum_confidence=self.minimum_confidence, + x_offset=0, + y_offset=0, + draw_mesh=False, + ) + + # Note - font may be found either in the repo if running an editable install, or in the venv if running a package install + font_path = [x for x in [Path(y, "inter/Inter-Regular.ttf") for y in font_assets.__path__] if x.exists()] + font = ImageFont.truetype(font_path[0].as_posix(), FONT_SIZE) + + # Paste face IDs on the output image + draw = ImageDraw.Draw(image) + for face in all_faces: + x_coord = face["x_center"] + y_coord = face["y_center"] + text = str(face["face_id"]) + # get bbox of the text so we can center the id on the face + _, _, bbox_w, bbox_h = draw.textbbox(xy=(0, 0), text=text, font=font, stroke_width=FONT_STROKE_WIDTH) + x = x_coord - bbox_w / 2 + y = y_coord - bbox_h / 2 + draw.text( + xy=(x, y), + text=str(text), + fill=(255, 255, 255, 255), + font=font, + stroke_width=FONT_STROKE_WIDTH, + stroke_fill=(0, 0, 0, 255), + ) + + # Create an RGBA image with transparency + image = image.convert("RGBA") + + return image + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + result_image = self.faceidentifier(context=context, image=image) + + image_dto = context.images.save(image=result_image) + + return ImageOutput.build(image_dto) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py new file mode 100644 index 0000000000000000000000000000000000000000..5e76931933ac866a7e377cde76e48e23f0978e9a --- /dev/null +++ b/invokeai/app/invocations/fields.py @@ -0,0 +1,632 @@ +from enum import Enum +from typing import Any, Callable, Optional, Tuple + +from pydantic import BaseModel, ConfigDict, Field, RootModel, TypeAdapter, model_validator +from pydantic.fields import _Unset +from pydantic_core import PydanticUndefined + +from invokeai.app.util.metaenum import MetaEnum +from invokeai.backend.util.logging import InvokeAILogger + +logger = InvokeAILogger.get_logger() + + +class UIType(str, Enum, metaclass=MetaEnum): + """ + Type hints for the UI for situations in which the field type is not enough to infer the correct UI type. + + - Model Fields + The most common node-author-facing use will be for model fields. Internally, there is no difference + between SD-1, SD-2 and SDXL model fields - they all use the class `MainModelField`. To ensure the + base-model-specific UI is rendered, use e.g. `ui_type=UIType.SDXLMainModelField` to indicate that + the field is an SDXL main model field. + + - Any Field + We cannot infer the usage of `typing.Any` via schema parsing, so you *must* use `ui_type=UIType.Any` to + indicate that the field accepts any type. Use with caution. This cannot be used on outputs. + + - Scheduler Field + Special handling in the UI is needed for this field, which otherwise would be parsed as a plain enum field. + + - Internal Fields + Similar to the Any Field, the `collect` and `iterate` nodes make use of `typing.Any`. To facilitate + handling these types in the client, we use `UIType._Collection` and `UIType._CollectionItem`. These + should not be used by node authors. + + - DEPRECATED Fields + These types are deprecated and should not be used by node authors. A warning will be logged if one is + used, and the type will be ignored. They are included here for backwards compatibility. + """ + + # region Model Field Types + MainModel = "MainModelField" + FluxMainModel = "FluxMainModelField" + SD3MainModel = "SD3MainModelField" + SDXLMainModel = "SDXLMainModelField" + SDXLRefinerModel = "SDXLRefinerModelField" + ONNXModel = "ONNXModelField" + VAEModel = "VAEModelField" + FluxVAEModel = "FluxVAEModelField" + LoRAModel = "LoRAModelField" + ControlNetModel = "ControlNetModelField" + IPAdapterModel = "IPAdapterModelField" + T2IAdapterModel = "T2IAdapterModelField" + T5EncoderModel = "T5EncoderModelField" + CLIPEmbedModel = "CLIPEmbedModelField" + CLIPLEmbedModel = "CLIPLEmbedModelField" + CLIPGEmbedModel = "CLIPGEmbedModelField" + SpandrelImageToImageModel = "SpandrelImageToImageModelField" + # endregion + + # region Misc Field Types + Scheduler = "SchedulerField" + Any = "AnyField" + # endregion + + # region Internal Field Types + _Collection = "CollectionField" + _CollectionItem = "CollectionItemField" + # endregion + + # region DEPRECATED + Boolean = "DEPRECATED_Boolean" + Color = "DEPRECATED_Color" + Conditioning = "DEPRECATED_Conditioning" + Control = "DEPRECATED_Control" + Float = "DEPRECATED_Float" + Image = "DEPRECATED_Image" + Integer = "DEPRECATED_Integer" + Latents = "DEPRECATED_Latents" + String = "DEPRECATED_String" + BooleanCollection = "DEPRECATED_BooleanCollection" + ColorCollection = "DEPRECATED_ColorCollection" + ConditioningCollection = "DEPRECATED_ConditioningCollection" + ControlCollection = "DEPRECATED_ControlCollection" + FloatCollection = "DEPRECATED_FloatCollection" + ImageCollection = "DEPRECATED_ImageCollection" + IntegerCollection = "DEPRECATED_IntegerCollection" + LatentsCollection = "DEPRECATED_LatentsCollection" + StringCollection = "DEPRECATED_StringCollection" + BooleanPolymorphic = "DEPRECATED_BooleanPolymorphic" + ColorPolymorphic = "DEPRECATED_ColorPolymorphic" + ConditioningPolymorphic = "DEPRECATED_ConditioningPolymorphic" + ControlPolymorphic = "DEPRECATED_ControlPolymorphic" + FloatPolymorphic = "DEPRECATED_FloatPolymorphic" + ImagePolymorphic = "DEPRECATED_ImagePolymorphic" + IntegerPolymorphic = "DEPRECATED_IntegerPolymorphic" + LatentsPolymorphic = "DEPRECATED_LatentsPolymorphic" + StringPolymorphic = "DEPRECATED_StringPolymorphic" + UNet = "DEPRECATED_UNet" + Vae = "DEPRECATED_Vae" + CLIP = "DEPRECATED_CLIP" + Collection = "DEPRECATED_Collection" + 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" + + +class UIComponent(str, Enum, metaclass=MetaEnum): + """ + The type of UI component to use for a field, used to override the default components, which are + inferred from the field type. + """ + + None_ = "none" + Textarea = "textarea" + Slider = "slider" + + +class FieldDescriptions: + denoising_start = "When to start denoising, expressed a percentage of total steps" + denoising_end = "When to stop denoising, expressed a percentage of total steps" + cfg_scale = "Classifier-Free Guidance scale" + cfg_rescale_multiplier = "Rescale multiplier for CFG guidance, used for models trained with zero-terminal SNR" + scheduler = "Scheduler to use during inference" + positive_cond = "Positive conditioning tensor" + 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" + 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" + main_model = "Main model (UNet, VAE, CLIP) to load" + flux_model = "Flux model (Transformer) to load" + sd3_model = "SD3 model (MMDiTX) to load" + 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" + 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)" + sdxl_aesthetic = "The aesthetic score to apply to the conditioning tensor" + skipped_layers = "Number of layers to skip in text encoder" + seed = "Seed for random number generation" + steps = "Number of steps to run" + width = "Width of output (px)" + height = "Height of output (px)" + control = "ControlNet(s) to apply" + ip_adapter = "IP-Adapter to apply" + t2i_adapter = "T2I-Adapter(s) to apply" + denoised_latents = "Denoised latents tensor" + latents = "Latents tensor" + strength = "Strength of denoising (proportional to steps)" + metadata = "Optional metadata to be saved with the image" + metadata_collection = "Collection of Metadata" + metadata_item_polymorphic = "A single metadata item or collection of metadata items" + metadata_item_label = "Label for this metadata item" + metadata_item_value = "The value for this metadata item (may be any type)" + workflow = "Optional workflow to be saved with the image" + interp_mode = "Interpolation mode" + torch_antialias = "Whether or not to apply antialiasing (bilinear or bicubic only)" + 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" + scribble_mode = "Whether or not to use scribble mode" + scale_factor = "The factor by which to scale" + blend_alpha = ( + "Blending factor. 0.0 = use input A only, 1.0 = use input B only, 0.5 = 50% mix of input A and input B." + ) + num_1 = "The first number" + num_2 = "The second number" + 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" + inclusive_low = "The inclusive low value" + exclusive_high = "The exclusive high value" + decimal_places = "The number of decimal places to round to" + freeu_s1 = 'Scaling factor for stage 1 to attenuate the contributions of the skip features. This is done to mitigate the "oversmoothing effect" in the enhanced denoising process.' + 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'." + + +class ImageField(BaseModel): + """An image primitive field""" + + image_name: str = Field(description="The name of the image") + + +class BoardField(BaseModel): + """A board primitive field""" + + board_id: str = Field(description="The id of the board") + + +class DenoiseMaskField(BaseModel): + """An inpaint mask field""" + + mask_name: str = Field(description="The name of the mask image") + masked_latents_name: Optional[str] = Field(default=None, description="The name of the masked image latents") + gradient: bool = Field(default=False, description="Used for gradient inpainting") + + +class TensorField(BaseModel): + """A tensor primitive field.""" + + tensor_name: str = Field(description="The name of a tensor.") + + +class LatentsField(BaseModel): + """A latents tensor primitive field""" + + latents_name: str = Field(description="The name of the latents") + seed: Optional[int] = Field(default=None, description="Seed used to generate this latents") + + +class ColorField(BaseModel): + """A color primitive field""" + + r: int = Field(ge=0, le=255, description="The red component") + g: int = Field(ge=0, le=255, description="The green component") + b: int = Field(ge=0, le=255, description="The blue component") + a: int = Field(ge=0, le=255, description="The alpha component") + + 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") + + +class SD3ConditioningField(BaseModel): + """A conditioning tensor primitive value""" + + conditioning_name: str = Field(description="The name of conditioning tensor") + + +class ConditioningField(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 BoundingBoxField(BaseModel): + """A bounding box primitive value.""" + + x_min: int = Field(ge=0, description="The minimum x-coordinate of the bounding box (inclusive).") + x_max: int = Field(ge=0, description="The maximum x-coordinate of the bounding box (exclusive).") + y_min: int = Field(ge=0, description="The minimum y-coordinate of the bounding box (inclusive).") + y_max: int = Field(ge=0, description="The maximum y-coordinate of the bounding box (exclusive).") + + 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.", + ) + + @model_validator(mode="after") + def check_coords(self): + if self.x_min > self.x_max: + raise ValueError(f"x_min ({self.x_min}) is greater than x_max ({self.x_max}).") + if self.y_min > self.y_max: + raise ValueError(f"y_min ({self.y_min}) is greater than y_max ({self.y_max}).") + return self + + +class MetadataField(RootModel[dict[str, Any]]): + """ + Pydantic model for metadata with custom root of type dict[str, Any]. + Metadata is stored without a strict schema. + """ + + root: dict[str, Any] = Field(description="The metadata") + + +MetadataFieldValidator = TypeAdapter(MetadataField) + + +class Input(str, Enum, metaclass=MetaEnum): + """ + The type of input a field accepts. + - `Input.Direct`: The field must have its value provided directly, when the invocation and field \ + are instantiated. + - `Input.Connection`: The field must have its value provided by a connection. + - `Input.Any`: The field may have its value provided either directly or by a connection. + """ + + Connection = "connection" + Direct = "direct" + Any = "any" + + +class FieldKind(str, Enum, metaclass=MetaEnum): + """ + The kind of field. + - `Input`: An input field on a node. + - `Output`: An output field on a node. + - `Internal`: A field which is treated as an input, but cannot be used in node definitions. Metadata is + one example. It is provided to nodes via the WithMetadata class, and we want to reserve the field name + "metadata" for this on all nodes. `FieldKind` is used to short-circuit the field name validation logic, + allowing "metadata" for that field. + - `NodeAttribute`: The field is a node attribute. These are fields which are not inputs or outputs, + but which are used to store information about the node. For example, the `id` and `type` fields are node + attributes. + + The presence of this in `json_schema_extra["field_kind"]` is used when initializing node schemas on app + startup, and when generating the OpenAPI schema for the workflow editor. + """ + + Input = "input" + Output = "output" + Internal = "internal" + NodeAttribute = "node_attribute" + + +class InputFieldJSONSchemaExtra(BaseModel): + """ + Extra attributes to be added to input fields and their OpenAPI schema. Used during graph execution, + and by the workflow editor during schema parsing and UI rendering. + """ + + input: Input + orig_required: bool + field_kind: FieldKind + default: Optional[Any] = None + orig_default: Optional[Any] = None + ui_hidden: bool = False + ui_type: Optional[UIType] = None + ui_component: Optional[UIComponent] = None + ui_order: Optional[int] = None + ui_choice_labels: Optional[dict[str, str]] = None + + model_config = ConfigDict( + validate_assignment=True, + json_schema_serialization_defaults_required=True, + ) + + +class WithMetadata(BaseModel): + """ + Inherit from this class if your node needs a metadata input field. + """ + + metadata: Optional[MetadataField] = Field( + default=None, + description=FieldDescriptions.metadata, + json_schema_extra=InputFieldJSONSchemaExtra( + field_kind=FieldKind.Internal, + input=Input.Connection, + orig_required=False, + ).model_dump(exclude_none=True), + ) + + +class WithWorkflow: + workflow = None + + def __init_subclass__(cls) -> None: + logger.warn( + f"{cls.__module__.split('.')[0]}.{cls.__name__}: WithWorkflow is deprecated. Use `context.workflow` to access the workflow." + ) + super().__init_subclass__() + + +class WithBoard(BaseModel): + """ + Inherit from this class if your node needs a board input field. + """ + + board: Optional[BoardField] = Field( + default=None, + description=FieldDescriptions.board, + json_schema_extra=InputFieldJSONSchemaExtra( + field_kind=FieldKind.Internal, + input=Input.Direct, + orig_required=False, + ).model_dump(exclude_none=True), + ) + + +class OutputFieldJSONSchemaExtra(BaseModel): + """ + Extra attributes to be added to input fields and their OpenAPI schema. Used by the workflow editor + during schema parsing and UI rendering. + """ + + field_kind: FieldKind + ui_hidden: bool + ui_type: Optional[UIType] + ui_order: Optional[int] + + model_config = ConfigDict( + validate_assignment=True, + json_schema_serialization_defaults_required=True, + ) + + +def InputField( + # copied from pydantic's Field + # TODO: Can we support default_factory? + default: Any = _Unset, + default_factory: Callable[[], Any] | None = _Unset, + title: str | None = _Unset, + description: str | None = _Unset, + pattern: str | None = _Unset, + strict: bool | None = _Unset, + gt: float | None = _Unset, + ge: float | None = _Unset, + lt: float | None = _Unset, + le: float | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + min_length: int | None = _Unset, + max_length: int | None = _Unset, + # custom + input: Input = Input.Any, + ui_type: Optional[UIType] = None, + ui_component: Optional[UIComponent] = None, + ui_hidden: bool = False, + ui_order: Optional[int] = None, + ui_choice_labels: Optional[dict[str, 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) \ + 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. + + :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. + + :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`. + + :param bool ui_hidden: [False] 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. + + :param dict[str, str] ui_choice_labels: [None] Specifies the labels to use for the choices in an enum field. + """ + + 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, + ) + + """ + There is a conflict between the typing of invocation definitions and the typing of an invocation's + `invoke()` function. + + On instantiation of a node, the invocation definition is used to create the python class. At this time, + any number of fields may be optional, because they may be provided by connections. + + On calling of `invoke()`, however, those fields may be required. + + For example, consider an ResizeImageInvocation with an `image: ImageField` field. + + `image` is required during the call to `invoke()`, but when the python class is instantiated, + the field may not be present. This is fine, because that image field will be provided by a + connection from an ancestor node, which outputs an image. + + This means we want to type the `image` field as optional for the node class definition, but required + for the `invoke()` function. + + If we use `typing.Optional` in the node class definition, the field will be typed as optional in the + `invoke()` method, and we'll have to do a lot of runtime checks to ensure the field is present - or + any static type analysis tools will complain. + + To get around this, in node class definitions, we type all fields correctly for the `invoke()` function, + but secretly make them optional in `InputField()`. We also store the original required bool and/or default + value. When we call `invoke()`, we use this stored information to do an additional check on the class. + """ + + 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"') + + # These are the args we may wish pass to the pydantic `Field()` function + field_args = { + "default": default, + "title": title, + "description": description, + "pattern": pattern, + "strict": strict, + "gt": gt, + "ge": ge, + "lt": lt, + "le": le, + "multiple_of": multiple_of, + "allow_inf_nan": allow_inf_nan, + "max_digits": max_digits, + "decimal_places": decimal_places, + "min_length": min_length, + "max_length": max_length, + } + + # We only want to pass the args that were provided, otherwise the `Field()`` function won't work as expected + provided_args = {k: v for (k, v) in field_args.items() if v is not PydanticUndefined} + + # Because we are manually making fields optional, we need to store the original required bool for reference later + json_schema_extra_.orig_required = default is PydanticUndefined + + # Make Input.Any and Input.Connection fields optional, providing None as a default if the field doesn't already have one + if input is Input.Any or input is Input.Connection: + default_ = None if default is PydanticUndefined else default + provided_args.update({"default": default_}) + if default is not PydanticUndefined: + # Before invoking, we'll check for the original default value and set it on the field if the field has no value + json_schema_extra_.default = default + json_schema_extra_.orig_default = default + elif default is not PydanticUndefined: + default_ = default + provided_args.update({"default": default_}) + json_schema_extra_.orig_default = default_ + + return Field( + **provided_args, + json_schema_extra=json_schema_extra_.model_dump(exclude_none=True), + ) + + +def OutputField( + # copied from pydantic's Field + default: Any = _Unset, + title: str | None = _Unset, + description: str | None = _Unset, + pattern: str | None = _Unset, + strict: bool | None = _Unset, + gt: float | None = _Unset, + ge: float | None = _Unset, + lt: float | None = _Unset, + le: float | None = _Unset, + multiple_of: float | None = _Unset, + allow_inf_nan: bool | None = _Unset, + max_digits: int | None = _Unset, + decimal_places: int | None = _Unset, + min_length: int | None = _Unset, + max_length: int | None = _Unset, + # custom + ui_type: Optional[UIType] = None, + ui_hidden: bool = False, + ui_order: Optional[int] = None, +) -> Any: + """ + 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) \ + 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. + + :param bool ui_hidden: [False] 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. \ + """ + return Field( + default=default, + title=title, + description=description, + pattern=pattern, + strict=strict, + gt=gt, + ge=ge, + lt=lt, + le=le, + multiple_of=multiple_of, + allow_inf_nan=allow_inf_nan, + max_digits=max_digits, + decimal_places=decimal_places, + min_length=min_length, + max_length=max_length, + json_schema_extra=OutputFieldJSONSchemaExtra( + ui_type=ui_type, + ui_hidden=ui_hidden, + ui_order=ui_order, + field_kind=FieldKind.Output, + ).model_dump(exclude_none=True), + ) diff --git a/invokeai/app/invocations/flux_controlnet.py b/invokeai/app/invocations/flux_controlnet.py new file mode 100644 index 0000000000000000000000000000000000000000..41b66975a758c251b3dc9fab1574e8a5ae54f800 --- /dev/null +++ b/invokeai/app/invocations/flux_controlnet.py @@ -0,0 +1,99 @@ +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, UIType +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 + + +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="controlnet", + version="1.0.0", + classification=Classification.Prototype, +) +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_type=UIType.ControlNetModel + ) + 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 0000000000000000000000000000000000000000..9e197626b53ab12f38f3ed4ec0a79dd901ee0b42 --- /dev/null +++ b/invokeai/app/invocations/flux_denoise.py @@ -0,0 +1,666 @@ +from contextlib import ExitStack +from typing import Callable, Iterator, Optional, Tuple + +import numpy as np +import numpy.typing as npt +import torch +import torchvision.transforms as tv_transforms +from torchvision.transforms.functional import resize as tv_resize +from transformers import CLIPImageProcessor, CLIPVisionModelWithProjection + +from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation +from invokeai.app.invocations.fields import ( + DenoiseMaskField, + FieldDescriptions, + FluxConditioningField, + ImageField, + Input, + InputField, + LatentsField, + WithBoard, + WithMetadata, +) +from invokeai.app.invocations.flux_controlnet import FluxControlNetField +from invokeai.app.invocations.ip_adapter import IPAdapterField +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.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.extensions.inpaint_extension import InpaintExtension +from invokeai.backend.flux.extensions.instantx_controlnet_extension import InstantXControlNetExtension +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.lora.conversions.flux_lora_constants import FLUX_LORA_TRANSFORMER_PREFIX +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.lora_patcher import LoRAPatcher +from invokeai.backend.model_manager.config import ModelFormat +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="image", + version="3.2.1", + classification=Classification.Prototype, +) +class FluxDenoiseInvocation(BaseInvocation, WithMetadata, WithBoard): + """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, + ) + # 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", + ) + positive_text_conditioning: FluxConditioningField = InputField( + description=FieldDescriptions.positive_cond, input=Input.Connection + ) + negative_text_conditioning: FluxConditioningField | None = InputField( + default=None, + description="Negative conditioning tensor. Can be None if cfg_scale is 1.0.", + 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." + ) + 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, + ) + + ip_adapter: IPAdapterField | list[IPAdapterField] | None = InputField( + description=FieldDescriptions.ip_adapter, title="IP-Adapter", default=None, input=Input.Connection + ) + + @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 _load_text_conditioning( + self, context: InvocationContext, conditioning_name: str, dtype: torch.dtype + ) -> Tuple[torch.Tensor, torch.Tensor]: + # Load the conditioning data. + cond_data = context.conditioning.load(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) + t5_embeddings = flux_conditioning.t5_embeds + clip_embeddings = flux_conditioning.clip_embeds + return t5_embeddings, clip_embeddings + + def _run_diffusion( + self, + context: InvocationContext, + ): + inference_dtype = torch.bfloat16 + + # Load the conditioning data. + pos_t5_embeddings, pos_clip_embeddings = self._load_text_conditioning( + context, self.positive_text_conditioning.conditioning_name, inference_dtype + ) + neg_t5_embeddings: torch.Tensor | None = None + neg_clip_embeddings: torch.Tensor | None = None + if self.negative_text_conditioning is not None: + neg_t5_embeddings, neg_clip_embeddings = self._load_text_conditioning( + context, self.negative_text_conditioning.conditioning_name, inference_dtype + ) + + # 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=TorchDevice.choose_torch_device(), dtype=inference_dtype) + + # Prepare input noise. + noise = get_noise( + num_samples=1, + height=self.height, + width=self.width, + device=TorchDevice.choose_torch_device(), + dtype=inference_dtype, + seed=self.seed, + ) + + transformer_info = context.models.load(self.transformer.transformer) + is_schnell = "schnell" in transformer_info.config.config_path + + # Calculate the timestep schedule. + image_seq_len = noise.shape[-1] * noise.shape[-2] // 4 + timesteps = get_schedule( + num_steps=self.num_steps, + image_seq_len=image_seq_len, + shift=not is_schnell, + ) + + # 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: + # Noise the orig_latents by the appropriate amount for the first timestep. + 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.") + + 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 + + inpaint_mask = self._prep_inpaint_mask(context, x) + + b, _c, latent_h, latent_w = x.shape + img_ids = generate_img_ids(h=latent_h, w=latent_w, batch_size=b, device=x.device, dtype=x.dtype) + + pos_bs, pos_t5_seq_len, _ = pos_t5_embeddings.shape + pos_txt_ids = torch.zeros( + pos_bs, pos_t5_seq_len, 3, dtype=inference_dtype, device=TorchDevice.choose_torch_device() + ) + neg_txt_ids: torch.Tensor | None = None + if neg_t5_embeddings is not None: + neg_bs, neg_t5_seq_len, _ = neg_t5_embeddings.shape + neg_txt_ids = torch.zeros( + neg_bs, neg_t5_seq_len, 3, dtype=inference_dtype, device=TorchDevice.choose_torch_device() + ) + + # 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 correctly. + assert image_seq_len == x.shape[1] + + # Prepare inpaint extension. + inpaint_extension: InpaintExtension | None = None + if inpaint_mask is not None: + assert init_latents is not None + inpaint_extension = InpaintExtension( + 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 + ) + + 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, + ) + + 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(transformer_info.model_on_device()) + assert isinstance(transformer, Flux) + config = transformer_info.config + assert config is not None + + # Apply LoRA models to the transformer. + # Note: We apply the LoRA after the transformer has been moved to its target device for faster patching. + if config.format in [ModelFormat.Checkpoint]: + # The model is non-quantized, so we can apply the LoRA weights directly into the model. + exit_stack.enter_context( + LoRAPatcher.apply_lora_patches( + model=transformer, + patches=self._lora_iterator(context), + prefix=FLUX_LORA_TRANSFORMER_PREFIX, + cached_weights=cached_weights, + ) + ) + elif config.format in [ + ModelFormat.BnbQuantizedLlmInt8b, + ModelFormat.BnbQuantizednf4b, + ModelFormat.GGUFQuantized, + ]: + # The model is quantized, so apply the LoRA weights as sidecar layers. This results in slower inference, + # than directly patching the weights, but is agnostic to the quantization format. + exit_stack.enter_context( + LoRAPatcher.apply_lora_sidecar_patches( + model=transformer, + patches=self._lora_iterator(context), + prefix=FLUX_LORA_TRANSFORMER_PREFIX, + dtype=inference_dtype, + ) + ) + else: + raise ValueError(f"Unsupported model format: {config.format}") + + # 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, + ) + + x = denoise( + model=transformer, + img=x, + img_ids=img_ids, + txt=pos_t5_embeddings, + txt_ids=pos_txt_ids, + vec=pos_clip_embeddings, + neg_txt=neg_t5_embeddings, + neg_txt_ids=neg_txt_ids, + neg_vec=neg_clip_embeddings, + 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, + ) + + x = unpack(x.float(), self.height, self.width) + return x + + @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. + + # First, load the ControlNet models so that we can determine the ControlNet types. + controlnet_models = [context.models.load(controlnet.control_model) for controlnet in controlnets] + + # 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, controlnet_model in zip(controlnets, controlnet_models, strict=True): + image = context.images.get_pil(controlnet.image.image_name) + 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, controlnet_model in zip( + controlnets, controlnet_conds, controlnet_models, strict=True + ): + model = exit_stack.enter_context(controlnet_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 _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, + ) -> 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=image_encoder_model.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=image_encoder_model.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[LoRAModelRaw, float]]: + for lora in self.transformer.loras: + lora_info = context.models.load(lora.lora) + assert isinstance(lora_info.model, LoRAModelRaw) + 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: + state.latents = unpack(state.latents.float(), self.height, self.width).squeeze() + context.util.flux_step_callback(state) + + return step_callback diff --git a/invokeai/app/invocations/flux_ip_adapter.py b/invokeai/app/invocations/flux_ip_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..9653f859ad06be0d61833af073b7ae66a6c28d20 --- /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, Classification, invocation +from invokeai.app.invocations.fields import InputField, UIType +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.config import ( + IPAdapterCheckpointConfig, + IPAdapterInvokeAIConfig, +) + + +@invocation( + "flux_ip_adapter", + title="FLUX IP-Adapter", + tags=["ip_adapter", "control"], + category="ip_adapter", + version="1.0.0", + classification=Classification.Prototype, +) +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_type=UIType.IPAdapterModel + ) + # 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, (IPAdapterInvokeAIConfig, IPAdapterCheckpointConfig)) + + # 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_lora_loader.py b/invokeai/app/invocations/flux_lora_loader.py new file mode 100644 index 0000000000000000000000000000000000000000..d9e655a5077118e0c19ac7be2470402b16ae7a12 --- /dev/null +++ b/invokeai/app/invocations/flux_lora_loader.py @@ -0,0 +1,143 @@ +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, UIType +from invokeai.app.invocations.model import CLIPField, LoRAField, ModelIdentifierField, TransformerField +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.model_manager.config import BaseModelType + + +@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") + + +@invocation( + "flux_lora_loader", + title="FLUX LoRA", + tags=["lora", "model", "flux"], + category="model", + version="1.1.0", + classification=Classification.Prototype, +) +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_type=UIType.LoRAModel + ) + 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, + ) + + 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.') + + 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, + ) + ) + + return output + + +@invocation( + "flux_lora_collection_loader", + title="FLUX LoRA Collection Loader", + tags=["lora", "model", "flux"], + category="model", + version="1.1.0", + classification=Classification.Prototype, +) +class FLUXLoRACollectionLoader(BaseInvocation): + """Applies a collection of LoRAs to a FLUX transformer.""" + + loras: LoRAField | list[LoRAField] = InputField( + 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, + ) + + def invoke(self, context: InvocationContext) -> FluxLoRALoaderOutput: + output = FluxLoRALoaderOutput() + loras = self.loras if isinstance(self.loras, list) else [self.loras] + added_loras: list[str] = [] + + for lora in loras: + 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 is BaseModelType.Flux + + added_loras.append(lora.lora.key) + + if self.transformer is not None: + if output.transformer is None: + output.transformer = self.transformer.model_copy(deep=True) + output.transformer.loras.append(lora) + + if self.clip is not None: + if output.clip is None: + output.clip = self.clip.model_copy(deep=True) + output.clip.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 0000000000000000000000000000000000000000..ab2d69aa02bb160aa3f110beaed17a8ffb67929f --- /dev/null +++ b/invokeai/app/invocations/flux_model_loader.py @@ -0,0 +1,89 @@ +from typing import Literal + +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + Classification, + invocation, + invocation_output, +) +from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField, OutputField, UIType +from invokeai.app.invocations.model import CLIPField, ModelIdentifierField, T5EncoderField, TransformerField, VAEField +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.flux.util import max_seq_lengths +from invokeai.backend.model_manager.config import ( + CheckpointConfigBase, + 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="Flux Main Model", + tags=["model", "flux"], + category="model", + version="1.0.4", + classification=Classification.Prototype, +) +class FluxModelLoaderInvocation(BaseInvocation): + """Loads a flux base model, outputting its submodels.""" + + model: ModelIdentifierField = InputField( + description=FieldDescriptions.flux_model, + ui_type=UIType.FluxMainModel, + input=Input.Direct, + ) + + t5_encoder_model: ModelIdentifierField = InputField( + description=FieldDescriptions.t5_encoder, ui_type=UIType.T5EncoderModel, input=Input.Direct, title="T5 Encoder" + ) + + clip_embed_model: ModelIdentifierField = InputField( + description=FieldDescriptions.clip_embed_model, + ui_type=UIType.CLIPEmbedModel, + input=Input.Direct, + title="CLIP Embed", + ) + + vae_model: ModelIdentifierField = InputField( + description=FieldDescriptions.vae_model, ui_type=UIType.FluxVAEModel, title="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 = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer2}) + t5_encoder = self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder2}) + + transformer_config = context.models.get_config(transformer) + assert isinstance(transformer_config, CheckpointConfigBase) + + 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), + vae=VAEField(vae=vae), + max_seq_len=max_seq_lengths[transformer_config.config_path], + ) diff --git a/invokeai/app/invocations/flux_text_encoder.py b/invokeai/app/invocations/flux_text_encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..af250f0f3b1deaf8a90d60e6ea64dfb5a0dd7465 --- /dev/null +++ b/invokeai/app/invocations/flux_text_encoder.py @@ -0,0 +1,126 @@ +from contextlib import ExitStack +from typing import Iterator, Literal, Tuple + +import torch +from transformers import CLIPTextModel, CLIPTokenizer, T5EncoderModel, T5Tokenizer + +from invokeai.app.invocations.baseinvocation import BaseInvocation, Classification, invocation +from invokeai.app.invocations.fields import FieldDescriptions, Input, InputField +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.lora.conversions.flux_lora_constants import FLUX_LORA_CLIP_PREFIX +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.lora_patcher import LoRAPatcher +from invokeai.backend.model_manager.config import ModelFormat +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData, FLUXConditioningInfo + + +@invocation( + "flux_text_encoder", + title="FLUX Text Encoding", + tags=["prompt", "conditioning", "flux"], + category="conditioning", + version="1.1.0", + classification=Classification.Prototype, +) +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.") + + @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) + conditioning_data = ConditioningFieldData( + conditionings=[FLUXConditioningInfo(clip_embeds=clip_embeddings, t5_embeds=t5_embeddings)] + ) + + conditioning_name = context.conditioning.save(conditioning_data) + return FluxConditioningOutput.build(conditioning_name) + + def _t5_encode(self, context: InvocationContext) -> torch.Tensor: + t5_tokenizer_info = context.models.load(self.t5_encoder.tokenizer) + t5_text_encoder_info = context.models.load(self.t5_encoder.text_encoder) + + prompt = [self.prompt] + + with ( + t5_text_encoder_info as t5_text_encoder, + t5_tokenizer_info as t5_tokenizer, + ): + assert isinstance(t5_text_encoder, T5EncoderModel) + assert isinstance(t5_tokenizer, T5Tokenizer) + + t5_encoder = HFEncoder(t5_text_encoder, t5_tokenizer, False, self.t5_max_seq_len) + + 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: + clip_tokenizer_info = context.models.load(self.clip.tokenizer) + clip_text_encoder_info = context.models.load(self.clip.text_encoder) + + prompt = [self.prompt] + + with ( + clip_text_encoder_info.model_on_device() as (cached_weights, clip_text_encoder), + clip_tokenizer_info as clip_tokenizer, + ExitStack() as exit_stack, + ): + assert isinstance(clip_text_encoder, CLIPTextModel) + assert isinstance(clip_tokenizer, CLIPTokenizer) + + 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( + LoRAPatcher.apply_lora_patches( + model=clip_text_encoder, + patches=self._clip_lora_iterator(context), + prefix=FLUX_LORA_CLIP_PREFIX, + 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) + + 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[LoRAModelRaw, float]]: + for lora in self.clip.loras: + lora_info = context.models.load(lora.lora) + assert isinstance(lora_info.model, LoRAModelRaw) + yield (lora_info.model, lora.weight) + del lora_info diff --git a/invokeai/app/invocations/flux_vae_decode.py b/invokeai/app/invocations/flux_vae_decode.py new file mode 100644 index 0000000000000000000000000000000000000000..362ce78de9d42a7d1578a4fefc02e87aef69c21a --- /dev/null +++ b/invokeai/app/invocations/flux_vae_decode.py @@ -0,0 +1,62 @@ +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 + + +@invocation( + "flux_vae_decode", + title="FLUX Latents to Image", + tags=["latents", "image", "vae", "l2i", "flux"], + category="latents", + version="1.0.0", +) +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: + with vae_info 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 0000000000000000000000000000000000000000..c4e8d0e42a2ea5c886674eefac59aefd807e7999 --- /dev/null +++ b/invokeai/app/invocations/flux_vae_encode.py @@ -0,0 +1,67 @@ +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 import LoadedModel +from invokeai.backend.stable_diffusion.diffusers_pipeline import image_resized_to_grid_as_tensor +from invokeai.backend.util.devices import TorchDevice + + +@invocation( + "flux_vae_encode", + title="FLUX Image to Latents", + tags=["latents", "image", "vae", "i2l", "flux"], + category="latents", + version="1.0.0", +) +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. + generator = torch.Generator(device=TorchDevice.choose_torch_device()).manual_seed(0) + with vae_info 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 0000000000000000000000000000000000000000..1e3d5cea0cf5efcbf8d4b2244870a7b443fbb007 --- /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="image", + 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 0000000000000000000000000000000000000000..5ea6e8df1f32f7b2b7d0141a535769f0b960792a --- /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", + 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 new file mode 100644 index 0000000000000000000000000000000000000000..120f8c1ba01265c8d322866f7f88a07ebd2ea502 --- /dev/null +++ b/invokeai/app/invocations/ideal_size.py @@ -0,0 +1,65 @@ +import math +from typing import Tuple + +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, 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 + + +@invocation_output("ideal_size_output") +class IdealSizeOutput(BaseInvocationOutput): + """Base class for invocations that output an image""" + + width: int = OutputField(description="The ideal width of the image (in pixels)") + height: int = OutputField(description="The ideal height of the image (in pixels)") + + +@invocation( + "ideal_size", + title="Ideal Size", + tags=["latents", "math", "ideal_size"], + version="1.0.3", +) +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) + multiplier: float = InputField( + default=1.0, + description="Amount to multiply the model's dimensions by when calculating the ideal size (may result in " + "initial generation artifacts if too large)", + ) + + def trim_to_multiple_of(self, *args: int, multiple_of: int = LATENT_SCALE_FACTOR) -> Tuple[int, ...]: + return tuple((x - x % multiple_of) for x in args) + + 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: + dimension = 768 + elif unet_config.base == BaseModelType.StableDiffusionXL: + dimension = 1024 + 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 + + if aspect > 1.0: + init_height = max(min_dimension, math.sqrt(model_area / aspect)) + init_width = init_height * aspect + else: + init_width = max(min_dimension, math.sqrt(model_area * aspect)) + init_height = init_width / aspect + + scaled_width, scaled_height = self.trim_to_multiple_of( + math.floor(init_width), + math.floor(init_height), + ) + + return IdealSizeOutput(width=scaled_width, height=scaled_height) diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py new file mode 100644 index 0000000000000000000000000000000000000000..7f7a060c557c9353768354a2f507e11e57efc90c --- /dev/null +++ b/invokeai/app/invocations/image.py @@ -0,0 +1,1057 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) + +from typing import Literal, Optional + +import cv2 +import numpy +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 ( + ColorField, + FieldDescriptions, + ImageField, + InputField, + WithBoard, + WithMetadata, +) +from invokeai.app.invocations.primitives import ImageOutput +from invokeai.app.services.image_records.image_records_common import ImageCategory +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.image_util.invisible_watermark import InvisibleWatermark +from invokeai.backend.image_util.safety_checker import SafetyChecker + + +@invocation("show_image", title="Show Image", tags=["image"], category="image", version="1.0.1") +class ShowImageInvocation(BaseInvocation): + """Displays a provided image using the OS image viewer, and passes it forward in the pipeline.""" + + image: ImageField = InputField(description="The image to show") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + image.show() + + # TODO: how to handle failure? + + return ImageOutput( + image=ImageField(image_name=self.image.image_name), + width=image.width, + height=image.height, + ) + + +@invocation( + "blank_image", + title="Blank Image", + tags=["image"], + category="image", + version="1.2.2", +) +class BlankImageInvocation(BaseInvocation, WithMetadata, WithBoard): + """Creates a blank image and forwards it to the pipeline""" + + width: int = InputField(default=512, description="The width of the image") + height: int = InputField(default=512, description="The height of the image") + mode: Literal["RGB", "RGBA"] = InputField(default="RGB", description="The mode of the image") + color: ColorField = InputField(default=ColorField(r=0, g=0, b=0, a=255), description="The color of the image") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = Image.new(mode=self.mode, size=(self.width, self.height), color=self.color.tuple()) + + image_dto = context.images.save(image=image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_crop", + title="Crop Image", + tags=["image", "crop"], + category="image", + version="1.2.2", +) +class ImageCropInvocation(BaseInvocation, WithMetadata, WithBoard): + """Crops an image to a specified box. The box can be outside of the image.""" + + image: ImageField = InputField(description="The image to crop") + x: int = InputField(default=0, description="The left x coordinate of the crop rectangle") + y: int = InputField(default=0, description="The top y coordinate of the crop rectangle") + width: int = InputField(default=512, gt=0, description="The width of the crop rectangle") + height: int = InputField(default=512, gt=0, description="The height of the crop rectangle") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + image_crop = Image.new(mode="RGBA", size=(self.width, self.height), color=(0, 0, 0, 0)) + image_crop.paste(image, (-self.x, -self.y)) + + image_dto = context.images.save(image=image_crop) + + return ImageOutput.build(image_dto) + + +@invocation( + invocation_type="img_pad_crop", + title="Center Pad or Crop Image", + category="image", + tags=["image", "pad", "crop"], + version="1.0.0", +) +class CenterPadCropInvocation(BaseInvocation): + """Pad or crop an image's sides from the center by specified pixels. Positive values are outside of the image.""" + + image: ImageField = InputField(description="The image to crop") + left: int = InputField( + default=0, + description="Number of pixels to pad/crop from the left (negative values crop inwards, positive values pad outwards)", + ) + right: int = InputField( + default=0, + description="Number of pixels to pad/crop from the right (negative values crop inwards, positive values pad outwards)", + ) + top: int = InputField( + default=0, + description="Number of pixels to pad/crop from the top (negative values crop inwards, positive values pad outwards)", + ) + bottom: int = InputField( + default=0, + description="Number of pixels to pad/crop from the bottom (negative values crop inwards, positive values pad outwards)", + ) + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + # Calculate and create new image dimensions + new_width = image.width + self.right + self.left + new_height = image.height + self.top + self.bottom + image_crop = Image.new(mode="RGBA", size=(new_width, new_height), color=(0, 0, 0, 0)) + + # Paste new image onto input + image_crop.paste(image, (self.left, self.top)) + + image_dto = context.images.save(image=image_crop) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_paste", + title="Paste Image", + tags=["image", "paste"], + category="image", + version="1.2.2", +) +class ImagePasteInvocation(BaseInvocation, WithMetadata, WithBoard): + """Pastes an image into another image.""" + + base_image: ImageField = InputField(description="The base image") + image: ImageField = InputField(description="The image to paste") + mask: Optional[ImageField] = InputField( + default=None, + description="The mask to use when pasting", + ) + x: int = InputField(default=0, description="The left x coordinate at which to paste the image") + y: int = InputField(default=0, description="The top y coordinate at which to paste the image") + 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) + mask = None + if self.mask is not None: + mask = context.images.get_pil(self.mask.image_name) + mask = ImageOps.invert(mask.convert("L")) + # TODO: probably shouldn't invert mask here... should user be required to do it? + + min_x = min(0, self.x) + min_y = min(0, self.y) + max_x = max(base_image.width, image.width + self.x) + max_y = max(base_image.height, image.height + self.y) + + 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) + + if self.crop: + base_w, base_h = base_image.size + new_image = new_image.crop((abs(min_x), abs(min_y), abs(min_x) + base_w, abs(min_y) + base_h)) + + image_dto = context.images.save(image=new_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "tomask", + title="Mask from Alpha", + tags=["image", "mask"], + category="image", + version="1.2.2", +) +class MaskFromAlphaInvocation(BaseInvocation, WithMetadata, WithBoard): + """Extracts the alpha channel of an image as a mask.""" + + image: ImageField = InputField(description="The image to create the mask from") + invert: bool = InputField(default=False, description="Whether or not to invert the mask") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + image_mask = image.split()[-1] + if self.invert: + image_mask = ImageOps.invert(image_mask) + + image_dto = context.images.save(image=image_mask, image_category=ImageCategory.MASK) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_mul", + title="Multiply Images", + tags=["image", "multiply"], + category="image", + version="1.2.2", +) +class ImageMultiplyInvocation(BaseInvocation, WithMetadata, WithBoard): + """Multiplies two images together using `PIL.ImageChops.multiply()`.""" + + image1: ImageField = InputField(description="The first image to multiply") + image2: ImageField = InputField(description="The second image to multiply") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image1 = context.images.get_pil(self.image1.image_name) + image2 = context.images.get_pil(self.image2.image_name) + + multiply_image = ImageChops.multiply(image1, image2) + + image_dto = context.images.save(image=multiply_image) + + return ImageOutput.build(image_dto) + + +IMAGE_CHANNELS = Literal["A", "R", "G", "B"] + + +@invocation( + "img_chan", + title="Extract Image Channel", + tags=["image", "channel"], + category="image", + version="1.2.2", +) +class ImageChannelInvocation(BaseInvocation, WithMetadata, WithBoard): + """Gets a channel from an image.""" + + image: ImageField = InputField(description="The image to get the channel from") + channel: IMAGE_CHANNELS = InputField(default="A", description="The channel to get") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + channel_image = image.getchannel(self.channel) + + image_dto = context.images.save(image=channel_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_conv", + title="Convert Image Mode", + tags=["image", "convert"], + category="image", + version="1.2.2", +) +class ImageConvertInvocation(BaseInvocation, WithMetadata, WithBoard): + """Converts an image to a different mode.""" + + image: ImageField = InputField(description="The image to convert") + mode: IMAGE_MODES = InputField(default="L", description="The mode to convert to") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + converted_image = image.convert(self.mode) + + image_dto = context.images.save(image=converted_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_blur", + title="Blur Image", + tags=["image", "blur"], + category="image", + version="1.2.2", +) +class ImageBlurInvocation(BaseInvocation, WithMetadata, WithBoard): + """Blurs an image""" + + image: ImageField = InputField(description="The image to blur") + radius: float = InputField(default=8.0, ge=0, description="The blur radius") + # Metadata + 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) + + blur = ( + ImageFilter.GaussianBlur(self.radius) if self.blur_type == "gaussian" else ImageFilter.BoxBlur(self.radius) + ) + blur_image = image.filter(blur) + + image_dto = context.images.save(image=blur_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "unsharp_mask", + title="Unsharp Mask", + 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""" + + 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_array(self, arr): + return Image.fromarray((arr * 255).astype("uint8")) + + def array_from_pil(self, img): + return numpy.array(img) / 255 + + 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 + image = image.convert("RGB") + image_blurred = self.array_from_pil(image.filter(ImageFilter.GaussianBlur(radius=self.radius))) + + image = self.array_from_pil(image) + image += (image - image_blurred) * (self.strength / 100.0) + image = numpy.clip(image, 0, 1) + image = self.pil_from_array(image) + + image = image.convert(mode) + + # Make the image RGBA if we had a source alpha channel + if alpha_channel is not None: + image.putalpha(alpha_channel) + + image_dto = context.images.save(image=image) + + return ImageOutput( + image=ImageField(image_name=image_dto.image_name), + width=image.width, + height=image.height, + ) + + +PIL_RESAMPLING_MODES = Literal[ + "nearest", + "box", + "bilinear", + "hamming", + "bicubic", + "lanczos", +] + + +PIL_RESAMPLING_MAP = { + "nearest": Image.Resampling.NEAREST, + "box": Image.Resampling.BOX, + "bilinear": Image.Resampling.BILINEAR, + "hamming": Image.Resampling.HAMMING, + "bicubic": Image.Resampling.BICUBIC, + "lanczos": Image.Resampling.LANCZOS, +} + + +@invocation( + "img_resize", + title="Resize Image", + tags=["image", "resize"], + category="image", + version="1.2.2", +) +class ImageResizeInvocation(BaseInvocation, WithMetadata, WithBoard): + """Resizes an image to specific dimensions""" + + image: ImageField = InputField(description="The image to resize") + width: int = InputField(default=512, gt=0, description="The width to resize to (px)") + height: int = InputField(default=512, gt=0, description="The height to resize to (px)") + resample_mode: PIL_RESAMPLING_MODES = InputField(default="bicubic", description="The resampling mode") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + resample_mode = PIL_RESAMPLING_MAP[self.resample_mode] + + resize_image = image.resize( + (self.width, self.height), + resample=resample_mode, + ) + + image_dto = context.images.save(image=resize_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_scale", + title="Scale Image", + tags=["image", "scale"], + category="image", + version="1.2.2", +) +class ImageScaleInvocation(BaseInvocation, WithMetadata, WithBoard): + """Scales an image by a factor""" + + image: ImageField = InputField(description="The image to scale") + scale_factor: float = InputField( + default=2.0, + gt=0, + description="The factor by which to scale the image", + ) + resample_mode: PIL_RESAMPLING_MODES = InputField(default="bicubic", description="The resampling mode") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + resample_mode = PIL_RESAMPLING_MAP[self.resample_mode] + width = int(image.width * self.scale_factor) + height = int(image.height * self.scale_factor) + + resize_image = image.resize( + (width, height), + resample=resample_mode, + ) + + image_dto = context.images.save(image=resize_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_lerp", + title="Lerp Image", + tags=["image", "lerp"], + category="image", + version="1.2.2", +) +class ImageLerpInvocation(BaseInvocation, WithMetadata, WithBoard): + """Linear interpolation of all pixels of an image""" + + image: ImageField = InputField(description="The image to lerp") + min: int = InputField(default=0, ge=0, le=255, description="The minimum output value") + max: int = InputField(default=255, ge=0, le=255, description="The maximum output value") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + image_arr = numpy.asarray(image, dtype=numpy.float32) / 255 + image_arr = image_arr * (self.max - self.min) + self.min + + lerp_image = Image.fromarray(numpy.uint8(image_arr)) + + image_dto = context.images.save(image=lerp_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_ilerp", + title="Inverse Lerp Image", + tags=["image", "ilerp"], + category="image", + version="1.2.2", +) +class ImageInverseLerpInvocation(BaseInvocation, WithMetadata, WithBoard): + """Inverse linear interpolation of all pixels of an image""" + + image: ImageField = InputField(description="The image to lerp") + min: int = InputField(default=0, ge=0, le=255, description="The minimum input value") + max: int = InputField(default=255, ge=0, le=255, description="The maximum input value") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + image_arr = numpy.asarray(image, dtype=numpy.float32) + image_arr = numpy.minimum(numpy.maximum(image_arr - self.min, 0) / float(self.max - self.min), 1) * 255 # type: ignore [assignment] + + ilerp_image = Image.fromarray(numpy.uint8(image_arr)) + + image_dto = context.images.save(image=ilerp_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_nsfw", + title="Blur NSFW Image", + tags=["image", "nsfw"], + category="image", + version="1.2.3", +) +class ImageNSFWBlurInvocation(BaseInvocation, WithMetadata, WithBoard): + """Add blur to NSFW-flagged images""" + + image: ImageField = InputField(description="The image to check") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + logger = context.logger + logger.debug("Running NSFW checker") + image = SafetyChecker.blur_if_nsfw(image) + + image_dto = context.images.save(image=image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_watermark", + title="Add Invisible Watermark", + tags=["image", "watermark"], + category="image", + version="1.2.2", +) +class ImageWatermarkInvocation(BaseInvocation, WithMetadata, WithBoard): + """Add an invisible watermark to an image""" + + image: ImageField = InputField(description="The image to check") + text: str = InputField(default="InvokeAI", description="Watermark text") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + new_image = InvisibleWatermark.add_watermark(image, self.text) + image_dto = context.images.save(image=new_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "mask_edge", + title="Mask Edge", + tags=["image", "mask", "inpaint"], + category="image", + version="1.2.2", +) +class MaskEdgeInvocation(BaseInvocation, WithMetadata, WithBoard): + """Applies an edge mask to an image""" + + image: ImageField = InputField(description="The image to apply the mask to") + edge_size: int = InputField(description="The size of the edge") + edge_blur: int = InputField(description="The amount of blur on the edge") + low_threshold: int = InputField(description="First threshold for the hysteresis procedure in Canny edge detection") + high_threshold: int = InputField( + description="Second threshold for the hysteresis procedure in Canny edge detection" + ) + + def invoke(self, context: InvocationContext) -> ImageOutput: + mask = context.images.get_pil(self.image.image_name).convert("L") + + npimg = numpy.asarray(mask, dtype=numpy.uint8) + npgradient = numpy.uint8(255 * (1.0 - numpy.floor(numpy.abs(0.5 - numpy.float32(npimg) / 255.0) * 2.0))) + npedge = cv2.Canny(npimg, threshold1=self.low_threshold, threshold2=self.high_threshold) + npmask = npgradient + npedge + npmask = cv2.dilate(npmask, numpy.ones((3, 3), numpy.uint8), iterations=int(self.edge_size / 2)) + + new_mask = Image.fromarray(npmask) + + if self.edge_blur > 0: + new_mask = new_mask.filter(ImageFilter.BoxBlur(self.edge_blur)) + + new_mask = ImageOps.invert(new_mask) + + image_dto = context.images.save(image=new_mask, image_category=ImageCategory.MASK) + + return ImageOutput.build(image_dto) + + +@invocation( + "mask_combine", + title="Combine Masks", + tags=["image", "mask", "multiply"], + category="image", + version="1.2.2", +) +class MaskCombineInvocation(BaseInvocation, WithMetadata, WithBoard): + """Combine two masks together by multiplying them using `PIL.ImageChops.multiply()`.""" + + mask1: ImageField = InputField(description="The first mask to combine") + mask2: ImageField = InputField(description="The second image to combine") + + def invoke(self, context: InvocationContext) -> ImageOutput: + mask1 = context.images.get_pil(self.mask1.image_name).convert("L") + mask2 = context.images.get_pil(self.mask2.image_name).convert("L") + + combined_mask = ImageChops.multiply(mask1, mask2) + + image_dto = context.images.save(image=combined_mask, image_category=ImageCategory.MASK) + + return ImageOutput.build(image_dto) + + +@invocation( + "color_correct", + title="Color Correct", + tags=["image", "color"], + category="image", + version="1.2.2", +) +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. + """ + + 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") + + 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") + 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), + ) + 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]) + + # Paste original on color-corrected generation (using blurred mask) + matched_result.paste(init_image, (0, 0), mask=multiplied_blurred_init_mask) + + image_dto = context.images.save(image=matched_result) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_hue_adjust", + title="Adjust Image Hue", + tags=["image", "hue"], + category="image", + version="1.2.2", +) +class ImageHueAdjustmentInvocation(BaseInvocation, WithMetadata, WithBoard): + """Adjusts the Hue of an image.""" + + 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: + pil_image = context.images.get_pil(self.image.image_name) + + # Convert image to HSV color space + hsv_image = numpy.array(pil_image.convert("HSV")) + + # Convert hue from 0..360 to 0..256 + hue = int(256 * ((self.hue % 360) / 360)) + + # Increment each hue and wrap around at 255 + hsv_image[:, :, 0] = (hsv_image[:, :, 0] + hue) % 256 + + # Convert back to PIL format and to original color mode + pil_image = Image.fromarray(hsv_image, mode="HSV").convert("RGBA") + + image_dto = context.images.save(image=pil_image) + + return ImageOutput.build(image_dto) + + +COLOR_CHANNELS = Literal[ + "Red (RGBA)", + "Green (RGBA)", + "Blue (RGBA)", + "Alpha (RGBA)", + "Cyan (CMYK)", + "Magenta (CMYK)", + "Yellow (CMYK)", + "Black (CMYK)", + "Hue (HSV)", + "Saturation (HSV)", + "Value (HSV)", + "Luminosity (LAB)", + "A (LAB)", + "B (LAB)", + "Y (YCbCr)", + "Cb (YCbCr)", + "Cr (YCbCr)", +] + +CHANNEL_FORMATS = { + "Red (RGBA)": ("RGBA", 0), + "Green (RGBA)": ("RGBA", 1), + "Blue (RGBA)": ("RGBA", 2), + "Alpha (RGBA)": ("RGBA", 3), + "Cyan (CMYK)": ("CMYK", 0), + "Magenta (CMYK)": ("CMYK", 1), + "Yellow (CMYK)": ("CMYK", 2), + "Black (CMYK)": ("CMYK", 3), + "Hue (HSV)": ("HSV", 0), + "Saturation (HSV)": ("HSV", 1), + "Value (HSV)": ("HSV", 2), + "Luminosity (LAB)": ("LAB", 0), + "A (LAB)": ("LAB", 1), + "B (LAB)": ("LAB", 2), + "Y (YCbCr)": ("YCbCr", 0), + "Cb (YCbCr)": ("YCbCr", 1), + "Cr (YCbCr)": ("YCbCr", 2), +} + + +@invocation( + "img_channel_offset", + title="Offset Image Channel", + tags=[ + "image", + "offset", + "red", + "green", + "blue", + "alpha", + "cyan", + "magenta", + "yellow", + "black", + "hue", + "saturation", + "luminosity", + "value", + ], + category="image", + version="1.2.2", +) +class ImageChannelOffsetInvocation(BaseInvocation, WithMetadata, WithBoard): + """Add or subtract a value from a specific color channel of an image.""" + + image: ImageField = InputField(description="The image to adjust") + channel: COLOR_CHANNELS = InputField(description="Which channel to adjust") + 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) + + # 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) + image_channel = converted_image[:, :, channel_number] + + # 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 + + # Convert back to RGBA format and output + pil_image = Image.fromarray(converted_image.astype(numpy.uint8), mode=mode).convert("RGBA") + + image_dto = context.images.save(image=pil_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "img_channel_multiply", + title="Multiply Image Channel", + tags=[ + "image", + "invert", + "scale", + "multiply", + "red", + "green", + "blue", + "alpha", + "cyan", + "magenta", + "yellow", + "black", + "hue", + "saturation", + "luminosity", + "value", + ], + category="image", + version="1.2.2", +) +class ImageChannelMultiplyInvocation(BaseInvocation, WithMetadata, WithBoard): + """Scale a specific color channel of an image.""" + + image: ImageField = InputField(description="The image to adjust") + channel: COLOR_CHANNELS = InputField(description="Which channel to adjust") + scale: float = InputField(default=1.0, ge=0.0, description="The amount to scale the channel by.") + 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) + + # 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) + image_channel = converted_image[:, :, channel_number] + + # Adjust the value, clipping to 0..255 + image_channel = numpy.clip(image_channel * self.scale, 0, 255) + + # Invert the channel if requested + if self.invert_channel: + image_channel = 255 - image_channel + + # Put the channel back into the image + converted_image[:, :, channel_number] = image_channel + + # Convert back to RGBA format and output + pil_image = Image.fromarray(converted_image.astype(numpy.uint8), mode=mode).convert("RGBA") + + image_dto = context.images.save(image=pil_image) + + return ImageOutput.build(image_dto) + + +@invocation( + "save_image", + title="Save Image", + tags=["primitives", "image"], + category="primitives", + version="1.2.2", + use_cache=False, +) +class SaveImageInvocation(BaseInvocation, WithMetadata, WithBoard): + """Saves an image. Unlike an image primitive, this invocation stores a copy of the image.""" + + 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) + + +@invocation( + "canvas_paste_back", + title="Canvas Paste Back", + tags=["image", "combine"], + category="image", + version="1.0.0", +) +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") + mask: ImageField = InputField( + description="The mask to use when pasting", + ) + 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: + source_image = context.images.get_pil(self.source_image.image_name) + target_image = context.images.get_pil(self.target_image.image_name) + mask = self._prepare_mask(context.images.get_pil(self.mask.image_name)) + + source_image.paste(target_image, (0, 0), mask) + + image_dto = context.images.save(image=source_image) + return ImageOutput.build(image_dto) + + +@invocation( + "mask_from_id", + title="Mask from ID", + tags=["image", "mask", "id"], + category="image", + version="1.0.0", +) +class MaskFromIDInvocation(BaseInvocation, WithMetadata, WithBoard): + """Generate a mask for a particular color in an ID Map""" + + image: ImageField = InputField(description="The image to create the mask from") + color: ColorField = InputField(description="ID color to mask") + 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") + + # 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)]) + + # 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) + + # Create a mask based on the threshold and the distance calculated above + binary_mask = (color_distance < threshold).astype(numpy.uint8) * 255 + + # Convert the mask back to PIL + binary_mask_pil = Image.fromarray(binary_mask) + + return binary_mask_pil + + 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) + + if self.invert: + mask = ImageOps.invert(mask) + + image_dto = context.images.save(image=mask, 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="image", + version="1.0.0", + classification=Classification.Internal, +) +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) diff --git a/invokeai/app/invocations/image_to_latents.py b/invokeai/app/invocations/image_to_latents.py new file mode 100644 index 0000000000000000000000000000000000000000..d288b8d99bba1ea25c3301d270d26ae9113575f6 --- /dev/null +++ b/invokeai/app/invocations/image_to_latents.py @@ -0,0 +1,144 @@ +from contextlib import nullcontext +from functools import singledispatchmethod + +import einops +import torch +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 invokeai.app.invocations.baseinvocation import BaseInvocation, invocation +from invokeai.app.invocations.constants import DEFAULT_PRECISION, LATENT_SCALE_FACTOR +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 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 + + +@invocation( + "i2l", + title="Image to Latents", + tags=["latents", "image", "vae", "i2l"], + category="latents", + version="1.1.0", +) +class ImageToLatentsInvocation(BaseInvocation): + """Encodes an image into latents.""" + + image: ImageField = InputField( + description="The image to encode", + ) + vae: VAEField = InputField( + description=FieldDescriptions.vae, + input=Input.Connection, + ) + tiled: bool = InputField(default=False, description=FieldDescriptions.tiled) + # 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=DEFAULT_PRECISION == torch.float32, description=FieldDescriptions.fp32) + + @staticmethod + def vae_encode( + vae_info: LoadedModel, upcast: bool, tiled: bool, image_tensor: torch.Tensor, tile_size: int = 0 + ) -> torch.Tensor: + with vae_info as vae: + assert isinstance(vae, (AutoencoderKL, AutoencoderTiny)) + orig_dtype = vae.dtype + if upcast: + 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(orig_dtype) + vae.decoder.conv_in.to(orig_dtype) + vae.decoder.mid_block.to(orig_dtype) + # else: + # latents = latents.float() + + else: + vae.to(dtype=torch.float16) + # latents = latents.half() + + if tiled: + vae.enable_tiling() + 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(), tiling_context: + latents = ImageToLatentsInvocation._encode_to_tensor(vae, image_tensor) + + latents = vae.config.scaling_factor * latents + latents = latents.to(dtype=orig_dtype) + + 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 encoder") + latents = self.vae_encode( + vae_info=vae_info, upcast=self.fp32, tiled=self.tiled, image_tensor=image_tensor, tile_size=self.tile_size + ) + + latents = latents.to("cpu") + name = context.tensors.save(tensor=latents) + return LatentsOutput.build(latents_name=name, latents=latents, seed=None) + + @singledispatchmethod + @staticmethod + def _encode_to_tensor(vae: AutoencoderKL, image_tensor: torch.FloatTensor) -> torch.FloatTensor: + assert isinstance(vae, torch.nn.Module) + image_tensor_dist = vae.encode(image_tensor).latent_dist + latents: torch.Tensor = image_tensor_dist.sample().to( + dtype=vae.dtype + ) # FIXME: uses torch.randn. make reproducible! + return latents + + @_encode_to_tensor.register + @staticmethod + def _(vae: AutoencoderTiny, image_tensor: torch.FloatTensor) -> torch.FloatTensor: + assert isinstance(vae, torch.nn.Module) + latents: torch.FloatTensor = vae.encode(image_tensor).latents + return latents diff --git a/invokeai/app/invocations/infill.py b/invokeai/app/invocations/infill.py new file mode 100644 index 0000000000000000000000000000000000000000..3314d72620e5f2759552bd0157d296c5c0e684eb --- /dev/null +++ b/invokeai/app/invocations/infill.py @@ -0,0 +1,170 @@ +from abc import abstractmethod +from typing import Literal, get_args + +from PIL import Image + +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 +from invokeai.backend.image_util.infill_methods.cv2_inpaint import cv2_inpaint +from invokeai.backend.image_util.infill_methods.lama import LaMA +from invokeai.backend.image_util.infill_methods.mosaic import infill_mosaic +from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch, infill_patchmatch +from invokeai.backend.image_util.infill_methods.tile import infill_tile +from invokeai.backend.util.logging import InvokeAILogger + +logger = InvokeAILogger.get_logger() + + +def get_infill_methods(): + methods = Literal["tile", "color", "lama", "cv2"] # TODO: add mosaic back + if PatchMatch.patchmatch_available(): + methods = Literal["patchmatch", "tile", "color", "lama", "cv2"] # TODO: add mosaic back + return methods + + +INFILL_METHODS = get_infill_methods() +DEFAULT_INFILL_METHOD = "patchmatch" if "patchmatch" in get_args(INFILL_METHODS) else "tile" + + +class InfillImageProcessorInvocation(BaseInvocation, WithMetadata, WithBoard): + """Base class for invocations that preprocess images for Infilling""" + + image: ImageField = InputField(description="The image to process") + + @abstractmethod + def infill(self, image: Image.Image) -> Image.Image: + """Infill the image with the specified method""" + pass + + def load_image(self) -> tuple[Image.Image, bool]: + """Process the image to have an alpha channel before being infilled""" + image = self._context.images.get_pil(self.image.image_name) + has_alpha = True if image.mode == "RGBA" else False + return image, has_alpha + + def invoke(self, context: InvocationContext) -> ImageOutput: + self._context = context + # Retrieve and process image to be infilled + input_image, has_alpha = self.load_image() + + # If the input image has no alpha channel, return it + if has_alpha is False: + return ImageOutput.build(context.images.get_dto(self.image.image_name)) + + # Perform Infill action + infilled_image = self.infill(input_image) + + # Create ImageDTO for Infilled Image + infilled_image_dto = context.images.save(image=infilled_image) + + # Return Infilled Image + return ImageOutput.build(infilled_image_dto) + + +@invocation("infill_rgba", title="Solid Color Infill", tags=["image", "inpaint"], category="inpaint", version="1.2.2") +class InfillColorInvocation(InfillImageProcessorInvocation): + """Infills transparent areas of an image with a solid color""" + + color: ColorField = InputField( + default=ColorField(r=127, g=127, b=127, a=255), + description="The color to use to infill", + ) + + def infill(self, image: Image.Image): + solid_bg = Image.new("RGBA", image.size, self.color.tuple()) + infilled = Image.alpha_composite(solid_bg, image.convert("RGBA")) + infilled.paste(image, (0, 0), image.split()[-1]) + return infilled + + +@invocation("infill_tile", title="Tile Infill", tags=["image", "inpaint"], category="inpaint", version="1.2.3") +class InfillTileInvocation(InfillImageProcessorInvocation): + """Infills transparent areas of an image with tiles of the image""" + + tile_size: int = InputField(default=32, ge=1, description="The tile size (px)") + seed: int = InputField( + default=0, + ge=0, + le=SEED_MAX, + description="The seed to use for tile generation (omit for random)", + ) + + def infill(self, image: Image.Image): + output = infill_tile(image, seed=self.seed, tile_size=self.tile_size) + return output.infilled + + +@invocation( + "infill_patchmatch", title="PatchMatch Infill", tags=["image", "inpaint"], category="inpaint", version="1.2.2" +) +class InfillPatchMatchInvocation(InfillImageProcessorInvocation): + """Infills transparent areas of an image using the PatchMatch algorithm""" + + downscale: float = InputField(default=2.0, gt=0, description="Run patchmatch on downscaled image to speedup infill") + resample_mode: PIL_RESAMPLING_MODES = InputField(default="bicubic", description="The resampling mode") + + def infill(self, image: Image.Image): + resample_mode = PIL_RESAMPLING_MAP[self.resample_mode] + + width = int(image.width / self.downscale) + height = int(image.height / self.downscale) + + infilled = image.resize( + (width, height), + resample=resample_mode, + ) + infilled = infill_patchmatch(image) + infilled = infilled.resize( + (image.width, image.height), + resample=resample_mode, + ) + infilled.paste(image, (0, 0), mask=image.split()[-1]) + + return infilled + + +@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", + loader=LaMA.load_jit_model, + ) as model: + lama = LaMA(model) + return lama(image) + + +@invocation("infill_cv2", title="CV2 Infill", tags=["image", "inpaint"], category="inpaint", version="1.2.2") +class CV2InfillInvocation(InfillImageProcessorInvocation): + """Infills transparent areas of an image using OpenCV Inpainting""" + + def infill(self, image: Image.Image): + return cv2_inpaint(image) + + +# @invocation( +# "infill_mosaic", title="Mosaic Infill", tags=["image", "inpaint", "outpaint"], category="inpaint", version="1.0.0" +# ) +class MosaicInfillInvocation(InfillImageProcessorInvocation): + """Infills transparent areas of an image with a mosaic pattern drawing colors from the rest of the image""" + + image: ImageField = InputField(description="The image to infill") + tile_width: int = InputField(default=64, description="Width of the tile") + tile_height: int = InputField(default=64, description="Height of the tile") + min_color: ColorField = InputField( + default=ColorField(r=0, g=0, b=0, a=255), + description="The min threshold for color", + ) + max_color: ColorField = InputField( + default=ColorField(r=255, g=255, b=255, a=255), + description="The max threshold for color", + ) + + def infill(self, image: Image.Image): + return infill_mosaic(image, (self.tile_width, self.tile_height), self.min_color.tuple(), self.max_color.tuple()) diff --git a/invokeai/app/invocations/ip_adapter.py b/invokeai/app/invocations/ip_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..e3d92374c75249fea642fa353c9983a0f1c0d279 --- /dev/null +++ b/invokeai/app/invocations/ip_adapter.py @@ -0,0 +1,192 @@ +from builtins import float +from typing import List, Literal, Optional, Union + +from pydantic import BaseModel, Field, field_validator, model_validator +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.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.starter_models import ( + StarterModel, + clip_vit_l_image_encoder, + ip_adapter_sd_image_encoder, + ip_adapter_sdxl_image_encoder, +) + + +class IPAdapterField(BaseModel): + image: Union[ImageField, List[ImageField]] = Field(description="The IP-Adapter image prompt(s).") + ip_adapter_model: ModelIdentifierField = Field(description="The IP-Adapter model to use.") + 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") + begin_step_percent: float = Field( + default=0, ge=0, le=1, description="When the IP-Adapter is first applied (% of total steps)" + ) + end_step_percent: float = Field( + default=1, ge=0, le=1, description="When the IP-Adapter is last applied (% of total steps)" + ) + mask: Optional[TensorField] = Field( + default=None, + description="The bool mask associated with this IP-Adapter. Excluded regions should be set to False, included " + "regions should be set to True.", + ) + + @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 + + +@invocation_output("ip_adapter_output") +class IPAdapterOutput(BaseInvocationOutput): + # Outputs + ip_adapter: IPAdapterField = OutputField(description=FieldDescriptions.ip_adapter, title="IP-Adapter") + + +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.5.0") +class IPAdapterInvocation(BaseInvocation): + """Collects IP-Adapter info to pass to other nodes.""" + + # Inputs + image: Union[ImageField, List[ImageField]] = InputField(description="The IP-Adapter image prompt(s).", ui_order=1) + ip_adapter_model: ModelIdentifierField = InputField( + description="The IP-Adapter model.", + title="IP-Adapter Model", + ui_order=-1, + ui_type=UIType.IPAdapterModel, + ) + 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, + ) + weight: Union[float, List[float]] = InputField( + default=1, description="The weight given to the IP-Adapter", title="Weight" + ) + method: Literal["full", "style", "composition"] = InputField( + default="full", description="The method to apply the IP-Adapter" + ) + 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)" + ) + mask: Optional[TensorField] = InputField( + default=None, description="A mask defining the region that this IP-Adapter applies to." + ) + + @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, (IPAdapterInvokeAIConfig, IPAdapterCheckpointConfig)) + + if isinstance(ip_adapter_info, IPAdapterInvokeAIConfig): + 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_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_clip_image_encoder(context, image_encoder_model_id, image_encoder_model_name) + + if self.method == "style": + if ip_adapter_info.base == "sd-1": + target_blocks = ["up_blocks.1"] + elif ip_adapter_info.base == "sdxl": + target_blocks = ["up_blocks.0.attentions.1"] + else: + raise ValueError(f"Unsupported IP-Adapter base type: '{ip_adapter_info.base}'.") + elif self.method == "composition": + if ip_adapter_info.base == "sd-1": + target_blocks = ["down_blocks.2", "mid_block"] + elif ip_adapter_info.base == "sdxl": + target_blocks = ["down_blocks.2.attentions.1"] + else: + raise ValueError(f"Unsupported IP-Adapter base type: '{ip_adapter_info.base}'.") + elif self.method == "full": + target_blocks = ["block"] + else: + raise ValueError(f"Unexpected IP-Adapter method: '{self.method}'.") + + 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, + begin_step_percent=self.begin_step_percent, + end_step_percent=self.end_step_percent, + mask=self.mask, + ), + ) + + @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 + ) + + if not len(image_encoder_models) > 0: + context.logger.warning( + f"The image encoder required by this IP Adapter ({image_encoder_model_name}) is not installed. \ + Downloading and installing now. This may take a while." + ) + + installer = context._services.model_manager.install + # 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 + ) + + if len(image_encoder_models) == 0: + context.logger.error("Error while fetching CLIP Vision Image Encoder") + assert len(image_encoder_models) == 1 + + return image_encoder_models[0] diff --git a/invokeai/app/invocations/latents_to_image.py b/invokeai/app/invocations/latents_to_image.py new file mode 100644 index 0000000000000000000000000000000000000000..1cb5ae78e77a743708ee86f05a3d74efd72532d4 --- /dev/null +++ b/invokeai/app/invocations/latents_to_image.py @@ -0,0 +1,122 @@ +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 invokeai.app.invocations.baseinvocation import BaseInvocation, invocation +from invokeai.app.invocations.constants import DEFAULT_PRECISION, LATENT_SCALE_FACTOR +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.stable_diffusion.vae_tiling import patch_vae_tiling_params +from invokeai.backend.util.devices import TorchDevice + + +@invocation( + "l2i", + title="Latents to Image", + tags=["latents", "image", "vae", "l2i"], + category="latents", + version="1.3.0", +) +class LatentsToImageInvocation(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, + ) + tiled: bool = InputField(default=False, description=FieldDescriptions.tiled) + # 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=DEFAULT_PRECISION == torch.float32, description=FieldDescriptions.fp32) + + @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, AutoencoderTiny)) + with SeamlessExt.static_patch_model(vae_info.model, self.vae.seamless_axes), vae_info as vae: + context.util.signal_progress("Running VAE decoder") + assert isinstance(vae, (AutoencoderKL, AutoencoderTiny)) + latents = latents.to(vae.device) + if self.fp32: + 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() + + else: + vae.to(dtype=torch.float16) + latents = latents.half() + + if self.tiled or context.config.get().force_tiled_decode: + 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(), tiling_context: + # copied from diffusers pipeline + latents = latents / vae.config.scaling_factor + image = vae.decode(latents, return_dict=False)[0] + image = (image / 2 + 0.5).clamp(0, 1) # denormalize + # we always cast to float32 as this does not cause significant overhead and is compatible with bfloat16 + np_image = image.cpu().permute(0, 2, 3, 1).float().numpy() + + image = VaeImageProcessor.numpy_to_pil(np_image)[0] + + TorchDevice.empty_cache() + + image_dto = context.images.save(image=image) + + return ImageOutput.build(image_dto) diff --git a/invokeai/app/invocations/lineart.py b/invokeai/app/invocations/lineart.py new file mode 100644 index 0000000000000000000000000000000000000000..c486c329ecd21d4ff99fc4485c1d365ae4e3f3da --- /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", + 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 0000000000000000000000000000000000000000..848756b113668d496b1e9f63ba5ac1f2f1bc7b31 --- /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", + 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/mask.py b/invokeai/app/invocations/mask.py new file mode 100644 index 0000000000000000000000000000000000000000..c2fc884bce28f603079de9dc3db4cf22510dd520 --- /dev/null +++ b/invokeai/app/invocations/mask.py @@ -0,0 +1,203 @@ +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, WithBoard, WithMetadata +from invokeai.app.invocations.primitives import ImageOutput, MaskOutput +from invokeai.backend.image_util.util import pil_to_np + + +@invocation( + "rectangle_mask", + title="Create Rectangle Mask", + tags=["conditioning"], + category="conditioning", + version="1.0.1", +) +class RectangleMaskInvocation(BaseInvocation, WithMetadata): + """Create a rectangular mask.""" + + width: int = InputField(description="The width of the entire mask.") + height: int = InputField(description="The height of the entire mask.") + x_left: int = InputField(description="The left x-coordinate of the rectangular masked region (inclusive).") + y_top: int = InputField(description="The top y-coordinate of the rectangular masked region (inclusive).") + rectangle_width: int = InputField(description="The width of the rectangular masked region.") + rectangle_height: int = InputField(description="The height of the rectangular masked region.") + + def invoke(self, context: InvocationContext) -> MaskOutput: + mask = torch.zeros((1, self.height, self.width), dtype=torch.bool) + mask[:, self.y_top : self.y_top + self.rectangle_height, self.x_left : self.x_left + self.rectangle_width] = ( + True + ) + + mask_tensor_name = context.tensors.save(mask) + return MaskOutput( + mask=TensorField(tensor_name=mask_tensor_name), + width=self.width, + height=self.height, + ) + + +@invocation( + "alpha_mask_to_tensor", + title="Alpha Mask to Tensor", + tags=["conditioning"], + category="conditioning", + 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.""" + + image: ImageField = InputField(description="The mask image to convert.") + 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) + 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) + else: + mask[0] = torch.tensor(np.array(image)[:, :, 3] > 0, dtype=torch.bool) + + return MaskOutput( + mask=TensorField(tensor_name=context.tensors.save(mask)), + height=mask.shape[1], + width=mask.shape[2], + ) + + +@invocation( + "invert_tensor_mask", + title="Invert Tensor Mask", + tags=["conditioning"], + category="conditioning", + version="1.0.0", + classification=Classification.Beta, +) +class InvertTensorMaskInvocation(BaseInvocation): + """Inverts a tensor mask.""" + + mask: TensorField = InputField(description="The tensor mask to convert.") + + def invoke(self, context: InvocationContext) -> MaskOutput: + mask = context.tensors.load(self.mask.tensor_name) + inverted = ~mask + + return MaskOutput( + mask=TensorField(tensor_name=context.tensors.save(inverted)), + height=inverted.shape[1], + width=inverted.shape[2], + ) + + +@invocation( + "image_mask_to_tensor", + title="Image Mask to Tensor", + tags=["conditioning"], + category="conditioning", + version="1.0.0", +) +class ImageMaskToTensorInvocation(BaseInvocation, WithMetadata): + """Convert a mask image to a tensor. Converts the image to grayscale and uses thresholding at the specified value.""" + + image: ImageField = InputField(description="The mask image to convert.") + cutoff: int = InputField(ge=0, le=255, description="Cutoff (<)", default=128) + 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, mode="L") + + mask = torch.zeros((1, image.height, image.width), dtype=torch.bool) + if self.invert: + mask[0] = torch.tensor(np.array(image)[:, :] >= self.cutoff, dtype=torch.bool) + else: + mask[0] = torch.tensor(np.array(image)[:, :] < self.cutoff, dtype=torch.bool) + + return MaskOutput( + mask=TensorField(tensor_name=context.tensors.save(mask)), + 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) diff --git a/invokeai/app/invocations/math.py b/invokeai/app/invocations/math.py new file mode 100644 index 0000000000000000000000000000000000000000..5d3988031ba177716474b877db00cac4096e6f83 --- /dev/null +++ b/invokeai/app/invocations/math.py @@ -0,0 +1,292 @@ +# Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654) + +from typing import Literal + +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 + + +@invocation("add", title="Add Integers", tags=["math", "add"], category="math", version="1.0.1") +class AddInvocation(BaseInvocation): + """Adds two numbers""" + + a: int = InputField(default=0, description=FieldDescriptions.num_1) + b: int = InputField(default=0, description=FieldDescriptions.num_2) + + def invoke(self, context: InvocationContext) -> IntegerOutput: + return IntegerOutput(value=self.a + self.b) + + +@invocation("sub", title="Subtract Integers", tags=["math", "subtract"], category="math", version="1.0.1") +class SubtractInvocation(BaseInvocation): + """Subtracts two numbers""" + + a: int = InputField(default=0, description=FieldDescriptions.num_1) + b: int = InputField(default=0, description=FieldDescriptions.num_2) + + def invoke(self, context: InvocationContext) -> IntegerOutput: + return IntegerOutput(value=self.a - self.b) + + +@invocation("mul", title="Multiply Integers", tags=["math", "multiply"], category="math", version="1.0.1") +class MultiplyInvocation(BaseInvocation): + """Multiplies two numbers""" + + a: int = InputField(default=0, description=FieldDescriptions.num_1) + b: int = InputField(default=0, description=FieldDescriptions.num_2) + + def invoke(self, context: InvocationContext) -> IntegerOutput: + return IntegerOutput(value=self.a * self.b) + + +@invocation("div", title="Divide Integers", tags=["math", "divide"], category="math", version="1.0.1") +class DivideInvocation(BaseInvocation): + """Divides two numbers""" + + a: int = InputField(default=0, description=FieldDescriptions.num_1) + b: int = InputField(default=0, description=FieldDescriptions.num_2) + + def invoke(self, context: InvocationContext) -> IntegerOutput: + return IntegerOutput(value=int(self.a / self.b)) + + +@invocation( + "rand_int", + title="Random Integer", + tags=["math", "random"], + category="math", + version="1.0.1", + use_cache=False, +) +class RandomIntInvocation(BaseInvocation): + """Outputs a single random integer.""" + + low: int = InputField(default=0, description=FieldDescriptions.inclusive_low) + high: int = InputField(default=np.iinfo(np.int32).max, description=FieldDescriptions.exclusive_high) + + def invoke(self, context: InvocationContext) -> IntegerOutput: + return IntegerOutput(value=np.random.randint(self.low, self.high)) + + +@invocation( + "rand_float", + title="Random Float", + tags=["math", "float", "random"], + category="math", + version="1.0.1", + use_cache=False, +) +class RandomFloatInvocation(BaseInvocation): + """Outputs a single random float""" + + low: float = InputField(default=0.0, description=FieldDescriptions.inclusive_low) + high: float = InputField(default=1.0, description=FieldDescriptions.exclusive_high) + decimals: int = InputField(default=2, description=FieldDescriptions.decimal_places) + + def invoke(self, context: InvocationContext) -> FloatOutput: + random_float = np.random.uniform(self.low, self.high) + rounded_float = round(random_float, self.decimals) + return FloatOutput(value=rounded_float) + + +@invocation( + "float_to_int", + title="Float To Integer", + tags=["math", "round", "integer", "float", "convert"], + category="math", + version="1.0.1", +) +class FloatToIntegerInvocation(BaseInvocation): + """Rounds a float number to (a multiple of) an integer.""" + + value: float = InputField(default=0, description="The value to round") + multiple: int = InputField(default=1, ge=1, title="Multiple of", description="The multiple to round to") + method: Literal["Nearest", "Floor", "Ceiling", "Truncate"] = InputField( + default="Nearest", description="The method to use for rounding" + ) + + def invoke(self, context: InvocationContext) -> IntegerOutput: + if self.method == "Nearest": + return IntegerOutput(value=round(self.value / self.multiple) * self.multiple) + elif self.method == "Floor": + return IntegerOutput(value=np.floor(self.value / self.multiple) * self.multiple) + elif self.method == "Ceiling": + return IntegerOutput(value=np.ceil(self.value / self.multiple) * self.multiple) + else: # self.method == "Truncate" + return IntegerOutput(value=int(self.value / self.multiple) * self.multiple) + + +@invocation("round_float", title="Round Float", tags=["math", "round"], category="math", version="1.0.1") +class RoundInvocation(BaseInvocation): + """Rounds a float to a specified number of decimal places.""" + + value: float = InputField(default=0, description="The float value") + decimals: int = InputField(default=0, description="The number of decimal places") + + def invoke(self, context: InvocationContext) -> FloatOutput: + return FloatOutput(value=round(self.value, self.decimals)) + + +INTEGER_OPERATIONS = Literal[ + "ADD", + "SUB", + "MUL", + "DIV", + "EXP", + "MOD", + "ABS", + "MIN", + "MAX", +] + + +INTEGER_OPERATIONS_LABELS = { + "ADD": "Add A+B", + "SUB": "Subtract A-B", + "MUL": "Multiply A*B", + "DIV": "Divide A/B", + "EXP": "Exponentiate A^B", + "MOD": "Modulus A%B", + "ABS": "Absolute Value of A", + "MIN": "Minimum(A,B)", + "MAX": "Maximum(A,B)", +} + + +@invocation( + "integer_math", + title="Integer Math", + tags=[ + "math", + "integer", + "add", + "subtract", + "multiply", + "divide", + "modulus", + "power", + "absolute value", + "min", + "max", + ], + category="math", + version="1.0.1", +) +class IntegerMathInvocation(BaseInvocation): + """Performs integer math.""" + + operation: INTEGER_OPERATIONS = InputField( + default="ADD", description="The operation to perform", ui_choice_labels=INTEGER_OPERATIONS_LABELS + ) + a: int = InputField(default=1, description=FieldDescriptions.num_1) + b: int = InputField(default=1, description=FieldDescriptions.num_2) + + @field_validator("b") + def no_unrepresentable_results(cls, v: int, info: ValidationInfo): + if info.data["operation"] == "DIV" and v == 0: + raise ValueError("Cannot divide by zero") + elif info.data["operation"] == "MOD" and v == 0: + raise ValueError("Cannot divide by zero") + elif info.data["operation"] == "EXP" and v < 0: + raise ValueError("Result of exponentiation is not an integer") + return v + + def invoke(self, context: InvocationContext) -> IntegerOutput: + # Python doesn't support switch statements until 3.10, but InvokeAI supports back to 3.9 + if self.operation == "ADD": + return IntegerOutput(value=self.a + self.b) + elif self.operation == "SUB": + return IntegerOutput(value=self.a - self.b) + elif self.operation == "MUL": + return IntegerOutput(value=self.a * self.b) + elif self.operation == "DIV": + return IntegerOutput(value=int(self.a / self.b)) + elif self.operation == "EXP": + return IntegerOutput(value=self.a**self.b) + elif self.operation == "MOD": + return IntegerOutput(value=self.a % self.b) + elif self.operation == "ABS": + return IntegerOutput(value=abs(self.a)) + elif self.operation == "MIN": + return IntegerOutput(value=min(self.a, self.b)) + else: # self.operation == "MAX": + return IntegerOutput(value=max(self.a, self.b)) + + +FLOAT_OPERATIONS = Literal[ + "ADD", + "SUB", + "MUL", + "DIV", + "EXP", + "ABS", + "SQRT", + "MIN", + "MAX", +] + + +FLOAT_OPERATIONS_LABELS = { + "ADD": "Add A+B", + "SUB": "Subtract A-B", + "MUL": "Multiply A*B", + "DIV": "Divide A/B", + "EXP": "Exponentiate A^B", + "ABS": "Absolute Value of A", + "SQRT": "Square Root of A", + "MIN": "Minimum(A,B)", + "MAX": "Maximum(A,B)", +} + + +@invocation( + "float_math", + title="Float Math", + tags=["math", "float", "add", "subtract", "multiply", "divide", "power", "root", "absolute value", "min", "max"], + category="math", + version="1.0.1", +) +class FloatMathInvocation(BaseInvocation): + """Performs floating point math.""" + + operation: FLOAT_OPERATIONS = InputField( + default="ADD", description="The operation to perform", ui_choice_labels=FLOAT_OPERATIONS_LABELS + ) + a: float = InputField(default=1, description=FieldDescriptions.num_1) + b: float = InputField(default=1, description=FieldDescriptions.num_2) + + @field_validator("b") + def no_unrepresentable_results(cls, v: float, info: ValidationInfo): + if info.data["operation"] == "DIV" and v == 0: + raise ValueError("Cannot divide by zero") + elif info.data["operation"] == "EXP" and info.data["a"] == 0 and v < 0: + raise ValueError("Cannot raise zero to a negative power") + elif info.data["operation"] == "EXP" and isinstance(info.data["a"] ** v, complex): + raise ValueError("Root operation resulted in a complex number") + return v + + def invoke(self, context: InvocationContext) -> FloatOutput: + # Python doesn't support switch statements until 3.10, but InvokeAI supports back to 3.9 + if self.operation == "ADD": + return FloatOutput(value=self.a + self.b) + elif self.operation == "SUB": + return FloatOutput(value=self.a - self.b) + elif self.operation == "MUL": + return FloatOutput(value=self.a * self.b) + elif self.operation == "DIV": + return FloatOutput(value=self.a / self.b) + elif self.operation == "EXP": + return FloatOutput(value=self.a**self.b) + elif self.operation == "SQRT": + return FloatOutput(value=np.sqrt(self.a)) + elif self.operation == "ABS": + return FloatOutput(value=abs(self.a)) + elif self.operation == "MIN": + return FloatOutput(value=min(self.a, self.b)) + else: # self.operation == "MAX": + return FloatOutput(value=max(self.a, self.b)) diff --git a/invokeai/app/invocations/mediapipe_face.py b/invokeai/app/invocations/mediapipe_face.py new file mode 100644 index 0000000000000000000000000000000000000000..89fccfc1ac0bbdbdd53c192aff91d401eccb1c04 --- /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", + 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 new file mode 100644 index 0000000000000000000000000000000000000000..5bc7658e84df3564d1d21eab9a4e81743ee48347 --- /dev/null +++ b/invokeai/app/invocations/metadata.py @@ -0,0 +1,277 @@ +from typing import Any, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field + +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + Classification, + invocation, + invocation_output, +) +from invokeai.app.invocations.fields import ( + FieldDescriptions, + ImageField, + InputField, + MetadataField, + OutputField, + UIType, +) +from invokeai.app.invocations.model import ModelIdentifierField +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES +from invokeai.version.invokeai_version import __version__ + + +class MetadataItemField(BaseModel): + label: str = Field(description=FieldDescriptions.metadata_item_label) + value: Any = Field(description=FieldDescriptions.metadata_item_value) + + +class LoRAMetadataField(BaseModel): + """LoRA Metadata Field""" + + model: ModelIdentifierField = Field(description=FieldDescriptions.lora_model) + weight: float = Field(description=FieldDescriptions.lora_weight) + + +class IPAdapterMetadataField(BaseModel): + """IP Adapter Field, minus the CLIP Vision Encoder model""" + + image: ImageField = Field(description="The IP-Adapter image prompt.") + ip_adapter_model: ModelIdentifierField = Field(description="The IP-Adapter model.") + clip_vision_model: Literal["ViT-L", "ViT-H", "ViT-G"] = Field(description="The CLIP Vision model") + method: Literal["full", "style", "composition"] = 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)") + + +class T2IAdapterMetadataField(BaseModel): + image: ImageField = Field(description="The control image.") + processed_image: Optional[ImageField] = Field(default=None, description="The control image, after processing.") + t2i_adapter_model: ModelIdentifierField = Field(description="The T2I-Adapter model to use.") + weight: Union[float, list[float]] = Field(default=1, description="The weight given to the T2I-Adapter") + begin_step_percent: float = Field( + default=0, ge=0, le=1, description="When the T2I-Adapter is first applied (% of total steps)" + ) + end_step_percent: float = Field( + default=1, ge=0, le=1, description="When the T2I-Adapter is last applied (% of total steps)" + ) + resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use") + + +class ControlNetMetadataField(BaseModel): + image: ImageField = Field(description="The control image") + processed_image: Optional[ImageField] = Field(default=None, description="The control image, after processing.") + 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") + + +@invocation_output("metadata_item_output") +class MetadataItemOutput(BaseInvocationOutput): + """Metadata Item Output""" + + item: MetadataItemField = OutputField(description="Metadata Item") + + +@invocation("metadata_item", title="Metadata Item", tags=["metadata"], category="metadata", version="1.0.1") +class MetadataItemInvocation(BaseInvocation): + """Used to create an arbitrary metadata item. Provide "label" and make a connection to "value" to store that data as the value.""" + + label: str = InputField(description=FieldDescriptions.metadata_item_label) + value: Any = InputField(description=FieldDescriptions.metadata_item_value, ui_type=UIType.Any) + + def invoke(self, context: InvocationContext) -> MetadataItemOutput: + return MetadataItemOutput(item=MetadataItemField(label=self.label, value=self.value)) + + +@invocation_output("metadata_output") +class MetadataOutput(BaseInvocationOutput): + metadata: MetadataField = OutputField(description="Metadata Dict") + + +@invocation("metadata", title="Metadata", tags=["metadata"], category="metadata", version="1.0.1") +class MetadataInvocation(BaseInvocation): + """Takes a MetadataItem or collection of MetadataItems and outputs a MetadataDict.""" + + items: Union[list[MetadataItemField], MetadataItemField] = InputField( + description=FieldDescriptions.metadata_item_polymorphic + ) + + def invoke(self, context: InvocationContext) -> MetadataOutput: + if isinstance(self.items, MetadataItemField): + # single metadata item + data = {self.items.label: self.items.value} + else: + # collection of metadata items + data = {item.label: item.value for item in self.items} + + # add app version + data.update({"app_version": __version__}) + return MetadataOutput(metadata=MetadataField.model_validate(data)) + + +@invocation("merge_metadata", title="Metadata Merge", tags=["metadata"], category="metadata", version="1.0.1") +class MergeMetadataInvocation(BaseInvocation): + """Merged a collection of MetadataDict into a single MetadataDict.""" + + collection: list[MetadataField] = InputField(description=FieldDescriptions.metadata_collection) + + def invoke(self, context: InvocationContext) -> MetadataOutput: + data = {} + for item in self.collection: + data.update(item.model_dump()) + + return MetadataOutput(metadata=MetadataField.model_validate(data)) + + +GENERATION_MODES = Literal[ + "txt2img", + "img2img", + "inpaint", + "outpaint", + "sdxl_txt2img", + "sdxl_img2img", + "sdxl_inpaint", + "sdxl_outpaint", + "flux_txt2img", + "flux_img2img", + "flux_inpaint", + "flux_outpaint", + "sd3_txt2img", + "sd3_img2img", + "sd3_inpaint", + "sd3_outpaint", +] + + +@invocation( + "core_metadata", + title="Core Metadata", + tags=["metadata"], + category="metadata", + version="2.0.0", + classification=Classification.Internal, +) +class CoreMetadataInvocation(BaseInvocation): + """Used internally by Invoke to collect metadata for generations.""" + + generation_mode: Optional[GENERATION_MODES] = InputField( + default=None, + description="The generation mode that output this image", + ) + positive_prompt: Optional[str] = InputField(default=None, description="The positive prompt parameter") + negative_prompt: Optional[str] = InputField(default=None, description="The negative prompt parameter") + width: Optional[int] = InputField(default=None, description="The width parameter") + height: Optional[int] = InputField(default=None, description="The height parameter") + seed: Optional[int] = InputField(default=None, description="The seed used for noise generation") + rand_device: Optional[str] = InputField(default=None, description="The device used for random number generation") + cfg_scale: Optional[float] = InputField(default=None, description="The classifier-free guidance scale parameter") + cfg_rescale_multiplier: Optional[float] = InputField( + default=None, description=FieldDescriptions.cfg_rescale_multiplier + ) + steps: Optional[int] = InputField(default=None, description="The number of steps used for inference") + scheduler: Optional[str] = InputField(default=None, description="The scheduler used for inference") + seamless_x: Optional[bool] = InputField(default=None, description="Whether seamless tiling was used on the X axis") + seamless_y: Optional[bool] = InputField(default=None, description="Whether seamless tiling was used on the Y axis") + clip_skip: Optional[int] = InputField( + default=None, + description="The number of skipped CLIP layers", + ) + model: Optional[ModelIdentifierField] = InputField(default=None, description="The main model used for inference") + controlnets: Optional[list[ControlNetMetadataField]] = InputField( + default=None, description="The ControlNets used for inference" + ) + ipAdapters: Optional[list[IPAdapterMetadataField]] = InputField( + default=None, description="The IP Adapters used for inference" + ) + t2iAdapters: Optional[list[T2IAdapterMetadataField]] = InputField( + default=None, description="The IP Adapters used for inference" + ) + loras: Optional[list[LoRAMetadataField]] = InputField(default=None, description="The LoRAs used for inference") + strength: Optional[float] = InputField( + default=None, + description="The strength used for latents-to-latents", + ) + init_image: Optional[str] = InputField( + default=None, + description="The name of the initial image", + ) + vae: Optional[ModelIdentifierField] = InputField( + default=None, + description="The VAE used for decoding, if the main model's default was not used", + ) + + # High resolution fix metadata. + hrf_enabled: Optional[bool] = InputField( + default=None, + description="Whether or not high resolution fix was enabled.", + ) + # TODO: should this be stricter or do we just let the UI handle it? + hrf_method: Optional[str] = InputField( + default=None, + description="The high resolution fix upscale method.", + ) + hrf_strength: Optional[float] = InputField( + default=None, + description="The high resolution fix img2img strength used in the upscale pass.", + ) + + # SDXL + positive_style_prompt: Optional[str] = InputField( + default=None, + description="The positive style prompt parameter", + ) + negative_style_prompt: Optional[str] = InputField( + default=None, + description="The negative style prompt parameter", + ) + + # SDXL Refiner + refiner_model: Optional[ModelIdentifierField] = InputField( + default=None, + description="The SDXL Refiner model used", + ) + refiner_cfg_scale: Optional[float] = InputField( + default=None, + description="The classifier-free guidance scale parameter used for the refiner", + ) + refiner_steps: Optional[int] = InputField( + default=None, + description="The number of steps used for the refiner", + ) + refiner_scheduler: Optional[str] = InputField( + default=None, + description="The scheduler used for the refiner", + ) + refiner_positive_aesthetic_score: Optional[float] = InputField( + default=None, + description="The aesthetic score used for the refiner", + ) + refiner_negative_aesthetic_score: Optional[float] = InputField( + default=None, + description="The aesthetic score used for the refiner", + ) + refiner_start: Optional[float] = InputField( + default=None, + description="The start value used for refiner denoising", + ) + + def invoke(self, context: InvocationContext) -> MetadataOutput: + """Collects and outputs a CoreMetadata object""" + + as_dict = self.model_dump(exclude_none=True, exclude={"id", "type", "is_intermediate", "use_cache"}) + as_dict["app_version"] = __version__ + + return MetadataOutput(metadata=MetadataField.model_validate(as_dict)) + + model_config = ConfigDict(extra="allow") diff --git a/invokeai/app/invocations/mlsd.py b/invokeai/app/invocations/mlsd.py new file mode 100644 index 0000000000000000000000000000000000000000..1526350db8c747e9ab69ac49f3cf280facb165bf --- /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", + 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 new file mode 100644 index 0000000000000000000000000000000000000000..adeb8341d234259069fcf4744462b7ea3dec92b6 --- /dev/null +++ b/invokeai/app/invocations/model.py @@ -0,0 +1,547 @@ +import copy +from typing import List, Optional + +from pydantic import BaseModel, Field + +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + Classification, + invocation, + invocation_output, +) +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, +) + + +class ModelIdentifierField(BaseModel): + key: str = Field(description="The model's unique key") + hash: str = Field(description="The model's BLAKE3 hash") + 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 + ) + + @classmethod + def from_config( + cls, config: "AnyModelConfig", submodel_type: Optional[SubModelType] = None + ) -> "ModelIdentifierField": + return cls( + key=config.key, + hash=config.hash, + name=config.name, + base=config.base, + type=config.type, + submodel_type=submodel_type, + ) + + +class LoRAField(BaseModel): + lora: ModelIdentifierField = Field(description="Info to load lora model") + weight: float = Field(description="Weight to apply to lora model") + + +class UNetField(BaseModel): + unet: ModelIdentifierField = Field(description="Info to load unet submodel") + scheduler: ModelIdentifierField = Field(description="Info to load scheduler submodel") + loras: List[LoRAField] = Field(description="LoRAs to apply on model loading") + seamless_axes: List[str] = Field(default_factory=list, description='Axes("x" and "y") to which apply seamless') + freeu_config: Optional[FreeUConfig] = Field(default=None, description="FreeU configuration") + + +class CLIPField(BaseModel): + tokenizer: ModelIdentifierField = Field(description="Info to load tokenizer submodel") + text_encoder: ModelIdentifierField = Field(description="Info to load text_encoder submodel") + skipped_layers: int = Field(description="Number of skipped layers in text_encoder") + loras: List[LoRAField] = Field(description="LoRAs to apply on model loading") + + +class TransformerField(BaseModel): + transformer: ModelIdentifierField = Field(description="Info to load Transformer submodel") + 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") + + +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') + + +@invocation_output("unet_output") +class UNetOutput(BaseInvocationOutput): + """Base class for invocations that output a UNet field.""" + + unet: UNetField = OutputField(description=FieldDescriptions.unet, title="UNet") + + +@invocation_output("vae_output") +class VAEOutput(BaseInvocationOutput): + """Base class for invocations that output a VAE field""" + + vae: VAEField = OutputField(description=FieldDescriptions.vae, title="VAE") + + +@invocation_output("clip_output") +class CLIPOutput(BaseInvocationOutput): + """Base class for invocations that output a CLIP field""" + + clip: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP") + + +@invocation_output("model_loader_output") +class ModelLoaderOutput(UNetOutput, CLIPOutput, VAEOutput): + """Model loader output""" + + pass + + +@invocation_output("model_identifier_output") +class ModelIdentifierOutput(BaseInvocationOutput): + """Model identifier output""" + + model: ModelIdentifierField = OutputField(description="Model identifier", title="Model") + + +@invocation( + "model_identifier", + title="Model identifier", + tags=["model"], + category="model", + version="1.0.0", + classification=Classification.Prototype, +) +class ModelIdentifierInvocation(BaseInvocation): + """Selects any model, outputting it its identifier. Be careful with this one! The identifier will be accepted as + input for any model, even if the model types don't match. If you connect this to a mismatched input, you'll get an + error.""" + + model: ModelIdentifierField = InputField(description="The model to select", title="Model") + + def invoke(self, context: InvocationContext) -> ModelIdentifierOutput: + if not context.models.exists(self.model.key): + raise Exception(f"Unknown model {self.model.key}") + + return ModelIdentifierOutput(model=self.model) + + +@invocation( + "main_model_loader", + title="Main Model", + tags=["model"], + category="model", + version="1.0.3", +) +class MainModelLoaderInvocation(BaseInvocation): + """Loads a main model, outputting its submodels.""" + + model: ModelIdentifierField = InputField(description=FieldDescriptions.main_model, ui_type=UIType.MainModel) + # TODO: precision? + + def invoke(self, context: InvocationContext) -> ModelLoaderOutput: + # TODO: not found exceptions + if not context.models.exists(self.model.key): + raise Exception(f"Unknown model {self.model.key}") + + unet = self.model.model_copy(update={"submodel_type": SubModelType.UNet}) + scheduler = self.model.model_copy(update={"submodel_type": SubModelType.Scheduler}) + tokenizer = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer}) + text_encoder = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder}) + vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE}) + + return ModelLoaderOutput( + unet=UNetField(unet=unet, scheduler=scheduler, loras=[]), + clip=CLIPField(tokenizer=tokenizer, text_encoder=text_encoder, loras=[], skipped_layers=0), + vae=VAEField(vae=vae), + ) + + +@invocation_output("lora_loader_output") +class LoRALoaderOutput(BaseInvocationOutput): + """Model loader output""" + + unet: Optional[UNetField] = OutputField(default=None, description=FieldDescriptions.unet, title="UNet") + clip: Optional[CLIPField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP") + + +@invocation("lora_loader", title="LoRA", tags=["model"], category="model", version="1.0.3") +class LoRALoaderInvocation(BaseInvocation): + """Apply selected lora to unet and text_encoder.""" + + lora: ModelIdentifierField = InputField( + description=FieldDescriptions.lora_model, title="LoRA", ui_type=UIType.LoRAModel + ) + weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight) + 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: + lora_key = self.lora.key + + if not context.models.exists(lora_key): + raise Exception(f"Unkown 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') + + if self.clip is not None and any(lora.lora.key == lora_key for lora in self.clip.loras): + raise Exception(f'LoRA "{lora_key}" already applied to clip') + + output = LoRALoaderOutput() + + if self.unet is not None: + output.unet = self.unet.model_copy(deep=True) + output.unet.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, + ) + ) + + return output + + +@invocation_output("lora_selector_output") +class LoRASelectorOutput(BaseInvocationOutput): + """Model loader output""" + + lora: LoRAField = OutputField(description="LoRA model and weight", title="LoRA") + + +@invocation("lora_selector", title="LoRA Selector", tags=["model"], category="model", version="1.0.1") +class LoRASelectorInvocation(BaseInvocation): + """Selects a LoRA model and weight.""" + + lora: ModelIdentifierField = InputField( + description=FieldDescriptions.lora_model, title="LoRA", ui_type=UIType.LoRAModel + ) + weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight) + + 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") +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" + ) + 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: + output = LoRALoaderOutput() + loras = self.loras if isinstance(self.loras, list) else [self.loras] + added_loras: list[str] = [] + + for lora in loras: + 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.StableDiffusion1, BaseModelType.StableDiffusion2) + + 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) + output.unet.loras.append(lora) + + if self.clip is not None: + if output.clip is None: + output.clip = self.clip.model_copy(deep=True) + output.clip.loras.append(lora) + + return output + + +@invocation_output("sdxl_lora_loader_output") +class SDXLLoRALoaderOutput(BaseInvocationOutput): + """SDXL LoRA Loader Output""" + + unet: Optional[UNetField] = OutputField(default=None, description=FieldDescriptions.unet, title="UNet") + clip: Optional[CLIPField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP 1") + clip2: Optional[CLIPField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP 2") + + +@invocation( + "sdxl_lora_loader", + title="SDXL LoRA", + tags=["lora", "model"], + category="model", + version="1.0.3", +) +class SDXLLoRALoaderInvocation(BaseInvocation): + """Apply selected lora to unet and text_encoder.""" + + lora: ModelIdentifierField = InputField( + description=FieldDescriptions.lora_model, title="LoRA", ui_type=UIType.LoRAModel + ) + weight: float = InputField(default=0.75, description=FieldDescriptions.lora_weight) + 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: + lora_key = self.lora.key + + if not context.models.exists(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') + + if self.clip is not None and any(lora.lora.key == lora_key for lora in self.clip.loras): + raise Exception(f'LoRA "{lora_key}" already applied to clip') + + if self.clip2 is not None and any(lora.lora.key == lora_key for lora in self.clip2.loras): + raise Exception(f'LoRA "{lora_key}" already applied to clip2') + + output = SDXLLoRALoaderOutput() + + if self.unet is not None: + output.unet = self.unet.model_copy(deep=True) + output.unet.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.clip2 is not None: + output.clip2 = self.clip2.model_copy(deep=True) + output.clip2.loras.append( + LoRAField( + lora=self.lora, + weight=self.weight, + ) + ) + + return output + + +@invocation( + "sdxl_lora_collection_loader", + title="SDXL LoRA Collection Loader", + tags=["model"], + category="model", + version="1.0.0", +) +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" + ) + 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", + ) + clip2: Optional[CLIPField] = InputField( + default=None, + description=FieldDescriptions.clip, + input=Input.Connection, + title="CLIP 2", + ) + + def invoke(self, context: InvocationContext) -> SDXLLoRALoaderOutput: + output = SDXLLoRALoaderOutput() + loras = self.loras if isinstance(self.loras, list) else [self.loras] + added_loras: list[str] = [] + + for lora in loras: + 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 is BaseModelType.StableDiffusionXL + + 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) + output.unet.loras.append(lora) + + if self.clip is not None: + if output.clip is None: + output.clip = self.clip.model_copy(deep=True) + output.clip.loras.append(lora) + + if self.clip2 is not None: + if output.clip2 is None: + output.clip2 = self.clip2.model_copy(deep=True) + output.clip2.loras.append(lora) + + return output + + +@invocation("vae_loader", title="VAE", tags=["vae", "model"], category="model", version="1.0.3") +class VAELoaderInvocation(BaseInvocation): + """Loads a VAE model, outputting a VaeLoaderOutput""" + + vae_model: ModelIdentifierField = InputField( + description=FieldDescriptions.vae_model, title="VAE", ui_type=UIType.VAEModel + ) + + def invoke(self, context: InvocationContext) -> VAEOutput: + key = self.vae_model.key + + if not context.models.exists(key): + raise Exception(f"Unkown vae: {key}!") + + return VAEOutput(vae=VAEField(vae=self.vae_model)) + + +@invocation_output("seamless_output") +class SeamlessModeOutput(BaseInvocationOutput): + """Modified Seamless Model output""" + + unet: Optional[UNetField] = OutputField(default=None, description=FieldDescriptions.unet, title="UNet") + vae: Optional[VAEField] = OutputField(default=None, description=FieldDescriptions.vae, title="VAE") + + +@invocation( + "seamless", + title="Seamless", + tags=["seamless", "model"], + category="model", + version="1.0.1", +) +class SeamlessModeInvocation(BaseInvocation): + """Applies the seamless transformation to the Model UNet and VAE.""" + + unet: Optional[UNetField] = InputField( + default=None, + description=FieldDescriptions.unet, + input=Input.Connection, + title="UNet", + ) + vae: Optional[VAEField] = InputField( + default=None, + description=FieldDescriptions.vae_model, + input=Input.Connection, + title="VAE", + ) + seamless_y: bool = InputField(default=True, input=Input.Any, description="Specify whether Y axis is seamless") + seamless_x: bool = InputField(default=True, input=Input.Any, description="Specify whether X axis is seamless") + + def invoke(self, context: InvocationContext) -> SeamlessModeOutput: + # Conditionally append 'x' and 'y' based on seamless_x and seamless_y + unet = copy.deepcopy(self.unet) + vae = copy.deepcopy(self.vae) + + seamless_axes_list = [] + + if self.seamless_x: + seamless_axes_list.append("x") + if self.seamless_y: + seamless_axes_list.append("y") + + if unet is not None: + unet.seamless_axes = seamless_axes_list + if vae is not None: + vae.seamless_axes = seamless_axes_list + + return SeamlessModeOutput(unet=unet, vae=vae) + + +@invocation("freeu", title="FreeU", tags=["freeu"], category="unet", version="1.0.1") +class FreeUInvocation(BaseInvocation): + """ + Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2): + + SD1.5: 1.2/1.4/0.9/0.2, + SD2: 1.1/1.2/0.9/0.2, + SDXL: 1.1/1.2/0.6/0.4, + """ + + unet: UNetField = InputField(description=FieldDescriptions.unet, input=Input.Connection, title="UNet") + b1: float = InputField(default=1.2, ge=-1, le=3, description=FieldDescriptions.freeu_b1) + b2: float = InputField(default=1.4, ge=-1, le=3, description=FieldDescriptions.freeu_b2) + s1: float = InputField(default=0.9, ge=-1, le=3, description=FieldDescriptions.freeu_s1) + s2: float = InputField(default=0.2, ge=-1, le=3, description=FieldDescriptions.freeu_s2) + + def invoke(self, context: InvocationContext) -> UNetOutput: + self.unet.freeu_config = FreeUConfig(s1=self.s1, s2=self.s2, b1=self.b1, b2=self.b2) + return UNetOutput(unet=self.unet) diff --git a/invokeai/app/invocations/noise.py b/invokeai/app/invocations/noise.py new file mode 100644 index 0000000000000000000000000000000000000000..1d3ff3a29c33d256cafee2150f01f492cef5ffb9 --- /dev/null +++ b/invokeai/app/invocations/noise.py @@ -0,0 +1,120 @@ +# 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.services.shared.invocation_context import InvocationContext +from invokeai.app.util.misc import SEED_MAX +from invokeai.backend.util.devices import TorchDevice + +""" +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 +""" + + +@invocation_output("noise_output") +class NoiseOutput(BaseInvocationOutput): + """Invocation noise output""" + + noise: LatentsField = OutputField(description=FieldDescriptions.noise) + width: int = OutputField(description=FieldDescriptions.width) + height: int = OutputField(description=FieldDescriptions.height) + + @classmethod + 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, + ) + + +@invocation( + "noise", + title="Noise", + tags=["latents", "noise"], + category="latents", + version="1.0.2", +) +class NoiseInvocation(BaseInvocation): + """Generates latent noise.""" + + seed: int = InputField( + default=0, + ge=0, + le=SEED_MAX, + description=FieldDescriptions.seed, + ) + width: int = InputField( + default=512, + multiple_of=LATENT_SCALE_FACTOR, + gt=0, + description=FieldDescriptions.width, + ) + height: int = InputField( + default=512, + multiple_of=LATENT_SCALE_FACTOR, + gt=0, + description=FieldDescriptions.height, + ) + use_cpu: bool = InputField( + default=True, + description="Use CPU for noise generation (for reproducible results across platforms)", + ) + + @field_validator("seed", mode="before") + def modulo_seed(cls, v): + """Return the seed modulo (SEED_MAX + 1) to ensure it is within the valid range.""" + return v % (SEED_MAX + 1) + + def invoke(self, context: InvocationContext) -> NoiseOutput: + noise = get_noise( + width=self.width, + height=self.height, + device=TorchDevice.choose_torch_device(), + seed=self.seed, + use_cpu=self.use_cpu, + ) + name = context.tensors.save(tensor=noise) + return NoiseOutput.build(latents_name=name, latents=noise, seed=self.seed) diff --git a/invokeai/app/invocations/normal_bae.py b/invokeai/app/invocations/normal_bae.py new file mode 100644 index 0000000000000000000000000000000000000000..ebbea869a162ab5d0559bc1f99ee09ca91d25d83 --- /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", + 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 new file mode 100644 index 0000000000000000000000000000000000000000..ed4318d95d1999402b23dc5feb0f592151a63d8f --- /dev/null +++ b/invokeai/app/invocations/param_easing.py @@ -0,0 +1,28 @@ +import numpy as np + +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 + + +@invocation( + "float_range", + title="Float Range", + tags=["math", "range"], + category="math", + version="1.0.1", +) +class FloatLinearRangeInvocation(BaseInvocation): + """Creates a range""" + + start: float = InputField(default=5, description="The first value of the range") + stop: float = InputField(default=10, description="The last value of the range") + steps: int = InputField( + default=30, + description="number of values to interpolate over (including start and stop)", + ) + + def invoke(self, context: InvocationContext) -> FloatCollectionOutput: + param_list = list(np.linspace(self.start, self.stop, self.steps)) + return FloatCollectionOutput(collection=param_list) diff --git a/invokeai/app/invocations/pidi.py b/invokeai/app/invocations/pidi.py new file mode 100644 index 0000000000000000000000000000000000000000..47b241ee1f8ef07fbebc95b59eb018cefc746d82 --- /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", + 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 new file mode 100644 index 0000000000000000000000000000000000000000..7906cd49d5eb551fb4c313a9c2e6d3bb6684fd91 --- /dev/null +++ b/invokeai/app/invocations/primitives.py @@ -0,0 +1,535 @@ +# Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654) + +from typing import Optional + +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 ( + BoundingBoxField, + ColorField, + ConditioningField, + DenoiseMaskField, + FieldDescriptions, + FluxConditioningField, + ImageField, + Input, + InputField, + LatentsField, + OutputField, + SD3ConditioningField, + TensorField, + UIComponent, +) +from invokeai.app.services.images.images_common import ImageDTO +from invokeai.app.services.shared.invocation_context import InvocationContext + +""" +Primitives: Boolean, Integer, Float, String, Image, Latents, Conditioning, Color +- primitive nodes +- primitive outputs +- primitive collection outputs +""" + +# region Boolean + + +@invocation_output("boolean_output") +class BooleanOutput(BaseInvocationOutput): + """Base class for nodes that output a single boolean""" + + value: bool = OutputField(description="The output boolean") + + +@invocation_output("boolean_collection_output") +class BooleanCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of booleans""" + + collection: list[bool] = OutputField( + description="The output boolean collection", + ) + + +@invocation( + "boolean", title="Boolean Primitive", tags=["primitives", "boolean"], category="primitives", version="1.0.1" +) +class BooleanInvocation(BaseInvocation): + """A boolean primitive value""" + + value: bool = InputField(default=False, description="The boolean value") + + def invoke(self, context: InvocationContext) -> BooleanOutput: + return BooleanOutput(value=self.value) + + +@invocation( + "boolean_collection", + title="Boolean Collection Primitive", + tags=["primitives", "boolean", "collection"], + category="primitives", + version="1.0.2", +) +class BooleanCollectionInvocation(BaseInvocation): + """A collection of boolean primitive values""" + + collection: list[bool] = InputField(default=[], description="The collection of boolean values") + + def invoke(self, context: InvocationContext) -> BooleanCollectionOutput: + return BooleanCollectionOutput(collection=self.collection) + + +# endregion + +# region Integer + + +@invocation_output("integer_output") +class IntegerOutput(BaseInvocationOutput): + """Base class for nodes that output a single integer""" + + value: int = OutputField(description="The output integer") + + +@invocation_output("integer_collection_output") +class IntegerCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of integers""" + + collection: list[int] = OutputField( + description="The int collection", + ) + + +@invocation( + "integer", title="Integer Primitive", tags=["primitives", "integer"], category="primitives", version="1.0.1" +) +class IntegerInvocation(BaseInvocation): + """An integer primitive value""" + + value: int = InputField(default=0, description="The integer value") + + def invoke(self, context: InvocationContext) -> IntegerOutput: + return IntegerOutput(value=self.value) + + +@invocation( + "integer_collection", + title="Integer Collection Primitive", + tags=["primitives", "integer", "collection"], + category="primitives", + version="1.0.2", +) +class IntegerCollectionInvocation(BaseInvocation): + """A collection of integer primitive values""" + + collection: list[int] = InputField(default=[], description="The collection of integer values") + + def invoke(self, context: InvocationContext) -> IntegerCollectionOutput: + return IntegerCollectionOutput(collection=self.collection) + + +# endregion + +# region Float + + +@invocation_output("float_output") +class FloatOutput(BaseInvocationOutput): + """Base class for nodes that output a single float""" + + value: float = OutputField(description="The output float") + + +@invocation_output("float_collection_output") +class FloatCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of floats""" + + collection: list[float] = OutputField( + description="The float collection", + ) + + +@invocation("float", title="Float Primitive", tags=["primitives", "float"], category="primitives", version="1.0.1") +class FloatInvocation(BaseInvocation): + """A float primitive value""" + + value: float = InputField(default=0.0, description="The float value") + + def invoke(self, context: InvocationContext) -> FloatOutput: + return FloatOutput(value=self.value) + + +@invocation( + "float_collection", + title="Float Collection Primitive", + tags=["primitives", "float", "collection"], + category="primitives", + version="1.0.2", +) +class FloatCollectionInvocation(BaseInvocation): + """A collection of float primitive values""" + + collection: list[float] = InputField(default=[], description="The collection of float values") + + def invoke(self, context: InvocationContext) -> FloatCollectionOutput: + return FloatCollectionOutput(collection=self.collection) + + +# endregion + +# region String + + +@invocation_output("string_output") +class StringOutput(BaseInvocationOutput): + """Base class for nodes that output a single string""" + + value: str = OutputField(description="The output string") + + +@invocation_output("string_collection_output") +class StringCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of strings""" + + collection: list[str] = OutputField( + description="The output strings", + ) + + +@invocation("string", title="String Primitive", tags=["primitives", "string"], category="primitives", version="1.0.1") +class StringInvocation(BaseInvocation): + """A string primitive value""" + + value: str = InputField(default="", description="The string value", ui_component=UIComponent.Textarea) + + def invoke(self, context: InvocationContext) -> StringOutput: + return StringOutput(value=self.value) + + +@invocation( + "string_collection", + title="String Collection Primitive", + tags=["primitives", "string", "collection"], + category="primitives", + version="1.0.2", +) +class StringCollectionInvocation(BaseInvocation): + """A collection of string primitive values""" + + collection: list[str] = InputField(default=[], description="The collection of string values") + + def invoke(self, context: InvocationContext) -> StringCollectionOutput: + return StringCollectionOutput(collection=self.collection) + + +# endregion + +# region Image + + +@invocation_output("image_output") +class ImageOutput(BaseInvocationOutput): + """Base class for nodes that output a single image""" + + image: ImageField = OutputField(description="The output image") + width: int = OutputField(description="The width of the image in pixels") + height: int = OutputField(description="The height of the image in pixels") + + @classmethod + def build(cls, image_dto: ImageDTO) -> "ImageOutput": + return cls( + image=ImageField(image_name=image_dto.image_name), + width=image_dto.width, + height=image_dto.height, + ) + + +@invocation_output("image_collection_output") +class ImageCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of images""" + + collection: list[ImageField] = OutputField( + description="The output images", + ) + + +@invocation("image", title="Image Primitive", tags=["primitives", "image"], category="primitives", version="1.0.2") +class ImageInvocation(BaseInvocation): + """An image primitive value""" + + image: ImageField = InputField(description="The image to load") + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + return ImageOutput( + image=ImageField(image_name=self.image.image_name), + width=image.width, + height=image.height, + ) + + +@invocation( + "image_collection", + title="Image Collection Primitive", + tags=["primitives", "image", "collection"], + category="primitives", + version="1.0.1", +) +class ImageCollectionInvocation(BaseInvocation): + """A collection of image primitive values""" + + collection: list[ImageField] = InputField(description="The collection of image values") + + def invoke(self, context: InvocationContext) -> ImageCollectionOutput: + return ImageCollectionOutput(collection=self.collection) + + +# endregion + +# region DenoiseMask + + +@invocation_output("denoise_mask_output") +class DenoiseMaskOutput(BaseInvocationOutput): + """Base class for nodes that output a single image""" + + denoise_mask: DenoiseMaskField = OutputField(description="Mask for denoise model run") + + @classmethod + def build( + cls, mask_name: str, masked_latents_name: Optional[str] = None, gradient: bool = False + ) -> "DenoiseMaskOutput": + return cls( + denoise_mask=DenoiseMaskField( + mask_name=mask_name, masked_latents_name=masked_latents_name, gradient=gradient + ), + ) + + +# endregion + +# region Latents + + +@invocation_output("latents_output") +class LatentsOutput(BaseInvocationOutput): + """Base class for nodes that output a single latents tensor""" + + latents: LatentsField = OutputField(description=FieldDescriptions.latents) + width: int = OutputField(description=FieldDescriptions.width) + height: int = OutputField(description=FieldDescriptions.height) + + @classmethod + def build(cls, latents_name: str, latents: torch.Tensor, seed: Optional[int] = None) -> "LatentsOutput": + return cls( + latents=LatentsField(latents_name=latents_name, seed=seed), + width=latents.size()[3] * LATENT_SCALE_FACTOR, + height=latents.size()[2] * LATENT_SCALE_FACTOR, + ) + + +@invocation_output("latents_collection_output") +class LatentsCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of latents tensors""" + + collection: list[LatentsField] = OutputField( + description=FieldDescriptions.latents, + ) + + +@invocation( + "latents", title="Latents Primitive", tags=["primitives", "latents"], category="primitives", version="1.0.2" +) +class LatentsInvocation(BaseInvocation): + """A latents tensor primitive value""" + + latents: LatentsField = InputField(description="The latents tensor", input=Input.Connection) + + def invoke(self, context: InvocationContext) -> LatentsOutput: + latents = context.tensors.load(self.latents.latents_name) + + return LatentsOutput.build(self.latents.latents_name, latents) + + +@invocation( + "latents_collection", + title="Latents Collection Primitive", + tags=["primitives", "latents", "collection"], + category="primitives", + version="1.0.1", +) +class LatentsCollectionInvocation(BaseInvocation): + """A collection of latents tensor primitive values""" + + collection: list[LatentsField] = InputField( + description="The collection of latents tensors", + ) + + def invoke(self, context: InvocationContext) -> LatentsCollectionOutput: + return LatentsCollectionOutput(collection=self.collection) + + +# endregion + +# region Color + + +@invocation_output("color_output") +class ColorOutput(BaseInvocationOutput): + """Base class for nodes that output a single color""" + + color: ColorField = OutputField(description="The output color") + + +@invocation_output("color_collection_output") +class ColorCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of colors""" + + collection: list[ColorField] = OutputField( + description="The output colors", + ) + + +@invocation("color", title="Color Primitive", tags=["primitives", "color"], category="primitives", version="1.0.1") +class ColorInvocation(BaseInvocation): + """A color primitive value""" + + color: ColorField = InputField(default=ColorField(r=0, g=0, b=0, a=255), description="The color value") + + def invoke(self, context: InvocationContext) -> ColorOutput: + return ColorOutput(color=self.color) + + +# endregion + + +# region Conditioning + + +@invocation_output("mask_output") +class MaskOutput(BaseInvocationOutput): + """A torch mask tensor.""" + + 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("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("conditioning_output") +class ConditioningOutput(BaseInvocationOutput): + """Base class for nodes that output a single conditioning tensor""" + + conditioning: ConditioningField = OutputField(description=FieldDescriptions.cond) + + @classmethod + def build(cls, conditioning_name: str) -> "ConditioningOutput": + return cls(conditioning=ConditioningField(conditioning_name=conditioning_name)) + + +@invocation_output("conditioning_collection_output") +class ConditioningCollectionOutput(BaseInvocationOutput): + """Base class for nodes that output a collection of conditioning tensors""" + + collection: list[ConditioningField] = OutputField( + description="The output conditioning tensors", + ) + + +@invocation( + "conditioning", + title="Conditioning Primitive", + tags=["primitives", "conditioning"], + category="primitives", + version="1.0.1", +) +class ConditioningInvocation(BaseInvocation): + """A conditioning tensor primitive value""" + + conditioning: ConditioningField = InputField(description=FieldDescriptions.cond, input=Input.Connection) + + def invoke(self, context: InvocationContext) -> ConditioningOutput: + return ConditioningOutput(conditioning=self.conditioning) + + +@invocation( + "conditioning_collection", + title="Conditioning Collection Primitive", + tags=["primitives", "conditioning", "collection"], + category="primitives", + version="1.0.2", +) +class ConditioningCollectionInvocation(BaseInvocation): + """A collection of conditioning tensor primitive values""" + + collection: list[ConditioningField] = InputField( + default=[], + description="The collection of conditioning tensors", + ) + + def invoke(self, context: InvocationContext) -> ConditioningCollectionOutput: + return ConditioningCollectionOutput(collection=self.collection) + + +# 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 new file mode 100644 index 0000000000000000000000000000000000000000..48eec0ac0efed8de6ae77fa371f2243ca08ca311 --- /dev/null +++ b/invokeai/app/invocations/prompt.py @@ -0,0 +1,102 @@ +from os.path import exists +from typing import Optional, Union + +import numpy as np +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 + + +@invocation( + "dynamic_prompt", + title="Dynamic Prompt", + tags=["prompt", "collection"], + category="prompt", + version="1.0.1", + use_cache=False, +) +class DynamicPromptInvocation(BaseInvocation): + """Parses a prompt using adieyal/dynamicprompts' random or combinatorial generator""" + + prompt: str = InputField( + description="The prompt to parse with dynamicprompts", + ui_component=UIComponent.Textarea, + ) + max_prompts: int = InputField(default=1, description="The number of prompts to generate") + combinatorial: bool = InputField(default=False, description="Whether to use the combinatorial generator") + + def invoke(self, context: InvocationContext) -> StringCollectionOutput: + if self.combinatorial: + generator = CombinatorialPromptGenerator() + prompts = generator.generate(self.prompt, max_prompts=self.max_prompts) + else: + generator = RandomPromptGenerator() + prompts = generator.generate(self.prompt, num_images=self.max_prompts) + + return StringCollectionOutput(collection=prompts) + + +@invocation( + "prompt_from_file", + title="Prompts from File", + tags=["prompt", "file"], + category="prompt", + version="1.0.2", +) +class PromptsFromFileInvocation(BaseInvocation): + """Loads prompts from a text file""" + + file_path: str = InputField(description="Path to prompt text file") + pre_prompt: Optional[str] = InputField( + default=None, + description="String to prepend to each prompt", + ui_component=UIComponent.Textarea, + ) + post_prompt: Optional[str] = InputField( + default=None, + description="String to append to each prompt", + ui_component=UIComponent.Textarea, + ) + start_line: int = InputField(default=1, ge=1, description="Line in the file to start start from") + max_prompts: int = InputField(default=1, ge=0, description="Max lines to read from file (0=all)") + + @field_validator("file_path") + def file_path_exists(cls, v): + if not exists(v): + raise ValueError(FileNotFoundError) + return v + + def promptsFromFile( + self, + file_path: str, + pre_prompt: Union[str, None], + post_prompt: Union[str, None], + start_line: int, + max_prompts: int, + ): + prompts = [] + start_line -= 1 + end_line = start_line + max_prompts + if max_prompts <= 0: + end_line = np.iinfo(np.int32).max + with open(file_path, encoding="utf-8") as f: + for i, line in enumerate(f): + if i >= start_line and i < end_line: + prompts.append((pre_prompt or "") + line.strip() + (post_prompt or "")) + if i >= end_line: + break + return prompts + + def invoke(self, context: InvocationContext) -> StringCollectionOutput: + prompts = self.promptsFromFile( + self.file_path, + self.pre_prompt, + self.post_prompt, + self.start_line, + self.max_prompts, + ) + return StringCollectionOutput(collection=prompts) diff --git a/invokeai/app/invocations/resize_latents.py b/invokeai/app/invocations/resize_latents.py new file mode 100644 index 0000000000000000000000000000000000000000..90253e52e8317a67c0aac6c3c5b246f944d6c32d --- /dev/null +++ b/invokeai/app/invocations/resize_latents.py @@ -0,0 +1,103 @@ +from typing import Literal + +import torch + +from invokeai.app.invocations.baseinvocation import BaseInvocation, invocation +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.app.invocations.fields import ( + FieldDescriptions, + Input, + InputField, + LatentsField, +) +from invokeai.app.invocations.primitives import LatentsOutput +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.util.devices import TorchDevice + +LATENTS_INTERPOLATION_MODE = Literal["nearest", "linear", "bilinear", "bicubic", "trilinear", "area", "nearest-exact"] + + +@invocation( + "lresize", + title="Resize Latents", + tags=["latents", "resize"], + category="latents", + version="1.0.2", +) +class ResizeLatentsInvocation(BaseInvocation): + """Resizes latents to explicit width/height (in pixels). Provided dimensions are floor-divided by 8.""" + + latents: LatentsField = InputField( + description=FieldDescriptions.latents, + input=Input.Connection, + ) + width: int = InputField( + ge=64, + multiple_of=LATENT_SCALE_FACTOR, + description=FieldDescriptions.width, + ) + height: int = InputField( + ge=64, + multiple_of=LATENT_SCALE_FACTOR, + description=FieldDescriptions.width, + ) + mode: LATENTS_INTERPOLATION_MODE = InputField(default="bilinear", description=FieldDescriptions.interp_mode) + antialias: bool = InputField(default=False, description=FieldDescriptions.torch_antialias) + + def invoke(self, context: InvocationContext) -> LatentsOutput: + latents = context.tensors.load(self.latents.latents_name) + device = TorchDevice.choose_torch_device() + + resized_latents = torch.nn.functional.interpolate( + latents.to(device), + size=(self.height // LATENT_SCALE_FACTOR, self.width // LATENT_SCALE_FACTOR), + mode=self.mode, + antialias=self.antialias if self.mode in ["bilinear", "bicubic"] else False, + ) + + # https://discuss.huggingface.co/t/memory-usage-by-later-pipeline-stages/23699 + resized_latents = resized_latents.to("cpu") + + TorchDevice.empty_cache() + + name = context.tensors.save(tensor=resized_latents) + return LatentsOutput.build(latents_name=name, latents=resized_latents, seed=self.latents.seed) + + +@invocation( + "lscale", + title="Scale Latents", + tags=["latents", "resize"], + category="latents", + version="1.0.2", +) +class ScaleLatentsInvocation(BaseInvocation): + """Scales latents by a given factor.""" + + latents: LatentsField = InputField( + description=FieldDescriptions.latents, + input=Input.Connection, + ) + scale_factor: float = InputField(gt=0, description=FieldDescriptions.scale_factor) + mode: LATENTS_INTERPOLATION_MODE = InputField(default="bilinear", description=FieldDescriptions.interp_mode) + antialias: bool = InputField(default=False, description=FieldDescriptions.torch_antialias) + + def invoke(self, context: InvocationContext) -> LatentsOutput: + latents = context.tensors.load(self.latents.latents_name) + + device = TorchDevice.choose_torch_device() + + # resizing + resized_latents = torch.nn.functional.interpolate( + latents.to(device), + scale_factor=self.scale_factor, + mode=self.mode, + antialias=self.antialias if self.mode in ["bilinear", "bicubic"] else False, + ) + + # https://discuss.huggingface.co/t/memory-usage-by-later-pipeline-stages/23699 + resized_latents = resized_latents.to("cpu") + TorchDevice.empty_cache() + + name = context.tensors.save(tensor=resized_latents) + return LatentsOutput.build(latents_name=name, latents=resized_latents, seed=self.latents.seed) diff --git a/invokeai/app/invocations/scheduler.py b/invokeai/app/invocations/scheduler.py new file mode 100644 index 0000000000000000000000000000000000000000..a870a442ef8cbbd313cf70d51131b6fbaf270d4b --- /dev/null +++ b/invokeai/app/invocations/scheduler.py @@ -0,0 +1,34 @@ +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import ( + FieldDescriptions, + InputField, + OutputField, + 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") +class SchedulerOutput(BaseInvocationOutput): + scheduler: SCHEDULER_NAME_VALUES = OutputField(description=FieldDescriptions.scheduler, ui_type=UIType.Scheduler) + + +@invocation( + "scheduler", + title="Scheduler", + tags=["scheduler"], + category="latents", + version="1.0.0", +) +class SchedulerInvocation(BaseInvocation): + """Selects a scheduler.""" + + scheduler: SCHEDULER_NAME_VALUES = InputField( + default="euler", + description=FieldDescriptions.scheduler, + ui_type=UIType.Scheduler, + ) + + def invoke(self, context: InvocationContext) -> SchedulerOutput: + return SchedulerOutput(scheduler=self.scheduler) diff --git a/invokeai/app/invocations/sd3_denoise.py b/invokeai/app/invocations/sd3_denoise.py new file mode 100644 index 0000000000000000000000000000000000000000..d71dc910a4f272486865a849ad4f2a23effe5385 --- /dev/null +++ b/invokeai/app/invocations/sd3_denoise.py @@ -0,0 +1,338 @@ +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, Classification, 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.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.config import BaseModelType +from invokeai.backend.sd3.extensions.inpaint_extension import InpaintExtension +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="SD3 Denoise", + tags=["image", "sd3"], + category="image", + version="1.1.0", + classification=Classification.Prototype, +) +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 + ) + # 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._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, + ) + + # 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: InpaintExtension | None = None + if inpaint_mask is not None: + assert init_latents is not None + inpaint_extension = InpaintExtension( + 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)))): + # 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 _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 0000000000000000000000000000000000000000..cdeae08b4a55b7fa6e82366ece6dd74a3681adc9 --- /dev/null +++ b/invokeai/app/invocations/sd3_image_to_latents.py @@ -0,0 +1,65 @@ +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 + + +@invocation( + "sd3_i2l", + title="SD3 Image to Latents", + tags=["image", "latents", "vae", "i2l", "sd3"], + category="image", + version="1.0.0", + classification=Classification.Prototype, +) +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: + with vae_info as vae: + assert isinstance(vae, AutoencoderKL) + + vae.disable_tiling() + + image_tensor = image_tensor.to(device=vae.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) + 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 0000000000000000000000000000000000000000..184759b2f0232bf77ce8283c061e536706ae08db --- /dev/null +++ b/invokeai/app/invocations/sd3_latents_to_image.py @@ -0,0 +1,74 @@ +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 + + +@invocation( + "sd3_l2i", + title="SD3 Latents to Image", + tags=["latents", "image", "vae", "l2i", "sd3"], + category="latents", + version="1.3.0", +) +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)) + with SeamlessExt.static_patch_model(vae_info.model, self.vae.seamless_axes), vae_info as vae: + context.util.signal_progress("Running VAE") + assert isinstance(vae, (AutoencoderKL)) + latents = latents.to(vae.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 0000000000000000000000000000000000000000..6b2d03ef3d9027cef3455b4315a3d5dae902f037 --- /dev/null +++ b/invokeai/app/invocations/sd3_model_loader.py @@ -0,0 +1,108 @@ +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, UIType +from invokeai.app.invocations.model import CLIPField, ModelIdentifierField, T5EncoderField, TransformerField, VAEField +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.model_manager.config import 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="SD3 Main Model", + tags=["model", "sd3"], + category="model", + version="1.0.0", + classification=Classification.Prototype, +) +class Sd3ModelLoaderInvocation(BaseInvocation): + """Loads a SD3 base model, outputting its submodels.""" + + model: ModelIdentifierField = InputField( + description=FieldDescriptions.sd3_model, + ui_type=UIType.SD3MainModel, + input=Input.Direct, + ) + + t5_encoder_model: Optional[ModelIdentifierField] = InputField( + description=FieldDescriptions.t5_encoder, + ui_type=UIType.T5EncoderModel, + input=Input.Direct, + title="T5 Encoder", + default=None, + ) + + clip_l_model: Optional[ModelIdentifierField] = InputField( + description=FieldDescriptions.clip_embed_model, + ui_type=UIType.CLIPLEmbedModel, + input=Input.Direct, + title="CLIP L Encoder", + default=None, + ) + + clip_g_model: Optional[ModelIdentifierField] = InputField( + description=FieldDescriptions.clip_g_model, + ui_type=UIType.CLIPGEmbedModel, + input=Input.Direct, + title="CLIP G Encoder", + default=None, + ) + + vae_model: Optional[ModelIdentifierField] = InputField( + description=FieldDescriptions.vae_model, ui_type=UIType.VAEModel, title="VAE", default=None + ) + + 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 = ( + self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.Tokenizer3}) + if self.t5_encoder_model + else self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer3}) + ) + t5_encoder = ( + self.t5_encoder_model.model_copy(update={"submodel_type": SubModelType.TextEncoder3}) + if self.t5_encoder_model + else self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder3}) + ) + + 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), + 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 0000000000000000000000000000000000000000..5969eda09558d95d9afd8ad4f58f736fdce59052 --- /dev/null +++ b/invokeai/app/invocations/sd3_text_encoder.py @@ -0,0 +1,201 @@ +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, Classification, 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.lora.conversions.flux_lora_constants import FLUX_LORA_CLIP_PREFIX +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.lora_patcher import LoRAPatcher +from invokeai.backend.model_manager.config import ModelFormat +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="SD3 Text Encoding", + tags=["prompt", "conditioning", "sd3"], + category="conditioning", + version="1.0.0", + classification=Classification.Prototype, +) +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) + + 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 + t5_tokenizer_info = context.models.load(self.t5_encoder.tokenizer) + t5_text_encoder_info = context.models.load(self.t5_encoder.text_encoder) + + prompt = [self.prompt] + + with ( + t5_text_encoder_info as t5_text_encoder, + t5_tokenizer_info as t5_tokenizer, + ): + context.util.signal_progress("Running T5 encoder") + assert isinstance(t5_text_encoder, T5EncoderModel) + assert isinstance(t5_tokenizer, (T5Tokenizer, T5TokenizerFast)) + + 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_text_encoder.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]: + clip_tokenizer_info = context.models.load(clip_model.tokenizer) + clip_text_encoder_info = context.models.load(clip_model.text_encoder) + + prompt = [self.prompt] + + with ( + clip_text_encoder_info.model_on_device() as (cached_weights, clip_text_encoder), + clip_tokenizer_info 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_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( + LoRAPatcher.apply_lora_patches( + model=clip_text_encoder, + patches=self._clip_lora_iterator(context, clip_model), + prefix=FLUX_LORA_CLIP_PREFIX, + 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_text_encoder.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[LoRAModelRaw, float]]: + for lora in clip_model.loras: + lora_info = context.models.load(lora.lora) + assert isinstance(lora_info.model, LoRAModelRaw) + yield (lora_info.model, lora.weight) + del lora_info diff --git a/invokeai/app/invocations/sdxl.py b/invokeai/app/invocations/sdxl.py new file mode 100644 index 0000000000000000000000000000000000000000..8eed158a61f091dc2f32467e202f0ed73b650477 --- /dev/null +++ b/invokeai/app/invocations/sdxl.py @@ -0,0 +1,91 @@ +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.fields import FieldDescriptions, InputField, OutputField, UIType +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 + + +@invocation_output("sdxl_model_loader_output") +class SDXLModelLoaderOutput(BaseInvocationOutput): + """SDXL base model loader output""" + + 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_output("sdxl_refiner_model_loader_output") +class SDXLRefinerModelLoaderOutput(BaseInvocationOutput): + """SDXL refiner model loader output""" + + unet: UNetField = OutputField(description=FieldDescriptions.unet, title="UNet") + clip2: CLIPField = OutputField(description=FieldDescriptions.clip, title="CLIP 2") + 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") +class SDXLModelLoaderInvocation(BaseInvocation): + """Loads an sdxl base model, outputting its submodels.""" + + model: ModelIdentifierField = InputField( + description=FieldDescriptions.sdxl_main_model, ui_type=UIType.SDXLMainModel + ) + # TODO: precision? + + def invoke(self, context: InvocationContext) -> SDXLModelLoaderOutput: + model_key = self.model.key + + # TODO: not found exceptions + if not context.models.exists(model_key): + raise Exception(f"Unknown model: {model_key}") + + unet = self.model.model_copy(update={"submodel_type": SubModelType.UNet}) + scheduler = self.model.model_copy(update={"submodel_type": SubModelType.Scheduler}) + tokenizer = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer}) + text_encoder = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder}) + tokenizer2 = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer2}) + text_encoder2 = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder2}) + vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE}) + + return SDXLModelLoaderOutput( + unet=UNetField(unet=unet, scheduler=scheduler, loras=[]), + clip=CLIPField(tokenizer=tokenizer, text_encoder=text_encoder, loras=[], skipped_layers=0), + clip2=CLIPField(tokenizer=tokenizer2, text_encoder=text_encoder2, loras=[], skipped_layers=0), + vae=VAEField(vae=vae), + ) + + +@invocation( + "sdxl_refiner_model_loader", + title="SDXL Refiner Model", + tags=["model", "sdxl", "refiner"], + category="model", + version="1.0.3", +) +class SDXLRefinerModelLoaderInvocation(BaseInvocation): + """Loads an sdxl refiner model, outputting its submodels.""" + + model: ModelIdentifierField = InputField( + description=FieldDescriptions.sdxl_refiner_model, ui_type=UIType.SDXLRefinerModel + ) + # TODO: precision? + + def invoke(self, context: InvocationContext) -> SDXLRefinerModelLoaderOutput: + model_key = self.model.key + + # TODO: not found exceptions + if not context.models.exists(model_key): + raise Exception(f"Unknown model: {model_key}") + + unet = self.model.model_copy(update={"submodel_type": SubModelType.UNet}) + scheduler = self.model.model_copy(update={"submodel_type": SubModelType.Scheduler}) + tokenizer2 = self.model.model_copy(update={"submodel_type": SubModelType.Tokenizer2}) + text_encoder2 = self.model.model_copy(update={"submodel_type": SubModelType.TextEncoder2}) + vae = self.model.model_copy(update={"submodel_type": SubModelType.VAE}) + + return SDXLRefinerModelLoaderOutput( + unet=UNetField(unet=unet, scheduler=scheduler, loras=[]), + clip2=CLIPField(tokenizer=tokenizer2, text_encoder=text_encoder2, loras=[], skipped_layers=0), + vae=VAEField(vae=vae), + ) diff --git a/invokeai/app/invocations/segment_anything.py b/invokeai/app/invocations/segment_anything.py new file mode 100644 index 0000000000000000000000000000000000000000..54587f90c6cb843fe170dabfc1033db92b24f778 --- /dev/null +++ b/invokeai/app/invocations/segment_anything.py @@ -0,0 +1,197 @@ +from enum import Enum +from pathlib import Path +from typing import Literal + +import numpy as np +import torch +from PIL import Image +from pydantic import BaseModel, Field +from transformers import AutoModelForMaskGeneration, AutoProcessor +from transformers.models.sam import SamModel +from transformers.models.sam.processing_sam import SamProcessor + +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_pipeline import SegmentAnythingPipeline + +SegmentAnythingModelKey = Literal["segment-anything-base", "segment-anything-large", "segment-anything-huge"] +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", +} + + +class SAMPointLabel(Enum): + negative = -1 + neutral = 0 + positive = 1 + + +class SAMPoint(BaseModel): + x: int = Field(..., description="The x-coordinate of the point") + y: int = Field(..., description="The y-coordinate of the point") + label: SAMPointLabel = Field(..., description="The label of the point") + + +class SAMPointsField(BaseModel): + points: list[SAMPoint] = Field(..., description="The points of the object") + + def to_list(self) -> list[list[int]]: + return [[point.x, point.y, point.label.value] for point in self.points] + + +@invocation( + "segment_anything", + title="Segment Anything", + tags=["prompt", "segmentation"], + category="segmentation", + version="1.1.0", +) +class SegmentAnythingInvocation(BaseInvocation): + """Runs a Segment Anything Model.""" + + # 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.") + image: ImageField = InputField(description="The image to segment.") + bounding_boxes: list[BoundingBoxField] | None = InputField( + default=None, description="The bounding boxes to prompt the SAM model with." + ) + point_lists: list[SAMPointsField] | None = InputField( + default=None, + description="The list of point lists to prompt the SAM 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", + ) + + @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 self.point_lists is not None and self.bounding_boxes is not None: + raise ValueError("Only one of point_lists or bounding_box can be provided.") + + 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) + + 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 = AutoModelForMaskGeneration.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(), + ) + assert isinstance(sam_model, SamModel) + + sam_processor = AutoProcessor.from_pretrained(model_path, local_files_only=True) + assert isinstance(sam_processor, SamProcessor) + return SegmentAnythingPipeline(sam_model=sam_model, sam_processor=sam_processor) + + def _segment(self, context: InvocationContext, image: Image.Image) -> list[torch.Tensor]: + """Use Segment Anything (SAM) to generate masks given an image + a set of bounding boxes.""" + # Convert the bounding boxes to the SAM input format. + sam_bounding_boxes = ( + [[bb.x_min, bb.y_min, bb.x_max, bb.y_max] for bb in self.bounding_boxes] if self.bounding_boxes else None + ) + sam_points = [p.to_list() for p in self.point_lists] if self.point_lists else None + + with ( + context.models.load_remote_model( + source=SEGMENT_ANYTHING_MODEL_IDS[self.model], loader=SegmentAnythingInvocation._load_sam_model + ) as sam_pipeline, + ): + assert isinstance(sam_pipeline, SegmentAnythingPipeline) + masks = sam_pipeline.segment(image=image, bounding_boxes=sam_bounding_boxes, point_lists=sam_points) + + 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 0000000000000000000000000000000000000000..0aa6dd334661193f2189b101dc68684581614e47 --- /dev/null +++ b/invokeai/app/invocations/spandrel_image_to_image.py @@ -0,0 +1,298 @@ +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, + UIType, + 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.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 + + +@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_type=UIType.SpandrelImageToImageModel, + ) + 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=spandrel_model.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 + ].to(device=spandrel_model.device, dtype=spandrel_model.dtype) + + # 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.inference_mode() + 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") + + # Load the model. + spandrel_model_info = context.models.load(self.image_to_image_model) + + 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 spandrel_model_info 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.inference_mode() + 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") + + # Load the model. + spandrel_model_info = context.models.load(self.image_to_image_model) + + # 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 spandrel_model_info 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 new file mode 100644 index 0000000000000000000000000000000000000000..2b6bf300b9bcd0639c07459e63326404a1cb98db --- /dev/null +++ b/invokeai/app/invocations/strings.py @@ -0,0 +1,132 @@ +# 2023 skunkworxdark (https://github.com/skunkworxdark) + +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 + + +@invocation_output("string_pos_neg_output") +class StringPosNegOutput(BaseInvocationOutput): + """Base class for invocations that output a positive and negative string""" + + positive_string: str = OutputField(description="Positive string") + negative_string: str = OutputField(description="Negative string") + + +@invocation( + "string_split_neg", + title="String Split Negative", + tags=["string", "split", "negative"], + category="string", + version="1.0.1", +) +class StringSplitNegInvocation(BaseInvocation): + """Splits string into two strings, inside [] goes into negative string everthing else goes into positive string. Each [ and ] character is replaced with a space""" + + string: str = InputField(default="", description="String to split", ui_component=UIComponent.Textarea) + + def invoke(self, context: InvocationContext) -> StringPosNegOutput: + p_string = "" + n_string = "" + brackets_depth = 0 + escaped = False + + for char in self.string or "": + if char == "[" and not escaped: + n_string += " " + brackets_depth += 1 + elif char == "]" and not escaped: + brackets_depth -= 1 + char = " " + elif brackets_depth > 0: + n_string += char + else: + p_string += char + + # keep track of the escape char but only if it isn't escaped already + if char == "\\" and not escaped: + escaped = True + else: + escaped = False + + return StringPosNegOutput(positive_string=p_string, negative_string=n_string) + + +@invocation_output("string_2_output") +class String2Output(BaseInvocationOutput): + """Base class for invocations that output two strings""" + + string_1: str = OutputField(description="string 1") + string_2: str = OutputField(description="string 2") + + +@invocation("string_split", title="String Split", tags=["string", "split"], category="string", 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""" + + string: str = InputField(default="", description="String to split", ui_component=UIComponent.Textarea) + delimiter: str = InputField( + default="", description="Delimiter to spilt with. blank will split on the first whitespace" + ) + + def invoke(self, context: InvocationContext) -> String2Output: + result = self.string.split(self.delimiter, 1) + if len(result) == 2: + part1, part2 = result + else: + part1 = result[0] + part2 = "" + + return String2Output(string_1=part1, string_2=part2) + + +@invocation("string_join", title="String Join", tags=["string", "join"], category="string", version="1.0.1") +class StringJoinInvocation(BaseInvocation): + """Joins string left to string right""" + + string_left: str = InputField(default="", description="String Left", ui_component=UIComponent.Textarea) + string_right: str = InputField(default="", description="String Right", ui_component=UIComponent.Textarea) + + 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") +class StringJoinThreeInvocation(BaseInvocation): + """Joins string left to string middle to string right""" + + string_left: str = InputField(default="", description="String Left", ui_component=UIComponent.Textarea) + string_middle: str = InputField(default="", description="String Middle", ui_component=UIComponent.Textarea) + string_right: str = InputField(default="", description="String Right", ui_component=UIComponent.Textarea) + + def invoke(self, context: InvocationContext) -> StringOutput: + return StringOutput(value=((self.string_left or "") + (self.string_middle or "") + (self.string_right or ""))) + + +@invocation( + "string_replace", title="String Replace", tags=["string", "replace", "regex"], category="string", version="1.0.1" +) +class StringReplaceInvocation(BaseInvocation): + """Replaces the search string with the replace string""" + + string: str = InputField(default="", description="String to work on", ui_component=UIComponent.Textarea) + search_string: str = InputField(default="", description="String to search for", ui_component=UIComponent.Textarea) + replace_string: str = InputField( + default="", description="String to replace the search", ui_component=UIComponent.Textarea + ) + use_regex: bool = InputField( + default=False, description="Use search string as a regex expression (non regex is case insensitive)" + ) + + def invoke(self, context: InvocationContext) -> StringOutput: + pattern = self.search_string or "" + new_string = self.string or "" + if len(pattern) > 0: + if not self.use_regex: + # None regex so make case insensitve + pattern = "(?i)" + re.escape(pattern) + new_string = re.sub(pattern, (self.replace_string or ""), new_string) + return StringOutput(value=new_string) diff --git a/invokeai/app/invocations/t2i_adapter.py b/invokeai/app/invocations/t2i_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..04f9a6c6954b6b7aa4de3850630f9088e1381897 --- /dev/null +++ b/invokeai/app/invocations/t2i_adapter.py @@ -0,0 +1,96 @@ +from typing import Union + +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, UIType +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 + + +class T2IAdapterField(BaseModel): + image: ImageField = Field(description="The T2I-Adapter image prompt.") + t2i_adapter_model: ModelIdentifierField = Field(description="The T2I-Adapter model to use.") + weight: Union[float, list[float]] = Field(default=1, description="The weight given to the T2I-Adapter") + begin_step_percent: float = Field( + default=0, ge=0, le=1, description="When the T2I-Adapter is first applied (% of total steps)" + ) + end_step_percent: float = Field( + default=1, ge=0, le=1, description="When the T2I-Adapter is last applied (% of total steps)" + ) + resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use") + + @field_validator("weight") + @classmethod + def validate_ip_adapter_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("t2i_adapter_output") +class T2IAdapterOutput(BaseInvocationOutput): + t2i_adapter: T2IAdapterField = OutputField(description=FieldDescriptions.t2i_adapter, title="T2I Adapter") + + +@invocation( + "t2i_adapter", title="T2I-Adapter", tags=["t2i_adapter", "control"], category="t2i_adapter", version="1.0.3" +) +class T2IAdapterInvocation(BaseInvocation): + """Collects T2I-Adapter info to pass to other nodes.""" + + # Inputs + image: ImageField = InputField(description="The IP-Adapter image prompt.") + t2i_adapter_model: ModelIdentifierField = InputField( + description="The T2I-Adapter model.", + title="T2I-Adapter Model", + ui_order=-1, + ui_type=UIType.T2IAdapterModel, + ) + weight: Union[float, list[float]] = InputField( + default=1, ge=0, description="The weight given to the T2I-Adapter", title="Weight" + ) + begin_step_percent: float = InputField( + default=0, ge=0, le=1, description="When the T2I-Adapter is first applied (% of total steps)" + ) + end_step_percent: float = InputField( + default=1, ge=0, le=1, description="When the T2I-Adapter is last applied (% of total steps)" + ) + resize_mode: CONTROLNET_RESIZE_VALUES = InputField( + default="just_resize", + description="The resize mode applied to the T2I-Adapter input image so that it matches the target output size.", + ) + + @field_validator("weight") + @classmethod + def validate_ip_adapter_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 + + def invoke(self, context: InvocationContext) -> T2IAdapterOutput: + return T2IAdapterOutput( + t2i_adapter=T2IAdapterField( + image=self.image, + t2i_adapter_model=self.t2i_adapter_model, + weight=self.weight, + begin_step_percent=self.begin_step_percent, + end_step_percent=self.end_step_percent, + resize_mode=self.resize_mode, + ) + ) 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 0000000000000000000000000000000000000000..556600b412844f76764d01bd5900b03eaa953e9d --- /dev/null +++ b/invokeai/app/invocations/tiled_multi_diffusion_denoise_latents.py @@ -0,0 +1,291 @@ +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, Classification, invocation +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.app.invocations.controlnet_image_processors 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.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.lora_patcher import LoRAPatcher +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 Latents", + tags=["upscale", "denoise"], + category="latents", + classification=Classification.Beta, + version="1.0.0", +) +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[LoRAModelRaw, float]]: + for lora in self.unet.loras: + lora_info = context.models.load(lora.lora) + assert isinstance(lora_info.model, LoRAModelRaw) + yield (lora_info.model, lora.weight) + del lora_info + + # Load the UNet model. + unet_info = context.models.load(self.unet.unet) + + with ( + ExitStack() as exit_stack, + unet_info as unet, + LoRAPatcher.apply_lora_patches(model=unet, patches=_lora_loader(), prefix="lora_unet_"), + ): + assert isinstance(unet, UNet2DConditionModel) + latents = latents.to(device=unet.device, dtype=unet.dtype) + if noise is not None: + noise = noise.to(device=unet.device, dtype=unet.dtype) + scheduler = get_scheduler( + context=context, + scheduler_info=self.unet.scheduler, + scheduler_name=self.scheduler, + seed=seed, + ) + 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=unet.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), + # 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=unet.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 new file mode 100644 index 0000000000000000000000000000000000000000..a54001a0a7952adbfc4471b5e137bbe4258f549d --- /dev/null +++ b/invokeai/app/invocations/tiles.py @@ -0,0 +1,291 @@ +from typing import Literal + +import numpy as np +from PIL import Image +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, WithBoard, WithMetadata +from invokeai.app.invocations.primitives import ImageOutput +from invokeai.app.services.shared.invocation_context import InvocationContext +from invokeai.backend.tiles.tiles import ( + calc_tiles_even_split, + calc_tiles_min_overlap, + calc_tiles_with_overlap, + merge_tiles_with_linear_blending, + merge_tiles_with_seam_blending, +) +from invokeai.backend.tiles.utils import Tile + + +class TileWithImage(BaseModel): + tile: Tile + image: ImageField + + +@invocation_output("calculate_image_tiles_output") +class CalculateImageTilesOutput(BaseInvocationOutput): + tiles: list[Tile] = OutputField(description="The tiles coordinates that cover a particular image shape.") + + +@invocation( + "calculate_image_tiles", + title="Calculate Image Tiles", + 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.""" + + image_width: int = InputField(ge=1, default=1024, description="The image width, in pixels, to calculate tiles for.") + image_height: int = InputField( + ge=1, default=1024, description="The image height, in pixels, to calculate tiles for." + ) + tile_width: int = InputField(ge=1, default=576, description="The tile width, in pixels.") + tile_height: int = InputField(ge=1, default=576, description="The tile height, in pixels.") + overlap: int = InputField( + ge=0, + default=128, + description="The target overlap, in pixels, between adjacent tiles. Adjacent tiles will overlap by at least this amount", + ) + + def invoke(self, context: InvocationContext) -> CalculateImageTilesOutput: + tiles = calc_tiles_with_overlap( + image_height=self.image_height, + image_width=self.image_width, + tile_height=self.tile_height, + tile_width=self.tile_width, + overlap=self.overlap, + ) + return CalculateImageTilesOutput(tiles=tiles) + + +@invocation( + "calculate_image_tiles_even_split", + title="Calculate Image Tiles Even Split", + 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.""" + + image_width: int = InputField(ge=1, default=1024, description="The image width, in pixels, to calculate tiles for.") + image_height: int = InputField( + ge=1, default=1024, description="The image height, in pixels, to calculate tiles for." + ) + num_tiles_x: int = InputField( + default=2, + ge=1, + description="Number of tiles to divide image into on the x axis", + ) + num_tiles_y: int = InputField( + default=2, + ge=1, + description="Number of tiles to divide image into on the y axis", + ) + overlap: int = InputField( + default=128, + ge=0, + multiple_of=8, + description="The overlap, in pixels, between adjacent tiles.", + ) + + def invoke(self, context: InvocationContext) -> CalculateImageTilesOutput: + tiles = calc_tiles_even_split( + image_height=self.image_height, + image_width=self.image_width, + num_tiles_x=self.num_tiles_x, + num_tiles_y=self.num_tiles_y, + overlap=self.overlap, + ) + return CalculateImageTilesOutput(tiles=tiles) + + +@invocation( + "calculate_image_tiles_min_overlap", + title="Calculate Image Tiles Minimum Overlap", + 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.""" + + image_width: int = InputField(ge=1, default=1024, description="The image width, in pixels, to calculate tiles for.") + image_height: int = InputField( + ge=1, default=1024, description="The image height, in pixels, to calculate tiles for." + ) + tile_width: int = InputField(ge=1, default=576, description="The tile width, in pixels.") + tile_height: int = InputField(ge=1, default=576, description="The tile height, in pixels.") + min_overlap: int = InputField(default=128, ge=0, description="Minimum overlap between adjacent tiles, in pixels.") + + def invoke(self, context: InvocationContext) -> CalculateImageTilesOutput: + tiles = calc_tiles_min_overlap( + image_height=self.image_height, + image_width=self.image_width, + tile_height=self.tile_height, + tile_width=self.tile_width, + min_overlap=self.min_overlap, + ) + return CalculateImageTilesOutput(tiles=tiles) + + +@invocation_output("tile_to_properties_output") +class TileToPropertiesOutput(BaseInvocationOutput): + coords_left: int = OutputField(description="Left coordinate of the tile relative to its parent image.") + coords_right: int = OutputField(description="Right coordinate of the tile relative to its parent image.") + coords_top: int = OutputField(description="Top coordinate of the tile relative to its parent image.") + coords_bottom: int = OutputField(description="Bottom coordinate of the tile relative to its parent image.") + + # HACK: The width and height fields are 'meta' fields that can easily be calculated from the other fields on this + # object. Including redundant fields that can cheaply/easily be re-calculated goes against conventional API design + # principles. These fields are included, because 1) they are often useful in tiled workflows, and 2) they are + # difficult to calculate in a workflow (even though it's just a couple of subtraction nodes the graph gets + # surprisingly complicated). + width: int = OutputField(description="The width of the tile. Equal to coords_right - coords_left.") + height: int = OutputField(description="The height of the tile. Equal to coords_bottom - coords_top.") + + overlap_top: int = OutputField(description="Overlap between this tile and its top neighbor.") + overlap_bottom: int = OutputField(description="Overlap between this tile and its bottom neighbor.") + overlap_left: int = OutputField(description="Overlap between this tile and its left neighbor.") + overlap_right: int = OutputField(description="Overlap between this tile and its right neighbor.") + + +@invocation( + "tile_to_properties", + title="Tile to Properties", + tags=["tiles"], + category="tiles", + version="1.0.1", + classification=Classification.Beta, +) +class TileToPropertiesInvocation(BaseInvocation): + """Split a Tile into its individual properties.""" + + tile: Tile = InputField(description="The tile to split into properties.") + + def invoke(self, context: InvocationContext) -> TileToPropertiesOutput: + return TileToPropertiesOutput( + coords_left=self.tile.coords.left, + coords_right=self.tile.coords.right, + coords_top=self.tile.coords.top, + coords_bottom=self.tile.coords.bottom, + width=self.tile.coords.right - self.tile.coords.left, + height=self.tile.coords.bottom - self.tile.coords.top, + overlap_top=self.tile.overlap.top, + overlap_bottom=self.tile.overlap.bottom, + overlap_left=self.tile.overlap.left, + overlap_right=self.tile.overlap.right, + ) + + +@invocation_output("pair_tile_image_output") +class PairTileImageOutput(BaseInvocationOutput): + tile_with_image: TileWithImage = OutputField(description="A tile description with its corresponding image.") + + +@invocation( + "pair_tile_image", + title="Pair Tile with Image", + tags=["tiles"], + category="tiles", + version="1.0.1", + classification=Classification.Beta, +) +class PairTileImageInvocation(BaseInvocation): + """Pair an image with its tile properties.""" + + # TODO(ryand): The only reason that PairTileImage is needed is because the iterate/collect nodes don't preserve + # order. Can this be fixed? + + image: ImageField = InputField(description="The tile image.") + tile: Tile = InputField(description="The tile properties.") + + def invoke(self, context: InvocationContext) -> PairTileImageOutput: + return PairTileImageOutput( + tile_with_image=TileWithImage( + tile=self.tile, + image=self.image, + ) + ) + + +BLEND_MODES = Literal["Linear", "Seam"] + + +@invocation( + "merge_tiles_to_image", + title="Merge Tiles to Image", + tags=["tiles"], + category="tiles", + version="1.1.1", + classification=Classification.Beta, +) +class MergeTilesToImageInvocation(BaseInvocation, WithMetadata, WithBoard): + """Merge multiple tile images into a single image.""" + + # Inputs + tiles_with_images: list[TileWithImage] = InputField(description="A list of tile images with tile properties.") + blend_mode: BLEND_MODES = InputField( + default="Seam", + description="blending type Linear or Seam", + input=Input.Direct, + ) + blend_amount: int = InputField( + default=32, + ge=0, + description="The amount to blend adjacent tiles in pixels. Must be <= the amount of overlap between adjacent tiles.", + ) + + def invoke(self, context: InvocationContext) -> ImageOutput: + images = [twi.image for twi in self.tiles_with_images] + tiles = [twi.tile for twi in self.tiles_with_images] + + # Infer the output image dimensions from the max/min tile limits. + height = 0 + width = 0 + for tile in tiles: + height = max(height, tile.coords.bottom) + width = max(width, tile.coords.right) + + # Get all tile images for processing. + # TODO(ryand): It pains me that we spend time PNG decoding each tile from disk when they almost certainly + # existed in memory at an earlier point in the graph. + tile_np_images: list[np.ndarray] = [] + for image in images: + pil_image = context.images.get_pil(image.image_name) + pil_image = pil_image.convert("RGB") + tile_np_images.append(np.array(pil_image)) + + # Prepare the output image buffer. + # Check the first tile to determine how many image channels are expected in the output. + channels = tile_np_images[0].shape[-1] + dtype = tile_np_images[0].dtype + np_image = np.zeros(shape=(height, width, channels), dtype=dtype) + if self.blend_mode == "Linear": + merge_tiles_with_linear_blending( + dst_image=np_image, tiles=tiles, tile_images=tile_np_images, blend_amount=self.blend_amount + ) + elif self.blend_mode == "Seam": + merge_tiles_with_seam_blending( + dst_image=np_image, tiles=tiles, tile_images=tile_np_images, blend_amount=self.blend_amount + ) + else: + raise ValueError(f"Unsupported blend mode: '{self.blend_mode}'.") + + # Convert into a PIL image and save + pil_image = Image.fromarray(np_image) + + image_dto = context.images.save(image=pil_image) + return ImageOutput.build(image_dto) diff --git a/invokeai/app/invocations/upscale.py b/invokeai/app/invocations/upscale.py new file mode 100644 index 0000000000000000000000000000000000000000..e7b3968aec6ae525db74ac86016ddb91b5e91291 --- /dev/null +++ b/invokeai/app/invocations/upscale.py @@ -0,0 +1,114 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) & the InvokeAI Team +from typing import Literal + +import cv2 +import numpy as np +from PIL import Image +from pydantic import ConfigDict + +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 + +# TODO: Populate this from disk? +# TODO: Use model manager to load? +ESRGAN_MODELS = Literal[ + "RealESRGAN_x4plus.pth", + "RealESRGAN_x4plus_anime_6B.pth", + "ESRGAN_SRx4_DF2KOST_official-ff704c30.pth", + "RealESRGAN_x2plus.pth", +] + +ESRGAN_MODEL_URLS: dict[str, str] = { + "RealESRGAN_x4plus.pth": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth", + "RealESRGAN_x4plus_anime_6B.pth": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth", + "ESRGAN_SRx4_DF2KOST_official-ff704c30.pth": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.1/ESRGAN_SRx4_DF2KOST_official-ff704c30.pth", + "RealESRGAN_x2plus.pth": "https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth", +} + + +@invocation("esrgan", title="Upscale (RealESRGAN)", tags=["esrgan", "upscale"], category="esrgan", version="1.3.2") +class ESRGANInvocation(BaseInvocation, WithMetadata, WithBoard): + """Upscales an image using RealESRGAN.""" + + image: ImageField = InputField(description="The input image") + model_name: ESRGAN_MODELS = InputField(default="RealESRGAN_x4plus.pth", description="The Real-ESRGAN model to use") + tile_size: int = InputField( + default=400, ge=0, description="Tile size for tiled ESRGAN upscaling (0=tiling disabled)" + ) + + model_config = ConfigDict(protected_namespaces=()) + + def invoke(self, context: InvocationContext) -> ImageOutput: + image = context.images.get_pil(self.image.image_name) + + rrdbnet_model = None + netscale = None + + if self.model_name in [ + "RealESRGAN_x4plus.pth", + "ESRGAN_SRx4_DF2KOST_official-ff704c30.pth", + ]: + # x4 RRDBNet model + rrdbnet_model = RRDBNet( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_block=23, + num_grow_ch=32, + scale=4, + ) + netscale = 4 + elif self.model_name in ["RealESRGAN_x4plus_anime_6B.pth"]: + # x4 RRDBNet model, 6 blocks + rrdbnet_model = RRDBNet( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_block=6, # 6 blocks + num_grow_ch=32, + scale=4, + ) + netscale = 4 + elif self.model_name in ["RealESRGAN_x2plus.pth"]: + # x2 RRDBNet model + rrdbnet_model = RRDBNet( + num_in_ch=3, + num_out_ch=3, + num_feat=64, + num_block=23, + num_grow_ch=32, + scale=2, + ) + netscale = 2 + else: + msg = f"Invalid RealESRGAN model: {self.model_name}" + context.logger.error(msg) + raise ValueError(msg) + + loadnet = context.models.load_remote_model( + source=ESRGAN_MODEL_URLS[self.model_name], + ) + + with loadnet as loadnet_model: + upscaler = RealESRGAN( + scale=netscale, + loadnet=loadnet_model, + model=rrdbnet_model, + half=False, + tile=self.tile_size, + ) + + # prepare image - Real-ESRGAN uses cv2 internally, and cv2 uses BGR vs RGB for PIL + # TODO: This strips the alpha... is that okay? + cv2_image = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR) + upscaled_image = upscaler.upscale(cv2_image) + + pil_image = Image.fromarray(cv2.cvtColor(upscaled_image, cv2.COLOR_BGR2RGB)).convert("RGBA") + + image_dto = context.images.save(image=pil_image) + + return ImageOutput.build(image_dto) diff --git a/invokeai/app/invocations/util.py b/invokeai/app/invocations/util.py new file mode 100644 index 0000000000000000000000000000000000000000..c69c32eed01db6a3a4002087e93c41813ec1a91a --- /dev/null +++ b/invokeai/app/invocations/util.py @@ -0,0 +1,14 @@ +from typing import Union + + +def validate_weights(weights: Union[float, list[float]]) -> None: + """Validate that all control weights in the valid range""" + to_validate = weights if isinstance(weights, list) else [weights] + if any(i < -1 or i > 2 for i in to_validate): + raise ValueError("Control weights must be within -1 to 2 range") + + +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: + raise ValueError("Begin step percent must be less than or equal to end step percent") diff --git a/invokeai/app/run_app.py b/invokeai/app/run_app.py new file mode 100644 index 0000000000000000000000000000000000000000..701f1dab7394f4ccc3a101fc6e7ff5b259070f81 --- /dev/null +++ b/invokeai/app/run_app.py @@ -0,0 +1,12 @@ +"""This is a wrapper around the main app entrypoint, to allow for CLI args to be parsed before running the app.""" + + +def run_app() -> None: + # Before doing _anything_, parse CLI args! + from invokeai.frontend.cli.arg_parser import InvokeAIArgs + + InvokeAIArgs.parse_args() + + from invokeai.app.api_app import invoke_api + + invoke_api() diff --git a/invokeai/app/services/__init__.py b/invokeai/app/services/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/board_image_records/__init__.py b/invokeai/app/services/board_image_records/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 new file mode 100644 index 0000000000000000000000000000000000000000..c8f7b35908536602aab89207143dc679b89e89e1 --- /dev/null +++ b/invokeai/app/services/board_image_records/board_image_records_base.py @@ -0,0 +1,47 @@ +from abc import ABC, abstractmethod +from typing import Optional + + +class BoardImageRecordStorageBase(ABC): + """Abstract base class for the one-to-many board-image relationship record storage.""" + + @abstractmethod + def add_image_to_board( + self, + board_id: str, + image_name: str, + ) -> None: + """Adds an image to a board.""" + pass + + @abstractmethod + def remove_image_from_board( + self, + image_name: str, + ) -> None: + """Removes an image from a board.""" + pass + + @abstractmethod + def get_all_board_image_names_for_board( + self, + board_id: str, + ) -> list[str]: + """Gets all board images for a board, as a list of the image names.""" + pass + + @abstractmethod + def get_board_for_image( + self, + image_name: str, + ) -> Optional[str]: + """Gets an image's board id, if it has one.""" + pass + + @abstractmethod + def get_image_count_for_board( + self, + board_id: str, + ) -> int: + """Gets the number of images 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 new file mode 100644 index 0000000000000000000000000000000000000000..c189c65c2137eb0ec978ac7398a8fb5ac9c950c7 --- /dev/null +++ b/invokeai/app/services/board_image_records/board_image_records_sqlite.py @@ -0,0 +1,163 @@ +import sqlite3 +import threading +from typing import Optional, cast + +from invokeai.app.services.board_image_records.board_image_records_base import BoardImageRecordStorageBase +from invokeai.app.services.image_records.image_records_common import ImageRecord, deserialize_image_record +from invokeai.app.services.shared.pagination import OffsetPaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase + + +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() + + def add_image_to_board( + self, + board_id: str, + image_name: str, + ) -> None: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + INSERT INTO board_images (board_id, image_name) + VALUES (?, ?) + ON CONFLICT (image_name) DO UPDATE SET board_id = ?; + """, + (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( + """--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, + board_id: str, + offset: int = 0, + limit: int = 10, + ) -> OffsetPaginatedResults[ImageRecord]: + # TODO: this isn't paginated yet? + try: + self._lock.acquire() + self._cursor.execute( + """--sql + SELECT images.* + FROM board_images + INNER JOIN images ON board_images.image_name = images.image_name + WHERE board_images.board_id = ? + ORDER BY board_images.updated_at DESC; + """, + (board_id,), + ) + result = cast(list[sqlite3.Row], self._cursor.fetchall()) + images = [deserialize_image_record(dict(r)) for r in result] + + self._cursor.execute( + """--sql + SELECT COUNT(*) FROM images WHERE 1=1; + """ + ) + count = cast(int, self._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_board_for_image( + self, + image_name: str, + ) -> Optional[str]: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + 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() + + def get_image_count_for_board(self, board_id: str) -> int: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + SELECT COUNT(*) + FROM board_images + INNER JOIN images ON board_images.image_name = images.image_name + WHERE images.is_intermediate = FALSE + AND board_images.board_id = ?; + """, + (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() diff --git a/invokeai/app/services/board_images/__init__.py b/invokeai/app/services/board_images/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/board_images/board_images_base.py b/invokeai/app/services/board_images/board_images_base.py new file mode 100644 index 0000000000000000000000000000000000000000..356ff7068b64cdd1403aa33e878e91ce9225312b --- /dev/null +++ b/invokeai/app/services/board_images/board_images_base.py @@ -0,0 +1,39 @@ +from abc import ABC, abstractmethod +from typing import Optional + + +class BoardImagesServiceABC(ABC): + """High-level service for board-image relationship management.""" + + @abstractmethod + def add_image_to_board( + self, + board_id: str, + image_name: str, + ) -> None: + """Adds an image to a board.""" + pass + + @abstractmethod + def remove_image_from_board( + self, + image_name: str, + ) -> None: + """Removes an image from a board.""" + pass + + @abstractmethod + def get_all_board_image_names_for_board( + self, + board_id: str, + ) -> list[str]: + """Gets all board images for a board, as a list of the image names.""" + pass + + @abstractmethod + def get_board_for_image( + self, + image_name: str, + ) -> Optional[str]: + """Gets an image's board id, if it has one.""" + pass diff --git a/invokeai/app/services/board_images/board_images_common.py b/invokeai/app/services/board_images/board_images_common.py new file mode 100644 index 0000000000000000000000000000000000000000..fe585215f30f3e5ec3ead0bbbf3f8f625367cc47 --- /dev/null +++ b/invokeai/app/services/board_images/board_images_common.py @@ -0,0 +1,8 @@ +from pydantic import Field + +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull + + +class BoardImage(BaseModelExcludeNull): + board_id: str = Field(description="The id of the board") + image_name: str = Field(description="The name of the image") diff --git a/invokeai/app/services/board_images/board_images_default.py b/invokeai/app/services/board_images/board_images_default.py new file mode 100644 index 0000000000000000000000000000000000000000..6a564f5a91f8372dcb79b993db83c379d70a8cec --- /dev/null +++ b/invokeai/app/services/board_images/board_images_default.py @@ -0,0 +1,37 @@ +from typing import Optional + +from invokeai.app.services.board_images.board_images_base import BoardImagesServiceABC +from invokeai.app.services.invoker import Invoker + + +class BoardImagesService(BoardImagesServiceABC): + __invoker: Invoker + + def start(self, invoker: Invoker) -> None: + self.__invoker = invoker + + def add_image_to_board( + self, + board_id: str, + image_name: str, + ) -> None: + self.__invoker.services.board_image_records.add_image_to_board(board_id, image_name) + + def remove_image_from_board( + self, + image_name: str, + ) -> None: + self.__invoker.services.board_image_records.remove_image_from_board(image_name) + + def get_all_board_image_names_for_board( + self, + board_id: str, + ) -> list[str]: + return self.__invoker.services.board_image_records.get_all_board_image_names_for_board(board_id) + + def get_board_for_image( + self, + image_name: str, + ) -> Optional[str]: + board_id = self.__invoker.services.board_image_records.get_board_for_image(image_name) + return board_id diff --git a/invokeai/app/services/board_records/board_records_base.py b/invokeai/app/services/board_records/board_records_base.py new file mode 100644 index 0000000000000000000000000000000000000000..4cfb565bd3116551efc776a015c47d0e52a61794 --- /dev/null +++ b/invokeai/app/services/board_records/board_records_base.py @@ -0,0 +1,58 @@ +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 invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection + + +class BoardRecordStorageBase(ABC): + """Low-level service responsible for interfacing with the board record store.""" + + @abstractmethod + def delete(self, board_id: str) -> None: + """Deletes a board record.""" + pass + + @abstractmethod + def save( + self, + board_name: str, + ) -> BoardRecord: + """Saves a board record.""" + pass + + @abstractmethod + def get( + self, + board_id: str, + ) -> BoardRecord: + """Gets a board record.""" + pass + + @abstractmethod + def update( + self, + board_id: str, + changes: BoardChanges, + ) -> BoardRecord: + """Updates a board record.""" + pass + + @abstractmethod + def get_many( + self, + order_by: BoardRecordOrderBy, + direction: SQLiteDirection, + offset: int = 0, + limit: int = 10, + include_archived: bool = False, + ) -> OffsetPaginatedResults[BoardRecord]: + """Gets many board records.""" + pass + + @abstractmethod + def get_all( + self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + ) -> list[BoardRecord]: + """Gets all board records.""" + pass diff --git a/invokeai/app/services/board_records/board_records_common.py b/invokeai/app/services/board_records/board_records_common.py new file mode 100644 index 0000000000000000000000000000000000000000..db82324d417e40558b176e8a4633a95ad2eebe27 --- /dev/null +++ b/invokeai/app/services/board_records/board_records_common.py @@ -0,0 +1,90 @@ +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 BoardRecord(BaseModelExcludeNull): + """Deserialized board record.""" + + board_id: str = Field(description="The unique ID of the board.") + """The unique ID of the board.""" + board_name: str = Field(description="The name of the board.") + """The name of the board.""" + 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.") + """The updated timestamp of the image.""" + deleted_at: Optional[Union[datetime, str]] = Field(default=None, description="The deleted timestamp of the board.") + """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.""" + is_private: Optional[bool] = Field(default=None, description="Whether the board is private.") + """Whether the board is private.""" + + +def deserialize_board_record(board_dict: dict) -> BoardRecord: + """Deserializes a board record.""" + + # Retrieve all the values, setting "reasonable" defaults if they are not present. + + board_id = board_dict.get("board_id", "unknown") + board_name = board_dict.get("board_name", "unknown") + 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) + is_private = board_dict.get("is_private", False) + + return BoardRecord( + board_id=board_id, + board_name=board_name, + cover_image_name=cover_image_name, + created_at=created_at, + updated_at=updated_at, + deleted_at=deleted_at, + archived=archived, + is_private=is_private, + ) + + +class BoardChanges(BaseModel, extra="forbid"): + board_name: Optional[str] = Field(default=None, description="The board's new name.") + 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") + + +class BoardRecordOrderBy(str, Enum, metaclass=MetaEnum): + """The order by options for board records""" + + CreatedAt = "created_at" + Name = "board_name" + + +class BoardRecordNotFoundException(Exception): + """Raised when an board record is not found.""" + + def __init__(self, message="Board record not found"): + super().__init__(message) + + +class BoardRecordSaveException(Exception): + """Raised when an board record cannot be saved.""" + + def __init__(self, message="Board record not saved"): + super().__init__(message) + + +class BoardRecordDeleteException(Exception): + """Raised when an board record cannot be deleted.""" + + def __init__(self, message="Board record not deleted"): + super().__init__(message) diff --git a/invokeai/app/services/board_records/board_records_sqlite.py b/invokeai/app/services/board_records/board_records_sqlite.py new file mode 100644 index 0000000000000000000000000000000000000000..bcd3833be9541da5642d27e284811b4894725f53 --- /dev/null +++ b/invokeai/app/services/board_records/board_records_sqlite.py @@ -0,0 +1,245 @@ +import sqlite3 +import threading +from typing import Union, cast + +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() + + 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() + + def save( + self, + board_name: 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() + 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() + if result is None: + raise BoardRecordNotFoundException + return BoardRecord(**dict(result)) + + def update( + self, + board_id: str, + changes: BoardChanges, + ) -> BoardRecord: + try: + self._lock.acquire() + + # 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: + 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: + self._cursor.execute( + """--sql + UPDATE boards + SET archived = ? + WHERE board_id = ?; + """, + (changes.archived, board_id), + ) + + self._conn.commit() + except sqlite3.Error as e: + self._conn.rollback() + raise BoardRecordSaveException from e + finally: + self._lock.release() + return self.get(board_id) + + def get_many( + self, + order_by: BoardRecordOrderBy, + direction: SQLiteDirection, + offset: int = 0, + limit: int = 10, + include_archived: bool = False, + ) -> OffsetPaginatedResults[BoardRecord]: + try: + self._lock.acquire() + + # Build base query + base_query = """ + SELECT * + FROM boards + {archived_filter} + ORDER BY {order_by} {direction} + LIMIT ? OFFSET ?; + """ + + # Determine archived filter condition + archived_filter = "" if include_archived else "WHERE archived = 0" + + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) + + # Execute query to fetch boards + self._cursor.execute(final_query, (limit, offset)) + + result = cast(list[sqlite3.Row], self._cursor.fetchall()) + boards = [deserialize_board_record(dict(r)) for r in result] + + # Determine count query + if include_archived: + count_query = """ + SELECT COUNT(*) + FROM boards; + """ + else: + count_query = """ + SELECT COUNT(*) + FROM boards + WHERE archived = 0; + """ + + # Execute count query + self._cursor.execute(count_query) + + count = cast(int, self._cursor.fetchone()[0]) + + return OffsetPaginatedResults[BoardRecord](items=boards, offset=offset, limit=limit, total=count) + + except sqlite3.Error as e: + self._conn.rollback() + raise e + finally: + self._lock.release() + + def get_all( + self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + ) -> list[BoardRecord]: + try: + self._lock.acquire() + + if order_by == BoardRecordOrderBy.Name: + base_query = """ + SELECT * + FROM boards + {archived_filter} + ORDER BY LOWER(board_name) {direction} + """ + else: + base_query = """ + SELECT * + FROM boards + {archived_filter} + ORDER BY {order_by} {direction} + """ + + archived_filter = "" if include_archived else "WHERE archived = 0" + + final_query = base_query.format( + archived_filter=archived_filter, order_by=order_by.value, direction=direction.value + ) + + self._cursor.execute(final_query) + + result = cast(list[sqlite3.Row], self._cursor.fetchall()) + boards = [deserialize_board_record(dict(r)) for r in result] + + return boards + + except sqlite3.Error as e: + self._conn.rollback() + raise e + finally: + self._lock.release() diff --git a/invokeai/app/services/boards/__init__.py b/invokeai/app/services/boards/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/boards/boards_base.py b/invokeai/app/services/boards/boards_base.py new file mode 100644 index 0000000000000000000000000000000000000000..ed9292a7469f3ace0252c2040f7602e84ac534d8 --- /dev/null +++ b/invokeai/app/services/boards/boards_base.py @@ -0,0 +1,62 @@ +from abc import ABC, abstractmethod + +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 invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection + + +class BoardServiceABC(ABC): + """High-level service for board management.""" + + @abstractmethod + def create( + self, + board_name: str, + ) -> BoardDTO: + """Creates a board.""" + pass + + @abstractmethod + def get_dto( + self, + board_id: str, + ) -> BoardDTO: + """Gets a board.""" + pass + + @abstractmethod + def update( + self, + board_id: str, + changes: BoardChanges, + ) -> BoardDTO: + """Updates a board.""" + pass + + @abstractmethod + def delete( + self, + board_id: str, + ) -> None: + """Deletes a board.""" + pass + + @abstractmethod + def get_many( + self, + order_by: BoardRecordOrderBy, + direction: SQLiteDirection, + offset: int = 0, + limit: int = 10, + include_archived: bool = False, + ) -> OffsetPaginatedResults[BoardDTO]: + """Gets many boards.""" + pass + + @abstractmethod + def get_all( + self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + ) -> list[BoardDTO]: + """Gets all boards.""" + pass diff --git a/invokeai/app/services/boards/boards_common.py b/invokeai/app/services/boards/boards_common.py new file mode 100644 index 0000000000000000000000000000000000000000..15d0b3c37f577e4972915626464aa9e88c9aac10 --- /dev/null +++ b/invokeai/app/services/boards/boards_common.py @@ -0,0 +1,23 @@ +from typing import Optional + +from pydantic import Field + +from invokeai.app.services.board_records.board_records_common import BoardRecord + + +class BoardDTO(BoardRecord): + """Deserialized board record with cover image URL and image count.""" + + cover_image_name: Optional[str] = Field(description="The name of the board's cover image.") + """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.""" + + +def board_record_to_dto(board_record: BoardRecord, cover_image_name: Optional[str], image_count: int) -> 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, + ) diff --git a/invokeai/app/services/boards/boards_default.py b/invokeai/app/services/boards/boards_default.py new file mode 100644 index 0000000000000000000000000000000000000000..9e2a3689302776bd82cb7f48678af807794184f5 --- /dev/null +++ b/invokeai/app/services/boards/boards_default.py @@ -0,0 +1,89 @@ +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 invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection + + +class BoardService(BoardServiceABC): + __invoker: Invoker + + def start(self, invoker: Invoker) -> None: + self.__invoker = invoker + + def create( + self, + board_name: str, + ) -> BoardDTO: + board_record = self.__invoker.services.board_records.save(board_name) + return board_record_to_dto(board_record, None, 0) + + def get_dto(self, board_id: str) -> BoardDTO: + board_record = self.__invoker.services.board_records.get(board_id) + cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(board_record.board_id) + if cover_image: + cover_image_name = cover_image.image_name + 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) + + def update( + self, + board_id: str, + changes: BoardChanges, + ) -> BoardDTO: + board_record = self.__invoker.services.board_records.update(board_id, changes) + cover_image = self.__invoker.services.image_records.get_most_recent_image_for_board(board_record.board_id) + if cover_image: + cover_image_name = cover_image.image_name + 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) + + def delete(self, board_id: str) -> None: + self.__invoker.services.board_records.delete(board_id) + + def get_many( + self, + 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( + 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) + if cover_image: + cover_image_name = cover_image.image_name + else: + 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)) + + return OffsetPaginatedResults[BoardDTO](items=board_dtos, offset=offset, limit=limit, total=len(board_dtos)) + + def get_all( + self, order_by: BoardRecordOrderBy, direction: SQLiteDirection, include_archived: bool = False + ) -> list[BoardDTO]: + board_records = self.__invoker.services.board_records.get_all(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) + if cover_image: + cover_image_name = cover_image.image_name + else: + 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)) + + return board_dtos diff --git a/invokeai/app/services/bulk_download/__init__.py b/invokeai/app/services/bulk_download/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/bulk_download/bulk_download_base.py b/invokeai/app/services/bulk_download/bulk_download_base.py new file mode 100644 index 0000000000000000000000000000000000000000..617b611f5666e511ca953009c748c231d0c7e742 --- /dev/null +++ b/invokeai/app/services/bulk_download/bulk_download_base.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod +from typing import Optional + + +class BulkDownloadBase(ABC): + """Responsible for creating a zip file containing the images specified by the given image names or board id.""" + + @abstractmethod + def handler( + self, image_names: Optional[list[str]], board_id: Optional[str], bulk_download_item_id: Optional[str] + ) -> None: + """ + Create a zip file containing the images specified by the given image names or board id. + + :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. + """ + + @abstractmethod + def get_path(self, bulk_download_item_name: str) -> str: + """ + Get the path to the bulk download file. + + :param bulk_download_item_name: The name of the bulk download item. + :return: The path to the bulk download file. + """ + + @abstractmethod + def generate_item_id(self, board_id: Optional[str]) -> str: + """ + Generate an item ID for a bulk download item. + + :param board_id: The ID of the board whose name is to be included in the item id. + :return: The generated item ID. + """ + + @abstractmethod + def delete(self, bulk_download_item_name: str) -> None: + """ + Delete the bulk download file. + + :param bulk_download_item_name: The name of the bulk download item. + """ diff --git a/invokeai/app/services/bulk_download/bulk_download_common.py b/invokeai/app/services/bulk_download/bulk_download_common.py new file mode 100644 index 0000000000000000000000000000000000000000..68724eb228bf0dabae24bc80479c8b335aaa2346 --- /dev/null +++ b/invokeai/app/services/bulk_download/bulk_download_common.py @@ -0,0 +1,25 @@ +DEFAULT_BULK_DOWNLOAD_ID = "default" + + +class BulkDownloadException(Exception): + """Exception raised when a bulk download fails.""" + + def __init__(self, message="Bulk download failed"): + super().__init__(message) + self.message = message + + +class BulkDownloadTargetException(BulkDownloadException): + """Exception raised when a bulk download target is not found.""" + + def __init__(self, message="The bulk download target was not found"): + super().__init__(message) + self.message = message + + +class BulkDownloadParametersException(BulkDownloadException): + """Exception raised when a bulk download parameter is invalid.""" + + def __init__(self, message="No image names or board ID provided"): + super().__init__(message) + self.message = message diff --git a/invokeai/app/services/bulk_download/bulk_download_default.py b/invokeai/app/services/bulk_download/bulk_download_default.py new file mode 100644 index 0000000000000000000000000000000000000000..4ebbd10d4f76f92079f14add7f4fada4ea81d945 --- /dev/null +++ b/invokeai/app/services/bulk_download/bulk_download_default.py @@ -0,0 +1,149 @@ +from pathlib import Path +from tempfile import TemporaryDirectory +from typing import Optional, Union +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, + BulkDownloadParametersException, + BulkDownloadTargetException, +) +from invokeai.app.services.image_records.image_records_common import ImageRecordNotFoundException +from invokeai.app.services.images.images_common import ImageDTO +from invokeai.app.services.invoker import Invoker +from invokeai.app.util.misc import uuid_string + + +class BulkDownloadService(BulkDownloadBase): + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + + 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) + + def handler( + self, image_names: Optional[list[str]], board_id: Optional[str], bulk_download_item_id: Optional[str] + ) -> 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) + + try: + image_dtos: list[ImageDTO] = [] + + if board_id: + image_dtos = self._board_handler(board_id) + elif image_names: + image_dtos = self._image_handler(image_names) + else: + 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) + except ( + ImageRecordNotFoundException, + BoardRecordNotFoundException, + BulkDownloadException, + BulkDownloadParametersException, + ) as e: + self._signal_job_failed(bulk_download_id, bulk_download_item_id, bulk_download_item_name, e) + except Exception as e: + self._signal_job_failed(bulk_download_id, bulk_download_item_id, bulk_download_item_name, e) + self._invoker.services.logger.error("Problem bulk downloading images.") + raise e + + 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) + return self._image_handler(image_names) + + def generate_item_id(self, board_id: Optional[str]) -> str: + return uuid_string() if board_id is None else self._get_clean_board_name(board_id) + "_" + uuid_string() + + def _get_clean_board_name(self, board_id: str) -> str: + if board_id == "none": + return "Uncategorized" + + return self._clean_string_to_path_safe(self._invoker.services.board_records.get(board_id).board_name) + + def _create_zip_file(self, image_dtos: list[ImageDTO], bulk_download_item_id: str) -> str: + """ + Create a zip file containing the images specified by the given image names or board id. + If download with the same bulk_download_id already exists, it will be overwritten. + + :return: The name of the zip file. + """ + zip_file_name = bulk_download_item_id + ".zip" + zip_file_path = self._bulk_downloads_folder / (zip_file_name) + + with ZipFile(zip_file_path, "w") as zip_file: + for image_dto in image_dtos: + image_zip_path = Path(image_dto.image_category.value) / image_dto.image_name + image_disk_path = self._invoker.services.images.get_path(image_dto.image_name) + zip_file.write(image_disk_path, arcname=image_zip_path) + + return str(zip_file_name) + + # from https://stackoverflow.com/questions/7406102/create-sane-safe-filename-from-any-unsafe-string + def _clean_string_to_path_safe(self, s: str) -> str: + """Clean a string to be path safe.""" + 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 + ) -> 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 + ) + + def _signal_job_completed( + self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str + ) -> 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 + ) + + def _signal_job_failed( + self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str, exception: Exception + ) -> 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) + ) + + def stop(self, *args, **kwargs): + self._temp_directory.cleanup() + + def delete(self, bulk_download_item_name: str) -> None: + path = self.get_path(bulk_download_item_name) + Path(path).unlink() + + def get_path(self, bulk_download_item_name: str) -> str: + path = str(self._bulk_downloads_folder / bulk_download_item_name) + if not self._is_valid_path(path): + raise BulkDownloadTargetException() + return path + + 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() diff --git a/invokeai/app/services/config/__init__.py b/invokeai/app/services/config/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..df1acbf10473dfa3e7e544e1d72612cf7c9c7245 --- /dev/null +++ b/invokeai/app/services/config/__init__.py @@ -0,0 +1,6 @@ +"""Init file for InvokeAI configure package.""" + +from invokeai.app.services.config.config_common import PagingArgumentParser +from invokeai.app.services.config.config_default import InvokeAIAppConfig, get_config + +__all__ = ["InvokeAIAppConfig", "get_config", "PagingArgumentParser"] diff --git a/invokeai/app/services/config/config_common.py b/invokeai/app/services/config/config_common.py new file mode 100644 index 0000000000000000000000000000000000000000..0765b93f2cf15af13eb1f2c19a7fb2ad9bfa3942 --- /dev/null +++ b/invokeai/app/services/config/config_common.py @@ -0,0 +1,25 @@ +# Copyright (c) 2023 Lincoln Stein (https://github.com/lstein) and the InvokeAI Development Team + +""" +Base class for the InvokeAI configuration system. +It defines a type of pydantic BaseSettings object that +is able to read and write from an omegaconf-based config file, +with overriding of settings from environment variables and/or +the command line. +""" + +from __future__ import annotations + +import argparse +import pydoc + + +class PagingArgumentParser(argparse.ArgumentParser): + """ + A custom ArgumentParser that uses pydoc to page its output. + It also supports reading defaults from an init file. + """ + + def print_help(self, file=None) -> None: + text = self.format_help() + pydoc.pager(text) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py new file mode 100644 index 0000000000000000000000000000000000000000..a033261bc669c9aad2de11fb16dd761e3eb820f9 --- /dev/null +++ b/invokeai/app/services/config/config_default.py @@ -0,0 +1,541 @@ +# TODO(psyche): pydantic-settings supports YAML settings sources. If we can figure out a way to integrate the YAML +# migration logic, we could use that for simpler config loading. + +from __future__ import annotations + +import copy +import locale +import os +import re +import shutil +from functools import lru_cache +from pathlib import Path +from typing import Any, Literal, Optional + +import psutil +import yaml +from pydantic import BaseModel, Field, PrivateAttr, field_validator +from pydantic_settings import BaseSettings, PydanticBaseSettingsSource, SettingsConfigDict + +import invokeai.configs as model_configs +from invokeai.backend.model_hash.model_hash import HASHING_ALGORITHMS +from invokeai.frontend.cli.arg_parser import InvokeAIArgs + +INIT_FILE = Path("invokeai.yaml") +DB_FILE = Path("invokeai.db") +LEGACY_INIT_FILE = Path("invokeai.init") +DEFAULT_RAM_CACHE = 10.0 +DEFAULT_VRAM_CACHE = 0.25 +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.2" + + +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 ;-) + + +class URLRegexTokenPair(BaseModel): + url_regex: str = Field(description="Regular expression to match against the URL") + token: str = Field(description="Token to use when the URL matches the regex") + + @field_validator("url_regex") + @classmethod + def validate_url_regex(cls, v: str) -> str: + """Validate that the value is a valid regex.""" + try: + re.compile(v) + except re.error as e: + raise ValueError(f"Invalid regex: {e}") + return v + + +class InvokeAIAppConfig(BaseSettings): + """Invoke's global app configuration. + + Typically, you won't need to interact with this class directly. Instead, use the `get_config` function from `invokeai.app.services.config` to get a singleton config object. + + Attributes: + host: IP address to bind to. Use `0.0.0.0` to serve to your local network. + port: Port to bind to. + allow_origins: Allowed CORS origins. + 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. + 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 (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. + custom_nodes_dir: Path to directory for custom nodes. + style_presets_dir: Path to directory for style presets. + 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. + 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). + lazy_offload: Keep models in VRAM until their space is needed. + 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` + 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` + attention_slice_size: Slice size, valid when attention_type=="sliced".
Valid values: `auto`, `balanced`, `max`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8` + 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. + 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. + """ + + _root: Optional[Path] = PrivateAttr(default=None) + _config_file: Optional[Path] = PrivateAttr(default=None) + + # fmt: off + + # INTERNAL + schema_version: str = Field(default=CONFIG_SCHEMA_VERSION, description="Schema version of the config file. This is not a user-configurable setting.") + # This is only used during v3 models.yaml migration + legacy_models_yaml_path: Optional[Path] = Field(default=None, description="Path to the legacy models.yaml file. This is not a user-configurable setting.") + + # WEB + host: str = Field(default="127.0.0.1", description="IP address to bind to. Use `0.0.0.0` to serve to your local network.") + port: int = Field(default=9090, description="Port to bind to.") + allow_origins: list[str] = Field(default=[], description="Allowed CORS origins.") + 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.") + + # MISC FEATURES + log_tokenization: bool = Field(default=False, description="Enable logging of parsed prompt tokens.") + patchmatch: bool = Field(default=True, description="Enable patchmatch inpaint code.") + + # 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 (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.") + 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.") + + # LOGGING + log_handlers: list[str] = Field(default=["console"], description='Log handler. Valid options are "console", "file=", "syslog=path|address:host:port", "http=".') + # note - would be better to read the log_format values from logging.py, but this creates circular dependencies issues + 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.") + + # Development + use_memory_db: bool = Field(default=False, description="Use in-memory database. Useful for development.") + dev_reload: bool = Field(default=False, description="Automatically reload when Python sources are changed. Does not reload node definitions.") + profile_graphs: bool = Field(default=False, description="Enable graph profiling using `cProfile`.") + profile_prefix: Optional[str] = Field(default=None, description="An optional prefix for profile output files.") + 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).") + lazy_offload: bool = Field(default=True, description="Keep models in VRAM until their space is needed.") + 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.") + + # 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.") + 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 + sequential_guidance: bool = Field(default=False, description="Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.") + attention_type: ATTENTION_TYPE = Field(default="auto", description="Attention type.") + attention_slice_size: ATTENTION_SLICE_SIZE = Field(default="auto", description='Slice size, valid when attention_type=="sliced".') + 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.") + + # NODES + allow_nodes: Optional[list[str]] = Field(default=None, description="List of nodes to allow. Omit to allow all.") + deny_nodes: Optional[list[str]] = Field(default=None, description="List of nodes to deny. Omit to deny none.") + node_cache_size: int = Field(default=512, description="How many cached nodes to keep in memory.") + + # MODEL INSTALL + 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.") + + # fmt: on + + model_config = SettingsConfigDict(env_prefix="INVOKEAI_", env_ignore_empty=True) + + def update_config(self, config: dict[str, Any] | InvokeAIAppConfig, clobber: bool = True) -> None: + """Updates the config, overwriting existing values. + + Args: + config: A dictionary of config settings, or instance of `InvokeAIAppConfig`. If an instance of \ + `InvokeAIAppConfig`, only the explicitly set fields will be merged into the singleton config. + clobber: If `True`, overwrite existing values. If `False`, only update fields that are not already set. + """ + + if isinstance(config, dict): + new_config = self.model_validate(config) + else: + new_config = config + + for field_name in new_config.model_fields_set: + new_value = getattr(new_config, field_name) + current_value = getattr(self, field_name) + + if field_name in self.model_fields_set and not clobber: + continue + + if new_value != current_value: + setattr(self, field_name, new_value) + + def write_file(self, dest_path: Path, as_example: bool = False) -> None: + """Write the current configuration to file. This will overwrite the existing file. + + A `meta` stanza is added to the top of the file, containing metadata about the config file. This is not stored in the config object. + + Args: + dest_path: Path to write the config to. + """ + dest_path.parent.mkdir(parents=True, exist_ok=True) + with open(dest_path, "w") as file: + # Meta fields should be written in a separate stanza - skip legacy_models_yaml_path + meta_dict = self.model_dump(mode="json", include={"schema_version"}) + + # User settings + config_dict = self.model_dump( + mode="json", + exclude_unset=False if as_example else True, + exclude_defaults=False if as_example else True, + exclude_none=True if as_example else False, + exclude={"schema_version", "legacy_models_yaml_path"}, + ) + + if as_example: + 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/configuration/:\n") + if len(config_dict) > 0: + file.write(yaml.dump(config_dict, sort_keys=False)) + + def _resolve(self, partial_path: Path) -> Path: + return (self.root_path / partial_path).resolve() + + @property + def root_path(self) -> Path: + """Path to the runtime root directory, resolved to an absolute path.""" + if self._root: + root = Path(self._root).expanduser().absolute() + else: + root = self.find_root().expanduser().absolute() + self._root = root # insulate ourselves from relative paths that may change + return root.resolve() + + @property + def config_file_path(self) -> Path: + """Path to invokeai.yaml, resolved to an absolute path..""" + resolved_path = self._resolve(self._config_file or INIT_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..""" + return self._resolve(self.outputs_dir) + + @property + def db_path(self) -> Path: + """Path to the invokeai.db file, resolved to an absolute path..""" + db_dir = self._resolve(self.db_dir) + assert db_dir is not None + return db_dir / DB_FILE + + @property + def legacy_conf_path(self) -> Path: + """Path to directory of legacy configuration files (e.g. v1-inference.yaml), resolved to an absolute path..""" + return self._resolve(self.legacy_conf_dir) + + @property + 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 convert_cache_path(self) -> Path: + """Path to the converted cache models directory, resolved to an absolute path..""" + return self._resolve(self.convert_cache_dir) + + @property + def download_cache_path(self) -> Path: + """Path to the downloaded models directory, resolved to an absolute path..""" + return self._resolve(self.download_cache_dir) + + @property + def custom_nodes_path(self) -> Path: + """Path to the custom nodes directory, resolved to an absolute path..""" + custom_nodes_path = self._resolve(self.custom_nodes_dir) + assert custom_nodes_path is not None + return custom_nodes_path + + @property + def profiles_path(self) -> Path: + """Path to the graph profiles directory, resolved to an absolute path..""" + return self._resolve(self.profiles_dir) + + @staticmethod + def find_root() -> Path: + """Choose the runtime root directory when not specified on command line or init file.""" + if os.environ.get("INVOKEAI_ROOT"): + root = Path(os.environ["INVOKEAI_ROOT"]) + elif venv := os.environ.get("VIRTUAL_ENV", None): + root = Path(venv).parent.resolve() + else: + root = Path("~/invokeai").expanduser().resolve() + return root + + +class DefaultInvokeAIAppConfig(InvokeAIAppConfig): + """A version of `InvokeAIAppConfig` that does not automatically parse any settings from environment variables + or any file. + + This is useful for writing out a default config file. + + Note that init settings are set if provided. + """ + + @classmethod + def settings_customise_sources( + cls, + settings_cls: type[BaseSettings], + init_settings: PydanticBaseSettingsSource, + env_settings: PydanticBaseSettingsSource, + dotenv_settings: PydanticBaseSettingsSource, + file_secret_settings: PydanticBaseSettingsSource, + ) -> tuple[PydanticBaseSettingsSource, ...]: + return (init_settings,) + + +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 `InvokeAIAppConfig` config dict. + + """ + parsed_config_dict: dict[str, Any] = {} + for _category_name, category_dict in config_dict["InvokeAI"].items(): + for k, v in category_dict.items(): + # `outdir` was renamed to `outputs_dir` in v4 + if k == "outdir": + parsed_config_dict["outputs_dir"] = v + # `max_cache_size` was renamed to `ram` some time in v3, but both names were used + if k == "max_cache_size" and "ram" not in category_dict: + parsed_config_dict["ram"] = v + # `max_vram_cache_size` was renamed to `vram` some time in v3, but both names were used + if k == "max_vram_cache_size" and "vram" not in category_dict: + parsed_config_dict["vram"] = v + # autocast was removed in v4.0.1 + if k == "precision" and v == "autocast": + parsed_config_dict["precision"] = "auto" + if k == "conf_path": + parsed_config_dict["legacy_models_yaml_path"] = v + if k == "legacy_conf_dir": + # The old default for this was "configs/stable-diffusion" ("configs\stable-diffusion" on Windows). + if v == "configs/stable-diffusion" or v == "configs\\stable-diffusion": + # If if the incoming config has the default value, skip + continue + elif Path(v).name == "stable-diffusion": + # Else if the path ends in "stable-diffusion", we assume the parent is the new correct path. + parsed_config_dict["legacy_conf_dir"] = str(Path(v).parent) + else: + # Else we do not attempt to migrate this setting + parsed_config_dict["legacy_conf_dir"] = v + elif k in InvokeAIAppConfig.model_fields: + # skip unknown fields + parsed_config_dict[k] = v + parsed_config_dict["schema_version"] = "4.0.0" + return parsed_config_dict + + +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: + A config dict with the settings migrated to v4.0.1. + """ + 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 load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig: + """Load and migrate a config file to the latest version. + + Args: + config_path: Path to the config file. + + Returns: + An instance of `InvokeAIAppConfig` with the loaded and migrated settings. + """ + assert config_path.suffix == ".yaml" + with open(config_path, "rt", encoding=locale.getpreferredencoding()) as 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: + 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 migrated: + shutil.copy(config_path, config_path.with_suffix(".yaml.bak")) + try: + # 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 + + 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}" + return config + except Exception as e: + raise RuntimeError(f"Failed to load config file {config_path}: {e}") from e + + +@lru_cache(maxsize=1) +def get_config() -> InvokeAIAppConfig: + """Get the global singleton app config. + + When first called, this function: + - Creates a config object. `pydantic-settings` handles merging of settings from environment variables, but not the init file. + - Retrieves any provided CLI args from the InvokeAIArgs class. It does not _parse_ the CLI args; that is done in the main entrypoint. + - Sets the root dir, if provided via CLI args. + - Logs in to HF if there is no valid token already. + - Copies all legacy configs to the legacy conf dir (needed for conversion from ckpt to diffusers). + - Reads and merges in settings from the config file if it exists, else writes out a default config file. + + On subsequent calls, the object is returned from the cache. + """ + # This object includes environment variables, as parsed by pydantic-settings + config = InvokeAIAppConfig() + + args = InvokeAIArgs.args + + # This flag serves as a proxy for whether the config was retrieved in the context of the full application or not. + # If it is False, we should just return a default config and not set the root, log in to HF, etc. + if not InvokeAIArgs.did_parse: + return config + + # Set CLI args + if root := getattr(args, "root", None): + config._root = Path(root) + if config_file := getattr(args, "config_file", None): + config._config_file = Path(config_file) + + # Create the example config file, with some extra example values provided + example_config = DefaultInvokeAIAppConfig() + example_config.remote_api_tokens = [ + URLRegexTokenPair(url_regex="cool-models.com", token="my_secret_token"), + URLRegexTokenPair(url_regex="nifty-models.com", token="some_other_token"), + ] + 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 + configs_src = Path(model_configs.__path__[0]) # pyright: ignore [reportUnknownMemberType, reportUnknownArgumentType, reportAttributeAccessIssue] + shutil.copytree(configs_src, config.legacy_conf_path, dirs_exist_ok=True) + + if config.config_file_path.exists(): + config_from_file = load_and_migrate_config(config.config_file_path) + # Clobbering here will overwrite any settings that were set via environment variables + config.update_config(config_from_file, clobber=False) + else: + # We should never write env vars to the config file + default_config = DefaultInvokeAIAppConfig() + default_config.write_file(config.config_file_path, as_example=False) + + return config diff --git a/invokeai/app/services/download/__init__.py b/invokeai/app/services/download/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..48ded7d5496572859c10ecc173343e51bc2b147c --- /dev/null +++ b/invokeai/app/services/download/__init__.py @@ -0,0 +1,20 @@ +"""Init file for download queue.""" + +from invokeai.app.services.download.download_base import ( + DownloadJob, + DownloadJobStatus, + DownloadQueueServiceBase, + MultiFileDownloadJob, + UnknownJobIDException, +) +from invokeai.app.services.download.download_default import DownloadQueueService, TqdmProgress + +__all__ = [ + "DownloadJob", + "MultiFileDownloadJob", + "DownloadQueueServiceBase", + "DownloadQueueService", + "TqdmProgress", + "DownloadJobStatus", + "UnknownJobIDException", +] diff --git a/invokeai/app/services/download/download_base.py b/invokeai/app/services/download/download_base.py new file mode 100644 index 0000000000000000000000000000000000000000..4880ab98b89d7244d6c7ba4be9927bbf9bc57e19 --- /dev/null +++ b/invokeai/app/services/download/download_base.py @@ -0,0 +1,340 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team +"""Model download service.""" + +from abc import ABC, abstractmethod +from enum import Enum +from functools import total_ordering +from pathlib import Path +from typing import Any, Callable, List, Optional, Set, Union + +from pydantic import BaseModel, Field, PrivateAttr +from pydantic.networks import AnyHttpUrl + +from invokeai.backend.model_manager.metadata import RemoteModelFile + + +class DownloadJobStatus(str, Enum): + """State of a download job.""" + + WAITING = "waiting" # not enqueued, will not run + RUNNING = "running" # actively downloading + COMPLETED = "completed" # finished running + CANCELLED = "cancelled" # user cancelled + ERROR = "error" # terminated with an error message + + +class DownloadJobCancelledException(Exception): + """This exception is raised when a download job is cancelled.""" + + +class UnknownJobIDException(Exception): + """This exception is raised when an invalid job id is referened.""" + + +class ServiceInactiveException(Exception): + """This exception is raised when user attempts to initiate a download before the service is started.""" + + +SingleFileDownloadEventHandler = Callable[["DownloadJob"], None] +SingleFileDownloadExceptionHandler = Callable[["DownloadJob", Optional[Exception]], None] +MultiFileDownloadEventHandler = Callable[["MultiFileDownloadJob"], None] +MultiFileDownloadExceptionHandler = Callable[["MultiFileDownloadJob", Optional[Exception]], None] +DownloadEventHandler = Union[SingleFileDownloadEventHandler, MultiFileDownloadEventHandler] +DownloadExceptionHandler = Union[SingleFileDownloadExceptionHandler, MultiFileDownloadExceptionHandler] + + +class DownloadJobBase(BaseModel): + """Base of classes to monitor and control downloads.""" + + # automatically assigned on creation + id: int = Field(description="Numeric ID of this job", default=-1) # default id is a sentinel + + dest: Path = Field(description="Initial destination of downloaded model on local disk; a directory or file path") + download_path: Optional[Path] = Field(default=None, description="Final location of downloaded file or directory") + status: DownloadJobStatus = Field(default=DownloadJobStatus.WAITING, description="Status of the download") + bytes: int = Field(default=0, description="Bytes downloaded so far") + total_bytes: int = Field(default=0, description="Total file size (bytes)") + + # set when an error occurs + error_type: Optional[str] = Field(default=None, description="Name of exception that caused an error") + error: Optional[str] = Field(default=None, description="Traceback of the exception that caused an error") + + # internal flag + _cancelled: bool = PrivateAttr(default=False) + + # optional event handlers passed in on creation + _on_start: Optional[DownloadEventHandler] = PrivateAttr(default=None) + _on_progress: Optional[DownloadEventHandler] = PrivateAttr(default=None) + _on_complete: Optional[DownloadEventHandler] = PrivateAttr(default=None) + _on_cancelled: Optional[DownloadEventHandler] = PrivateAttr(default=None) + _on_error: Optional[DownloadExceptionHandler] = PrivateAttr(default=None) + + def cancel(self) -> None: + """Call to cancel the job.""" + 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 + @property + def cancelled(self) -> bool: + """Call to cancel the job.""" + return self._cancelled + + @property + def complete(self) -> bool: + """Return true if job completed without errors.""" + return self.status == DownloadJobStatus.COMPLETED + + @property + def waiting(self) -> bool: + """Return true if the job is waiting to run.""" + return self.status == DownloadJobStatus.WAITING + + @property + def running(self) -> bool: + """Return true if the job is running.""" + return self.status == DownloadJobStatus.RUNNING + + @property + def errored(self) -> bool: + """Return true if the job is errored.""" + return self.status == DownloadJobStatus.ERROR + + @property + def in_terminal_state(self) -> bool: + """Return true if job has finished, one way or another.""" + return self.status not in [DownloadJobStatus.WAITING, DownloadJobStatus.RUNNING] + + @property + def on_start(self) -> Optional[DownloadEventHandler]: + """Return the on_start event handler.""" + return self._on_start + + @property + def on_progress(self) -> Optional[DownloadEventHandler]: + """Return the on_progress event handler.""" + return self._on_progress + + @property + def on_complete(self) -> Optional[DownloadEventHandler]: + """Return the on_complete event handler.""" + return self._on_complete + + @property + def on_error(self) -> Optional[DownloadExceptionHandler]: + """Return the on_error event handler.""" + return self._on_error + + @property + def on_cancelled(self) -> Optional[DownloadEventHandler]: + """Return the on_cancelled event handler.""" + return self._on_cancelled + + def set_callbacks( + self, + on_start: Optional[DownloadEventHandler] = None, + on_progress: Optional[DownloadEventHandler] = None, + on_complete: Optional[DownloadEventHandler] = None, + on_cancelled: Optional[DownloadEventHandler] = None, + on_error: Optional[DownloadExceptionHandler] = None, + ) -> None: + """Set the callbacks for download events.""" + self._on_start = on_start + self._on_progress = on_progress + self._on_complete = on_complete + self._on_error = on_error + self._on_cancelled = on_cancelled + + +@total_ordering +class DownloadJob(DownloadJobBase): + """Class to monitor and control a model download request.""" + + # required variables to be passed in on creation + source: AnyHttpUrl = Field(description="Where to download from. Specific types specified in child classes.") + access_token: Optional[str] = Field(default=None, description="authorization token for protected resources") + priority: int = Field(default=10, description="Queue priority; lower values are higher priority") + + # set internally during download process + job_started: Optional[str] = Field(default=None, description="Timestamp for when the download job started") + job_ended: Optional[str] = Field( + 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") + + def __hash__(self) -> int: + """Return hash of the string representation of this object, for indexing.""" + return hash(str(self)) + + def __le__(self, other: "DownloadJob") -> bool: + """Return True if this job's priority is less than another's.""" + return self.priority <= other.priority + + +class MultiFileDownloadJob(DownloadJobBase): + """Class to monitor and control multifile downloads.""" + + download_parts: Set[DownloadJob] = Field(default_factory=set, description="List of download parts.") + + +class DownloadQueueServiceBase(ABC): + """Multithreaded queue for downloading models via URL.""" + + @abstractmethod + def start(self, *args: Any, **kwargs: Any) -> None: + """Start the download worker threads.""" + + @abstractmethod + def stop(self, *args: Any, **kwargs: Any) -> None: + """Stop the download worker threads.""" + + @abstractmethod + def download( + self, + source: AnyHttpUrl, + dest: Path, + priority: int = 10, + access_token: Optional[str] = None, + on_start: Optional[DownloadEventHandler] = None, + on_progress: Optional[DownloadEventHandler] = None, + on_complete: Optional[DownloadEventHandler] = None, + on_cancelled: Optional[DownloadEventHandler] = None, + on_error: Optional[DownloadExceptionHandler] = None, + ) -> DownloadJob: + """ + Create and enqueue download job. + + :param source: Source of the download as a URL. + :param dest: Path to download to. See below. + :param on_start, on_progress, on_complete, on_error: Callbacks for the indicated + events. + :returns: A DownloadJob object for monitoring the state of the download. + + The `dest` argument is a Path object. Its behavior is: + + 1. If the path exists and is a directory, then the URL contents will be downloaded + into that directory using the filename indicated in the response's `Content-Disposition` field. + If no content-disposition is present, then the last component of the URL will be used (similar to + wget's behavior). + 2. If the path does not exist, then it is taken as the name of a new file to create with the downloaded + content. + 3. If the path exists and is an existing file, then the downloader will try to resume the download from + the end of the existing file. + + """ + pass + + @abstractmethod + def multifile_download( + self, + parts: List[RemoteModelFile], + dest: Path, + access_token: Optional[str] = None, + submit_job: bool = True, + on_start: Optional[DownloadEventHandler] = None, + on_progress: Optional[DownloadEventHandler] = None, + on_complete: Optional[DownloadEventHandler] = None, + on_cancelled: Optional[DownloadEventHandler] = None, + on_error: Optional[DownloadExceptionHandler] = None, + ) -> MultiFileDownloadJob: + """ + Create and enqueue a multifile download job. + + :param parts: Set of URL / filename pairs + :param dest: Path to download to. See below. + :param access_token: Access token to download the indicated files. If not provided, + each file's URL may be matched to an access token using the config file matching + system. + :param submit_job: If true [default] then submit the job for execution. Otherwise, + you will need to pass the job to submit_multifile_download(). + :param on_start, on_progress, on_complete, on_error: Callbacks for the indicated + events. + :returns: A MultiFileDownloadJob object for monitoring the state of the download. + + The `dest` argument is a Path object pointing to a directory. All downloads + with be placed inside this directory. The callbacks will receive the + MultiFileDownloadJob. + """ + pass + + @abstractmethod + def submit_multifile_download(self, job: MultiFileDownloadJob) -> None: + """ + Enqueue a previously-created multi-file download job. + + :param job: A MultiFileDownloadJob created with multifile_download() + """ + pass + + @abstractmethod + def submit_download_job( + self, + job: DownloadJob, + on_start: Optional[DownloadEventHandler] = None, + on_progress: Optional[DownloadEventHandler] = None, + on_complete: Optional[DownloadEventHandler] = None, + on_cancelled: Optional[DownloadEventHandler] = None, + on_error: Optional[DownloadExceptionHandler] = None, + ) -> None: + """ + Enqueue a download job. + + :param job: The DownloadJob + :param on_start, on_progress, on_complete, on_error: Callbacks for the indicated + events. + """ + pass + + @abstractmethod + def list_jobs(self) -> List[DownloadJob]: + """ + List active download jobs. + + :returns List[DownloadJob]: List of download jobs whose state is not "completed." + """ + pass + + @abstractmethod + def id_to_job(self, id: int) -> DownloadJob: + """ + Return the DownloadJob corresponding to the integer ID. + + :param id: ID of the DownloadJob. + + Exceptions: + * UnknownJobIDException + """ + pass + + @abstractmethod + def cancel_all_jobs(self) -> None: + """Cancel all active and enquedjobs.""" + pass + + @abstractmethod + def prune_jobs(self) -> None: + """Prune completed and errored queue items from the job list.""" + pass + + @abstractmethod + def cancel_job(self, job: DownloadJobBase) -> None: + """Cancel the job, clearing partial downloads and putting it into ERROR state.""" + pass + + @abstractmethod + def join(self) -> None: + """Wait until all jobs are off the queue.""" + pass + + @abstractmethod + def wait_for_job(self, job: DownloadJobBase, timeout: int = 0) -> DownloadJobBase: + """Wait until the indicated download job has reached a terminal state. + + This will block until the indicated install job has completed, + been cancelled, or errored out. + + :param job: The job to wait on. + :param timeout: Wait up to indicated number of seconds. Raise a TimeoutError if + the job hasn't completed within the indicated time. + """ + pass diff --git a/invokeai/app/services/download/download_default.py b/invokeai/app/services/download/download_default.py new file mode 100644 index 0000000000000000000000000000000000000000..b97f61657cd3bb889ee29449050a89f4b0a5c6e4 --- /dev/null +++ b/invokeai/app/services/download/download_default.py @@ -0,0 +1,594 @@ +# Copyright (c) 2023, Lincoln D. Stein +"""Implementation of multithreaded download queue for invokeai.""" + +import os +import re +import threading +import time +import traceback +from pathlib import Path +from queue import Empty, PriorityQueue +from typing import Any, Dict, List, Literal, Optional, Set + +import requests +from pydantic.networks import AnyHttpUrl +from requests import HTTPError +from tqdm import tqdm + +from invokeai.app.services.config import InvokeAIAppConfig, get_config +from invokeai.app.services.download.download_base import ( + DownloadEventHandler, + DownloadExceptionHandler, + DownloadJob, + DownloadJobBase, + DownloadJobCancelledException, + DownloadJobStatus, + DownloadQueueServiceBase, + MultiFileDownloadJob, + ServiceInactiveException, + UnknownJobIDException, +) +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 + +# Maximum number of bytes to download during each call to requests.iter_content() +DOWNLOAD_CHUNK_SIZE = 100000 + + +class DownloadQueueService(DownloadQueueServiceBase): + """Class for queued download of models.""" + + def __init__( + self, + max_parallel_dl: int = 5, + app_config: Optional[InvokeAIAppConfig] = None, + event_bus: Optional["EventServiceBase"] = None, + requests_session: Optional[requests.sessions.Session] = None, + ): + """ + Initialize DownloadQueue. + + :param app_config: InvokeAIAppConfig object + :param max_parallel_dl: Number of simultaneous downloads allowed [5]. + :param requests_session: Optional requests.sessions.Session object, for unit tests. + """ + self._app_config = app_config or get_config() + self._jobs: Dict[int, DownloadJob] = {} + self._download_part2parent: Dict[AnyHttpUrl, MultiFileDownloadJob] = {} + self._next_job_id = 0 + self._queue: PriorityQueue[DownloadJob] = PriorityQueue() + self._stop_event = threading.Event() + self._job_terminated_event = threading.Event() + self._worker_pool: Set[threading.Thread] = set() + self._lock = threading.Lock() + self._logger = InvokeAILogger.get_logger("DownloadQueueService") + self._event_bus = event_bus + self._requests = requests_session or requests.Session() + self._accept_download_requests = False + self._max_parallel_dl = max_parallel_dl + + def start(self, *args: Any, **kwargs: Any) -> None: + """Start the download worker threads.""" + with self._lock: + if self._worker_pool: + raise Exception("Attempt to start the download service twice") + self._stop_event.clear() + self._start_workers(self._max_parallel_dl) + self._accept_download_requests = True + + 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") + 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] + if queued_jobs: + self._logger.warning(f"Cancelling {len(queued_jobs)} queued downloads") + if active_jobs: + self._logger.info(f"Waiting for {len(active_jobs)} active download jobs to complete") + with self._queue.mutex: + self._queue.queue.clear() + self.cancel_all_jobs() + self._stop_event.set() + for thread in self._worker_pool: + thread.join() + self._worker_pool.clear() + + def submit_download_job( + self, + job: DownloadJob, + on_start: Optional[DownloadEventHandler] = None, + on_progress: Optional[DownloadEventHandler] = None, + on_complete: Optional[DownloadEventHandler] = None, + on_cancelled: Optional[DownloadEventHandler] = None, + on_error: Optional[DownloadExceptionHandler] = None, + ) -> None: + """Enqueue a download job.""" + if not self._accept_download_requests: + raise ServiceInactiveException( + "The download service is not currently accepting requests. Please call start() to initialize the service." + ) + job.id = self._next_id() + job.set_callbacks( + on_start=on_start, + on_progress=on_progress, + on_complete=on_complete, + on_cancelled=on_cancelled, + on_error=on_error, + ) + self._jobs[job.id] = job + self._queue.put(job) + + def download( + self, + source: AnyHttpUrl, + dest: Path, + priority: int = 10, + access_token: Optional[str] = None, + on_start: Optional[DownloadEventHandler] = None, + on_progress: Optional[DownloadEventHandler] = None, + on_complete: Optional[DownloadEventHandler] = None, + on_cancelled: Optional[DownloadEventHandler] = None, + on_error: Optional[DownloadExceptionHandler] = None, + ) -> DownloadJob: + """Create and enqueue a download job and return it.""" + if not self._accept_download_requests: + raise ServiceInactiveException( + "The download service is not currently accepting requests. Please call start() to initialize the service." + ) + job = DownloadJob( + source=source, + dest=dest, + priority=priority, + access_token=access_token or self._lookup_access_token(source), + ) + self.submit_download_job( + job, + on_start=on_start, + on_progress=on_progress, + on_complete=on_complete, + on_cancelled=on_cancelled, + on_error=on_error, + ) + return job + + def multifile_download( + self, + parts: List[RemoteModelFile], + dest: Path, + access_token: Optional[str] = None, + submit_job: bool = True, + on_start: Optional[DownloadEventHandler] = None, + on_progress: Optional[DownloadEventHandler] = None, + on_complete: Optional[DownloadEventHandler] = None, + on_cancelled: Optional[DownloadEventHandler] = None, + on_error: Optional[DownloadExceptionHandler] = None, + ) -> MultiFileDownloadJob: + mfdj = MultiFileDownloadJob(dest=dest, id=self._next_id()) + mfdj.set_callbacks( + on_start=on_start, + on_progress=on_progress, + on_complete=on_complete, + on_cancelled=on_cancelled, + on_error=on_error, + ) + + for part in parts: + url = part.url + path = dest / part.path + assert path.is_relative_to(dest), "only relative download paths accepted" + job = DownloadJob( + source=url, + dest=path, + access_token=access_token or self._lookup_access_token(url), + ) + mfdj.download_parts.add(job) + self._download_part2parent[job.source] = 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, + ) + + def join(self) -> None: + """Wait for all jobs to complete.""" + self._queue.join() + + def _next_id(self) -> int: + with self._lock: + id = self._next_job_id + self._next_job_id += 1 + return id + + def list_jobs(self) -> List[DownloadJob]: + """List all the jobs.""" + return list(self._jobs.values()) + + def prune_jobs(self) -> None: + """Prune completed and errored queue items from the job list.""" + with self._lock: + to_delete = set() + for job_id, job in self._jobs.items(): + if job.in_terminal_state: + to_delete.add(job_id) + for job_id in to_delete: + del self._jobs[job_id] + + def id_to_job(self, id: int) -> DownloadJob: + """Translate a job ID into a DownloadJob object.""" + try: + return self._jobs[id] + except KeyError as excp: + raise UnknownJobIDException("Unrecognized job") from excp + + def cancel_job(self, job: DownloadJobBase) -> None: + """ + Cancel the indicated job. + + If it is running it will be stopped. + job.status will be set to DownloadJobStatus.CANCELLED + """ + if job.status in [DownloadJobStatus.WAITING, DownloadJobStatus.RUNNING]: + job.cancel() + + def cancel_all_jobs(self) -> None: + """Cancel all jobs (those not in enqueued, running or paused state).""" + for job in self._jobs.values(): + if not job.in_terminal_state: + self.cancel_job(job) + + def wait_for_job(self, job: DownloadJobBase, timeout: int = 0) -> DownloadJobBase: + """Block until the indicated job has reached terminal state, or when timeout limit reached.""" + start = time.time() + while not job.in_terminal_state: + if self._job_terminated_event.wait(timeout=0.25): # in case we miss an event + self._job_terminated_event.clear() + if timeout > 0 and time.time() - start > timeout: + raise TimeoutError("Timeout exceeded") + return job + + def _start_workers(self, max_workers: int) -> None: + """Start the requested number of worker threads.""" + self._stop_event.clear() + for i in range(0, max_workers): # noqa B007 + worker = threading.Thread(target=self._download_next_item, daemon=True) + self._logger.debug(f"Download queue worker thread {worker.name} starting.") + worker.start() + self._worker_pool.add(worker) + + def _download_next_item(self) -> None: + """Worker thread gets next job on priority queue.""" + done = False + while not done: + if self._stop_event.is_set(): + done = True + continue + try: + job = self._queue.get(timeout=1) + except Empty: + continue + try: + job.job_started = get_iso_timestamp() + self._do_download(job) + self._signal_job_complete(job) + except DownloadJobCancelledException: + 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() + self._signal_job_error(job, excp) + 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._queue.task_done() + + self._logger.debug(f"Download queue worker thread {threading.current_thread().name} exiting.") + + def _do_download(self, job: DownloadJob) -> None: + """Do the actual download.""" + + url = job.source + header = {"Authorization": f"Bearer {job.access_token}"} if job.access_token else {} + open_mode = "wb" + + # 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) + if not resp.ok: + raise HTTPError(resp.reason) + + job.content_type = resp.headers.get("Content-Type") + 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 + + if match := re.search('filename="(.+)"', resp.headers.get("Content-Disposition", "")): + remote_name = match.group(1) + if self._validate_filename(job.dest.as_posix(), remote_name): + file_name = remote_name + + job.download_path = job.dest / file_name + + else: + job.dest.parent.mkdir(parents=True, exist_ok=True) + job.download_path = job.dest + + assert job.download_path + + # Don't clobber an existing file. See commit 82c2c85202f88c6d24ff84710f297cfc6ae174af + # for code that instead resumes an interrupted download. + if job.download_path.exists(): + 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) + + # "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.") + 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") + + # some other error + elif resp.status_code != 200: + raise HTTPError(resp.reason) + + self._logger.debug(f"{job.source}: Downloading {job.download_path}") + report_delta = job.total_bytes / 100 # report every 1% change + last_report_bytes = 0 + + # DOWNLOAD LOOP + with open(in_progress_path, open_mode) as file: + for data in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE): + if job.cancelled: + raise DownloadJobCancelledException("Job was cancelled at caller's request") + + job.bytes += file.write(data) + if (job.bytes - last_report_bytes >= report_delta) or (job.bytes >= job.total_bytes): + last_report_bytes = job.bytes + self._signal_job_progress(job) + + # 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) + + def _validate_filename(self, directory: str, filename: str) -> bool: + pc_name_max = get_pc_name_max(directory) + pc_path_max = get_pc_path_max(directory) + if "/" in filename: + return False + if filename.startswith(".."): + return False + if len(filename) > pc_name_max: + return False + if len(os.path.join(directory, filename)) > pc_path_max: + return False + return True + + def _in_progress_path(self, path: Path) -> Path: + return path.with_name(path.name + ".downloading") + + def _lookup_access_token(self, source: AnyHttpUrl) -> Optional[str]: + # Pull the token from config if it exists and matches the URL + token = None + for pair in self._app_config.remote_api_tokens or []: + if re.search(pair.url_regex, str(source)): + token = pair.token + break + return token + + def _signal_job_started(self, job: DownloadJob) -> None: + job.status = DownloadJobStatus.RUNNING + self._execute_cb(job, "on_start") + if self._event_bus: + self._event_bus.emit_download_started(job) + + def _signal_job_progress(self, job: DownloadJob) -> None: + self._execute_cb(job, "on_progress") + if self._event_bus: + self._event_bus.emit_download_progress(job) + + def _signal_job_complete(self, job: DownloadJob) -> None: + job.status = DownloadJobStatus.COMPLETED + self._execute_cb(job, "on_complete") + if self._event_bus: + self._event_bus.emit_download_complete(job) + + def _signal_job_cancelled(self, job: DownloadJob) -> None: + if job.status not in [DownloadJobStatus.RUNNING, DownloadJobStatus.WAITING]: + return + job.status = DownloadJobStatus.CANCELLED + self._execute_cb(job, "on_cancelled") + if self._event_bus: + 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 not parent_job.in_terminal_state: + parent_job.status = DownloadJobStatus.CANCELLED + 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)}") + self._execute_cb(job, "on_error", excp) + + if self._event_bus: + self._event_bus.emit_download_error(job) + + def _cleanup_cancelled_job(self, job: DownloadJob) -> None: + self._logger.debug(f"Cleaning up leftover files from cancelled download job {job.download_path}") + try: + if job.download_path: + partial_file = self._in_progress_path(job.download_path) + partial_file.unlink() + except OSError as excp: + self._logger.warning(excp) + + ######################################## + # callbacks used for multifile downloads + ######################################## + 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] + if mf_job.waiting: + mf_job.total_bytes = sum(x.total_bytes for x in mf_job.download_parts) + mf_job.status = DownloadJobStatus.RUNNING + assert download_job.download_path is not None + path_relative_to_destdir = download_job.download_path.relative_to(mf_job.dest) + mf_job.download_path = ( + mf_job.dest / path_relative_to_destdir.parts[0] + ) # keep just the first component of the path + self._execute_cb(mf_job, "on_start") + + def _mfd_progress(self, download_job: DownloadJob) -> None: + with self._lock: + mf_job = self._download_part2parent[download_job.source] + 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) + self._execute_cb(mf_job, "on_progress") + + def _mfd_complete(self, download_job: DownloadJob) -> None: + self._logger.info(f"Download complete: {download_job.source}") + with self._lock: + mf_job = self._download_part2parent[download_job.source] + + # 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): + mf_job.status = DownloadJobStatus.COMPLETED + self._execute_cb(mf_job, "on_complete") + + # we're done with this sub-job + self._job_terminated_event.set() + + def _mfd_cancelled(self, download_job: DownloadJob) -> None: + with self._lock: + mf_job = self._download_part2parent[download_job.source] + assert mf_job is not None + + if not mf_job.in_terminal_state: + self._logger.warning(f"Download cancelled: {download_job.source}") + mf_job.cancel() + + for s in mf_job.download_parts: + self.cancel_job(s) + + def _mfd_error(self, download_job: DownloadJob, excp: Optional[Exception] = None) -> None: + with self._lock: + mf_job = self._download_part2parent[download_job.source] + assert mf_job is not None + if not mf_job.in_terminal_state: + mf_job.status = download_job.status + mf_job.error = download_job.error + mf_job.error_type = download_job.error_type + self._execute_cb(mf_job, "on_error", excp) + self._logger.error( + f"Cancelling {mf_job.dest} due to an error while downloading {download_job.source}: {str(excp)}" + ) + 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._job_terminated_event.set() + + def _execute_cb( + self, + job: DownloadJob | MultiFileDownloadJob, + callback_name: Literal[ + "on_start", + "on_progress", + "on_complete", + "on_cancelled", + "on_error", + ], + excp: Optional[Exception] = None, + ) -> None: + if callback := getattr(job, callback_name, None): + args = [job, excp] if excp else [job] + try: + callback(*args) + except Exception as e: + self._logger.error( + f"An error occurred while processing the {callback_name} callback: {traceback.format_exception(e)}" + ) + + +def get_pc_name_max(directory: str) -> int: + if hasattr(os, "pathconf"): + try: + return os.pathconf(directory, "PC_NAME_MAX") + except OSError: + # macOS w/ external drives raise OSError + pass + return 260 # hardcoded for windows + + +def get_pc_path_max(directory: str) -> int: + if hasattr(os, "pathconf"): + try: + return os.pathconf(directory, "PC_PATH_MAX") + except OSError: + # some platforms may not have this value + pass + return 32767 # hardcoded for windows with long names enabled + + +# Example on_progress event handler to display a TQDM status bar +# Activate with: +# download_service.download(DownloadJob('http://foo.bar/baz', '/tmp', on_progress=TqdmProgress().update)) +class TqdmProgress(object): + """TQDM-based progress bar object to use in on_progress handlers.""" + + _bars: Dict[int, tqdm] # type: ignore + _last: Dict[int, int] # last bytes downloaded + + def __init__(self) -> None: # noqa D107 + self._bars = {} + self._last = {} + + def update(self, job: DownloadJob) -> None: # noqa D102 + job_id = job.id + # new job + if job_id not in self._bars: + assert job.download_path + dest = Path(job.download_path).name + self._bars[job_id] = tqdm( + desc=dest, + initial=0, + total=job.total_bytes, + unit="iB", + unit_scale=True, + ) + self._last[job_id] = 0 + self._bars[job_id].update(job.bytes - self._last[job_id]) + self._last[job_id] = job.bytes diff --git a/invokeai/app/services/events/__init__.py b/invokeai/app/services/events/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..17407d3b72a62c00fbab23013e9ae77f1c35eca5 --- /dev/null +++ b/invokeai/app/services/events/__init__.py @@ -0,0 +1 @@ +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 new file mode 100644 index 0000000000000000000000000000000000000000..71afddbc2570b5a7c614206f299d83edb2c766c5 --- /dev/null +++ b/invokeai/app/services/events/events_base.py @@ -0,0 +1,199 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) + + +from typing import TYPE_CHECKING, Optional + +from invokeai.app.services.events.events_common import ( + BatchEnqueuedEvent, + BulkDownloadCompleteEvent, + BulkDownloadErrorEvent, + BulkDownloadStartedEvent, + DownloadCancelledEvent, + DownloadCompleteEvent, + DownloadErrorEvent, + DownloadProgressEvent, + DownloadStartedEvent, + EventBase, + InvocationCompleteEvent, + InvocationErrorEvent, + InvocationProgressEvent, + InvocationStartedEvent, + ModelInstallCancelledEvent, + ModelInstallCompleteEvent, + ModelInstallDownloadProgressEvent, + ModelInstallDownloadsCompleteEvent, + ModelInstallDownloadStartedEvent, + ModelInstallErrorEvent, + ModelInstallStartedEvent, + ModelLoadCompleteEvent, + ModelLoadStartedEvent, + QueueClearedEvent, + QueueItemStatusChangedEvent, +) + +if TYPE_CHECKING: + from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput + from invokeai.app.services.download.download_base import DownloadJob + from invokeai.app.services.model_install.model_install_common import ModelInstallJob + from invokeai.app.services.session_processor.session_processor_common import ProgressImage + from invokeai.app.services.session_queue.session_queue_common import ( + BatchStatus, + EnqueueBatchResult, + SessionQueueItem, + SessionQueueStatus, + ) + from invokeai.backend.model_manager.config import AnyModelConfig, SubModelType + + +class EventServiceBase: + """Basic event bus, to have an empty stand-in when not needed""" + + def dispatch(self, event: "EventBase") -> None: + pass + + # region: Invocation + + def emit_invocation_started(self, queue_item: "SessionQueueItem", invocation: "BaseInvocation") -> None: + """Emitted when an invocation is started""" + self.dispatch(InvocationStartedEvent.build(queue_item, invocation)) + + def emit_invocation_progress( + self, + queue_item: "SessionQueueItem", + invocation: "BaseInvocation", + message: str, + percentage: float | None = None, + image: "ProgressImage | None" = None, + ) -> None: + """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" + ) -> None: + """Emitted when an invocation is complete""" + self.dispatch(InvocationCompleteEvent.build(queue_item, invocation, output)) + + def emit_invocation_error( + self, + queue_item: "SessionQueueItem", + invocation: "BaseInvocation", + error_type: str, + error_message: str, + error_traceback: str, + ) -> None: + """Emitted when an invocation encounters an error""" + self.dispatch(InvocationErrorEvent.build(queue_item, invocation, error_type, error_message, error_traceback)) + + # endregion + + # region Queue + + def emit_queue_item_status_changed( + self, queue_item: "SessionQueueItem", batch_status: "BatchStatus", queue_status: "SessionQueueStatus" + ) -> None: + """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: + """Emitted when a batch is enqueued""" + self.dispatch(BatchEnqueuedEvent.build(enqueue_result)) + + def emit_queue_cleared(self, queue_id: str) -> None: + """Emitted when a queue is cleared""" + self.dispatch(QueueClearedEvent.build(queue_id)) + + # endregion + + # region Download + + def emit_download_started(self, job: "DownloadJob") -> None: + """Emitted when a download is started""" + self.dispatch(DownloadStartedEvent.build(job)) + + def emit_download_progress(self, job: "DownloadJob") -> None: + """Emitted at intervals during a download""" + self.dispatch(DownloadProgressEvent.build(job)) + + def emit_download_complete(self, job: "DownloadJob") -> None: + """Emitted when a download is completed""" + self.dispatch(DownloadCompleteEvent.build(job)) + + def emit_download_cancelled(self, job: "DownloadJob") -> None: + """Emitted when a download is cancelled""" + self.dispatch(DownloadCancelledEvent.build(job)) + + def emit_download_error(self, job: "DownloadJob") -> None: + """Emitted when a download encounters an error""" + self.dispatch(DownloadErrorEvent.build(job)) + + # endregion + + # region Model loading + + def emit_model_load_started(self, config: "AnyModelConfig", submodel_type: Optional["SubModelType"] = None) -> None: + """Emitted when a model load is started.""" + self.dispatch(ModelLoadStartedEvent.build(config, submodel_type)) + + def emit_model_load_complete( + self, config: "AnyModelConfig", submodel_type: Optional["SubModelType"] = None + ) -> None: + """Emitted when a model load is complete.""" + self.dispatch(ModelLoadCompleteEvent.build(config, submodel_type)) + + # endregion + + # region Model install + + def emit_model_install_download_started(self, job: "ModelInstallJob") -> None: + """Emitted at intervals while the install job is started (remote models only).""" + self.dispatch(ModelInstallDownloadStartedEvent.build(job)) + + def emit_model_install_download_progress(self, job: "ModelInstallJob") -> None: + """Emitted at intervals while the install job is in progress (remote models only).""" + self.dispatch(ModelInstallDownloadProgressEvent.build(job)) + + def emit_model_install_downloads_complete(self, job: "ModelInstallJob") -> None: + self.dispatch(ModelInstallDownloadsCompleteEvent.build(job)) + + def emit_model_install_started(self, job: "ModelInstallJob") -> None: + """Emitted once when an install job is started (after any download).""" + self.dispatch(ModelInstallStartedEvent.build(job)) + + def emit_model_install_complete(self, job: "ModelInstallJob") -> None: + """Emitted when an install job is completed successfully.""" + self.dispatch(ModelInstallCompleteEvent.build(job)) + + def emit_model_install_cancelled(self, job: "ModelInstallJob") -> None: + """Emitted when an install job is cancelled.""" + self.dispatch(ModelInstallCancelledEvent.build(job)) + + def emit_model_install_error(self, job: "ModelInstallJob") -> None: + """Emitted when an install job encounters an exception.""" + self.dispatch(ModelInstallErrorEvent.build(job)) + + # endregion + + # region Bulk image download + + def emit_bulk_download_started( + self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str + ) -> None: + """Emitted when a bulk image download is started""" + self.dispatch(BulkDownloadStartedEvent.build(bulk_download_id, bulk_download_item_id, bulk_download_item_name)) + + def emit_bulk_download_complete( + self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str + ) -> None: + """Emitted when a bulk image download is complete""" + self.dispatch(BulkDownloadCompleteEvent.build(bulk_download_id, bulk_download_item_id, bulk_download_item_name)) + + def emit_bulk_download_error( + self, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str, error: str + ) -> 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) + ) + + # endregion diff --git a/invokeai/app/services/events/events_common.py b/invokeai/app/services/events/events_common.py new file mode 100644 index 0000000000000000000000000000000000000000..98b1ee7724114d421774e7363ce4262fac9148b6 --- /dev/null +++ b/invokeai/app/services/events/events_common.py @@ -0,0 +1,626 @@ +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.session_processor.session_processor_common import ProgressImage +from invokeai.app.services.session_queue.session_queue_common import ( + QUEUE_ITEM_STATUS, + BatchStatus, + EnqueueBatchResult, + 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 + +if TYPE_CHECKING: + from invokeai.app.services.download.download_base import DownloadJob + from invokeai.app.services.model_install.model_install_common import ModelInstallJob + + +class EventBase(BaseModel): + """Base class for all events. All events must inherit from this class. + + Events must define a class attribute `__event_name__` to identify the event. + + All other attributes should be defined as normal for a pydantic model. + + A timestamp is automatically added to the event when it is created. + """ + + __event_name__: ClassVar[str] + timestamp: int = Field(description="The timestamp of the event", default_factory=get_timestamp) + + model_config = ConfigDict(json_schema_serialization_defaults_required=True) + + @classmethod + def get_events(cls) -> set[type["EventBase"]]: + """Get a set of all event models.""" + + event_subclasses: set[type["EventBase"]] = set() + for subclass in cls.__subclasses__(): + # We only want to include subclasses that are event models, not intermediary classes + if hasattr(subclass, "__event_name__"): + event_subclasses.add(subclass) + event_subclasses.update(subclass.get_events()) + + return event_subclasses + + +TEvent = TypeVar("TEvent", bound=EventBase, contravariant=True) + +FastAPIEvent: TypeAlias = tuple[str, TEvent] +""" +A tuple representing a `fastapi-events` event, with the event name and payload. +Provide a generic type to `TEvent` to specify the payload type. +""" + + +class FastAPIEventFunc(Protocol, Generic[TEvent]): + def __call__(self, event: FastAPIEvent[TEvent]) -> Optional[Coroutine[Any, Any, None]]: ... + + +def register_events(events: set[type[TEvent]] | type[TEvent], func: FastAPIEventFunc[TEvent]) -> None: + """Register a function to handle specific events. + + :param events: An event or set of events to handle + :param func: The function to handle the events + """ + events = events if isinstance(events, set) else {events} + for event in events: + assert hasattr(event, "__event_name__") + local_handler.register(event_name=event.__event_name__, _func=func) # pyright: ignore [reportUnknownMemberType, reportUnknownArgumentType, reportAttributeAccessIssue] + + +class QueueEventBase(EventBase): + """Base class for queue events""" + + queue_id: str = Field(description="The ID of the queue") + + +class QueueItemEventBase(QueueEventBase): + """Base class for queue item events""" + + 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") + + +class InvocationEventBase(QueueItemEventBase): + """Base class for invocation events""" + + session_id: str = Field(description="The ID of the session (aka graph execution state)") + queue_id: str = Field(description="The ID of the queue") + 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") + + +@payload_schema.register +class InvocationStartedEvent(InvocationEventBase): + """Event model for invocation_started""" + + __event_name__ = "invocation_started" + + @classmethod + def build(cls, queue_item: SessionQueueItem, invocation: AnyInvocation) -> "InvocationStartedEvent": + 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, + session_id=queue_item.session_id, + invocation=invocation, + invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id], + ) + + +@payload_schema.register +class InvocationProgressEvent(InvocationEventBase): + """Event model for invocation_progress""" + + __event_name__ = "invocation_progress" + + 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" + ) + + @classmethod + def build( + cls, + queue_item: SessionQueueItem, + invocation: AnyInvocation, + message: str, + percentage: float | None = None, + image: ProgressImage | None = None, + ) -> "InvocationProgressEvent": + 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, + session_id=queue_item.session_id, + invocation=invocation, + invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id], + percentage=percentage, + image=image, + message=message, + ) + + +@payload_schema.register +class InvocationCompleteEvent(InvocationEventBase): + """Event model for invocation_complete""" + + __event_name__ = "invocation_complete" + + result: AnyInvocationOutput = Field(description="The result of the invocation") + + @classmethod + def build( + cls, queue_item: SessionQueueItem, invocation: AnyInvocation, result: AnyInvocationOutput + ) -> "InvocationCompleteEvent": + 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, + session_id=queue_item.session_id, + invocation=invocation, + invocation_source_id=queue_item.session.prepared_source_mapping[invocation.id], + result=result, + ) + + +@payload_schema.register +class InvocationErrorEvent(InvocationEventBase): + """Event model for invocation_error""" + + __event_name__ = "invocation_error" + + 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( + cls, + queue_item: SessionQueueItem, + invocation: AnyInvocation, + error_type: str, + error_message: str, + error_traceback: str, + ) -> "InvocationErrorEvent": + 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, + 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), + ) + + +@payload_schema.register +class QueueItemStatusChangedEvent(QueueItemEventBase): + """Event model for queue_item_status_changed""" + + __event_name__ = "queue_item_status_changed" + + status: QUEUE_ITEM_STATUS = Field(description="The new status of the queue item") + 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") + 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") + queue_status: SessionQueueStatus = Field(description="The status of the queue") + session_id: str = Field(description="The ID of the session (aka graph execution state)") + + @classmethod + def build( + cls, queue_item: SessionQueueItem, batch_status: BatchStatus, queue_status: SessionQueueStatus + ) -> "QueueItemStatusChangedEvent": + 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, + session_id=queue_item.session_id, + status=queue_item.status, + 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, + 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, + queue_status=queue_status, + ) + + +@payload_schema.register +class BatchEnqueuedEvent(QueueEventBase): + """Event model for batch_enqueued""" + + __event_name__ = "batch_enqueued" + + batch_id: str = Field(description="The ID of the batch") + enqueued: int = Field(description="The number of invocations enqueued") + requested: int = Field( + 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") + + @classmethod + def build(cls, enqueue_result: EnqueueBatchResult) -> "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, + ) + + +@payload_schema.register +class QueueClearedEvent(QueueEventBase): + """Event model for queue_cleared""" + + __event_name__ = "queue_cleared" + + @classmethod + def build(cls, queue_id: str) -> "QueueClearedEvent": + return cls(queue_id=queue_id) + + +class DownloadEventBase(EventBase): + """Base class for events associated with a download""" + + source: str = Field(description="The source of the download") + + +@payload_schema.register +class DownloadStartedEvent(DownloadEventBase): + """Event model for download_started""" + + __event_name__ = "download_started" + + download_path: str = Field(description="The local path where the download is saved") + + @classmethod + def build(cls, job: "DownloadJob") -> "DownloadStartedEvent": + assert job.download_path + return cls(source=str(job.source), download_path=job.download_path.as_posix()) + + +@payload_schema.register +class DownloadProgressEvent(DownloadEventBase): + """Event model for download_progress""" + + __event_name__ = "download_progress" + + download_path: str = Field(description="The local path where the download is saved") + current_bytes: int = Field(description="The number of bytes downloaded so far") + total_bytes: int = Field(description="The total number of bytes to be downloaded") + + @classmethod + def build(cls, job: "DownloadJob") -> "DownloadProgressEvent": + assert job.download_path + return cls( + source=str(job.source), + download_path=job.download_path.as_posix(), + current_bytes=job.bytes, + total_bytes=job.total_bytes, + ) + + +@payload_schema.register +class DownloadCompleteEvent(DownloadEventBase): + """Event model for download_complete""" + + __event_name__ = "download_complete" + + download_path: str = Field(description="The local path where the download is saved") + total_bytes: int = Field(description="The total number of bytes downloaded") + + @classmethod + def build(cls, job: "DownloadJob") -> "DownloadCompleteEvent": + assert job.download_path + return cls(source=str(job.source), download_path=job.download_path.as_posix(), total_bytes=job.total_bytes) + + +@payload_schema.register +class DownloadCancelledEvent(DownloadEventBase): + """Event model for download_cancelled""" + + __event_name__ = "download_cancelled" + + @classmethod + def build(cls, job: "DownloadJob") -> "DownloadCancelledEvent": + return cls(source=str(job.source)) + + +@payload_schema.register +class DownloadErrorEvent(DownloadEventBase): + """Event model for download_error""" + + __event_name__ = "download_error" + + error_type: str = Field(description="The type of error") + error: str = Field(description="The error message") + + @classmethod + def build(cls, job: "DownloadJob") -> "DownloadErrorEvent": + assert job.error_type + assert job.error + return cls(source=str(job.source), error_type=job.error_type, error=job.error) + + +class ModelEventBase(EventBase): + """Base class for events associated with a model""" + + +@payload_schema.register +class ModelLoadStartedEvent(ModelEventBase): + """Event model for model_load_started""" + + __event_name__ = "model_load_started" + + config: AnyModelConfig = Field(description="The model's config") + submodel_type: Optional[SubModelType] = Field(default=None, description="The submodel type, if any") + + @classmethod + def build(cls, config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> "ModelLoadStartedEvent": + return cls(config=config, submodel_type=submodel_type) + + +@payload_schema.register +class ModelLoadCompleteEvent(ModelEventBase): + """Event model for model_load_complete""" + + __event_name__ = "model_load_complete" + + config: AnyModelConfig = Field(description="The model's config") + submodel_type: Optional[SubModelType] = Field(default=None, description="The submodel type, if any") + + @classmethod + def build(cls, config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> "ModelLoadCompleteEvent": + return cls(config=config, submodel_type=submodel_type) + + +@payload_schema.register +class ModelInstallDownloadStartedEvent(ModelEventBase): + """Event model for model_install_download_started""" + + __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") + 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") + parts: list[dict[str, int | str]] = Field( + description="Progress of downloading URLs that comprise the model, if any" + ) + + @classmethod + def build(cls, job: "ModelInstallJob") -> "ModelInstallDownloadStartedEvent": + parts: list[dict[str, str | int]] = [ + { + "url": str(x.source), + "local_path": str(x.download_path), + "bytes": x.bytes, + "total_bytes": x.total_bytes, + } + for x in job.download_parts + ] + return cls( + id=job.id, + source=str(job.source), + local_path=job.local_path.as_posix(), + parts=parts, + bytes=job.bytes, + total_bytes=job.total_bytes, + ) + + +@payload_schema.register +class ModelInstallDownloadProgressEvent(ModelEventBase): + """Event model for model_install_download_progress""" + + __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") + 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") + parts: list[dict[str, int | str]] = Field( + description="Progress of downloading URLs that comprise the model, if any" + ) + + @classmethod + def build(cls, job: "ModelInstallJob") -> "ModelInstallDownloadProgressEvent": + parts: list[dict[str, str | int]] = [ + { + "url": str(x.source), + "local_path": str(x.download_path), + "bytes": x.bytes, + "total_bytes": x.total_bytes, + } + for x in job.download_parts + ] + return cls( + id=job.id, + source=str(job.source), + local_path=job.local_path.as_posix(), + parts=parts, + bytes=job.bytes, + total_bytes=job.total_bytes, + ) + + +@payload_schema.register +class ModelInstallDownloadsCompleteEvent(ModelEventBase): + """Emitted once when an install job becomes active.""" + + __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") + + @classmethod + def build(cls, job: "ModelInstallJob") -> "ModelInstallDownloadsCompleteEvent": + return cls(id=job.id, source=str(job.source)) + + +@payload_schema.register +class ModelInstallStartedEvent(ModelEventBase): + """Event model for model_install_started""" + + __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") + + @classmethod + def build(cls, job: "ModelInstallJob") -> "ModelInstallStartedEvent": + return cls(id=job.id, source=str(job.source)) + + +@payload_schema.register +class ModelInstallCompleteEvent(ModelEventBase): + """Event model for model_install_complete""" + + __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") + 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)") + + @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) + + +@payload_schema.register +class ModelInstallCancelledEvent(ModelEventBase): + """Event model for model_install_cancelled""" + + __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") + + @classmethod + def build(cls, job: "ModelInstallJob") -> "ModelInstallCancelledEvent": + return cls(id=job.id, source=str(job.source)) + + +@payload_schema.register +class ModelInstallErrorEvent(ModelEventBase): + """Event model for model_install_error""" + + __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") + error_type: str = Field(description="The name of the exception") + error: str = Field(description="A text description of the exception") + + @classmethod + 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) + + +class BulkDownloadEventBase(EventBase): + """Base class for events associated with a bulk image download""" + + 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") + + +@payload_schema.register +class BulkDownloadStartedEvent(BulkDownloadEventBase): + """Event model for bulk_download_started""" + + __event_name__ = "bulk_download_started" + + @classmethod + def build( + cls, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str + ) -> "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, + ) + + +@payload_schema.register +class BulkDownloadCompleteEvent(BulkDownloadEventBase): + """Event model for bulk_download_complete""" + + __event_name__ = "bulk_download_complete" + + @classmethod + def build( + cls, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str + ) -> "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, + ) + + +@payload_schema.register +class BulkDownloadErrorEvent(BulkDownloadEventBase): + """Event model for bulk_download_error""" + + __event_name__ = "bulk_download_error" + + error: str = Field(description="The error message") + + @classmethod + def build( + cls, bulk_download_id: str, bulk_download_item_id: str, bulk_download_item_name: str, error: str + ) -> "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, + ) diff --git a/invokeai/app/services/events/events_fastapievents.py b/invokeai/app/services/events/events_fastapievents.py new file mode 100644 index 0000000000000000000000000000000000000000..3c46b37fd68432fd75906a84af66a79606cfff07 --- /dev/null +++ b/invokeai/app/services/events/events_fastapievents.py @@ -0,0 +1,44 @@ +import asyncio +import threading + +from fastapi_events.dispatcher import dispatch + +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, loop: asyncio.AbstractEventLoop) -> None: + self.event_handler_id = event_handler_id + self._queue = asyncio.Queue[EventBase | None]() + self._stop_event = threading.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._loop.call_soon_threadsafe(self._queue.put_nowait, None) + + def dispatch(self, event: EventBase) -> None: + 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 = 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 asyncio.CancelledError as e: + raise e # Raise a proper error diff --git a/invokeai/app/services/image_files/__init__.py b/invokeai/app/services/image_files/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/image_files/image_files_base.py b/invokeai/app/services/image_files/image_files_base.py new file mode 100644 index 0000000000000000000000000000000000000000..dc6609aa48cbdff4b5cad7cb6fcc220429e97b66 --- /dev/null +++ b/invokeai/app/services/image_files/image_files_base.py @@ -0,0 +1,54 @@ +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Optional + +from PIL.Image import Image as PILImageType + + +class ImageFileStorageBase(ABC): + """Low-level service responsible for storing and retrieving image files.""" + + @abstractmethod + def get(self, image_name: str) -> PILImageType: + """Retrieves an image as PIL Image.""" + pass + + @abstractmethod + def get_path(self, image_name: str, thumbnail: bool = False) -> Path: + """Gets the internal path to an image or thumbnail.""" + pass + + # TODO: We need to validate paths before starlette makes the FileResponse, else we get a + # 500 internal server error. I don't like having this method on the service. + @abstractmethod + def validate_path(self, path: str) -> bool: + """Validates the path given for an image or thumbnail.""" + pass + + @abstractmethod + def save( + self, + image: PILImageType, + image_name: str, + metadata: Optional[str] = None, + workflow: Optional[str] = None, + graph: Optional[str] = None, + thumbnail_size: int = 256, + ) -> 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: + """Deletes an image and its thumbnail (if one exists).""" + pass + + @abstractmethod + def get_workflow(self, image_name: str) -> Optional[str]: + """Gets the workflow of an image.""" + pass + + @abstractmethod + def get_graph(self, image_name: str) -> Optional[str]: + """Gets the graph of an image.""" + pass diff --git a/invokeai/app/services/image_files/image_files_common.py b/invokeai/app/services/image_files/image_files_common.py new file mode 100644 index 0000000000000000000000000000000000000000..e9cc2a3fa754d6049852731f40245eda76950405 --- /dev/null +++ b/invokeai/app/services/image_files/image_files_common.py @@ -0,0 +1,20 @@ +# TODO: Should these excpetions subclass existing python exceptions? +class ImageFileNotFoundException(Exception): + """Raised when an image file is not found in storage.""" + + def __init__(self, message="Image file not found"): + super().__init__(message) + + +class ImageFileSaveException(Exception): + """Raised when an image cannot be saved.""" + + def __init__(self, message="Image file not saved"): + super().__init__(message) + + +class ImageFileDeleteException(Exception): + """Raised when an image cannot be deleted.""" + + def __init__(self, message="Image file not deleted"): + super().__init__(message) diff --git a/invokeai/app/services/image_files/image_files_disk.py b/invokeai/app/services/image_files/image_files_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..e5bfd72781d95c89e9d2a7b1302b98335a03eccf --- /dev/null +++ b/invokeai/app/services/image_files/image_files_disk.py @@ -0,0 +1,169 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team +from pathlib import Path +from queue import Queue +from typing import Optional, Union + +from PIL import Image, PngImagePlugin +from PIL.Image import Image as PILImageType + +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 + + +class DiskImageFileStorage(ImageFileStorageBase): + """Stores images on disk""" + + def __init__(self, output_folder: Union[str, Path]): + self.__cache: dict[Path, PILImageType] = {} + self.__cache_ids = Queue[Path]() + self.__max_cache_size = 10 # TODO: get this from config + + 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() + + def start(self, invoker: Invoker) -> None: + self.__invoker = invoker + + def get(self, image_name: str) -> PILImageType: + try: + image_path = self.get_path(image_name) + + cache_item = self.__get_cache(image_path) + if cache_item: + return cache_item + + image = Image.open(image_path) + self.__set_cache(image_path, image) + return image + except FileNotFoundError as e: + raise ImageFileNotFoundException from e + + def save( + self, + image: PILImageType, + image_name: str, + metadata: Optional[str] = None, + workflow: Optional[str] = None, + graph: Optional[str] = None, + thumbnail_size: int = 256, + ) -> None: + try: + self.__validate_storage_folders() + image_path = self.get_path(image_name) + + pnginfo = PngImagePlugin.PngInfo() + info_dict = {} + + if metadata is not None: + info_dict["invokeai_metadata"] = metadata + pnginfo.add_text("invokeai_metadata", metadata) + if workflow is not None: + info_dict["invokeai_workflow"] = workflow + pnginfo.add_text("invokeai_workflow", workflow) + if graph is not None: + info_dict["invokeai_graph"] = graph + pnginfo.add_text("invokeai_graph", graph) + + # When saving the image, the image object's info field is not populated. We need to set it + image.info = info_dict + image.save( + image_path, + "PNG", + pnginfo=pnginfo, + compress_level=self.__invoker.services.configuration.pil_compress_level, + ) + + thumbnail_name = get_thumbnail_name(image_name) + thumbnail_path = self.get_path(thumbnail_name, thumbnail=True) + thumbnail_image = make_thumbnail(image, thumbnail_size) + thumbnail_image.save(thumbnail_path) + + self.__set_cache(image_path, image) + self.__set_cache(thumbnail_path, thumbnail_image) + except Exception as e: + raise ImageFileSaveException from e + + def delete(self, image_name: str) -> None: + try: + image_path = self.get_path(image_name) + + if image_path.exists(): + image_path.unlink() + if image_path in self.__cache: + del self.__cache[image_path] + + thumbnail_name = get_thumbnail_name(image_name) + thumbnail_path = self.get_path(thumbnail_name, True) + + if thumbnail_path.exists(): + thumbnail_path.unlink() + if thumbnail_path in self.__cache: + del self.__cache[thumbnail_path] + except Exception as e: + raise ImageFileDeleteException from e + + def get_path(self, image_name: str, thumbnail: bool = False) -> Path: + base_folder = self.__thumbnails_folder if thumbnail else self.__output_folder + filename = get_thumbnail_name(image_name) if thumbnail else image_name + + # Strip any path information from the filename + basename = Path(filename).name + + if basename != filename: + raise ValueError("Invalid image name, potential directory traversal detected") + + 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 + + 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) + 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) + graph = image.info.get("invokeai_graph", None) + if isinstance(graph, str): + return graph + return None + + def __validate_storage_folders(self) -> None: + """Checks if the required output folders exist and create them if they don't""" + folders: list[Path] = [self.__output_folder, self.__thumbnails_folder] + for folder in folders: + 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] + + 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] diff --git a/invokeai/app/services/image_records/__init__.py b/invokeai/app/services/image_records/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py new file mode 100644 index 0000000000000000000000000000000000000000..1211c9762ce43e9741fa90b4cf8b14f42af670bd --- /dev/null +++ b/invokeai/app/services/image_records/image_records_base.py @@ -0,0 +1,99 @@ +from abc import ABC, abstractmethod +from datetime import datetime +from typing import Optional + +from invokeai.app.invocations.fields import MetadataField +from invokeai.app.services.image_records.image_records_common import ( + ImageCategory, + ImageRecord, + ImageRecordChanges, + ResourceOrigin, +) +from invokeai.app.services.shared.pagination import OffsetPaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection + + +class ImageRecordStorageBase(ABC): + """Low-level service responsible for interfacing with the image record store.""" + + # TODO: Implement an `update()` method + + @abstractmethod + def get(self, image_name: str) -> ImageRecord: + """Gets an image record.""" + pass + + @abstractmethod + def get_metadata(self, image_name: str) -> Optional[MetadataField]: + """Gets an image's metadata'.""" + pass + + @abstractmethod + def update( + self, + image_name: str, + changes: ImageRecordChanges, + ) -> None: + """Updates an image record.""" + pass + + @abstractmethod + 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, + ) -> OffsetPaginatedResults[ImageRecord]: + """Gets a page of image records.""" + pass + + # TODO: The database has a nullable `deleted_at` column, currently unused. + # Should we implement soft deletes? Would need coordination with ImageFileStorage. + @abstractmethod + def delete(self, image_name: str) -> None: + """Deletes an image record.""" + pass + + @abstractmethod + def delete_many(self, image_names: list[str]) -> None: + """Deletes many image records.""" + pass + + @abstractmethod + def delete_intermediates(self) -> list[str]: + """Deletes all intermediate image records, returning a list of deleted image names.""" + pass + + @abstractmethod + def get_intermediates_count(self) -> int: + """Gets a count of all intermediate images.""" + pass + + @abstractmethod + def save( + self, + image_name: str, + image_origin: ResourceOrigin, + image_category: ImageCategory, + width: int, + height: int, + has_workflow: bool, + is_intermediate: Optional[bool] = False, + starred: Optional[bool] = False, + session_id: Optional[str] = None, + node_id: Optional[str] = None, + metadata: Optional[str] = None, + ) -> datetime: + """Saves an image record.""" + pass + + @abstractmethod + def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]: + """Gets the most recent image for a board.""" + pass diff --git a/invokeai/app/services/image_records/image_records_common.py b/invokeai/app/services/image_records/image_records_common.py new file mode 100644 index 0000000000000000000000000000000000000000..af681e90e114191c0b0e03d41bf61a920aa84809 --- /dev/null +++ b/invokeai/app/services/image_records/image_records_common.py @@ -0,0 +1,209 @@ +# TODO: Should these excpetions subclass existing python exceptions? +import datetime +from enum import Enum +from typing import Optional, Union + +from pydantic import Field, StrictBool, StrictStr + +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 ResourceOrigin(str, Enum, metaclass=MetaEnum): + """The origin of a resource (eg image). + + - INTERNAL: The resource was created by the application. + - EXTERNAL: The resource was not created by the application. + This may be a user-initiated upload, or an internal application upload (eg Canvas init image). + """ + + INTERNAL = "internal" + """The resource was created by the application.""" + EXTERNAL = "external" + """The resource was not created by the application. + This may be a user-initiated upload, or an internal application upload (eg Canvas init image). + """ + + +class InvalidOriginException(ValueError): + """Raised when a provided value is not a valid ResourceOrigin. + + Subclasses `ValueError`. + """ + + def __init__(self, message="Invalid resource origin."): + super().__init__(message) + + +class ImageCategory(str, Enum, metaclass=MetaEnum): + """The category of an image. + + - GENERAL: The image is an output, init image, or otherwise an image without a specialized purpose. + - MASK: The image is a mask image. + - CONTROL: The image is a ControlNet control image. + - USER: The image is a user-provide image. + - OTHER: The image is some other type of image with a specialized purpose. To be used by external nodes. + """ + + GENERAL = "general" + """GENERAL: The image is an output, init image, or otherwise an image without a specialized purpose.""" + MASK = "mask" + """MASK: The image is a mask image.""" + CONTROL = "control" + """CONTROL: The image is a ControlNet control image.""" + USER = "user" + """USER: The image is a user-provide image.""" + OTHER = "other" + """OTHER: The image is some other type of image with a specialized purpose. To be used by external nodes.""" + + +class InvalidImageCategoryException(ValueError): + """Raised when a provided value is not a valid ImageCategory. + + Subclasses `ValueError`. + """ + + def __init__(self, message="Invalid image category."): + super().__init__(message) + + +class ImageRecordNotFoundException(Exception): + """Raised when an image record is not found.""" + + def __init__(self, message="Image record not found"): + super().__init__(message) + + +class ImageRecordSaveException(Exception): + """Raised when an image record cannot be saved.""" + + def __init__(self, message="Image record not saved"): + super().__init__(message) + + +class ImageRecordDeleteException(Exception): + """Raised when an image record cannot be deleted.""" + + def __init__(self, message="Image record not deleted"): + super().__init__(message) + + +IMAGE_DTO_COLS = ", ".join( + [ + "images." + c + for c in [ + "image_name", + "image_origin", + "image_category", + "width", + "height", + "session_id", + "node_id", + "has_workflow", + "is_intermediate", + "created_at", + "updated_at", + "deleted_at", + "starred", + ] + ] +) + + +class ImageRecord(BaseModelExcludeNull): + """Deserialized image record without metadata.""" + + image_name: str = Field(description="The unique name of the image.") + """The unique name of the image.""" + image_origin: ResourceOrigin = Field(description="The type of the image.") + """The origin of the image.""" + image_category: ImageCategory = Field(description="The category of the image.") + """The category of the image.""" + width: int = Field(description="The width of the image in px.") + """The actual width of the image in px. This may be different from the width in metadata.""" + height: int = Field(description="The height of the image in px.") + """The actual height of the image in px. This may be different from the height in metadata.""" + created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the image.") + """The created timestamp of the image.""" + updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the image.") + """The updated timestamp of the image.""" + deleted_at: Optional[Union[datetime.datetime, str]] = Field( + default=None, description="The deleted timestamp of the image." + ) + """The deleted timestamp of the image.""" + is_intermediate: bool = Field(description="Whether this is an intermediate image.") + """Whether this is an intermediate image.""" + session_id: Optional[str] = Field( + default=None, + description="The session ID that generated this image, if it is a generated image.", + ) + """The session ID that generated this image, if it is a generated image.""" + node_id: Optional[str] = Field( + default=None, + description="The node ID that generated this image, if it is a generated image.", + ) + """The node ID that generated this image, if it is a generated image.""" + 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.") + + +class ImageRecordChanges(BaseModelExcludeNull, extra="allow"): + """A set of changes to apply to an image record. + + Only limited changes are valid: + - `image_category`: change the category of an image + - `session_id`: change the session associated with an image + - `is_intermediate`: change the image's `is_intermediate` flag + - `starred`: change whether the image is starred + """ + + image_category: Optional[ImageCategory] = Field(default=None, description="The image's new category.") + """The image's new category.""" + session_id: Optional[StrictStr] = Field( + default=None, + description="The image's new session ID.", + ) + """The image's new session ID.""" + is_intermediate: Optional[StrictBool] = Field(default=None, description="The image's new `is_intermediate` flag.") + """The image's new `is_intermediate` flag.""" + starred: Optional[StrictBool] = Field(default=None, description="The image's new `starred` state") + """The image's new `starred` state.""" + + +def deserialize_image_record(image_dict: dict) -> ImageRecord: + """Deserializes an image record.""" + + # Retrieve all the values, setting "reasonable" defaults if they are not present. + + # TODO: do we really need to handle default values here? ideally the data is the correct shape... + image_name = image_dict.get("image_name", "unknown") + image_origin = ResourceOrigin(image_dict.get("image_origin", ResourceOrigin.INTERNAL.value)) + image_category = ImageCategory(image_dict.get("image_category", ImageCategory.GENERAL.value)) + width = image_dict.get("width", 0) + height = image_dict.get("height", 0) + session_id = image_dict.get("session_id", None) + node_id = image_dict.get("node_id", None) + created_at = image_dict.get("created_at", get_iso_timestamp()) + updated_at = image_dict.get("updated_at", get_iso_timestamp()) + deleted_at = image_dict.get("deleted_at", get_iso_timestamp()) + is_intermediate = image_dict.get("is_intermediate", False) + starred = image_dict.get("starred", False) + has_workflow = image_dict.get("has_workflow", False) + + return ImageRecord( + image_name=image_name, + image_origin=image_origin, + image_category=image_category, + width=width, + height=height, + session_id=session_id, + node_id=node_id, + created_at=created_at, + updated_at=updated_at, + deleted_at=deleted_at, + is_intermediate=is_intermediate, + starred=starred, + has_workflow=has_workflow, + ) diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py new file mode 100644 index 0000000000000000000000000000000000000000..2eafdfa2de97b69e11c11715c34458ed10b24c7a --- /dev/null +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -0,0 +1,423 @@ +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.image_records.image_records_base import ImageRecordStorageBase +from invokeai.app.services.image_records.image_records_common import ( + IMAGE_DTO_COLS, + ImageCategory, + ImageRecord, + ImageRecordChanges, + ImageRecordDeleteException, + ImageRecordNotFoundException, + ImageRecordSaveException, + 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 + + +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() + + 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,), + ) + + result = cast(Optional[sqlite3.Row], self._cursor.fetchone()) + except sqlite3.Error as e: + self._conn.rollback() + raise ImageRecordNotFoundException from e + finally: + self._lock.release() + + 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( + """--sql + SELECT metadata FROM images + WHERE image_name = ?; + """, + (image_name,), + ) + + result = cast(Optional[sqlite3.Row], self._cursor.fetchone()) + + if not result: + raise ImageRecordNotFoundException + + 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), + ) + + # 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 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 `starred`` state + if changes.starred is not None: + self._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() + + 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, + ) -> OffsetPaginatedResults[ImageRecord]: + try: + self._lock.acquire() + + # Manually build two queries - one for the count, one for the records + count_query = """--sql + SELECT COUNT(*) + FROM images + LEFT JOIN board_images ON board_images.image_name = images.image_name + WHERE 1=1 + """ + + images_query = f"""--sql + SELECT {IMAGE_DTO_COLS} + FROM images + LEFT JOIN board_images ON board_images.image_name = images.image_name + WHERE 1=1 + """ + + 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: + # 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)) + + query_conditions += f"""--sql + AND images.image_category IN ( {placeholders} ) + """ + + # Unpack the included categories into the query params + 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) + + # board_id of "none" is reserved for images without a board + if board_id == "none": + query_conditions += """--sql + AND board_images.board_id IS NULL + """ + elif board_id is not None: + query_conditions += """--sql + AND board_images.board_id = ? + """ + query_params.append(board_id) + + # Search term condition + if search_term: + query_conditions += """--sql + AND images.metadata LIKE ? + """ + 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 + ";" + # Add all the parameters + images_params = query_params.copy() + # Add the pagination parameters + 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()) + 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() + + 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() + + 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() + + def save( + self, + image_name: str, + image_origin: ResourceOrigin, + image_category: ImageCategory, + width: int, + height: int, + has_workflow: bool, + is_intermediate: Optional[bool] = False, + starred: Optional[bool] = False, + session_id: Optional[str] = None, + node_id: Optional[str] = None, + metadata: Optional[str] = None, + ) -> 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() + + self._cursor.execute( + """--sql + SELECT created_at + FROM images + WHERE image_name = ?; + """, + (image_name,), + ) + + created_at = datetime.fromisoformat(self._cursor.fetchone()[0]) + + return created_at + except sqlite3.Error as e: + self._conn.rollback() + raise ImageRecordSaveException from e + finally: + self._lock.release() + + def get_most_recent_image_for_board(self, board_id: str) -> Optional[ImageRecord]: + try: + self._lock.acquire() + self._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() + if result is None: + return None + + return deserialize_image_record(dict(result)) diff --git a/invokeai/app/services/images/__init__.py b/invokeai/app/services/images/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/images/images_base.py b/invokeai/app/services/images/images_base.py new file mode 100644 index 0000000000000000000000000000000000000000..5328c1854e3beca870cc5ee79260eef63e0afec6 --- /dev/null +++ b/invokeai/app/services/images/images_base.py @@ -0,0 +1,149 @@ +from abc import ABC, abstractmethod +from typing import Callable, Optional + +from PIL.Image import Image as PILImageType + +from invokeai.app.invocations.fields import MetadataField +from invokeai.app.services.image_records.image_records_common import ( + ImageCategory, + 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): + """High-level service for image management.""" + + _on_changed_callbacks: list[Callable[[ImageDTO], None]] + _on_deleted_callbacks: list[Callable[[str], None]] + + def __init__(self) -> None: + self._on_changed_callbacks = [] + self._on_deleted_callbacks = [] + + def on_changed(self, on_changed: Callable[[ImageDTO], None]) -> None: + """Register a callback for when an image is changed""" + self._on_changed_callbacks.append(on_changed) + + def on_deleted(self, on_deleted: Callable[[str], None]) -> None: + """Register a callback for when an image is deleted""" + self._on_deleted_callbacks.append(on_deleted) + + def _on_changed(self, item: ImageDTO) -> None: + for callback in self._on_changed_callbacks: + callback(item) + + def _on_deleted(self, item_id: str) -> None: + for callback in self._on_deleted_callbacks: + callback(item_id) + + @abstractmethod + def create( + self, + image: PILImageType, + image_origin: ResourceOrigin, + image_category: ImageCategory, + node_id: Optional[str] = None, + session_id: Optional[str] = None, + board_id: Optional[str] = None, + is_intermediate: Optional[bool] = False, + metadata: Optional[str] = None, + workflow: Optional[str] = None, + graph: Optional[str] = None, + ) -> ImageDTO: + """Creates an image, storing the file and its metadata.""" + pass + + @abstractmethod + def update( + self, + image_name: str, + changes: ImageRecordChanges, + ) -> ImageDTO: + """Updates an image.""" + pass + + @abstractmethod + def get_pil_image(self, image_name: str) -> PILImageType: + """Gets an image as a PIL image.""" + pass + + @abstractmethod + def get_record(self, image_name: str) -> ImageRecord: + """Gets an image record.""" + pass + + @abstractmethod + def get_dto(self, image_name: str) -> ImageDTO: + """Gets an image DTO.""" + pass + + @abstractmethod + def get_metadata(self, image_name: str) -> Optional[MetadataField]: + """Gets an image's metadata.""" + pass + + @abstractmethod + def get_workflow(self, image_name: str) -> Optional[str]: + """Gets an image's workflow.""" + pass + + @abstractmethod + def get_graph(self, image_name: str) -> Optional[str]: + """Gets an image's workflow.""" + pass + + @abstractmethod + def get_path(self, image_name: str, thumbnail: bool = False) -> str: + """Gets an image's path.""" + pass + + @abstractmethod + def validate_path(self, path: str) -> bool: + """Validates an image's path.""" + pass + + @abstractmethod + def get_url(self, image_name: str, thumbnail: bool = False) -> str: + """Gets an image's or thumbnail's URL.""" + pass + + @abstractmethod + 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, + ) -> OffsetPaginatedResults[ImageDTO]: + """Gets a paginated list of image DTOs.""" + pass + + @abstractmethod + def delete(self, image_name: str): + """Deletes an image.""" + pass + + @abstractmethod + def delete_intermediates(self) -> int: + """Deletes all intermediate images.""" + pass + + @abstractmethod + def get_intermediates_count(self) -> int: + """Gets the number of intermediate images.""" + pass + + @abstractmethod + def delete_images_on_board(self, board_id: str): + """Deletes all images on a board.""" + pass diff --git a/invokeai/app/services/images/images_common.py b/invokeai/app/services/images/images_common.py new file mode 100644 index 0000000000000000000000000000000000000000..0464244b9449283b738b3c15808fe2663ca8ab30 --- /dev/null +++ b/invokeai/app/services/images/images_common.py @@ -0,0 +1,41 @@ +from typing import Optional + +from pydantic import Field + +from invokeai.app.services.image_records.image_records_common import ImageRecord +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull + + +class ImageUrlsDTO(BaseModelExcludeNull): + """The URLs for an image and its thumbnail.""" + + image_name: str = Field(description="The unique name of the image.") + """The unique name of the image.""" + image_url: str = Field(description="The URL of the image.") + """The URL of the image.""" + thumbnail_url: str = Field(description="The URL of the image's thumbnail.") + """The URL of the image's thumbnail.""" + + +class ImageDTO(ImageRecord, ImageUrlsDTO): + """Deserialized image record, enriched for the frontend.""" + + board_id: Optional[str] = Field( + default=None, description="The id of the board the image belongs to, if one exists." + ) + """The id of the board the image belongs to, if one exists.""" + + +def image_record_to_dto( + image_record: ImageRecord, + image_url: str, + thumbnail_url: str, + board_id: Optional[str], +) -> ImageDTO: + """Converts an image record to an image DTO.""" + return ImageDTO( + **image_record.model_dump(), + image_url=image_url, + thumbnail_url=thumbnail_url, + board_id=board_id, + ) diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py new file mode 100644 index 0000000000000000000000000000000000000000..15d950bab80b47bf89c80c6d0bd7fb0d048e4919 --- /dev/null +++ b/invokeai/app/services/images/images_default.py @@ -0,0 +1,307 @@ +from typing import Optional + +from PIL.Image import Image as PILImageType + +from invokeai.app.invocations.fields import MetadataField +from invokeai.app.services.image_files.image_files_common import ( + ImageFileDeleteException, + ImageFileNotFoundException, + ImageFileSaveException, +) +from invokeai.app.services.image_records.image_records_common import ( + ImageCategory, + ImageRecord, + ImageRecordChanges, + ImageRecordDeleteException, + ImageRecordNotFoundException, + ImageRecordSaveException, + InvalidImageCategoryException, + InvalidOriginException, + ResourceOrigin, +) +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): + __invoker: Invoker + + def start(self, invoker: Invoker) -> None: + self.__invoker = invoker + + def create( + self, + image: PILImageType, + image_origin: ResourceOrigin, + image_category: ImageCategory, + node_id: Optional[str] = None, + session_id: Optional[str] = None, + board_id: Optional[str] = None, + is_intermediate: Optional[bool] = False, + metadata: Optional[str] = None, + workflow: Optional[str] = None, + graph: Optional[str] = None, + ) -> ImageDTO: + if image_origin not in ResourceOrigin: + raise InvalidOriginException + + if image_category not in ImageCategory: + raise InvalidImageCategoryException + + image_name = self.__invoker.services.names.create_image_name() + + (width, height) = image.size + + try: + # TODO: Consider using a transaction here to ensure consistency between storage and database + self.__invoker.services.image_records.save( + # Non-nullable fields + image_name=image_name, + image_origin=image_origin, + image_category=image_category, + width=width, + height=height, + has_workflow=workflow is not None or graph is not None, + # Meta fields + is_intermediate=is_intermediate, + # Nullable fields + node_id=node_id, + metadata=metadata, + session_id=session_id, + ) + if board_id is not None: + 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.warn(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_dto = self.get_dto(image_name) + + self._on_changed(image_dto) + return image_dto + except ImageRecordSaveException: + self.__invoker.services.logger.error("Failed to save image record") + raise + except ImageFileSaveException: + self.__invoker.services.logger.error("Failed to save image file") + raise + except Exception as e: + self.__invoker.services.logger.error(f"Problem saving image record and file: {str(e)}") + raise e + + def update( + self, + image_name: str, + changes: ImageRecordChanges, + ) -> ImageDTO: + try: + self.__invoker.services.image_records.update(image_name, changes) + image_dto = self.get_dto(image_name) + self._on_changed(image_dto) + return image_dto + except ImageRecordSaveException: + self.__invoker.services.logger.error("Failed to update image record") + raise + except Exception as e: + self.__invoker.services.logger.error("Problem updating image record") + raise e + + def get_pil_image(self, image_name: str) -> PILImageType: + try: + return self.__invoker.services.image_files.get(image_name) + except ImageFileNotFoundException: + self.__invoker.services.logger.error("Failed to get image file") + raise + except Exception as e: + self.__invoker.services.logger.error("Problem getting image file") + raise e + + def get_record(self, image_name: str) -> ImageRecord: + try: + return self.__invoker.services.image_records.get(image_name) + except ImageRecordNotFoundException: + self.__invoker.services.logger.error("Image record not found") + raise + except Exception as e: + self.__invoker.services.logger.error("Problem getting image record") + raise e + + def get_dto(self, image_name: str) -> ImageDTO: + try: + image_record = self.__invoker.services.image_records.get(image_name) + + image_dto = image_record_to_dto( + image_record=image_record, + image_url=self.__invoker.services.urls.get_image_url(image_name), + thumbnail_url=self.__invoker.services.urls.get_image_url(image_name, True), + board_id=self.__invoker.services.board_image_records.get_board_for_image(image_name), + ) + + return image_dto + except ImageRecordNotFoundException: + self.__invoker.services.logger.error("Image record not found") + raise + except Exception as e: + self.__invoker.services.logger.error("Problem getting image DTO") + raise e + + def get_metadata(self, image_name: str) -> Optional[MetadataField]: + try: + return self.__invoker.services.image_records.get_metadata(image_name) + except ImageRecordNotFoundException: + self.__invoker.services.logger.error("Image record not found") + raise + except Exception as e: + self.__invoker.services.logger.error("Problem getting image metadata") + raise e + + def get_workflow(self, image_name: str) -> Optional[str]: + try: + return self.__invoker.services.image_files.get_workflow(image_name) + except ImageFileNotFoundException: + self.__invoker.services.logger.error("Image file not found") + raise + except Exception: + self.__invoker.services.logger.error("Problem getting image workflow") + raise + + def get_graph(self, image_name: str) -> Optional[str]: + try: + return self.__invoker.services.image_files.get_graph(image_name) + except ImageFileNotFoundException: + self.__invoker.services.logger.error("Image file not found") + raise + except Exception: + self.__invoker.services.logger.error("Problem getting image graph") + raise + + def get_path(self, image_name: str, thumbnail: bool = False) -> str: + try: + return str(self.__invoker.services.image_files.get_path(image_name, thumbnail)) + except Exception as e: + self.__invoker.services.logger.error("Problem getting image path") + raise e + + def validate_path(self, path: str) -> bool: + try: + return self.__invoker.services.image_files.validate_path(path) + except Exception as e: + self.__invoker.services.logger.error("Problem validating image path") + raise e + + def get_url(self, image_name: str, thumbnail: bool = False) -> str: + try: + return self.__invoker.services.urls.get_image_url(image_name, thumbnail) + except Exception as e: + self.__invoker.services.logger.error("Problem getting image path") + raise 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, + ) -> 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, + ) + + image_dtos = [ + image_record_to_dto( + image_record=r, + image_url=self.__invoker.services.urls.get_image_url(r.image_name), + thumbnail_url=self.__invoker.services.urls.get_image_url(r.image_name, True), + board_id=self.__invoker.services.board_image_records.get_board_for_image(r.image_name), + ) + for r in results.items + ] + + return OffsetPaginatedResults[ImageDTO]( + items=image_dtos, + offset=results.offset, + limit=results.limit, + total=results.total, + ) + except Exception as e: + self.__invoker.services.logger.error("Problem getting paginated image DTOs") + raise e + + def delete(self, image_name: str): + try: + self.__invoker.services.image_files.delete(image_name) + self.__invoker.services.image_records.delete(image_name) + self._on_deleted(image_name) + except ImageRecordDeleteException: + self.__invoker.services.logger.error("Failed to delete image record") + raise + except ImageFileDeleteException: + self.__invoker.services.logger.error("Failed to delete image file") + raise + except Exception as e: + self.__invoker.services.logger.error("Problem deleting image record and file") + raise e + + 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) + for image_name in image_names: + self.__invoker.services.image_files.delete(image_name) + self.__invoker.services.image_records.delete_many(image_names) + for image_name in image_names: + self._on_deleted(image_name) + except ImageRecordDeleteException: + self.__invoker.services.logger.error("Failed to delete image records") + raise + except ImageFileDeleteException: + 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") + 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) + self._on_deleted(image_name) + return count + except ImageRecordDeleteException: + self.__invoker.services.logger.error("Failed to delete image records") + raise + except ImageFileDeleteException: + 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") + raise e + + def get_intermediates_count(self) -> int: + try: + return self.__invoker.services.image_records.get_intermediates_count() + except Exception as e: + self.__invoker.services.logger.error("Problem getting intermediates count") + raise e diff --git a/invokeai/app/services/invocation_cache/__init__.py b/invokeai/app/services/invocation_cache/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/invocation_cache/invocation_cache_base.py b/invokeai/app/services/invocation_cache/invocation_cache_base.py new file mode 100644 index 0000000000000000000000000000000000000000..bde6a0f11469a9e5abc3d76a63dc73b3bc3d6e50 --- /dev/null +++ b/invokeai/app/services/invocation_cache/invocation_cache_base.py @@ -0,0 +1,63 @@ +from abc import ABC, abstractmethod +from typing import Optional, Union + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput +from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus + + +class InvocationCacheBase(ABC): + """ + Base class for invocation caches. + When an invocation is executed, it is hashed and its output stored in the cache. + When new invocations are executed, if they are flagged with `use_cache`, they + will attempt to pull their value from the cache before executing. + + Implementations should register for the `on_deleted` event of the `images` and `latents` + services, and delete any cached outputs that reference the deleted image or latent. + + See the memory implementation for an example. + + Implementations should respect the `node_cache_size` configuration value, and skip all + cache logic if the value is set to 0. + """ + + @abstractmethod + def get(self, key: Union[int, str]) -> Optional[BaseInvocationOutput]: + """Retrieves an invocation output from the cache""" + pass + + @abstractmethod + def save(self, key: Union[int, str], invocation_output: BaseInvocationOutput) -> None: + """Stores an invocation output in the cache""" + pass + + @abstractmethod + def delete(self, key: Union[int, str]) -> None: + """Deletes an invocation output from the cache""" + pass + + @abstractmethod + def clear(self) -> None: + """Clears the cache""" + pass + + @staticmethod + @abstractmethod + def create_key(invocation: BaseInvocation) -> int: + """Gets the key for the invocation's cache item""" + pass + + @abstractmethod + def disable(self) -> None: + """Disables the cache, overriding the max cache size""" + pass + + @abstractmethod + def enable(self) -> None: + """Enables the cache, letting the the max cache size take effect""" + pass + + @abstractmethod + def get_status(self) -> InvocationCacheStatus: + """Returns the status of the cache""" + pass diff --git a/invokeai/app/services/invocation_cache/invocation_cache_common.py b/invokeai/app/services/invocation_cache/invocation_cache_common.py new file mode 100644 index 0000000000000000000000000000000000000000..6ce2d02f3b4589da18641969bf6b9c5f3ac615f9 --- /dev/null +++ b/invokeai/app/services/invocation_cache/invocation_cache_common.py @@ -0,0 +1,9 @@ +from pydantic import BaseModel, Field + + +class InvocationCacheStatus(BaseModel): + size: int = Field(description="The current size of the invocation cache") + hits: int = Field(description="The number of cache hits") + misses: int = Field(description="The number of cache misses") + enabled: bool = Field(description="Whether the invocation cache is enabled") + max_size: int = Field(description="The maximum size of the invocation cache") diff --git a/invokeai/app/services/invocation_cache/invocation_cache_memory.py b/invokeai/app/services/invocation_cache/invocation_cache_memory.py new file mode 100644 index 0000000000000000000000000000000000000000..d15269caf914197d3d3f9ab4100bfa7ec8559f07 --- /dev/null +++ b/invokeai/app/services/invocation_cache/invocation_cache_memory.py @@ -0,0 +1,130 @@ +from collections import OrderedDict +from dataclasses import dataclass, field +from threading import Lock +from typing import Optional, Union + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput +from invokeai.app.services.invocation_cache.invocation_cache_base import InvocationCacheBase +from invokeai.app.services.invocation_cache.invocation_cache_common import InvocationCacheStatus +from invokeai.app.services.invoker import Invoker + + +@dataclass(order=True) +class CachedItem: + invocation_output: BaseInvocationOutput = field(compare=False) + invocation_output_json: str = field(compare=False) + + +class MemoryInvocationCache(InvocationCacheBase): + _cache: OrderedDict[Union[int, str], CachedItem] + _max_cache_size: int + _disabled: bool + _hits: int + _misses: int + _invoker: Invoker + _lock: Lock + + def __init__(self, max_cache_size: int = 0) -> None: + self._cache = OrderedDict() + self._max_cache_size = max_cache_size + self._disabled = False + self._hits = 0 + self._misses = 0 + self._lock = Lock() + + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + if self._max_cache_size == 0: + return + self._invoker.services.images.on_deleted(self._delete_by_match) + self._invoker.services.tensors.on_deleted(self._delete_by_match) + self._invoker.services.conditioning.on_deleted(self._delete_by_match) + + def get(self, key: Union[int, str]) -> Optional[BaseInvocationOutput]: + with self._lock: + if self._max_cache_size == 0 or self._disabled: + return None + item = self._cache.get(key, None) + if item is not None: + self._hits += 1 + self._cache.move_to_end(key) + return item.invocation_output + self._misses += 1 + return None + + def save(self, key: Union[int, str], invocation_output: BaseInvocationOutput) -> None: + with self._lock: + if self._max_cache_size == 0 or self._disabled or key in self._cache: + return + # If the cache is full, we need to remove the least used + number_to_delete = len(self._cache) + 1 - self._max_cache_size + self._delete_oldest_access(number_to_delete) + self._cache[key] = CachedItem( + invocation_output, + invocation_output.model_dump_json(warnings=False, exclude_defaults=True, exclude_unset=True), + ) + + def _delete_oldest_access(self, number_to_delete: int) -> None: + number_to_delete = min(number_to_delete, len(self._cache)) + for _ in range(number_to_delete): + self._cache.popitem(last=False) + + def _delete(self, key: Union[int, str]) -> None: + if self._max_cache_size == 0: + return + if key in self._cache: + del self._cache[key] + + def delete(self, key: Union[int, str]) -> None: + with self._lock: + return self._delete(key) + + def clear(self) -> None: + with self._lock: + if self._max_cache_size == 0: + return + self._cache.clear() + self._misses = 0 + self._hits = 0 + + @staticmethod + def create_key(invocation: BaseInvocation) -> int: + return hash(invocation.model_dump_json(exclude={"id"}, warnings=False)) + + def disable(self) -> None: + with self._lock: + if self._max_cache_size == 0: + return + self._disabled = True + + def enable(self) -> None: + with self._lock: + if self._max_cache_size == 0: + return + self._disabled = False + + def get_status(self) -> InvocationCacheStatus: + with self._lock: + return InvocationCacheStatus( + hits=self._hits, + misses=self._misses, + enabled=not self._disabled and self._max_cache_size > 0, + size=len(self._cache), + max_size=self._max_cache_size, + ) + + def _delete_by_match(self, to_match: str) -> None: + with self._lock: + if self._max_cache_size == 0: + return + keys_to_delete = set() + for key, cached_item in self._cache.items(): + if to_match in cached_item.invocation_output_json: + keys_to_delete.add(key) + if not keys_to_delete: + return + for key in keys_to_delete: + self._delete(key) + self._invoker.services.logger.debug( + f"Deleted {len(keys_to_delete)} cached invocation outputs for {to_match}" + ) diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py new file mode 100644 index 0000000000000000000000000000000000000000..db693dc837570b237d8e68e53f20c104d3ee094d --- /dev/null +++ b/invokeai/app/services/invocation_services.py @@ -0,0 +1,93 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team +from __future__ import annotations + +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.config import InvokeAIAppConfig + from invokeai.app.services.download import DownloadQueueServiceBase + from invokeai.app.services.events.events_base import EventServiceBase + 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.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.workflow_records.workflow_records_base import WorkflowRecordsStorageBase + from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData + + +class InvocationServices: + """Services that can be used by invocations""" + + def __init__( + self, + board_images: "BoardImagesServiceABC", + board_image_records: "BoardImageRecordStorageBase", + boards: "BoardServiceABC", + board_records: "BoardRecordStorageBase", + bulk_download: "BulkDownloadBase", + configuration: "InvokeAIAppConfig", + events: "EventServiceBase", + images: "ImageServiceABC", + image_files: "ImageFileStorageBase", + image_records: "ImageRecordStorageBase", + logger: "Logger", + model_images: "ModelImageFileStorageBase", + model_manager: "ModelManagerServiceBase", + download_queue: "DownloadQueueServiceBase", + performance_statistics: "InvocationStatsServiceBase", + session_queue: "SessionQueueBase", + session_processor: "SessionProcessorBase", + invocation_cache: "InvocationCacheBase", + names: "NameServiceBase", + urls: "UrlServiceBase", + workflow_records: "WorkflowRecordsStorageBase", + tensors: "ObjectSerializerBase[torch.Tensor]", + conditioning: "ObjectSerializerBase[ConditioningFieldData]", + style_preset_records: "StylePresetRecordsStorageBase", + style_preset_image_files: "StylePresetImageFileStorageBase", + ): + self.board_images = board_images + self.board_image_records = board_image_records + self.boards = boards + self.board_records = board_records + self.bulk_download = bulk_download + self.configuration = configuration + self.events = events + self.images = images + self.image_files = image_files + self.image_records = image_records + self.logger = logger + self.model_images = model_images + self.model_manager = model_manager + self.download_queue = download_queue + self.performance_statistics = performance_statistics + self.session_queue = session_queue + self.session_processor = session_processor + self.invocation_cache = invocation_cache + self.names = names + self.urls = urls + 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 diff --git a/invokeai/app/services/invocation_stats/__init__.py b/invokeai/app/services/invocation_stats/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/invocation_stats/invocation_stats_base.py b/invokeai/app/services/invocation_stats/invocation_stats_base.py new file mode 100644 index 0000000000000000000000000000000000000000..3266d985fef75254e0e66caaf2520aca2d2590fb --- /dev/null +++ b/invokeai/app/services/invocation_stats/invocation_stats_base.py @@ -0,0 +1,93 @@ +# Copyright 2023 Lincoln D. Stein +"""Utility to collect execution time and GPU usage stats on invocations in flight + +Usage: + +statistics = InvocationStatsService() +with statistics.collect_stats(invocation, graph_execution_state.id): + ... execute graphs... +statistics.log_stats() + +Typical output: +[2023-08-02 18:03:04,507]::[InvokeAI]::INFO --> Graph stats: c7764585-9c68-4d9d-a199-55e8186790f3 +[2023-08-02 18:03:04,507]::[InvokeAI]::INFO --> Node Calls Seconds VRAM Used +[2023-08-02 18:03:04,507]::[InvokeAI]::INFO --> main_model_loader 1 0.005s 0.01G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> clip_skip 1 0.004s 0.01G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> compel 2 0.512s 0.26G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> rand_int 1 0.001s 0.01G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> range_of_size 1 0.001s 0.01G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> iterate 1 0.001s 0.01G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> metadata_accumulator 1 0.002s 0.01G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> noise 1 0.002s 0.01G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> t2l 1 3.541s 1.93G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> l2i 1 0.679s 0.58G +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> TOTAL GRAPH EXECUTION TIME: 4.749s +[2023-08-02 18:03:04,508]::[InvokeAI]::INFO --> Current VRAM utilization 0.01G + +The abstract base class for this class is InvocationStatsServiceBase. An implementing class which +writes to the system log is stored in InvocationServices.performance_statistics. +""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import ContextManager + +from invokeai.app.invocations.baseinvocation import BaseInvocation +from invokeai.app.services.invocation_stats.invocation_stats_common import InvocationStatsSummary + + +class InvocationStatsServiceBase(ABC): + "Abstract base class for recording node memory/time performance statistics" + + @abstractmethod + def __init__(self) -> None: + """ + Initialize the InvocationStatsService and reset counters to zero + """ + + @abstractmethod + def collect_stats( + self, + invocation: BaseInvocation, + graph_execution_state_id: str, + ) -> ContextManager[None]: + """ + Return a context object that will capture the statistics on the execution + of invocaation. Use with: to place around the part of the code that executes the invocation. + :param invocation: BaseInvocation object from the current graph. + :param graph_execution_state_id: The id of the current session. + """ + pass + + @abstractmethod + def reset_stats(self): + """Reset all stored statistics.""" + pass + + @abstractmethod + def log_stats(self, graph_execution_state_id: str) -> None: + """ + Write out the accumulated statistics to the log or somewhere else. + :param graph_execution_state_id: The id of the session whose stats to log. + :raises GESStatsNotFoundError: if the graph isn't tracked in the stats. + """ + pass + + @abstractmethod + def get_stats(self, graph_execution_state_id: str) -> InvocationStatsSummary: + """ + Gets the accumulated statistics for the indicated graph. + :param graph_execution_state_id: The id of the session whose stats to get. + :raises GESStatsNotFoundError: if the graph isn't tracked in the stats. + """ + pass + + @abstractmethod + def dump_stats(self, graph_execution_state_id: str, output_path: Path) -> None: + """ + Write out the accumulated statistics to the indicated path as JSON. + :param graph_execution_state_id: The id of the session whose stats to dump. + :param output_path: The file to write the stats to. + :raises GESStatsNotFoundError: if the graph isn't tracked in the stats. + """ + pass diff --git a/invokeai/app/services/invocation_stats/invocation_stats_common.py b/invokeai/app/services/invocation_stats/invocation_stats_common.py new file mode 100644 index 0000000000000000000000000000000000000000..f4c906a58f72670e7afacf8a8928fed9533eaa49 --- /dev/null +++ b/invokeai/app/services/invocation_stats/invocation_stats_common.py @@ -0,0 +1,183 @@ +from collections import defaultdict +from dataclasses import asdict, dataclass +from typing import Any, Optional + + +class GESStatsNotFoundError(Exception): + """Raised when execution stats are not found for a given Graph Execution State.""" + + +@dataclass +class NodeExecutionStatsSummary: + """The stats for a specific type of node.""" + + node_type: str + num_calls: int + time_used_seconds: float + peak_vram_gb: float + + +@dataclass +class ModelCacheStatsSummary: + """The stats for the model cache.""" + + high_water_mark_gb: float + cache_size_gb: float + total_usage_gb: float + cache_hits: int + cache_misses: int + models_cached: int + models_cleared: int + + +@dataclass +class GraphExecutionStatsSummary: + """The stats for the graph execution state.""" + + graph_execution_state_id: str + execution_time_seconds: float + # `wall_time_seconds`, `ram_usage_gb` and `ram_change_gb` are derived from the node execution stats. + # In some situations, there are no node stats, so these values are optional. + wall_time_seconds: Optional[float] + ram_usage_gb: Optional[float] + ram_change_gb: Optional[float] + + +@dataclass +class InvocationStatsSummary: + """ + The accumulated stats for a graph execution. + Its `__str__` method returns a human-readable stats summary. + """ + + vram_usage_gb: Optional[float] + graph_stats: GraphExecutionStatsSummary + model_cache_stats: ModelCacheStatsSummary + node_stats: list[NodeExecutionStatsSummary] + + 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" + + 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"TOTAL GRAPH EXECUTION TIME: {self.graph_stats.execution_time_seconds:7.3f}s\n" + + if self.graph_stats.wall_time_seconds is not None: + _str += f"TOTAL GRAPH WALL TIME: {self.graph_stats.wall_time_seconds:7.3f}s\n" + + if self.graph_stats.ram_usage_gb is not None and self.graph_stats.ram_change_gb is not None: + _str += f"RAM used by InvokeAI process: {self.graph_stats.ram_usage_gb:4.2f}G ({self.graph_stats.ram_change_gb:+5.3f}G)\n" + + _str += f"RAM used to load models: {self.model_cache_stats.total_usage_gb:4.2f}G\n" + if self.vram_usage_gb: + _str += f"VRAM in use: {self.vram_usage_gb:4.3f}G\n" + _str += "RAM cache statistics:\n" + _str += f" Model cache hits: {self.model_cache_stats.cache_hits}\n" + _str += f" Model cache misses: {self.model_cache_stats.cache_misses}\n" + _str += f" Models cached: {self.model_cache_stats.models_cached}\n" + _str += f" Models cleared from cache: {self.model_cache_stats.models_cleared}\n" + _str += f" Cache high water mark: {self.model_cache_stats.high_water_mark_gb:4.2f}/{self.model_cache_stats.cache_size_gb:4.2f}G\n" + + return _str + + def as_dict(self) -> dict[str, Any]: + """Returns the stats as a dictionary.""" + return asdict(self) + + +@dataclass +class NodeExecutionStats: + """Class for tracking execution stats of an invocation node.""" + + invocation_type: str + + start_time: float # Seconds since the epoch. + end_time: float # Seconds since the epoch. + + start_ram_gb: float # GB + end_ram_gb: float # GB + + peak_vram_gb: float # GB + + def total_time(self) -> float: + return self.end_time - self.start_time + + +class GraphExecutionStats: + """Class for tracking execution stats of a graph.""" + + def __init__(self): + self._node_stats_list: list[NodeExecutionStats] = [] + + def add_node_execution_stats(self, node_stats: NodeExecutionStats): + self._node_stats_list.append(node_stats) + + def get_total_run_time(self) -> float: + """Get the total time spent executing nodes in the graph.""" + total = 0.0 + for node_stats in self._node_stats_list: + total += node_stats.total_time() + return total + + def get_first_node_stats(self) -> NodeExecutionStats | None: + """Get the stats of the first node in the graph (by start_time).""" + first_node = None + for node_stats in self._node_stats_list: + if first_node is None or node_stats.start_time < first_node.start_time: + first_node = node_stats + + assert first_node is not None + return first_node + + def get_last_node_stats(self) -> NodeExecutionStats | None: + """Get the stats of the last node in the graph (by end_time).""" + last_node = None + for node_stats in self._node_stats_list: + if last_node is None or node_stats.end_time > last_node.end_time: + last_node = node_stats + + return last_node + + def get_graph_stats_summary(self, graph_execution_state_id: str) -> GraphExecutionStatsSummary: + """Get a summary of the graph stats.""" + first_node = self.get_first_node_stats() + last_node = self.get_last_node_stats() + + wall_time_seconds: Optional[float] = None + ram_usage_gb: Optional[float] = None + ram_change_gb: Optional[float] = None + + if last_node and first_node: + wall_time_seconds = last_node.end_time - first_node.start_time + ram_usage_gb = last_node.end_ram_gb + ram_change_gb = last_node.end_ram_gb - first_node.start_ram_gb + + return GraphExecutionStatsSummary( + graph_execution_state_id=graph_execution_state_id, + execution_time_seconds=self.get_total_run_time(), + wall_time_seconds=wall_time_seconds, + ram_usage_gb=ram_usage_gb, + ram_change_gb=ram_change_gb, + ) + + def get_node_stats_summaries(self) -> list[NodeExecutionStatsSummary]: + """Get a summary of the node stats.""" + summaries: list[NodeExecutionStatsSummary] = [] + node_stats_by_type: dict[str, list[NodeExecutionStats]] = defaultdict(list) + + for node_stats in self._node_stats_list: + node_stats_by_type[node_stats.invocation_type].append(node_stats) + + 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]) + summary = NodeExecutionStatsSummary( + node_type=node_type, num_calls=num_calls, time_used_seconds=time_used, peak_vram_gb=peak_vram + ) + summaries.append(summary) + + return summaries diff --git a/invokeai/app/services/invocation_stats/invocation_stats_default.py b/invokeai/app/services/invocation_stats/invocation_stats_default.py new file mode 100644 index 0000000000000000000000000000000000000000..5533657dc7f1d9537349746f4232cf30fa8ce3a3 --- /dev/null +++ b/invokeai/app/services/invocation_stats/invocation_stats_default.py @@ -0,0 +1,138 @@ +import json +import time +from contextlib import contextmanager +from pathlib import Path +from typing import Generator + +import psutil +import torch + +import invokeai.backend.util.logging as logger +from invokeai.app.invocations.baseinvocation import BaseInvocation +from invokeai.app.services.invocation_stats.invocation_stats_base import InvocationStatsServiceBase +from invokeai.app.services.invocation_stats.invocation_stats_common import ( + GESStatsNotFoundError, + GraphExecutionStats, + GraphExecutionStatsSummary, + InvocationStatsSummary, + ModelCacheStatsSummary, + NodeExecutionStats, + NodeExecutionStatsSummary, +) +from invokeai.app.services.invoker import Invoker +from invokeai.backend.model_manager.load.model_cache import CacheStats + +# Size of 1GB in bytes. +GB = 2**30 + + +class InvocationStatsService(InvocationStatsServiceBase): + """Accumulate performance information about a running graph. Collects time spent in each node, + as well as the maximum and current VRAM utilisation for CUDA systems""" + + def __init__(self): + # Maps graph_execution_state_id to GraphExecutionStats. + self._stats: dict[str, GraphExecutionStats] = {} + # Maps graph_execution_state_id to model manager CacheStats. + self._cache_stats: dict[str, CacheStats] = {} + + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + + @contextmanager + def collect_stats(self, invocation: BaseInvocation, graph_execution_state_id: str) -> Generator[None, None, None]: + # This is to handle case of the model manager not being initialized, which happens + # during some tests. + services = self._invoker.services + if not self._stats.get(graph_execution_state_id): + # First time we're seeing this graph_execution_state_id. + self._stats[graph_execution_state_id] = GraphExecutionStats() + self._cache_stats[graph_execution_state_id] = CacheStats() + + # 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() + + assert services.model_manager.load is not None + services.model_manager.load.ram_cache.stats = self._cache_stats[graph_execution_state_id] + + try: + # Let the invocation run. + yield None + finally: + # Record state after the invocation. + 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, + ) + self._stats[graph_execution_state_id].add_node_execution_stats(node_stats) + + def reset_stats(self): + self._stats = {} + self._cache_stats = {} + + 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) + vram_usage_gb = torch.cuda.memory_allocated() / GB if torch.cuda.is_available() else None + + return InvocationStatsSummary( + graph_stats=graph_stats_summary, + model_cache_stats=model_cache_stats_summary, + node_stats=node_stats_summaries, + vram_usage_gb=vram_usage_gb, + ) + + def log_stats(self, graph_execution_state_id: str) -> None: + stats = self.get_stats(graph_execution_state_id) + logger.info(str(stats)) + + def dump_stats(self, graph_execution_state_id: str, output_path: Path) -> None: + stats = self.get_stats(graph_execution_state_id) + with open(output_path, "w") as f: + f.write(json.dumps(stats.as_dict(), indent=2)) + + def _get_model_cache_summary(self, graph_execution_state_id: str) -> ModelCacheStatsSummary: + try: + cache_stats = self._cache_stats[graph_execution_state_id] + except KeyError as e: + raise GESStatsNotFoundError( + f"Attempted to get model cache statistics for unknown graph {graph_execution_state_id}: {e}." + ) from e + + return ModelCacheStatsSummary( + cache_hits=cache_stats.hits, + cache_misses=cache_stats.misses, + high_water_mark_gb=cache_stats.high_watermark / GB, + cache_size_gb=cache_stats.cache_size / GB, + total_usage_gb=sum(list(cache_stats.loaded_model_sizes.values())) / GB, + models_cached=cache_stats.in_cache, + models_cleared=cache_stats.cleared, + ) + + def _get_graph_summary(self, graph_execution_state_id: str) -> GraphExecutionStatsSummary: + try: + graph_stats = self._stats[graph_execution_state_id] + except KeyError as e: + raise GESStatsNotFoundError( + f"Attempted to get graph statistics for unknown graph {graph_execution_state_id}: {e}." + ) from e + + return graph_stats.get_graph_stats_summary(graph_execution_state_id) + + def _get_node_summaries(self, graph_execution_state_id: str) -> list[NodeExecutionStatsSummary]: + try: + graph_stats = self._stats[graph_execution_state_id] + except KeyError as e: + raise GESStatsNotFoundError( + f"Attempted to get node statistics for unknown graph {graph_execution_state_id}: {e}." + ) from e + + return graph_stats.get_node_stats_summaries() diff --git a/invokeai/app/services/invoker.py b/invokeai/app/services/invoker.py new file mode 100644 index 0000000000000000000000000000000000000000..64f83725a1d295eeceaddd34277217f18392e948 --- /dev/null +++ b/invokeai/app/services/invoker.py @@ -0,0 +1,37 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) + + +from invokeai.app.services.invocation_services import InvocationServices + + +class Invoker: + """The invoker, used to execute invocations""" + + services: InvocationServices + + def __init__(self, services: InvocationServices): + self.services = services + self._start() + + def __start_service(self, service) -> None: + # Call start() method on any services that have it + start_op = getattr(service, "start", None) + if callable(start_op): + start_op(self) + + def __stop_service(self, service) -> None: + # Call stop() method on any services that have it + stop_op = getattr(service, "stop", None) + if callable(stop_op): + stop_op(self) + + def _start(self) -> None: + """Starts the invoker. This is called automatically when the invoker is created.""" + for service in vars(self.services): + self.__start_service(getattr(self.services, service)) + + def stop(self) -> None: + """Stops the invoker. A new invoker will have to be created to execute further.""" + # First stop all services + for service in vars(self.services): + self.__stop_service(getattr(self.services, service)) diff --git a/invokeai/app/services/item_storage/__init__.py b/invokeai/app/services/item_storage/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/item_storage/item_storage_base.py b/invokeai/app/services/item_storage/item_storage_base.py new file mode 100644 index 0000000000000000000000000000000000000000..ef227ba241c14250c09bbd213eba2f92c731c53f --- /dev/null +++ b/invokeai/app/services/item_storage/item_storage_base.py @@ -0,0 +1,59 @@ +from abc import ABC, abstractmethod +from typing import Callable, Generic, TypeVar + +from pydantic import BaseModel + +T = TypeVar("T", bound=BaseModel) + + +class ItemStorageABC(ABC, Generic[T]): + """Provides storage for a single type of item. The type must be a Pydantic model.""" + + _on_changed_callbacks: list[Callable[[T], None]] + _on_deleted_callbacks: list[Callable[[str], None]] + + def __init__(self) -> None: + self._on_changed_callbacks = [] + self._on_deleted_callbacks = [] + + """Base item storage class""" + + @abstractmethod + def get(self, item_id: str) -> T: + """ + Gets the item. + :param item_id: the id of the item to get + :raises ItemNotFoundError: if the item is not found + """ + pass + + @abstractmethod + def set(self, item: T) -> None: + """ + Sets the item. + :param item: the item to set + """ + pass + + @abstractmethod + def delete(self, item_id: str) -> None: + """ + Deletes the item, if it exists. + """ + pass + + def on_changed(self, on_changed: Callable[[T], None]) -> None: + """Register a callback for when an item is changed""" + self._on_changed_callbacks.append(on_changed) + + def on_deleted(self, on_deleted: Callable[[str], None]) -> None: + """Register a callback for when an item is deleted""" + self._on_deleted_callbacks.append(on_deleted) + + def _on_changed(self, item: T) -> None: + for callback in self._on_changed_callbacks: + callback(item) + + def _on_deleted(self, item_id: str) -> None: + for callback in self._on_deleted_callbacks: + callback(item_id) diff --git a/invokeai/app/services/item_storage/item_storage_common.py b/invokeai/app/services/item_storage/item_storage_common.py new file mode 100644 index 0000000000000000000000000000000000000000..8fd677c71b7c007160fae7c2e5398ac4f4f5afbe --- /dev/null +++ b/invokeai/app/services/item_storage/item_storage_common.py @@ -0,0 +1,5 @@ +class ItemNotFoundError(KeyError): + """Raised when an item is not found in storage""" + + def __init__(self, item_id: str) -> None: + super().__init__(f"Item with id {item_id} not found") diff --git a/invokeai/app/services/item_storage/item_storage_memory.py b/invokeai/app/services/item_storage/item_storage_memory.py new file mode 100644 index 0000000000000000000000000000000000000000..d8dd0e06645e3dfe2d20311a1f9c47bfd58a4717 --- /dev/null +++ b/invokeai/app/services/item_storage/item_storage_memory.py @@ -0,0 +1,52 @@ +from collections import OrderedDict +from contextlib import suppress +from typing import Generic, TypeVar + +from pydantic import BaseModel + +from invokeai.app.services.item_storage.item_storage_base import ItemStorageABC +from invokeai.app.services.item_storage.item_storage_common import ItemNotFoundError + +T = TypeVar("T", bound=BaseModel) + + +class ItemStorageMemory(ItemStorageABC[T], Generic[T]): + """ + Provides a simple in-memory storage for items, with a maximum number of items to store. + The storage uses the LRU strategy to evict items from storage when the max has been reached. + """ + + def __init__(self, id_field: str = "id", max_items: int = 10) -> None: + super().__init__() + if max_items < 1: + raise ValueError("max_items must be at least 1") + if not id_field: + raise ValueError("id_field must not be empty") + self._id_field = id_field + self._items: OrderedDict[str, T] = OrderedDict() + self._max_items = max_items + + def get(self, item_id: str) -> T: + # If the item exists, move it to the end of the OrderedDict. + item = self._items.pop(item_id, None) + if item is None: + raise ItemNotFoundError(item_id) + self._items[item_id] = item + return item + + def set(self, item: T) -> None: + item_id = getattr(item, self._id_field) + if item_id in self._items: + # If item already exists, remove it and add it to the end + self._items.pop(item_id) + elif len(self._items) >= self._max_items: + # If cache is full, evict the least recently used item + self._items.popitem(last=False) + self._items[item_id] = item + self._on_changed(item) + + def delete(self, item_id: str) -> None: + # This is a no-op if the item doesn't exist. + with suppress(KeyError): + del self._items[item_id] + self._on_deleted(item_id) diff --git a/invokeai/app/services/model_images/model_images_base.py b/invokeai/app/services/model_images/model_images_base.py new file mode 100644 index 0000000000000000000000000000000000000000..e66137c4c5cdde14cb7a478df7be99704d3ce18c --- /dev/null +++ b/invokeai/app/services/model_images/model_images_base.py @@ -0,0 +1,33 @@ +from abc import ABC, abstractmethod +from pathlib import Path + +from PIL.Image import Image as PILImageType + + +class ModelImageFileStorageBase(ABC): + """Low-level service responsible for storing and retrieving image files.""" + + @abstractmethod + def get(self, model_key: str) -> PILImageType: + """Retrieves a model image as PIL Image.""" + pass + + @abstractmethod + def get_path(self, model_key: str) -> Path: + """Gets the internal path to a model image.""" + pass + + @abstractmethod + def get_url(self, model_key: str) -> str | None: + """Gets the URL to fetch a model image.""" + pass + + @abstractmethod + def save(self, image: PILImageType, model_key: str) -> None: + """Saves a model image.""" + pass + + @abstractmethod + def delete(self, model_key: str) -> None: + """Deletes a model image.""" + pass diff --git a/invokeai/app/services/model_images/model_images_common.py b/invokeai/app/services/model_images/model_images_common.py new file mode 100644 index 0000000000000000000000000000000000000000..0d856f2dfe6f7e1aae6ed0a714595d25e6068728 --- /dev/null +++ b/invokeai/app/services/model_images/model_images_common.py @@ -0,0 +1,20 @@ +# TODO: Should these excpetions subclass existing python exceptions? +class ModelImageFileNotFoundException(Exception): + """Raised when an image file is not found in storage.""" + + def __init__(self, message="Model image file not found"): + super().__init__(message) + + +class ModelImageFileSaveException(Exception): + """Raised when an image cannot be saved.""" + + def __init__(self, message="Model image file not saved"): + super().__init__(message) + + +class ModelImageFileDeleteException(Exception): + """Raised when an image cannot be deleted.""" + + def __init__(self, message="Model image file not deleted"): + super().__init__(message) diff --git a/invokeai/app/services/model_images/model_images_default.py b/invokeai/app/services/model_images/model_images_default.py new file mode 100644 index 0000000000000000000000000000000000000000..5fe8086c6a5f69d25eb62811089775f9505476b2 --- /dev/null +++ b/invokeai/app/services/model_images/model_images_default.py @@ -0,0 +1,83 @@ +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.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): + """Stores images on disk""" + + def __init__(self, model_images_folder: Path): + self._model_images_folder = model_images_folder + self._validate_storage_folders() + + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + + def get(self, model_key: str) -> PILImageType: + try: + path = self.get_path(model_key) + + if not self._validate_path(path): + raise ModelImageFileNotFoundException + + return Image.open(path) + except FileNotFoundError as e: + raise ModelImageFileNotFoundException from e + + def save(self, image: PILImageType, model_key: str) -> None: + try: + self._validate_storage_folders() + image_path = self._model_images_folder / (model_key + ".webp") + thumbnail = make_thumbnail(image, 256) + thumbnail.save(image_path, format="webp") + + except Exception as e: + raise ModelImageFileSaveException from e + + def get_path(self, model_key: str) -> Path: + path = self._model_images_folder / (model_key + ".webp") + + return path + + def get_url(self, model_key: str) -> str | None: + path = self.get_path(model_key) + if not self._validate_path(path): + return + + url = self._invoker.services.urls.get_model_image_url(model_key) + + # 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, model_key: str) -> None: + try: + path = self.get_path(model_key) + + if not self._validate_path(path): + raise ModelImageFileNotFoundException + + path.unlink() + + except Exception as e: + raise ModelImageFileDeleteException 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._model_images_folder.mkdir(parents=True, exist_ok=True) diff --git a/invokeai/app/services/model_install/__init__.py b/invokeai/app/services/model_install/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d96e86cbfed000afefddf800c4514abb60bddf1a --- /dev/null +++ b/invokeai/app/services/model_install/__init__.py @@ -0,0 +1,25 @@ +"""Initialization file for model install service package.""" + +from invokeai.app.services.model_install.model_install_base import ModelInstallServiceBase +from invokeai.app.services.model_install.model_install_common import ( + HFModelSource, + InstallStatus, + LocalModelSource, + ModelInstallJob, + ModelSource, + UnknownInstallJobException, + URLModelSource, +) +from invokeai.app.services.model_install.model_install_default import ModelInstallService + +__all__ = [ + "ModelInstallServiceBase", + "ModelInstallService", + "InstallStatus", + "ModelInstallJob", + "UnknownInstallJobException", + "ModelSource", + "LocalModelSource", + "HFModelSource", + "URLModelSource", +] diff --git a/invokeai/app/services/model_install/model_install_base.py b/invokeai/app/services/model_install/model_install_base.py new file mode 100644 index 0000000000000000000000000000000000000000..9ce80dc03559145432172c3f4ab66ab3b3d2c98a --- /dev/null +++ b/invokeai/app/services/model_install/model_install_base.py @@ -0,0 +1,261 @@ +# Copyright 2023 Lincoln D. Stein and the InvokeAI development team +"""Baseclass definitions for the model installer.""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import 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 ModelRecordChanges, ModelRecordServiceBase +from invokeai.backend.model_manager import AnyModelConfig + + +class ModelInstallServiceBase(ABC): + """Abstract base class for InvokeAI model installation.""" + + @abstractmethod + def __init__( + self, + app_config: InvokeAIAppConfig, + record_store: ModelRecordServiceBase, + download_queue: DownloadQueueServiceBase, + event_bus: Optional["EventServiceBase"] = None, + ): + """ + Create ModelInstallService object. + + :param config: Systemwide InvokeAIAppConfig. + :param store: Systemwide ModelConfigStore + :param event_bus: InvokeAI event bus for reporting events to. + """ + + # make the invoker optional here because we don't need it and it + # makes the installer harder to use outside the web app + @abstractmethod + def start(self, invoker: Optional[Invoker] = None) -> None: + """Start the installer service.""" + + @abstractmethod + def stop(self, invoker: Optional[Invoker] = None) -> None: + """Stop the model install service. After this the objection can be safely deleted.""" + + @property + @abstractmethod + def app_config(self) -> InvokeAIAppConfig: + """Return the appConfig object associated with the installer.""" + + @property + @abstractmethod + def record_store(self) -> ModelRecordServiceBase: + """Return the ModelRecoreService object associated with the installer.""" + + @property + @abstractmethod + def event_bus(self) -> Optional["EventServiceBase"]: + """Return the event service base object associated with the installer.""" + + @abstractmethod + def register_path( + self, + model_path: Union[Path, str], + config: Optional[ModelRecordChanges] = None, + ) -> str: + """ + Probe and register the model at model_path. + + This keeps the model in its current location. + + :param model_path: Filesystem Path to the model. + :param config: ModelRecordChanges object that will override autoassigned model record values. + :returns id: The string ID of the registered model. + """ + + @abstractmethod + def unregister(self, key: str) -> None: + """Remove model with indicated key from the database.""" + + @abstractmethod + def delete(self, key: str) -> None: + """Remove model with indicated key from the database. Delete its files only if they are within our models directory.""" + + @abstractmethod + def unconditionally_delete(self, key: str) -> None: + """Remove model with indicated key from the database and unconditionally delete weight files from disk.""" + + @abstractmethod + def install_path( + self, + model_path: Union[Path, str], + config: Optional[ModelRecordChanges] = None, + ) -> str: + """ + Probe, register and install the model in the models directory. + + This moves the model from its current location into + the models directory handled by InvokeAI. + + :param model_path: Filesystem Path to the model. + :param config: ModelRecordChanges object that will override autoassigned model record values. + :returns id: The string ID of the registered model. + """ + + @abstractmethod + def heuristic_import( + self, + source: str, + 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 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. + + The source can be: + 1. A local file path in posix() format (`/foo/bar` or `C:\foo\bar`) + 2. An http or https URL (`https://foo.bar/foo`) + 3. A HuggingFace repo_id (`foo/bar`, `foo/bar:fp16`, `foo/bar:fp16:vae`) + + We extend the HuggingFace repo_id syntax to include the variant and the + subfolder or path. The following are acceptable alternatives: + stabilityai/stable-diffusion-v4 + stabilityai/stable-diffusion-v4:fp16 + stabilityai/stable-diffusion-v4:fp16:vae + stabilityai/stable-diffusion-v4::/checkpoints/sd4.safetensors + stabilityai/stable-diffusion-v4:onnx:vae + + Because a local file path can look like a huggingface repo_id, the logic + first checks whether the path exists on disk, and if not, it is treated as + a parseable huggingface repo. + + The previous support for recursing into a local folder and loading all model-like files + has been removed. + """ + pass + + @abstractmethod + def import_model( + self, + source: ModelSource, + config: Optional[ModelRecordChanges] = None, + ) -> ModelInstallJob: + """Install the indicated model. + + :param source: ModelSource object + + :param config: Optional dict. Any fields in this dict + will override corresponding autoassigned probe fields in the + model's config record. Use it to override + `name`, `description`, `base_type`, `model_type`, `format`, + `prediction_type`, and/or `image_size`. + + This will download the model located at `source`, + probe it, and install it into the models directory. + This call is executed asynchronously in a separate + thread and will issue the following events on the event bus: + + - model_install_started + - model_install_error + - model_install_completed + + The `inplace` flag does not affect the behavior of downloaded + models, which are always moved into the `models` directory. + + The call returns a ModelInstallJob object which can be + polled to learn the current status and/or error message. + + Variants recognized by HuggingFace currently are: + 1. onnx + 2. openvino + 3. fp16 + 4. None (usually returns fp32 model) + + """ + + @abstractmethod + def get_job_by_source(self, source: ModelSource) -> List[ModelInstallJob]: + """Return the ModelInstallJob(s) corresponding to the provided source.""" + + @abstractmethod + def get_job_by_id(self, id: int) -> ModelInstallJob: + """Return the ModelInstallJob corresponding to the provided id. Raises ValueError if no job has that ID.""" + + @abstractmethod + def list_jobs(self) -> List[ModelInstallJob]: # noqa D102 + """ + List active and complete install jobs. + """ + + @abstractmethod + def prune_jobs(self) -> None: + """Prune all completed and errored jobs.""" + + @abstractmethod + def cancel_job(self, job: ModelInstallJob) -> None: + """Cancel the indicated job.""" + + @abstractmethod + def wait_for_job(self, job: ModelInstallJob, timeout: int = 0) -> ModelInstallJob: + """Wait for the indicated job to reach a terminal state. + + This will block until the indicated install job has completed, + been cancelled, or errored out. + + :param job: The job to wait on. + :param timeout: Wait up to indicated number of seconds. Raise a TimeoutError if + the job hasn't completed within the indicated time. + """ + + @abstractmethod + def wait_for_installs(self, timeout: int = 0) -> List[ModelInstallJob]: + """ + Wait for all pending installs to complete. + + This will block until all pending installs have + completed, been cancelled, or errored out. + + :param timeout: Wait up to indicated number of seconds. Raise an Exception('timeout') if + installs do not complete within the indicated time. A timeout of zero (the default) + 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: + """ + Download the model file located at source to the models cache and return its Path. + + :param source: A string representing a URL or repo_id. + + The model file will be downloaded into the system-wide model cache + (`models/.cache`) if it isn't already there. Note that the model cache + is periodically cleared of infrequently-used entries when the model + converter runs. + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..4dbd1ef1c127bc1998cfc4f311885f4f150866e4 --- /dev/null +++ b/invokeai/app/services/model_install/model_install_common.py @@ -0,0 +1,229 @@ +import re +import traceback +from enum import Enum +from pathlib import Path +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.app.services.model_records import ModelRecordChanges +from invokeai.backend.model_manager import AnyModelConfig, ModelRepoVariant +from invokeai.backend.model_manager.config import ModelSourceType +from invokeai.backend.model_manager.metadata import AnyModelRepoMetadata + + +class InstallStatus(str, Enum): + """State of an install job running in the background.""" + + WAITING = "waiting" # waiting to be dequeued + DOWNLOADING = "downloading" # downloading of model files in process + DOWNLOADS_DONE = "downloads_done" # downloading done, waiting to run + RUNNING = "running" # being processed + COMPLETED = "completed" # finished running + ERROR = "error" # terminated with an error message + CANCELLED = "cancelled" # terminated with an error message + + +class UnknownInstallJobException(Exception): + """Raised when the status of an unknown job is requested.""" + + +class StringLikeSource(BaseModel): + """ + Base class for model sources, implements functions that lets the source be sorted and indexed. + + These shenanigans let this stuff work: + + source1 = LocalModelSource(path='C:/users/mort/foo.safetensors') + mydict = {source1: 'model 1'} + assert mydict['C:/users/mort/foo.safetensors'] == 'model 1' + assert mydict[LocalModelSource(path='C:/users/mort/foo.safetensors')] == 'model 1' + + source2 = LocalModelSource(path=Path('C:/users/mort/foo.safetensors')) + assert source1 == source2 + assert source1 == 'C:/users/mort/foo.safetensors' + """ + + def __hash__(self) -> int: + """Return hash of the path field, for indexing.""" + return hash(str(self)) + + def __lt__(self, other: object) -> int: + """Return comparison of the stringified version, for sorting.""" + return str(self) < str(other) + + def __eq__(self, other: object) -> bool: + """Return equality on the stringified version.""" + if isinstance(other, Path): + return str(self) == other.as_posix() + else: + return str(self) == str(other) + + +class LocalModelSource(StringLikeSource): + """A local file or directory path.""" + + path: str | Path + inplace: Optional[bool] = False + type: Literal["local"] = "local" + + # these methods allow the source to be used in a string-like way, + # for example as an index into a dict + def __str__(self) -> str: + """Return string version of path when string rep needed.""" + return Path(self.path).as_posix() + + +class HFModelSource(StringLikeSource): + """ + A HuggingFace repo_id with optional variant, sub-folder 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. + """ + + repo_id: str + variant: Optional[ModelRepoVariant] = ModelRepoVariant.FP16 + subfolder: Optional[Path] = None + access_token: Optional[str] = None + type: Literal["hf"] = "hf" + + @field_validator("repo_id") + @classmethod + def proper_repo_id(cls, v: str) -> str: # noqa D102 + if not re.match(r"^([.\w-]+/[.\w-]+)$", v): + raise ValueError(f"{v}: invalid repo_id format") + return v + + 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.as_posix()}" + return base + + +class URLModelSource(StringLikeSource): + """A generic URL point to a checkpoint file.""" + + url: AnyHttpUrl + access_token: Optional[str] = None + type: Literal["url"] = "url" + + def __str__(self) -> str: + """Return string version of the url when string rep needed.""" + return str(self.url) + + +ModelSource = Annotated[Union[LocalModelSource, HFModelSource, URLModelSource], Field(discriminator="type")] + +MODEL_SOURCE_TO_TYPE_MAP = { + URLModelSource: ModelSourceType.Url, + HFModelSource: ModelSourceType.HFRepoID, + LocalModelSource: ModelSourceType.Path, +} + + +class ModelInstallJob(BaseModel): + """Object that tracks the current status of an install request.""" + + 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: 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." + ) + inplace: bool = Field( + default=False, description="Leave model in its current location; otherwise install under models directory" + ) + source: ModelSource = Field(description="Source (URL, repo_id, or local path) of model") + local_path: Path = Field(description="Path to locally-downloaded model; may be the same as the source") + bytes: int = Field( + default=0, description="For a remote model, the number of bytes downloaded so far (may not be available)" + ) + total_bytes: int = Field(default=0, description="Total size of the model to be installed") + source_metadata: Optional[AnyModelRepoMetadata] = Field( + default=None, description="Metadata provided by the model source" + ) + download_parts: Set[DownloadJob] = Field( + default_factory=set, description="Download jobs contributing to this install" + ) + error: Optional[str] = Field( + default=None, description="On an error condition, this field will contain the text of the exception" + ) + error_traceback: Optional[str] = Field( + default=None, description="On an error condition, this field will contain the exception traceback" + ) + # internal flags and transitory settings + _install_tmpdir: Optional[Path] = PrivateAttr(default=None) + _multifile_job: Optional[MultiFileDownloadJob] = PrivateAttr(default=None) + _exception: Optional[Exception] = PrivateAttr(default=None) + + def set_error(self, e: Exception) -> None: + """Record the error and traceback from an exception.""" + self._exception = e + self.error = str(e) + self.error_traceback = self._format_error(e) + self.status = InstallStatus.ERROR + self.error_reason = self._exception.__class__.__name__ if self._exception else None + + def cancel(self) -> None: + """Call to cancel the job.""" + self.status = InstallStatus.CANCELLED + + @property + def error_type(self) -> Optional[str]: + """Class name of the exception that led to status==ERROR.""" + return self._exception.__class__.__name__ if self._exception else None + + def _format_error(self, exception: Exception) -> str: + """Error traceback.""" + return "".join(traceback.format_exception(exception)) + + @property + def cancelled(self) -> bool: + """Set status to CANCELLED.""" + return self.status == InstallStatus.CANCELLED + + @property + def errored(self) -> bool: + """Return true if job has errored.""" + return self.status == InstallStatus.ERROR + + @property + def waiting(self) -> bool: + """Return true if job is waiting to run.""" + return self.status == InstallStatus.WAITING + + @property + def downloading(self) -> bool: + """Return true if job is downloading.""" + return self.status == InstallStatus.DOWNLOADING + + @property + def downloads_done(self) -> bool: + """Return true if job's downloads ae done.""" + return self.status == InstallStatus.DOWNLOADS_DONE + + @property + def running(self) -> bool: + """Return true if job is running.""" + return self.status == InstallStatus.RUNNING + + @property + def complete(self) -> bool: + """Return true if job completed without errors.""" + return self.status == InstallStatus.COMPLETED + + @property + def in_terminal_state(self) -> bool: + """Return true if job is in a terminal state.""" + return self.status in [InstallStatus.COMPLETED, InstallStatus.ERROR, InstallStatus.CANCELLED] diff --git a/invokeai/app/services/model_install/model_install_default.py b/invokeai/app/services/model_install/model_install_default.py new file mode 100644 index 0000000000000000000000000000000000000000..4ff480343858dfacdbd912003fc36da27f21f5a6 --- /dev/null +++ b/invokeai/app/services/model_install/model_install_default.py @@ -0,0 +1,933 @@ +"""Model installation class.""" + +import locale +import os +import re +import threading +import time +from pathlib import Path +from queue import Empty, Queue +from shutil import copyfile, copytree, move, rmtree +from tempfile import mkdtemp +from typing import Any, Dict, List, Optional, Tuple, Type, Union + +import torch +import yaml +from huggingface_hub import HfFolder +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_install.model_install_common import ( + MODEL_SOURCE_TO_TYPE_MAP, + HFModelSource, + InstallStatus, + LocalModelSource, + ModelInstallJob, + ModelSource, + StringLikeSource, + URLModelSource, +) +from invokeai.app.services.model_records import DuplicateModelException, ModelRecordServiceBase +from invokeai.app.services.model_records.model_records_base import ModelRecordChanges +from invokeai.backend.model_manager.config import ( + AnyModelConfig, + CheckpointConfigBase, + InvalidModelConfigException, + ModelRepoVariant, + ModelSourceType, +) +from invokeai.backend.model_manager.metadata import ( + AnyModelRepoMetadata, + HuggingFaceMetadataFetch, + ModelMetadataFetchBase, + ModelMetadataWithFiles, + 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.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 + +TMPDIR_PREFIX = "tmpinstall_" + + +class ModelInstallService(ModelInstallServiceBase): + """class for InvokeAI model installation.""" + + def __init__( + self, + app_config: InvokeAIAppConfig, + record_store: ModelRecordServiceBase, + download_queue: DownloadQueueServiceBase, + event_bus: Optional["EventServiceBase"] = None, + session: Optional[Session] = None, + ): + """ + Initialize the installer object. + + :param app_config: InvokeAIAppConfig object + :param record_store: Previously-opened ModelRecordService database + :param event_bus: Optional EventService object + """ + self._app_config = app_config + self._record_store = record_store + self._event_bus = event_bus + self._logger = InvokeAILogger.get_logger(name=self.__class__.__name__) + self._install_jobs: List[ModelInstallJob] = [] + self._install_queue: Queue[ModelInstallJob] = Queue() + self._lock = threading.Lock() + self._stop_event = threading.Event() + self._downloads_changed_event = threading.Event() + self._install_completed_event = threading.Event() + self._download_queue = download_queue + self._download_cache: Dict[int, ModelInstallJob] = {} + self._running = False + self._session = session + self._install_thread: Optional[threading.Thread] = None + self._next_job_id = 0 + + @property + def app_config(self) -> InvokeAIAppConfig: # noqa D102 + return self._app_config + + @property + def record_store(self) -> ModelRecordServiceBase: # noqa D102 + return self._record_store + + @property + def event_bus(self) -> Optional["EventServiceBase"]: # noqa D102 + return self._event_bus + + # make the invoker optional here because we don't need it and it + # makes the installer harder to use outside the web app + def start(self, invoker: Optional[Invoker] = None) -> None: + """Start the installer thread.""" + + with self._lock: + if self._running: + raise Exception("Attempt to start the installer service twice") + self._start_installer_thread() + self._remove_dangling_install_dirs() + self._migrate_yaml() + # In normal use, we do not want to scan the models directory - it should never have orphaned models. + # We should only do the scan when the flag is set (which should only be set when testing). + if self.app_config.scan_models_on_startup: + with catch_sigint(): + self._register_orphaned_models() + + # Check all models' paths and confirm they exist. A model could be missing if it was installed on a volume + # that isn't currently mounted. In this case, we don't want to delete the model from the database, but we do + # want to alert the user. + for model in self._scan_for_missing_models(): + self._logger.warning(f"Missing model file: {model.name} at {model.path}") + + 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") + self._logger.debug("calling stop_event.set()") + self._stop_event.set() + self._clear_pending_jobs() + self._download_cache.clear() + assert self._install_thread is not None + self._install_thread.join() + self._running = False + + 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) + while True: + try: + job = self._install_queue.get(block=False) + self._install_queue.task_done() + except Empty: + break + + def _put_in_queue(self, job: ModelInstallJob) -> None: + if self._stop_event.is_set(): + self.cancel_job(job) + else: + self._install_queue.put(job) + + def register_path( + self, + model_path: Union[Path, str], + config: Optional[ModelRecordChanges] = None, + ) -> str: # noqa D102 + model_path = Path(model_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) + + def install_path( + self, + model_path: Union[Path, str], + config: Optional[ModelRecordChanges] = None, + ) -> str: # noqa D102 + model_path = Path(model_path) + config = config or ModelRecordChanges() + info: AnyModelConfig = ModelProbe.probe( + Path(model_path), config.model_dump(), hash_algo=self._app_config.hashing_algorithm + ) # type: ignore + + if preferred_name := config.name: + preferred_name = Path(preferred_name).with_suffix(model_path.suffix) + + dest_path = ( + self.app_config.models_path / info.base.value / info.type.value / (preferred_name or model_path.name) + ) + try: + new_path = self._copy_model(model_path, dest_path) + except FileExistsError as excp: + raise DuplicateModelException( + f"A model named {model_path.name} is already installed at {dest_path.as_posix()}" + ) from excp + + return self._register( + new_path, + config, + info, + ) + + def heuristic_import( + self, + source: str, + config: Optional[ModelRecordChanges] = None, + access_token: Optional[str] = None, + inplace: Optional[bool] = False, + ) -> ModelInstallJob: + """Install a model using pattern matching to infer the type of source.""" + source_obj = self._guess_source(source) + if isinstance(source_obj, LocalModelSource): + source_obj.inplace = inplace + elif isinstance(source_obj, HFModelSource) or isinstance(source_obj, URLModelSource): + source_obj.access_token = access_token + return self.import_model(source_obj, config) + + def import_model(self, source: ModelSource, config: Optional[ModelRecordChanges] = None) -> ModelInstallJob: # noqa D102 + 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.") + return similar_jobs[0] + + if isinstance(source, LocalModelSource): + install_job = self._import_local_model(source, config) + self._put_in_queue(install_job) # synchronously install + elif isinstance(source, HFModelSource): + install_job = self._import_from_hf(source, config) + elif isinstance(source, URLModelSource): + install_job = self._import_from_url(source, config) + else: + raise ValueError(f"Unsupported model source: '{type(source)}'") + + self._install_jobs.append(install_job) + return install_job + + def list_jobs(self) -> List[ModelInstallJob]: # noqa D102 + return self._install_jobs + + def get_job_by_source(self, source: ModelSource) -> List[ModelInstallJob]: # noqa D102 + return [x for x in self._install_jobs if x.source == source] + + def get_job_by_id(self, id: int) -> ModelInstallJob: # noqa D102 + jobs = [x for x in self._install_jobs if x.id == id] + if not jobs: + raise ValueError(f"No job with id {id} known") + assert len(jobs) == 1 + assert isinstance(jobs[0], ModelInstallJob) + return jobs[0] + + def wait_for_job(self, job: ModelInstallJob, timeout: int = 0) -> ModelInstallJob: + """Block until the indicated job has reached terminal state, or when timeout limit reached.""" + start = time.time() + while not job.in_terminal_state: + if self._install_completed_event.wait(timeout=5): # in case we miss an event + self._install_completed_event.clear() + if timeout > 0 and time.time() - start > timeout: + raise TimeoutError("Timeout exceeded") + return job + + def wait_for_installs(self, timeout: int = 0) -> List[ModelInstallJob]: # noqa D102 + """Block until all installation jobs are done.""" + start = time.time() + while len(self._download_cache) > 0: + if self._downloads_changed_event.wait(timeout=0.25): # in case we miss an event + self._downloads_changed_event.clear() + if timeout > 0 and time.time() - start > timeout: + raise TimeoutError("Timeout exceeded") + self._install_queue.join() + + return self._install_jobs + + def cancel_job(self, job: ModelInstallJob) -> None: + """Cancel the indicated job.""" + job.cancel() + self._logger.warning(f"Cancelling {job.source}") + if dj := job._multifile_job: + self._download_queue.cancel_job(dj) + + def prune_jobs(self) -> None: + """Prune all completed and errored jobs.""" + unfinished_jobs = [x for x in self._install_jobs if not x.in_terminal_state] + self._install_jobs = unfinished_jobs + + def _migrate_yaml(self) -> None: + db_models = self.record_store.all_models() + + legacy_models_yaml_path = ( + self._app_config.legacy_models_yaml_path or self._app_config.root_path / "configs" / "models.yaml" + ) + + # The old path may be relative to the root path + if not legacy_models_yaml_path.exists(): + legacy_models_yaml_path = Path(self._app_config.root_path, legacy_models_yaml_path) + + if legacy_models_yaml_path.exists(): + with open(legacy_models_yaml_path, "rt", encoding=locale.getpreferredencoding()) as file: + legacy_models_yaml = yaml.safe_load(file) + + yaml_metadata = legacy_models_yaml.pop("__metadata__") + yaml_version = yaml_metadata.get("version") + + if yaml_version != "3.0.0": + raise ValueError( + f"Attempted migration of unsupported `models.yaml` v{yaml_version}. Only v3.0.0 is supported. Exiting." + ) + + self._logger.info( + f"Starting one-time migration of {len(legacy_models_yaml.items())} models from {str(legacy_models_yaml_path)}. This may take a few minutes." + ) + + if len(db_models) == 0 and len(legacy_models_yaml.items()) != 0: + for model_key, stanza in legacy_models_yaml.items(): + _, _, model_name = str(model_key).split("/") + model_path = Path(stanza["path"]) + if not model_path.is_absolute(): + model_path = self._app_config.models_path / model_path + model_path = model_path.resolve() + + 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) + try: + id = self.register_path(model_path=model_path, config=config) + self._logger.info(f"Migrated {model_name} with id {id}") + except Exception as e: + self._logger.warning(f"Model at {model_path} could not be migrated: {e}") + + # Rename `models.yaml` to `models.yaml.bak` to prevent re-migration + legacy_models_yaml_path.rename(legacy_models_yaml_path.with_suffix(".yaml.bak")) + + # Unset the path - we are done with it either way + self._app_config.legacy_models_yaml_path = None + + def unregister(self, key: str) -> None: # noqa D102 + self.record_store.del_model(key) + + def delete(self, key: str) -> None: # noqa D102 + """Unregister the model. Delete its files only if they are within our models directory.""" + model = self.record_store.get_model(key) + model_path = self.app_config.models_path / model.path + + if model_path.is_relative_to(self.app_config.models_path): + # If the models is in the Invoke-managed models dir, we delete it + self.unconditionally_delete(key) + else: + # Else we only unregister it, leaving the file in place + self.unregister(key) + + 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 + if model_path.is_file() or model_path.is_symlink(): + model_path.unlink() + elif model_path.is_dir(): + rmtree(model_path) + self.unregister(key) + + @classmethod + def _download_cache_path(cls, source: Union[str, AnyHttpUrl], app_config: InvokeAIAppConfig) -> Path: + escaped_source = slugify(str(source)) + return app_config.download_cache_path / escaped_source + + def download_and_cache_model( + self, + source: str | AnyHttpUrl, + ) -> Path: + """Download the model file located at source to the models cache and return its Path.""" + model_path = self._download_cache_path(str(source), self._app_config) + + # We expect the cache directory to contain one and only one downloaded file or directory. + # We don't know the file's name in advance, as it is set by the download + # content-disposition header. + if model_path.exists(): + contents: List[Path] = 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) + 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) + + def _remote_files_from_source( + self, source: ModelSource + ) -> Tuple[List[RemoteModelFile], Optional[AnyModelRepoMetadata]]: + metadata = None + if isinstance(source, HFModelSource): + metadata = HuggingFaceMetadataFetch(self._session).from_id(source.repo_id, source.variant) + assert isinstance(metadata, ModelMetadataWithFiles) + return ( + metadata.download_urls( + variant=source.variant or self._guess_variant(), + subfolder=source.subfolder, + session=self._session, + ), + metadata, + ) + + if isinstance(source, URLModelSource): + try: + fetcher = self.get_fetcher_from_url(str(source.url)) + kwargs: dict[str, Any] = {"session": self._session} + metadata = fetcher(**kwargs).from_url(source.url) + assert isinstance(metadata, ModelMetadataWithFiles) + return metadata.download_urls(session=self._session), metadata + except ValueError: + pass + + return [RemoteModelFile(url=source.url, path=Path("."), size=0)], None + + raise Exception(f"No files associated with {source}") + + def _guess_source(self, source: str) -> ModelSource: + """Turn a source string into a ModelSource object.""" + 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)) + elif match := re.match(hf_repoid_re, source): + source_obj = HFModelSource( + repo_id=match.group(1), + variant=ModelRepoVariant(match.group(2)) if match.group(2) else None, # pass None rather than '' + subfolder=Path(match.group(3)) if match.group(3) else None, + ) + elif re.match(r"^https?://[^/]+", source): + source_obj = URLModelSource( + url=Url(source), + ) + else: + raise ValueError(f"Unsupported model source: '{source}'") + return source_obj + + # -------------------------------------------------------------------------------------------- + # Internal functions that manage the installer threads + # -------------------------------------------------------------------------------------------- + def _start_installer_thread(self) -> None: + self._install_thread = threading.Thread(target=self._install_next_item, daemon=True) + self._install_thread.start() + self._running = True + + def _install_next_item(self) -> None: + self._logger.debug(f"Installer thread {threading.get_ident()} starting") + while True: + if self._stop_event.is_set(): + break + self._logger.debug(f"Installer thread {threading.get_ident()} polling") + try: + job = self._install_queue.get(timeout=1) + except Empty: + continue + assert job.local_path is not None + try: + if job.cancelled: + self._signal_job_cancelled(job) + + elif job.errored: + self._signal_job_errored(job) + + elif job.waiting or job.downloads_done: + self._register_or_install(job) + + except Exception as e: + # Expected errors include InvalidModelConfigException, DuplicateModelException, OSError, but we must + # gracefully handle _any_ error here. + self._set_error(job, e) + + 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._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: + # 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__] + # enter the metadata, if there is any + if isinstance(job.source_metadata, (HuggingFaceMetadata)): + job.config_in.source_api_response = job.source_metadata.api_response + + if job.inplace: + key = self.register_path(job.local_path, job.config_in) + else: + key = self.install_path(job.local_path, job.config_in) + job.config_out = self.record_store.get_model(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( + 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." + ) + ) + else: + install_job.set_error(excp) + self._signal_job_errored(install_job) + + # -------------------------------------------------------------------------------------------- + # Internal functions that manage the models directory + # -------------------------------------------------------------------------------------------- + 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) + + 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 not (self.app_config.models_path / model_config.path).resolve().exists(): + missing_models.append(model_config) + return missing_models + + def _register_orphaned_models(self) -> None: + """Scan the invoke-managed models directory for orphaned models and registers them. + + This is typically only used during testing with a new DB or when using the memory DB, because those are the + only situations in which we may have orphaned models in the models directory. + """ + installed_model_paths = { + (self._app_config.models_path / x.path).resolve() for x in self.record_store.all_models() + } + + # The bool returned by this callback determines if the model is added to the list of models found by the search + def on_model_found(model_path: Path) -> bool: + resolved_path = model_path.resolve() + # Already registered models should be in the list of found models, but not re-registered. + if resolved_path in installed_model_paths: + return True + # Skip core models entirely - these aren't registered with the model manager. + for special_directory in [ + self.app_config.models_path / "core", + self.app_config.convert_cache_dir, + self.app_config.download_cache_dir, + ]: + if resolved_path.is_relative_to(special_directory): + return False + try: + model_id = self.register_path(model_path) + self._logger.info(f"Registered {model_path.name} with id {model_id}") + except DuplicateModelException: + # In case a duplicate models sneaks by, we will ignore this error - we "found" the model + pass + return True + + self._logger.info(f"Scanning {self._app_config.models_path} for orphaned models") + search = ModelSearch(on_model_found=on_model_found) + 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. + + 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 + + new_path.parent.mkdir(parents=True, exist_ok=True) + + # 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 + + def _register( + self, model_path: Path, config: Optional[ModelRecordChanges] = None, info: Optional[AnyModelConfig] = None + ) -> str: + config = config or ModelRecordChanges() + + info = info or ModelProbe.probe(model_path, config.model_dump(), hash_algo=self._app_config.hashing_algorithm) # type: ignore + + model_path = model_path.resolve() + + # Models in the Invoke-managed models dir should use relative paths. + if model_path.is_relative_to(self.app_config.models_path): + model_path = model_path.relative_to(self.app_config.models_path) + + info.path = model_path.as_posix() + + if isinstance(info, CheckpointConfigBase): + # 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 + 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) + info.config_path = legacy_config_path.as_posix() + self.record_store.add_model(info) + return info.key + + def _next_id(self) -> int: + with self._lock: + id = self._next_job_id + self._next_job_id += 1 + return id + + def _guess_variant(self) -> Optional[ModelRepoVariant]: + """Guess the best HuggingFace variant type to download.""" + precision = TorchDevice.choose_torch_dtype() + return ModelRepoVariant.FP16 if precision == torch.float16 else None + + def _import_local_model( + self, source: LocalModelSource, config: Optional[ModelRecordChanges] = None + ) -> ModelInstallJob: + return ModelInstallJob( + id=self._next_id(), + source=source, + config_in=config or ModelRecordChanges(), + local_path=Path(source.path), + inplace=source.inplace or False, + ) + + def _import_from_hf( + self, + source: HFModelSource, + 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() + remote_files, metadata = self._remote_files_from_source(source) + return self._import_remote_model( + source=source, + config=config, + remote_files=remote_files, + metadata=metadata, + ) + + def _import_from_url( + self, + source: URLModelSource, + config: Optional[ModelRecordChanges] = None, + ) -> ModelInstallJob: + remote_files, metadata = self._remote_files_from_source(source) + return self._import_remote_model( + source=source, + config=config, + metadata=metadata, + remote_files=remote_files, + ) + + def _import_remote_model( + self, + source: HFModelSource | URLModelSource, + remote_files: List[RemoteModelFile], + metadata: Optional[AnyModelRepoMetadata], + 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, + ) + ) + install_job = ModelInstallJob( + id=self._next_id(), + source=source, + 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, + total_bytes=0, + ) + # 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) + + multifile_job = self._multifile_download( + remote_files=remote_files, + dest=destdir, + subfolder=source.subfolder if isinstance(source, HFModelSource) else None, + 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 + + 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 + + def _stat_size(self, path: Path) -> int: + size = 0 + if path.is_file(): + size = path.stat().st_size + elif path.is_dir(): + for root, _, files in os.walk(path): + size += sum(self._stat_size(Path(root, x)) for x in files) + return size + + def _multifile_download( + self, + remote_files: List[RemoteModelFile], + dest: Path, + subfolder: Optional[Path] = None, + access_token: Optional[str] = None, + submit_job: bool = True, + ) -> MultiFileDownloadJob: + # HuggingFace repo subfolders are a little tricky. If the name of the model is "sdxl-turbo", and + # 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: + top = Path(remote_files[0].path.parts[0]) # e.g. "sdxl-turbo/" + path_to_remove = top / subfolder # sdxl-turbo/vae/ + subfolder_rename = subfolder.name.replace("/", "_").replace("\\", "_") + path_to_add = Path(f"{top}_{subfolder_rename}") + 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), + ) + ) + + return self._download_queue.multifile_download( + parts=parts, + dest=dest, + access_token=access_token, + submit_job=submit_job, + on_start=self._download_started_callback, + on_progress=self._download_progress_callback, + on_complete=self._download_complete_callback, + on_error=self._download_error_callback, + on_cancelled=self._download_cancelled_callback, + ) + + # ------------------------------------------------------------------ + # Callbacks are executed by the download queue in a separate thread + # ------------------------------------------------------------------ + def _download_started_callback(self, download_job: MultiFileDownloadJob) -> None: + with self._lock: + if install_job := self._download_cache.get(download_job.id, None): + install_job.status = InstallStatus.DOWNLOADING + + if install_job.local_path == install_job._install_tmpdir: # first time + assert download_job.download_path + 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 + self._signal_job_download_started(install_job) + + def _download_progress_callback(self, download_job: MultiFileDownloadJob) -> None: + with self._lock: + if install_job := self._download_cache.get(download_job.id, None): + if install_job.cancelled: # This catches the case in which the caller directly calls job.cancel() + self._download_queue.cancel_job(download_job) + 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) + self._signal_job_downloading(install_job) + + def _download_complete_callback(self, download_job: MultiFileDownloadJob) -> None: + with self._lock: + if install_job := self._download_cache.pop(download_job.id, None): + self._signal_job_downloads_done(install_job) + self._put_in_queue(install_job) # this starts the installation and registration + + # Let other threads know that the number of downloads has changed + self._downloads_changed_event.set() + + def _download_error_callback(self, download_job: MultiFileDownloadJob, excp: Optional[Exception] = None) -> None: + with self._lock: + if install_job := self._download_cache.pop(download_job.id, None): + assert excp is not None + self._set_error(install_job, excp) + self._download_queue.cancel_job(download_job) + + # Let other threads know that the number of downloads has changed + self._downloads_changed_event.set() + + def _download_cancelled_callback(self, download_job: MultiFileDownloadJob) -> None: + with self._lock: + if install_job := self._download_cache.pop(download_job.id, None): + self._downloads_changed_event.set() + # if install job has already registered an error, then do not replace its status with cancelled + if not install_job.errored: + install_job.cancel() + + # Let other threads know that the number of downloads has changed + self._downloads_changed_event.set() + + # ------------------------------------------------------------------------------------------------ + # Internal methods that put events on the event bus + # ------------------------------------------------------------------------------------------------ + def _signal_job_running(self, job: ModelInstallJob) -> None: + job.status = InstallStatus.RUNNING + self._logger.info(f"Model install started: {job.source}") + if self._event_bus: + self._event_bus.emit_model_install_started(job) + + def _signal_job_download_started(self, job: ModelInstallJob) -> None: + if self._event_bus: + assert job._multifile_job is not None + assert job.bytes is not None + assert job.total_bytes is not None + self._event_bus.emit_model_install_download_started(job) + + def _signal_job_downloading(self, job: ModelInstallJob) -> None: + if self._event_bus: + assert job._multifile_job is not None + assert job.bytes is not None + assert job.total_bytes is not None + self._event_bus.emit_model_install_download_progress(job) + + def _signal_job_downloads_done(self, job: ModelInstallJob) -> None: + job.status = InstallStatus.DOWNLOADS_DONE + self._logger.info(f"Model download complete: {job.source}") + if self._event_bus: + self._event_bus.emit_model_install_downloads_complete(job) + + def _signal_job_completed(self, job: ModelInstallJob) -> None: + job.status = InstallStatus.COMPLETED + 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 self._event_bus: + assert job.local_path is not None + assert job.config_out is not None + self._event_bus.emit_model_install_complete(job) + + def _signal_job_errored(self, job: ModelInstallJob) -> None: + self._logger.error(f"Model install error: {job.source}\n{job.error_type}: {job.error}") + if self._event_bus: + assert job.error_type is not None + assert job.error is not None + self._event_bus.emit_model_install_error(job) + + def _signal_job_cancelled(self, job: ModelInstallJob) -> None: + self._logger.info(f"Model install canceled: {job.source}") + if self._event_bus: + self._event_bus.emit_model_install_cancelled(job) + + @staticmethod + def get_fetcher_from_url(url: str) -> Type[ModelMetadataFetchBase]: + """ + Return a metadata fetcher appropriate for provided url. + + This used to be more useful, but the number of supported model + sources has been reduced to HuggingFace alone. + """ + if re.match(r"^https?://huggingface.co/[^/]+/[^/]+$", url.lower()): + return HuggingFaceMetadataFetch + raise ValueError(f"Unsupported model source: '{url}'") diff --git a/invokeai/app/services/model_load/__init__.py b/invokeai/app/services/model_load/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4c7e40c8c76f5ade6de64b63fe097a72adabeb9a --- /dev/null +++ b/invokeai/app/services/model_load/__init__.py @@ -0,0 +1,6 @@ +"""Initialization file for model load service module.""" + +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 new file mode 100644 index 0000000000000000000000000000000000000000..03c2a81e0c60e31efb18ca5f7f915772b3b7447d --- /dev/null +++ b/invokeai/app/services/model_load/model_load_base.py @@ -0,0 +1,51 @@ +# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Team +"""Base class for model loader.""" + +from abc import ABC, abstractmethod +from pathlib import Path +from typing import Callable, Optional + +from invokeai.backend.model_manager import AnyModel, AnyModelConfig, SubModelType +from invokeai.backend.model_manager.load import LoadedModel, LoadedModelWithoutConfig +from invokeai.backend.model_manager.load.model_cache.model_cache_base import ModelCacheBase + + +class ModelLoadServiceBase(ABC): + """Wrapper around AnyModelLoader.""" + + @abstractmethod + def load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> LoadedModel: + """ + Given a model's configuration, load it and return the LoadedModel object. + + :param model_config: Model configuration record (as returned by ModelRecordBase.get_model()) + :param submodel: For main (pipeline models), the submodel to fetch. + """ + + @property + @abstractmethod + def ram_cache(self) -> ModelCacheBase[AnyModel]: + """Return the RAM cache used by this loader.""" + + @abstractmethod + def load_model_from_path( + self, model_path: Path, loader: Optional[Callable[[Path], AnyModel]] = None + ) -> LoadedModelWithoutConfig: + """ + Load the model file or directory located at the indicated Path. + + This will load an arbitrary model file into the RAM cache. If the optional loader + argument is provided, the loader will be invoked to load the model into + memory. Otherwise the method will call safetensors.torch.load_file() or + torch.load() as appropriate to the file suffix. + + Be aware that this returns a LoadedModelWithoutConfig object, which is the same as + LoadedModel, but without the config attribute. + + Args: + model_path: A pathlib.Path to a checkpoint-style models file + loader: A Callable that expects a Path and returns a Dict[str, Tensor] + + Returns: + A LoadedModel object. + """ diff --git a/invokeai/app/services/model_load/model_load_default.py b/invokeai/app/services/model_load/model_load_default.py new file mode 100644 index 0000000000000000000000000000000000000000..be2cc2478af2423d29ae333621d9ba2493c0e582 --- /dev/null +++ b/invokeai/app/services/model_load/model_load_default.py @@ -0,0 +1,113 @@ +# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Team +"""Implementation of model loader service.""" + +from pathlib import Path +from typing import Callable, Optional, Type + +from picklescan.scanner import scan_file_path +from safetensors.torch import load_file as safetensors_load_file +from torch import load as torch_load + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.model_load.model_load_base import ModelLoadServiceBase +from invokeai.backend.model_manager import AnyModel, AnyModelConfig, SubModelType +from invokeai.backend.model_manager.load import ( + LoadedModel, + LoadedModelWithoutConfig, + ModelLoaderRegistry, + ModelLoaderRegistryBase, +) +from invokeai.backend.model_manager.load.model_cache.model_cache_base import ModelCacheBase +from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader +from invokeai.backend.util.devices import TorchDevice +from invokeai.backend.util.logging import InvokeAILogger + + +class ModelLoadService(ModelLoadServiceBase): + """Wrapper around ModelLoaderRegistry.""" + + def __init__( + self, + app_config: InvokeAIAppConfig, + ram_cache: ModelCacheBase[AnyModel], + registry: Optional[Type[ModelLoaderRegistryBase]] = ModelLoaderRegistry, + ): + """Initialize the model load service.""" + 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._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 load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> LoadedModel: + """ + Given a model's configuration, load it and return the LoadedModel object. + + :param model_config: Model configuration record (as returned by ModelRecordBase.get_model()) + :param submodel: For main (pipeline models), the submodel to fetch. + """ + + # We don't have an invoker during testing + # TODO(psyche): Mock this method on the invoker in the tests + if hasattr(self, "_invoker"): + self._invoker.services.events.emit_model_load_started(model_config, submodel_type) + + implementation, model_config, submodel_type = self._registry.get_implementation(model_config, submodel_type) # type: ignore + loaded_model: LoadedModel = implementation( + app_config=self._app_config, + logger=self._logger, + ram_cache=self._ram_cache, + ).load_model(model_config, submodel_type) + + if hasattr(self, "_invoker"): + self._invoker.services.events.emit_model_load_complete(model_config, submodel_type) + + return loaded_model + + def load_model_from_path( + self, model_path: Path, loader: Optional[Callable[[Path], AnyModel]] = None + ) -> LoadedModelWithoutConfig: + cache_key = str(model_path) + ram_cache = self.ram_cache + try: + return LoadedModelWithoutConfig(_locker=ram_cache.get(key=cache_key)) + 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.") + result = torch_load(checkpoint, map_location="cpu") + return result + + def diffusers_load_directory(directory: Path) -> AnyModel: + load_class = GenericDiffusersLoader( + app_config=self._app_config, + logger=self._logger, + ram_cache=self._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()) + + loader = loader or ( + diffusers_load_directory + if model_path.is_dir() + else torch_load_file + if model_path.suffix.endswith((".ckpt", ".pt", ".pth", ".bin")) + 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)) diff --git a/invokeai/app/services/model_manager/__init__.py b/invokeai/app/services/model_manager/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..07c27cee31ae95dc16125b1a30236f19919ed25f --- /dev/null +++ b/invokeai/app/services/model_manager/__init__.py @@ -0,0 +1,16 @@ +"""Initialization file for model manager service.""" + +from invokeai.app.services.model_manager.model_manager_default import ModelManagerService, ModelManagerServiceBase +from invokeai.backend.model_manager import AnyModel, AnyModelConfig, BaseModelType, ModelType, SubModelType +from invokeai.backend.model_manager.load import LoadedModel + +__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 new file mode 100644 index 0000000000000000000000000000000000000000..a906076b163ddac9869f7d7e9e287e65f8031370 --- /dev/null +++ b/invokeai/app/services/model_manager/model_manager_base.py @@ -0,0 +1,67 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Team + +from abc import ABC, abstractmethod + +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.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): + """Abstract base class for the model manager service.""" + + # attributes: + # store: ModelRecordServiceBase = Field(description="An instance of the model record configuration service.") + # install: ModelInstallServiceBase = Field(description="An instance of the model install service.") + # load: ModelLoadServiceBase = Field(description="An instance of the model load service.") + + @classmethod + @abstractmethod + def build_model_manager( + cls, + app_config: InvokeAIAppConfig, + model_record_service: ModelRecordServiceBase, + download_queue: DownloadQueueServiceBase, + events: EventServiceBase, + execution_device: torch.device, + ) -> Self: + """ + Construct the model manager service instance. + + Use it rather than the __init__ constructor. This class + method simplifies the construction considerably. + """ + pass + + @property + @abstractmethod + def store(self) -> ModelRecordServiceBase: + """Return the ModelRecordServiceBase used to store and retrieve configuration records.""" + pass + + @property + @abstractmethod + def load(self) -> ModelLoadServiceBase: + """Return the ModelLoadServiceBase used to load models from their configuration records.""" + pass + + @property + @abstractmethod + def install(self) -> ModelInstallServiceBase: + """Return the ModelInstallServiceBase used to download and manipulate model files.""" + pass + + @abstractmethod + def start(self, invoker: Invoker) -> None: + pass + + @abstractmethod + def stop(self, invoker: Invoker) -> None: + pass diff --git a/invokeai/app/services/model_manager/model_manager_common.py b/invokeai/app/services/model_manager/model_manager_common.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/model_manager/model_manager_default.py b/invokeai/app/services/model_manager/model_manager_default.py new file mode 100644 index 0000000000000000000000000000000000000000..78f8e09e74269b7fc84f2967ebcdf3a8bfbdf54c --- /dev/null +++ b/invokeai/app/services/model_manager/model_manager_default.py @@ -0,0 +1,101 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Team +"""Implementation of ModelManagerServiceBase.""" + +from typing import Optional + +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.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 import ModelCache, ModelLoaderRegistry +from invokeai.backend.util.devices import TorchDevice +from invokeai.backend.util.logging import InvokeAILogger + + +class ModelManagerService(ModelManagerServiceBase): + """ + The ModelManagerService handles various aspects of model installation, maintenance and loading. + + It bundles three distinct services: + model_manager.store -- Routines to manage the database of model configuration records. + model_manager.install -- Routines to install, move and delete models. + model_manager.load -- Routines to load models into memory. + """ + + def __init__( + self, + store: ModelRecordServiceBase, + install: ModelInstallServiceBase, + load: ModelLoadServiceBase, + ): + self._store = store + self._install = install + self._load = load + + @property + def store(self) -> ModelRecordServiceBase: + return self._store + + @property + def install(self) -> ModelInstallServiceBase: + return self._install + + @property + def load(self) -> ModelLoadServiceBase: + return self._load + + def start(self, invoker: Invoker) -> None: + for service in [self._store, self._install, self._load]: + if hasattr(service, "start"): + service.start(invoker) + + def stop(self, invoker: Invoker) -> None: + for service in [self._store, self._install, self._load]: + if hasattr(service, "stop"): + service.stop(invoker) + + @classmethod + def build_model_manager( + cls, + app_config: InvokeAIAppConfig, + model_record_service: ModelRecordServiceBase, + download_queue: DownloadQueueServiceBase, + events: EventServiceBase, + execution_device: Optional[torch.device] = None, + ) -> Self: + """ + Construct the model manager service instance. + + For simplicity, use this class method rather than the __init__ constructor. + """ + 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(), + ) + loader = ModelLoadService( + app_config=app_config, + ram_cache=ram_cache, + registry=ModelLoaderRegistry, + ) + installer = ModelInstallService( + app_config=app_config, + record_store=model_record_service, + download_queue=download_queue, + event_bus=events, + ) + return cls(store=model_record_service, install=installer, load=loader) diff --git a/invokeai/app/services/model_records/__init__.py b/invokeai/app/services/model_records/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..4fee477466d8c08edaf598329421262550759561 --- /dev/null +++ b/invokeai/app/services/model_records/__init__.py @@ -0,0 +1,23 @@ +"""Init file for model record services.""" + +from .model_records_base import ( # noqa F401 + DuplicateModelException, + InvalidModelException, + ModelRecordServiceBase, + UnknownModelException, + ModelSummary, + ModelRecordChanges, + ModelRecordOrderBy, +) +from .model_records_sql import ModelRecordServiceSQL # noqa F401 + +__all__ = [ + "ModelRecordServiceBase", + "ModelRecordServiceSQL", + "DuplicateModelException", + "InvalidModelException", + "UnknownModelException", + "ModelSummary", + "ModelRecordChanges", + "ModelRecordOrderBy", +] diff --git a/invokeai/app/services/model_records/model_records_base.py b/invokeai/app/services/model_records/model_records_base.py new file mode 100644 index 0000000000000000000000000000000000000000..4f40da37d45020ad0d6ae72763f3514acea201c7 --- /dev/null +++ b/invokeai/app/services/model_records/model_records_base.py @@ -0,0 +1,229 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team +""" +Abstract base class for storing and retrieving model configuration records. +""" + +from abc import ABC, abstractmethod +from enum import Enum +from pathlib import Path +from typing import List, Optional, Set, Union + +from pydantic import BaseModel, Field + +from invokeai.app.services.shared.pagination import PaginatedResults +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull +from invokeai.backend.model_manager.config import ( + AnyModelConfig, + BaseModelType, + ClipVariantType, + ControlAdapterDefaultSettings, + MainModelDefaultSettings, + ModelFormat, + ModelSourceType, + ModelType, + ModelVariantType, + SchedulerPredictionType, +) + + +class DuplicateModelException(Exception): + """Raised on an attempt to add a model with the same key twice.""" + + +class InvalidModelException(Exception): + """Raised when an invalid model is detected.""" + + +class UnknownModelException(Exception): + """Raised on an attempt to fetch or delete a model with a nonexistent key.""" + + +class ConfigFileVersionMismatchException(Exception): + """Raised on an attempt to open a config with an incompatible version.""" + + +class ModelRecordOrderBy(str, Enum): + """The order in which to return model summaries.""" + + Default = "default" # order by type, base, format and name + Type = "type" + Base = "base" + Name = "name" + Format = "format" + + +class ModelSummary(BaseModel): + """A short summary of models for UI listing purposes.""" + + key: str = Field(description="model key") + type: ModelType = Field(description="model type") + base: BaseModelType = Field(description="base model") + format: ModelFormat = Field(description="model format") + name: str = Field(description="model name") + description: str = Field(description="short description of model") + tags: Set[str] = Field(description="tags associated with model") + + +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) + 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) + 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 + ) + + # Checkpoint-specific changes + # TODO(MM2): Should we expose these? Feels footgun-y... + variant: Optional[ModelVariantType | ClipVariantType] = Field(description="The variant of the model.", default=None) + prediction_type: Optional[SchedulerPredictionType] = Field( + description="The prediction type of the model.", default=None + ) + upcast_attention: Optional[bool] = Field(description="Whether to upcast attention.", default=None) + config_path: Optional[str] = Field(description="Path to config file for model", default=None) + + +class ModelRecordServiceBase(ABC): + """Abstract base class for storage and retrieval of model configs.""" + + @abstractmethod + def add_model(self, config: AnyModelConfig) -> AnyModelConfig: + """ + Add a model to the database. + + :param key: Unique key for the model + :param config: Model configuration record, either a dict with the + required fields or a ModelConfigBase instance. + + Can raise DuplicateModelException and InvalidModelConfigException exceptions. + """ + pass + + @abstractmethod + def del_model(self, key: str) -> None: + """ + Delete a model. + + :param key: Unique key for the model to be deleted + + Can raise an UnknownModelException + """ + pass + + @abstractmethod + def update_model(self, key: str, changes: ModelRecordChanges) -> 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. + """ + pass + + @abstractmethod + def get_model(self, key: str) -> AnyModelConfig: + """ + Retrieve the configuration for the indicated model. + + :param key: Key of model config to be fetched. + + Exceptions: UnknownModelException + """ + pass + + @abstractmethod + def get_model_by_hash(self, hash: str) -> AnyModelConfig: + """ + Retrieve the configuration for the indicated model. + + :param hash: Hash of model config to be fetched. + + Exceptions: UnknownModelException + """ + pass + + @abstractmethod + def list_models( + self, page: int = 0, per_page: int = 10, order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default + ) -> PaginatedResults[ModelSummary]: + """Return a paginated summary listing of each model in the database.""" + pass + + @abstractmethod + def exists(self, key: str) -> bool: + """ + Return True if a model with the indicated key exists in the database. + + :param key: Unique key for the model to be deleted + """ + pass + + @abstractmethod + def search_by_path( + self, + path: Union[str, Path], + ) -> List[AnyModelConfig]: + """Return the model(s) having the indicated path.""" + pass + + @abstractmethod + def search_by_hash( + self, + hash: str, + ) -> List[AnyModelConfig]: + """Return the model(s) having the indicated original hash.""" + pass + + @abstractmethod + def search_by_attr( + self, + model_name: Optional[str] = None, + base_model: Optional[BaseModelType] = None, + model_type: Optional[ModelType] = None, + model_format: Optional[ModelFormat] = None, + ) -> List[AnyModelConfig]: + """ + Return models matching name, base and/or type. + + :param model_name: Filter by name of model (optional) + :param base_model: Filter by base model (optional) + :param model_type: Filter by type of model (optional) + :param model_format: Filter by model format (e.g. "diffusers") (optional) + + If none of the optional filters are passed, will return all + models in the database. + """ + pass + + def all_models(self) -> List[AnyModelConfig]: + """Return all the model configs in the database.""" + return self.search_by_attr() + + def model_info_by_name(self, model_name: str, base_model: BaseModelType, model_type: ModelType) -> AnyModelConfig: + """ + Return information about a single model using its name, base type and model type. + + If there are more than one model that match, raises a DuplicateModelException. + If no model matches, raises an UnknownModelException + """ + model_configs = self.search_by_attr(model_name=model_name, base_model=base_model, model_type=model_type) + if len(model_configs) > 1: + raise DuplicateModelException( + f"More than one model matched the search criteria: base_model='{base_model}', model_type='{model_type}', model_name='{model_name}'." + ) + if len(model_configs) == 0: + raise UnknownModelException( + f"More than one model matched the search criteria: base_model='{base_model}', model_type='{model_type}', model_name='{model_name}'." + ) + return model_configs[0] diff --git a/invokeai/app/services/model_records/model_records_sql.py b/invokeai/app/services/model_records/model_records_sql.py new file mode 100644 index 0000000000000000000000000000000000000000..1d0780efe1fdd824c1dddf99fba511796e6969d9 --- /dev/null +++ b/invokeai/app/services/model_records/model_records_sql.py @@ -0,0 +1,388 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team +""" +SQL Implementation of the ModelRecordServiceBase API + +Typical usage: + + from invokeai.backend.model_manager import ModelConfigStoreSQL + store = ModelConfigStoreSQL(sqlite_db) + config = dict( + path='/tmp/pokemon.bin', + name='old name', + base_model='sd-1', + type='embedding', + format='embedding_file', + ) + + # adding - the key becomes the model's "key" field + store.add_model('key1', config) + + # updating + config.name='new name' + store.update_model('key1', config) + + # checking for existence + if store.exists('key1'): + print("yes") + + # fetching config + new_config = store.get_model('key1') + print(new_config.name, new_config.base) + assert new_config.key == 'key1' + + # deleting + store.del_model('key1') + + # searching + configs = store.search_by_path(path='/tmp/pokemon.bin') + configs = store.search_by_hash('750a499f35e43b7e1b4d15c207aa2f01') + configs = store.search_by_attr(base_model='sd-2', model_type='main') +""" + +import json +import logging +import sqlite3 +from math import ceil +from pathlib import Path +from typing import List, Optional, Union + +import pydantic + +from invokeai.app.services.model_records.model_records_base import ( + DuplicateModelException, + ModelRecordChanges, + ModelRecordOrderBy, + ModelRecordServiceBase, + ModelSummary, + UnknownModelException, +) +from invokeai.app.services.shared.pagination import PaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.backend.model_manager.config import ( + AnyModelConfig, + BaseModelType, + ModelConfigFactory, + ModelFormat, + ModelType, +) + + +class ModelRecordServiceSQL(ModelRecordServiceBase): + """Implementation of the ModelConfigStore ABC using a SQL database.""" + + def __init__(self, db: SqliteDatabase, logger: logging.Logger): + """ + Initialize a new object from preexisting sqlite3 connection and threading lock objects. + + :param db: Sqlite connection object + """ + super().__init__() + self._db = db + self._cursor = db.conn.cursor() + self._logger = logger + + @property + def db(self) -> SqliteDatabase: + """Return the underlying database.""" + return self._db + + def add_model(self, config: AnyModelConfig) -> AnyModelConfig: + """ + Add a model to the database. + + :param key: Unique key for the model + :param config: Model configuration record, either a dict with the + required fields or a ModelConfigBase instance. + + Can raise DuplicateModelException and InvalidModelConfigException exceptions. + """ + with self._db.lock: + try: + self._cursor.execute( + """--sql + INSERT INTO models ( + id, + config + ) + VALUES (?,?); + """, + ( + config.key, + 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" + elif "models.name" in str(e): + msg = f"A model with name='{config.name}', type='{config.type}', base='{config.base}' is already installed" + else: + msg = f"A model with key '{config.key}' is already installed" + 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) + + def del_model(self, key: str) -> None: + """ + Delete a model. + + :param key: Unique key for the model to be deleted + + 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)) + + json_serialized = record.model_dump_json() + + 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 get_model(self, key: str) -> AnyModelConfig: + """ + Retrieve the ModelConfigBase instance for the indicated model. + + :param key: Key of model config to be fetched. + + Exceptions: UnknownModelException + """ + with self._db.lock: + self._cursor.execute( + """--sql + SELECT config, strftime('%s',updated_at) 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]) + return model + + def get_model_by_hash(self, hash: str) -> AnyModelConfig: + with self._db.lock: + self._cursor.execute( + """--sql + SELECT config, strftime('%s',updated_at) 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]) + return model + + def exists(self, key: str) -> bool: + """ + Return True if a model with the indicated key exists in the databse. + + :param key: Unique key for the model to be deleted + """ + count = 0 + with self._db.lock: + self._cursor.execute( + """--sql + select count(*) FROM models + WHERE id=?; + """, + (key,), + ) + count = self._cursor.fetchone()[0] + return count > 0 + + def search_by_attr( + self, + model_name: Optional[str] = None, + base_model: Optional[BaseModelType] = None, + model_type: Optional[ModelType] = None, + model_format: Optional[ModelFormat] = None, + order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default, + ) -> List[AnyModelConfig]: + """ + Return models matching name, base and/or type. + + :param model_name: Filter by name of model (optional) + :param base_model: Filter by base model (optional) + :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 + + 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( + f"""--sql + SELECT config, strftime('%s',updated_at) + FROM models + {where} + ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason; + """, + tuple(bindings), + ) + result = self._cursor.fetchall() + + # Parse the model configs. + results: list[AnyModelConfig] = [] + for row in result: + try: + model_config = ModelConfigFactory.make_config(json.loads(row[0]), timestamp=row[1]) + except pydantic.ValidationError: + # 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. + self._logger.warning(f"Found an invalid model config in the database. Ignoring this model. ({row[0]})") + 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( + """--sql + SELECT config, strftime('%s',updated_at) FROM models + WHERE path=?; + """, + (str(path),), + ) + results = [ + ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in self._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( + """--sql + SELECT config, strftime('%s',updated_at) FROM models + WHERE hash=?; + """, + (hash,), + ) + results = [ + ModelConfigFactory.make_config(json.loads(x[0]), timestamp=x[1]) for x in self._cursor.fetchall() + ] + return results + + def list_models( + self, page: int = 0, per_page: int = 10, order_by: ModelRecordOrderBy = ModelRecordOrderBy.Default + ) -> 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: + # query1: get the total number of model configs + self._cursor.execute( + """--sql + select count(*) from models; + """, + (), + ) + total = int(self._cursor.fetchone()[0]) + + # query2: fetch key fields + self._cursor.execute( + f"""--sql + SELECT config + FROM models + ORDER BY {ordering[order_by]} -- using ? to bind doesn't work here for some reason + LIMIT ? + OFFSET ?; + """, + ( + per_page, + 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 + ) diff --git a/invokeai/app/services/names/__init__.py b/invokeai/app/services/names/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/names/names_base.py b/invokeai/app/services/names/names_base.py new file mode 100644 index 0000000000000000000000000000000000000000..f892c43c55a11234d3125c48237705be14ce34db --- /dev/null +++ b/invokeai/app/services/names/names_base.py @@ -0,0 +1,11 @@ +from abc import ABC, abstractmethod + + +class NameServiceBase(ABC): + """Low-level service responsible for naming resources (images, latents, etc).""" + + # TODO: Add customizable naming schemes + @abstractmethod + def create_image_name(self) -> str: + """Creates a name for an image.""" + pass diff --git a/invokeai/app/services/names/names_common.py b/invokeai/app/services/names/names_common.py new file mode 100644 index 0000000000000000000000000000000000000000..7c69f8abe8d42ce9bcd35f6f94e0c0c98fccc26c --- /dev/null +++ b/invokeai/app/services/names/names_common.py @@ -0,0 +1,8 @@ +from enum import Enum, EnumMeta + + +class ResourceType(str, Enum, metaclass=EnumMeta): + """Enum for resource types.""" + + IMAGE = "image" + LATENT = "latent" diff --git a/invokeai/app/services/names/names_default.py b/invokeai/app/services/names/names_default.py new file mode 100644 index 0000000000000000000000000000000000000000..5804a937d6ac2af1661f960eb0f8e591c66fdcde --- /dev/null +++ b/invokeai/app/services/names/names_default.py @@ -0,0 +1,12 @@ +from invokeai.app.services.names.names_base import NameServiceBase +from invokeai.app.util.misc import uuid_string + + +class SimpleNameService(NameServiceBase): + """Creates image names from UUIDs.""" + + # TODO: Add customizable naming schemes + def create_image_name(self) -> str: + uuid_str = uuid_string() + filename = f"{uuid_str}.png" + return filename diff --git a/invokeai/app/services/object_serializer/object_serializer_base.py b/invokeai/app/services/object_serializer/object_serializer_base.py new file mode 100644 index 0000000000000000000000000000000000000000..ff19b4a039dc61ba5f15dc48ffdb369b79ca36a0 --- /dev/null +++ b/invokeai/app/services/object_serializer/object_serializer_base.py @@ -0,0 +1,44 @@ +from abc import ABC, abstractmethod +from typing import Callable, Generic, TypeVar + +T = TypeVar("T") + + +class ObjectSerializerBase(ABC, Generic[T]): + """Saves and loads arbitrary python objects.""" + + def __init__(self) -> None: + self._on_deleted_callbacks: list[Callable[[str], None]] = [] + + @abstractmethod + def load(self, name: str) -> T: + """ + Loads the object. + :param name: The name of the object to load. + :raises ObjectNotFoundError: if the object is not found + """ + pass + + @abstractmethod + def save(self, obj: T) -> str: + """ + Saves the object, returning its name. + :param obj: The object to save. + """ + pass + + @abstractmethod + def delete(self, name: str) -> None: + """ + Deletes the object, if it exists. + :param name: The name of the object to delete. + """ + pass + + def on_deleted(self, on_deleted: Callable[[str], None]) -> None: + """Register a callback for when an object is deleted""" + self._on_deleted_callbacks.append(on_deleted) + + def _on_deleted(self, name: str) -> None: + for callback in self._on_deleted_callbacks: + callback(name) diff --git a/invokeai/app/services/object_serializer/object_serializer_common.py b/invokeai/app/services/object_serializer/object_serializer_common.py new file mode 100644 index 0000000000000000000000000000000000000000..7057386541fff38528973194268cc23fd8fcccdd --- /dev/null +++ b/invokeai/app/services/object_serializer/object_serializer_common.py @@ -0,0 +1,5 @@ +class ObjectNotFoundError(KeyError): + """Raised when an object is not found while loading""" + + def __init__(self, name: str) -> None: + super().__init__(f"Object with name {name} not found") diff --git a/invokeai/app/services/object_serializer/object_serializer_disk.py b/invokeai/app/services/object_serializer/object_serializer_disk.py new file mode 100644 index 0000000000000000000000000000000000000000..8edd29e1505c35513d145d16f28b194755468fe9 --- /dev/null +++ b/invokeai/app/services/object_serializer/object_serializer_disk.py @@ -0,0 +1,85 @@ +import shutil +import tempfile +import typing +from pathlib import Path +from typing import TYPE_CHECKING, Optional, TypeVar + +import torch + +from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase +from invokeai.app.services.object_serializer.object_serializer_common import ObjectNotFoundError +from invokeai.app.util.misc import uuid_string + +if TYPE_CHECKING: + from invokeai.app.services.invoker import Invoker + + +T = TypeVar("T") + + +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 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): + super().__init__() + self._ephemeral = ephemeral + self._base_output_dir = output_dir + self._base_output_dir.mkdir(parents=True, exist_ok=True) + + if self._ephemeral: + # Remove dangling tempdirs that might have been left over from an earlier unplanned shutdown. + for temp_dir in filter(Path.is_dir, self._base_output_dir.glob("tmp*")): + shutil.rmtree(temp_dir) + + # Must specify `ignore_cleanup_errors` to avoid fatal errors during cleanup on Windows + self._tempdir = ( + tempfile.TemporaryDirectory(dir=self._base_output_dir, ignore_cleanup_errors=True) if ephemeral else None + ) + self._output_dir = Path(self._tempdir.name) if self._tempdir else self._base_output_dir + self.__obj_class_name: Optional[str] = None + + def load(self, name: str) -> T: + file_path = self._get_path(name) + try: + return torch.load(file_path) # pyright: ignore [reportUnknownMemberType] + except FileNotFoundError as e: + raise ObjectNotFoundError(name) from e + + def save(self, obj: T) -> str: + name = self._new_name() + file_path = self._get_path(name) + torch.save(obj, file_path) # pyright: ignore [reportUnknownMemberType] + return name + + def delete(self, name: str) -> None: + file_path = self._get_path(name) + file_path.unlink() + + @property + def _obj_class_name(self) -> str: + if not self.__obj_class_name: + # `__orig_class__` is not available in the constructor for some technical, undoubtedly very pythonic reason + self.__obj_class_name = typing.get_args(self.__orig_class__)[0].__name__ # pyright: ignore [reportUnknownMemberType, reportAttributeAccessIssue] + return self.__obj_class_name + + def _get_path(self, name: str) -> Path: + return self._output_dir / name + + def _new_name(self) -> str: + return f"{self._obj_class_name}_{uuid_string()}" + + def _tempdir_cleanup(self) -> None: + """Calls `cleanup` on the temporary directory, if it exists.""" + if self._tempdir: + self._tempdir.cleanup() + + def __del__(self) -> None: + # In case the service is not properly stopped, clean up the temporary directory when the class instance is GC'd. + self._tempdir_cleanup() + + def stop(self, invoker: "Invoker") -> None: + self._tempdir_cleanup() diff --git a/invokeai/app/services/object_serializer/object_serializer_forward_cache.py b/invokeai/app/services/object_serializer/object_serializer_forward_cache.py new file mode 100644 index 0000000000000000000000000000000000000000..b361259a4b19f5af5572ed3d34af68b0771645b9 --- /dev/null +++ b/invokeai/app/services/object_serializer/object_serializer_forward_cache.py @@ -0,0 +1,65 @@ +from queue import Queue +from typing import TYPE_CHECKING, Optional, TypeVar + +from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase + +T = TypeVar("T") + +if TYPE_CHECKING: + from invokeai.app.services.invoker import Invoker + + +class ObjectSerializerForwardCache(ObjectSerializerBase[T]): + """ + Provides a LRU cache for an instance of `ObjectSerializerBase`. + Saving an object to the cache always writes through to the underlying storage. + """ + + def __init__(self, underlying_storage: ObjectSerializerBase[T], max_cache_size: int = 20): + super().__init__() + self._underlying_storage = underlying_storage + self._cache: dict[str, T] = {} + self._cache_ids = Queue[str]() + self._max_cache_size = max_cache_size + + def start(self, invoker: "Invoker") -> None: + self._invoker = invoker + start_op = getattr(self._underlying_storage, "start", None) + if callable(start_op): + start_op(invoker) + + def stop(self, invoker: "Invoker") -> None: + self._invoker = invoker + stop_op = getattr(self._underlying_storage, "stop", None) + if callable(stop_op): + stop_op(invoker) + + def load(self, name: str) -> T: + cache_item = self._get_cache(name) + if cache_item is not None: + return cache_item + + obj = self._underlying_storage.load(name) + self._set_cache(name, obj) + return obj + + def save(self, obj: T) -> str: + name = self._underlying_storage.save(obj) + self._set_cache(name, obj) + return name + + def delete(self, name: str) -> None: + self._underlying_storage.delete(name) + 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] + + 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()) diff --git a/invokeai/app/services/session_processor/__init__.py b/invokeai/app/services/session_processor/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/session_processor/session_processor_base.py b/invokeai/app/services/session_processor/session_processor_base.py new file mode 100644 index 0000000000000000000000000000000000000000..15611bb5f87d28fcd9a0534f7c71f599402160dd --- /dev/null +++ b/invokeai/app/services/session_processor/session_processor_base.py @@ -0,0 +1,153 @@ +from abc import ABC, abstractmethod +from threading import Event +from typing import Optional, Protocol + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput +from invokeai.app.services.invocation_services import InvocationServices +from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus +from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem +from invokeai.app.util.profiler import Profiler + + +class SessionRunnerBase(ABC): + """ + Base class for session runner. + """ + + @abstractmethod + def start(self, services: InvocationServices, cancel_event: Event, profiler: Optional[Profiler] = None) -> None: + """Starts the session runner. + + Args: + services: The invocation services. + cancel_event: The cancel event. + profiler: The profiler to use for session profiling via cProfile. Omit to disable profiling. Basic session + stats will be still be recorded and logged when profiling is disabled. + """ + pass + + @abstractmethod + def run(self, queue_item: SessionQueueItem) -> None: + """Runs a session. + + Args: + queue_item: The session to run. + """ + pass + + @abstractmethod + def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem) -> None: + """Run a single node in the graph. + + Args: + invocation: The invocation to run. + queue_item: The session queue item. + """ + pass + + +class SessionProcessorBase(ABC): + """ + Base class for session processor. + + The session processor is responsible for executing sessions. It runs a simple polling loop, + checking the session queue for new sessions to execute. It must coordinate with the + invocation queue to ensure only one session is executing at a time. + """ + + @abstractmethod + def resume(self) -> SessionProcessorStatus: + """Starts or resumes the session processor""" + pass + + @abstractmethod + def pause(self) -> SessionProcessorStatus: + """Pauses the session processor""" + pass + + @abstractmethod + def get_status(self) -> SessionProcessorStatus: + """Gets the status of the session processor""" + pass + + +class OnBeforeRunNode(Protocol): + def __call__(self, invocation: BaseInvocation, queue_item: SessionQueueItem) -> None: + """Callback to run before executing a node. + + Args: + invocation: The invocation that will be executed. + queue_item: The session queue item. + """ + ... + + +class OnAfterRunNode(Protocol): + def __call__(self, invocation: BaseInvocation, queue_item: SessionQueueItem, output: BaseInvocationOutput) -> None: + """Callback to run before executing a node. + + Args: + invocation: The invocation that was executed. + queue_item: The session queue item. + """ + ... + + +class OnNodeError(Protocol): + def __call__( + self, + invocation: BaseInvocation, + queue_item: SessionQueueItem, + error_type: str, + error_message: str, + error_traceback: str, + ) -> None: + """Callback to run when a node has an error. + + Args: + invocation: The invocation that errored. + queue_item: The session queue item. + error_type: The type of error, e.g. "ValueError". + error_message: The error message, e.g. "Invalid value". + error_traceback: The stringified error traceback. + """ + ... + + +class OnBeforeRunSession(Protocol): + def __call__(self, queue_item: SessionQueueItem) -> None: + """Callback to run before executing a session. + + Args: + queue_item: The session queue item. + """ + ... + + +class OnAfterRunSession(Protocol): + def __call__(self, queue_item: SessionQueueItem) -> None: + """Callback to run after executing a session. + + Args: + queue_item: The session queue item. + """ + ... + + +class OnNonFatalProcessorError(Protocol): + def __call__( + self, + queue_item: Optional[SessionQueueItem], + error_type: str, + error_message: str, + error_traceback: str, + ) -> None: + """Callback to run when a non-fatal error occurs in the processor. + + Args: + queue_item: The session queue item, if one was being executed when the error occurred. + error_type: The type of error, e.g. "ValueError". + error_message: The error message, e.g. "Invalid value". + error_traceback: The stringified error traceback. + """ + ... diff --git a/invokeai/app/services/session_processor/session_processor_common.py b/invokeai/app/services/session_processor/session_processor_common.py new file mode 100644 index 0000000000000000000000000000000000000000..346f12d8bbc78a8c1730da176e4b699d7f43aff7 --- /dev/null +++ b/invokeai/app/services/session_processor/session_processor_common.py @@ -0,0 +1,33 @@ +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") + is_processing: bool = Field(description="Whether a session is being processed") + + +class CanceledException(Exception): + """Execution canceled by user.""" + + pass + + +class ProgressImage(BaseModel): + """The progress image sent intermittently during processing""" + + 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 new file mode 100644 index 0000000000000000000000000000000000000000..e4faaeb9113480a763edfdcfbebe3a4ee7c5382c --- /dev/null +++ b/invokeai/app/services/session_processor/session_processor_default.py @@ -0,0 +1,507 @@ +import traceback +from contextlib import suppress +from threading import BoundedSemaphore, Thread +from threading import Event as ThreadEvent +from typing import Optional + +from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput +from invokeai.app.services.events.events_common import ( + BatchEnqueuedEvent, + FastAPIEvent, + QueueClearedEvent, + QueueItemStatusChangedEvent, + 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, 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 + + +class DefaultSessionRunner(SessionRunnerBase): + """Processes a single session's invocations.""" + + def __init__( + self, + on_before_run_session_callbacks: Optional[list[OnBeforeRunSession]] = None, + on_before_run_node_callbacks: Optional[list[OnBeforeRunNode]] = None, + on_after_run_node_callbacks: Optional[list[OnAfterRunNode]] = None, + on_node_error_callbacks: Optional[list[OnNodeError]] = None, + on_after_run_session_callbacks: Optional[list[OnAfterRunSession]] = None, + ): + """ + Args: + on_before_run_session_callbacks: Callbacks to run before the session starts. + on_before_run_node_callbacks: Callbacks to run before each node starts. + on_after_run_node_callbacks: Callbacks to run after each node completes. + on_node_error_callbacks: Callbacks to run when a node errors. + on_after_run_session_callbacks: Callbacks to run after the session completes. + """ + + self._on_before_run_session_callbacks = on_before_run_session_callbacks or [] + self._on_before_run_node_callbacks = on_before_run_node_callbacks or [] + self._on_after_run_node_callbacks = on_after_run_node_callbacks or [] + self._on_node_error_callbacks = on_node_error_callbacks or [] + self._on_after_run_session_callbacks = on_after_run_session_callbacks or [] + + def start(self, services: InvocationServices, cancel_event: ThreadEvent, profiler: Optional[Profiler] = None): + self._services = services + self._cancel_event = cancel_event + self._profiler = profiler + + def _is_canceled(self) -> bool: + """Check if the cancel event is set. This is also passed to the invocation context builder and called during + denoising to check if the session has been canceled.""" + return self._cancel_event.is_set() + + def run(self, queue_item: SessionQueueItem): + # Exceptions raised outside `run_node` are handled by the processor. There is no need to catch them here. + + self._on_before_run_session(queue_item=queue_item) + + # Loop over invocations until the session is complete or canceled + while True: + try: + invocation = queue_item.session.next() + # Anything other than a `NodeInputError` is handled as a processor error + except NodeInputError as e: + error_type = e.__class__.__name__ + error_message = str(e) + error_traceback = traceback.format_exc() + self._on_node_error( + invocation=e.node, + queue_item=queue_item, + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) + break + + if invocation is None or self._is_canceled(): + break + + self.run_node(invocation, queue_item) + + # The session is complete if all invocations have been run or there is an error on the session. + # At this time, the queue item may be canceled, but the object itself here won't be updated yet. We must + # use the cancel event to check if the session is canceled. + if ( + queue_item.session.is_complete() + or self._is_canceled() + or queue_item.status in ["failed", "canceled", "completed"] + ): + break + + self._on_after_run_session(queue_item=queue_item) + + def run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem): + try: + # Any unhandled exception in this scope is an invocation error & will fail the graph + with self._services.performance_statistics.collect_stats(invocation, queue_item.session_id): + self._on_before_run_node(invocation, queue_item) + + data = InvocationContextData( + invocation=invocation, + source_invocation_id=queue_item.session.prepared_source_mapping[invocation.id], + queue_item=queue_item, + ) + context = build_invocation_context( + data=data, + services=self._services, + is_canceled=self._is_canceled, + ) + + # Invoke the node + 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 + # correctly in the next iteration of the session runner loop. + # + # See the comment in the processor's `_on_queue_item_status_changed()` method for more details on how we + # handle cancellation. + pass + except Exception as e: + error_type = e.__class__.__name__ + error_message = str(e) + error_traceback = traceback.format_exc() + self._on_node_error( + invocation=invocation, + queue_item=queue_item, + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) + + def _on_before_run_session(self, queue_item: SessionQueueItem) -> None: + """Called before a session is run. + + - Start the profiler if profiling is enabled. + - Run any callbacks registered for this event. + """ + + self._services.logger.debug( + f"On before run session: queue item {queue_item.item_id}, session {queue_item.session_id}" + ) + + # If profiling is enabled, start the profiler + if self._profiler is not None: + self._profiler.start(profile_id=queue_item.session_id) + + for callback in self._on_before_run_session_callbacks: + callback(queue_item=queue_item) + + def _on_after_run_session(self, queue_item: SessionQueueItem) -> None: + """Called after a session is run. + + - Stop the profiler if profiling is enabled. + - Update the queue item's session object in the database. + - If not already canceled or failed, complete the queue item. + - Log and reset performance statistics. + - Run any callbacks registered for this event. + """ + + self._services.logger.debug( + f"On after run session: queue item {queue_item.item_id}, session {queue_item.session_id}" + ) + + # If we are profiling, stop the profiler and dump the profile & stats + if self._profiler is not None: + profile_path = self._profiler.stop() + stats_path = profile_path.with_suffix(".json") + self._services.performance_statistics.dump_stats( + graph_execution_state_id=queue_item.session.id, output_path=stats_path + ) + + try: + # Update the queue item with the completed session. If the queue item has been removed from the queue, + # we'll get a SessionQueueItemNotFoundError and we can ignore it. This can happen if the queue is cleared + # while the session is running. + queue_item = self._services.session_queue.set_queue_item_session(queue_item.item_id, queue_item.session) + + # The queue item may have been canceled or failed while the session was running. We should only complete it + # if it is not already canceled or failed. + if queue_item.status not in ["canceled", "failed"]: + queue_item = self._services.session_queue.complete_queue_item(queue_item.item_id) + + # We'll get a GESStatsNotFoundError if we try to log stats for an untracked graph, but in the processor + # 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() + + for callback in self._on_after_run_session_callbacks: + callback(queue_item=queue_item) + except SessionQueueItemNotFoundError: + pass + + def _on_before_run_node(self, invocation: BaseInvocation, queue_item: SessionQueueItem): + """Called before a node is run. + + - Emits an invocation started event. + - Run any callbacks registered for this event. + """ + + self._services.logger.debug( + f"On before run node: queue item {queue_item.item_id}, session {queue_item.session_id}, node {invocation.id} ({invocation.get_type()})" + ) + + # Send starting event + self._services.events.emit_invocation_started(queue_item=queue_item, invocation=invocation) + + for callback in self._on_before_run_node_callbacks: + callback(invocation=invocation, queue_item=queue_item) + + def _on_after_run_node( + self, invocation: BaseInvocation, queue_item: SessionQueueItem, output: BaseInvocationOutput + ): + """Called after a node is run. + + - Emits an invocation complete event. + - Run any callbacks registered for this event. + """ + + self._services.logger.debug( + f"On after run node: queue item {queue_item.item_id}, session {queue_item.session_id}, node {invocation.id} ({invocation.get_type()})" + ) + + # Send complete event on successful runs + self._services.events.emit_invocation_complete(invocation=invocation, queue_item=queue_item, output=output) + + for callback in self._on_after_run_node_callbacks: + callback(invocation=invocation, queue_item=queue_item, output=output) + + def _on_node_error( + self, + invocation: BaseInvocation, + queue_item: SessionQueueItem, + error_type: str, + error_message: str, + error_traceback: str, + ): + """Called when a node errors. Node errors may occur when running or preparing the node.. + + - Set the node error on the session object. + - Log the error. + - Fail the queue item. + - Emits an invocation error event. + - Run any callbacks registered for this event. + """ + + self._services.logger.debug( + f"On node error: queue item {queue_item.item_id}, session {queue_item.session_id}, node {invocation.id} ({invocation.get_type()})" + ) + + # Node errors do not get the full traceback. Only the queue item gets the full traceback. + node_error = f"{error_type}: {error_message}" + queue_item.session.set_node_error(invocation.id, node_error) + self._services.logger.error( + f"Error while invoking session {queue_item.session_id}, invocation {invocation.id} ({invocation.get_type()}): {error_message}" + ) + self._services.logger.error(error_traceback) + + # Fail the queue item + queue_item = self._services.session_queue.set_queue_item_session(queue_item.item_id, queue_item.session) + queue_item = self._services.session_queue.fail_queue_item( + queue_item.item_id, error_type, error_message, error_traceback + ) + + # Send error event + self._services.events.emit_invocation_error( + queue_item=queue_item, + invocation=invocation, + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) + + for callback in self._on_node_error_callbacks: + callback( + invocation=invocation, + queue_item=queue_item, + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) + + +class DefaultSessionProcessor(SessionProcessorBase): + def __init__( + self, + session_runner: Optional[SessionRunnerBase] = None, + on_non_fatal_processor_error_callbacks: Optional[list[OnNonFatalProcessorError]] = None, + thread_limit: int = 1, + polling_interval: int = 1, + ) -> None: + super().__init__() + + self.session_runner = session_runner if session_runner else DefaultSessionRunner() + self._on_non_fatal_processor_error_callbacks = on_non_fatal_processor_error_callbacks or [] + self._thread_limit = thread_limit + self._polling_interval = polling_interval + + 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) + + # 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. + 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 + 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() + + def stop(self, *args, **kwargs) -> None: + self._stop_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() + 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() + + def resume(self) -> SessionProcessorStatus: + if not self._resume_event.is_set(): + self._resume_event.set() + return self.get_status() + + def pause(self) -> SessionProcessorStatus: + if self._resume_event.is_set(): + self._resume_event.clear() + return self.get_status() + + def get_status(self) -> SessionProcessorStatus: + return SessionProcessorStatus( + is_started=self._resume_event.is_set(), + is_processing=self._queue_item is not None, + ) + + def _process( + self, + 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. + self._thread_semaphore.acquire() + stop_event.clear() + resume_event.set() + cancel_event.clear() + + while not stop_event.is_set(): + poll_now_event.clear() + try: + # Any unhandled exception in this block is a nonfatal processor error and will be handled. + # 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 self._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() + + # Run the graph + self.session_runner.run(queue_item=self._queue_item) + + 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, + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) + # Wait for next polling interval or event to try again + poll_now_event.wait(self._polling_interval) + continue + except Exception as e: + # Fatal error in processor, log and pass - we're done here + error_type = e.__class__.__name__ + error_message = str(e) + error_traceback = traceback.format_exc() + self._invoker.services.logger.error(f"Fatal Error in session processor {error_type}: {error_message}") + self._invoker.services.logger.error(error_traceback) + pass + finally: + stop_event.clear() + poll_now_event.clear() + self._queue_item = None + self._thread_semaphore.release() + + def _on_non_fatal_processor_error( + self, + queue_item: Optional[SessionQueueItem], + error_type: str, + error_message: str, + error_traceback: str, + ) -> None: + """Called when a non-fatal error occurs in the processor. + + - Log the error. + - If a queue item is provided, update the queue item with the completed session & fail it. + - Run any callbacks registered for this event. + """ + + self._invoker.services.logger.error(f"Non-fatal error in session processor {error_type}: {error_message}") + self._invoker.services.logger.error(error_traceback) + + if queue_item is not None: + # Update the queue item with the completed session & fail it + queue_item = self._invoker.services.session_queue.set_queue_item_session( + queue_item.item_id, queue_item.session + ) + queue_item = self._invoker.services.session_queue.fail_queue_item( + item_id=queue_item.item_id, + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) + + for callback in self._on_non_fatal_processor_error_callbacks: + callback( + queue_item=queue_item, + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) diff --git a/invokeai/app/services/session_queue/__init__.py b/invokeai/app/services/session_queue/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/session_queue/session_queue_base.py b/invokeai/app/services/session_queue/session_queue_base.py new file mode 100644 index 0000000000000000000000000000000000000000..70f6c697fdfe6a280fe5d0cec3db3dca60f9e887 --- /dev/null +++ b/invokeai/app/services/session_queue/session_queue_base.py @@ -0,0 +1,135 @@ +from abc import ABC, abstractmethod +from typing import Optional + +from invokeai.app.services.session_queue.session_queue_common import ( + QUEUE_ITEM_STATUS, + Batch, + BatchStatus, + CancelByBatchIDsResult, + CancelByDestinationResult, + CancelByQueueIDResult, + ClearResult, + EnqueueBatchResult, + IsEmptyResult, + IsFullResult, + PruneResult, + SessionQueueCountsByDestination, + SessionQueueItem, + SessionQueueItemDTO, + SessionQueueStatus, +) +from invokeai.app.services.shared.graph import GraphExecutionState +from invokeai.app.services.shared.pagination import CursorPaginatedResults + + +class SessionQueueBase(ABC): + """Base class for session queue""" + + @abstractmethod + def dequeue(self) -> Optional[SessionQueueItem]: + """Dequeues the next session queue item.""" + pass + + @abstractmethod + def enqueue_batch(self, queue_id: str, batch: Batch, prepend: bool) -> EnqueueBatchResult: + """Enqueues all permutations of a batch for execution.""" + pass + + @abstractmethod + def get_current(self, queue_id: str) -> Optional[SessionQueueItem]: + """Gets the currently-executing session queue item""" + pass + + @abstractmethod + def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: + """Gets the next session queue item (does not dequeue it)""" + pass + + @abstractmethod + def clear(self, queue_id: str) -> ClearResult: + """Deletes all session queue items""" + pass + + @abstractmethod + def prune(self, queue_id: str) -> PruneResult: + """Deletes all completed and errored session queue items""" + pass + + @abstractmethod + def is_empty(self, queue_id: str) -> IsEmptyResult: + """Checks if the queue is empty""" + pass + + @abstractmethod + def is_full(self, queue_id: str) -> IsFullResult: + """Checks if the queue is empty""" + pass + + @abstractmethod + def get_queue_status(self, queue_id: str) -> SessionQueueStatus: + """Gets the status of the queue""" + pass + + @abstractmethod + def get_counts_by_destination(self, queue_id: str, destination: str) -> SessionQueueCountsByDestination: + """Gets the counts of queue items by destination""" + pass + + @abstractmethod + def get_batch_status(self, queue_id: str, batch_id: str) -> BatchStatus: + """Gets the status of a batch""" + pass + + @abstractmethod + def complete_queue_item(self, item_id: int) -> SessionQueueItem: + """Completes a session queue item""" + pass + + @abstractmethod + def cancel_queue_item(self, item_id: int) -> SessionQueueItem: + """Cancels a session queue item""" + pass + + @abstractmethod + def fail_queue_item( + self, item_id: int, error_type: str, error_message: str, error_traceback: str + ) -> SessionQueueItem: + """Fails a session 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""" + pass + + @abstractmethod + def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDestinationResult: + """Cancels all queue items with the given batch destination""" + pass + + @abstractmethod + def cancel_by_queue_id(self, queue_id: str) -> CancelByQueueIDResult: + """Cancels all queue items with matching queue ID""" + pass + + @abstractmethod + def list_queue_items( + self, + queue_id: str, + limit: int, + priority: int, + cursor: Optional[int] = None, + status: Optional[QUEUE_ITEM_STATUS] = None, + ) -> CursorPaginatedResults[SessionQueueItemDTO]: + """Gets a page of session queue items""" + pass + + @abstractmethod + def get_queue_item(self, item_id: int) -> SessionQueueItem: + """Gets a session queue item by ID""" + 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 diff --git a/invokeai/app/services/session_queue/session_queue_common.py b/invokeai/app/services/session_queue/session_queue_common.py new file mode 100644 index 0000000000000000000000000000000000000000..81c9b6b44edad1a31f3bb4a70025ccb27c7c8819 --- /dev/null +++ b/invokeai/app/services/session_queue/session_queue_common.py @@ -0,0 +1,503 @@ +import datetime +import json +from itertools import chain, product +from typing import Generator, Iterable, Literal, NamedTuple, Optional, TypeAlias, Union, cast + +from pydantic import ( + AliasChoices, + BaseModel, + ConfigDict, + Field, + StrictStr, + TypeAdapter, + field_validator, + model_validator, +) +from pydantic_core import to_jsonable_python + +from invokeai.app.invocations.baseinvocation import BaseInvocation +from invokeai.app.services.shared.graph import Graph, GraphExecutionState, NodeNotFoundError +from invokeai.app.services.workflow_records.workflow_records_common import ( + WorkflowWithoutID, + WorkflowWithoutIDValidator, +) +from invokeai.app.util.misc import uuid_string + +# region Errors + + +class BatchZippedLengthError(ValueError): + """Raise when a batch has items of different lengths.""" + + +class BatchItemsTypeError(ValueError): # this cannot be a TypeError in pydantic v2 + """Raise when a batch has items of different types.""" + + +class BatchDuplicateNodeFieldError(ValueError): + """Raise when a batch has duplicate node_path and field_name.""" + + +class TooManySessionsError(ValueError): + """Raise when too many sessions are requested.""" + + +class SessionQueueItemNotFoundError(ValueError): + """Raise when a queue item is not found.""" + + +# endregion + + +# region Batch + +BatchDataType = Union[ + StrictStr, + float, + int, +] + + +class NodeFieldValue(BaseModel): + node_path: str = Field(description="The node into which this batch data item will be substituted.") + field_name: str = Field(description="The field into which this batch data item will be substituted.") + value: BatchDataType = Field(description="The value to substitute into the node/field.") + + +class BatchDatum(BaseModel): + node_path: str = Field(description="The node into which this batch data collection will be substituted.") + field_name: str = Field(description="The field into which this batch data collection will be substituted.") + items: list[BatchDataType] = Field( + default_factory=list, description="The list of items to substitute into the node/field." + ) + + +BatchDataCollection: TypeAlias = list[list[BatchDatum]] + + +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( + default=None, description="The workflow to initialize the session with" + ) + runs: int = Field( + default=1, ge=1, description="Int stating how many times to iterate through all possible batch indices" + ) + + @field_validator("data") + def validate_lengths(cls, v: Optional[BatchDataCollection]): + if v is None: + return v + for batch_data_list in v: + first_item_length = len(batch_data_list[0].items) if batch_data_list and batch_data_list[0].items else 0 + for i in batch_data_list: + if len(i.items) != first_item_length: + raise BatchZippedLengthError("Zipped batch items must all have the same length") + return v + + @field_validator("data") + def validate_types(cls, v: Optional[BatchDataCollection]): + if v is None: + return v + for batch_data_list in v: + for datum in batch_data_list: + # Get the type of the first item in the list + first_item_type = type(datum.items[0]) if datum.items else None + 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") + return v + + @field_validator("data") + def validate_unique_field_mappings(cls, v: Optional[BatchDataCollection]): + if v is None: + return v + paths: set[tuple[str, str]] = set() + for batch_data_list in v: + for datum in batch_data_list: + pair = (datum.node_path, datum.field_name) + if pair in paths: + raise BatchDuplicateNodeFieldError("Each batch data must have unique node_id and field_name") + paths.add(pair) + 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: + for batch_data in batch_data_list: + try: + node = cast(BaseInvocation, 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: + raise NodeNotFoundError(f"Field {batch_data.field_name} not found in node {batch_data.node_path}") + return values + + @field_validator("graph") + def validate_graph(cls, v: Graph): + v.validate_self() + return v + + model_config = ConfigDict( + json_schema_extra={ + "required": [ + "graph", + "runs", + ] + } + ) + + +# endregion Batch + + +# region Queue Items + +DEFAULT_QUEUE_ID = "default" + +QUEUE_ITEM_STATUS = Literal["pending", "in_progress", "completed", "failed", "canceled"] + +NodeFieldValueValidator = TypeAdapter(list[NodeFieldValue]) + + +def get_field_values(queue_item_dict: dict) -> Optional[list[NodeFieldValue]]: + field_values_raw = queue_item_dict.get("field_values", None) + return NodeFieldValueValidator.validate_json(field_values_raw) if field_values_raw is not None else None + + +GraphExecutionStateValidator = TypeAdapter(GraphExecutionState) + + +def get_session(queue_item_dict: dict) -> GraphExecutionState: + session_raw = queue_item_dict.get("session", "{}") + session = GraphExecutionStateValidator.validate_json(session_raw, strict=False) + return session + + +def get_workflow(queue_item_dict: dict) -> Optional[WorkflowWithoutID]: + workflow_raw = queue_item_dict.get("workflow", None) + if workflow_raw is not None: + workflow = WorkflowWithoutIDValidator.validate_json(workflow_raw, strict=False) + return workflow + return None + + +class SessionQueueItemWithoutGraph(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") + 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." + ) + error_type: Optional[str] = Field(default=None, description="The error type if this queue item errored") + error_message: Optional[str] = Field(default=None, description="The error message if this queue item errored") + error_traceback: Optional[str] = Field( + default=None, + description="The error traceback if this queue item errored", + validation_alias=AliasChoices("error_traceback", "error"), + ) + created_at: Union[datetime.datetime, str] = Field(description="When this queue item was created") + updated_at: Union[datetime.datetime, str] = Field(description="When this queue item was updated") + 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") + 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", + ] + } + ) + + +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" + ) + + @classmethod + def queue_item_from_dict(cls, queue_item_dict: dict) -> "SessionQueueItem": + # must parse these manually + queue_item_dict["field_values"] = get_field_values(queue_item_dict) + queue_item_dict["session"] = get_session(queue_item_dict) + queue_item_dict["workflow"] = get_workflow(queue_item_dict) + return SessionQueueItem(**queue_item_dict) + + model_config = ConfigDict( + json_schema_extra={ + "required": [ + "item_id", + "status", + "batch_id", + "queue_id", + "session_id", + "session", + "priority", + "session_id", + "created_at", + "updated_at", + ] + } + ) + + +# endregion Queue Items + +# region Query Results + + +class SessionQueueStatus(BaseModel): + queue_id: str = Field(..., description="The ID of the queue") + item_id: Optional[int] = Field(description="The current queue item id") + batch_id: Optional[str] = Field(description="The current queue item's batch id") + session_id: Optional[str] = Field(description="The current queue item's session id") + 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'") + 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") + + +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'") + 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") + + +class EnqueueBatchResult(BaseModel): + queue_id: str = Field(description="The ID of the queue") + enqueued: int = Field(description="The total number of queue items enqueued") + 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") + + +class ClearResult(BaseModel): + """Result of clearing the session queue""" + + deleted: int = Field(..., description="Number of queue items deleted") + + +class PruneResult(ClearResult): + """Result of pruning the session queue""" + + pass + + +class CancelByBatchIDsResult(BaseModel): + """Result of canceling by list of batch ids""" + + canceled: int = Field(..., description="Number of queue items canceled") + + +class CancelByDestinationResult(CancelByBatchIDsResult): + """Result of canceling by a destination""" + + pass + + +class CancelByQueueIDResult(CancelByBatchIDsResult): + """Result of canceling by queue id""" + + pass + + +class IsEmptyResult(BaseModel): + """Result of checking if the session queue is empty""" + + is_empty: bool = Field(..., description="Whether the session queue is empty") + + +class IsFullResult(BaseModel): + """Result of checking if the session queue is full""" + + is_full: bool = Field(..., description="Whether the session queue is full") + + +# endregion Query Results + + +# region Util + + +def populate_graph(graph: Graph, node_field_values: Iterable[NodeFieldValue]) -> Graph: + """ + 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. + """ + + # TODO: Should this be a class method on Batch? + + data: list[list[tuple[NodeFieldValue]]] = [] + 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 in batch_datum_list: + node_field_values = [ + NodeFieldValue(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) + data.append(list(zip(*node_field_values_to_zip, strict=True))) # type: ignore [arg-type] + + # create generator to yield session,nfv tuples + count = 0 + for _ in range(batch.runs): + for d in product(*data): + if count >= maximum: + return + 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) + 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(). + """ + # TODO: Should this be a class method on Batch? + if not batch.data: + return batch.runs + data = [] + for batch_datum_list in batch.data: + to_zip = [] + for batch_datum in batch_datum_list: + batch_data_items = range(len(batch_datum.items)) + to_zip.append(batch_data_items) + data.append(list(zip(*to_zip, strict=True))) + data_product = list(product(*data)) + return len(data_product) * batch.runs + + +class SessionQueueValueToInsert(NamedTuple): + """A tuple of values to insert into the session_queue table""" + + # 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 + origin: str | None + destination: str | None + + +ValuesToInsert: TypeAlias = list[SessionQueueValueToInsert] + + +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() + 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) + batch.origin, # origin + batch.destination, # destination + ) + ) + return values_to_insert + + +# endregion Util + +Batch.model_rebuild(force=True) +SessionQueueItem.model_rebuild(force=True) diff --git a/invokeai/app/services/session_queue/session_queue_sqlite.py b/invokeai/app/services/session_queue/session_queue_sqlite.py new file mode 100644 index 0000000000000000000000000000000000000000..5c631b704948872cd6fa6edb22e0f6b112c4a607 --- /dev/null +++ b/invokeai/app/services/session_queue/session_queue_sqlite.py @@ -0,0 +1,729 @@ +import sqlite3 +import threading +from typing import Optional, Union, cast + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase +from invokeai.app.services.session_queue.session_queue_common import ( + DEFAULT_QUEUE_ID, + QUEUE_ITEM_STATUS, + Batch, + BatchStatus, + CancelByBatchIDsResult, + CancelByDestinationResult, + CancelByQueueIDResult, + ClearResult, + EnqueueBatchResult, + IsEmptyResult, + IsFullResult, + PruneResult, + SessionQueueCountsByDestination, + SessionQueueItem, + SessionQueueItemDTO, + SessionQueueItemNotFoundError, + SessionQueueStatus, + 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_database import SqliteDatabase + + +class SqliteSessionQueue(SessionQueueBase): + __invoker: Invoker + __conn: sqlite3.Connection + __cursor: sqlite3.Cursor + __lock: threading.RLock + + def start(self, invoker: Invoker) -> None: + self.__invoker = invoker + self._set_in_progress_to_canceled() + if self.__invoker.services.configuration.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") + + def __init__(self, db: SqliteDatabase) -> None: + super().__init__() + self.__lock = db.lock + self.__conn = db.conn + self.__cursor = self.__conn.cursor() + + 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( + """--sql + UPDATE session_queue + SET status = 'canceled' + WHERE status = 'in_progress'; + """ + ) + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + + 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]) + + 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 + + priority = 0 + if prepend: + priority = self._get_highest_priority(queue_id) + 1 + + 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) + + if requested_count > enqueued_count: + values_to_insert = values_to_insert[:max_new_queue_items] + + self.__cursor.executemany( + """--sql + INSERT INTO session_queue (queue_id, session, session_id, batch_id, field_values, priority, workflow, origin, destination) + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?) + """, + values_to_insert, + ) + self.__conn.commit() + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + enqueue_result = EnqueueBatchResult( + queue_id=queue_id, + requested=requested_count, + enqueued=enqueued_count, + batch=batch, + priority=priority, + ) + self.__invoker.services.events.emit_batch_enqueued(enqueue_result) + 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") + return queue_item + + def get_next(self, queue_id: str) -> Optional[SessionQueueItem]: + try: + self.__lock.acquire() + self.__cursor.execute( + """--sql + SELECT * + FROM session_queue + WHERE + queue_id = ? + AND status = 'pending' + ORDER BY + priority DESC, + 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() + 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( + """--sql + SELECT * + FROM session_queue + WHERE + queue_id = ? + AND 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() + if result is None: + return None + return SessionQueueItem.queue_item_from_dict(dict(result)) + + def _set_queue_item_status( + self, + item_id: int, + status: QUEUE_ITEM_STATUS, + error_type: Optional[str] = None, + error_message: Optional[str] = None, + error_traceback: Optional[str] = None, + ) -> SessionQueueItem: + try: + self.__lock.acquire() + self.__cursor.execute( + """--sql + UPDATE session_queue + SET status = ?, error_type = ?, error_message = ?, error_traceback = ? + WHERE item_id = ? + """, + (status, error_type, error_message, error_traceback, 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) + 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( + """--sql + SELECT count(*) + FROM session_queue + WHERE queue_id = ? + """, + (queue_id,), + ) + is_empty = cast(int, self.__cursor.fetchone()[0]) == 0 + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + return IsEmptyResult(is_empty=is_empty) + + def is_full(self, queue_id: str) -> IsFullResult: + try: + self.__lock.acquire() + self.__cursor.execute( + """--sql + SELECT count(*) + FROM session_queue + WHERE queue_id = ? + """, + (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() + return IsFullResult(is_full=is_full) + + def clear(self, queue_id: str) -> ClearResult: + try: + self.__lock.acquire() + self.__cursor.execute( + """--sql + SELECT COUNT(*) + FROM session_queue + WHERE queue_id = ? + """, + (queue_id,), + ) + count = self.__cursor.fetchone()[0] + self.__cursor.execute( + """--sql + DELETE + FROM session_queue + WHERE queue_id = ? + """, + (queue_id,), + ) + 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 + WHERE + queue_id = ? + AND ( + status = 'completed' + OR status = 'failed' + OR status = 'canceled' + ) + """ + self.__lock.acquire() + self.__cursor.execute( + f"""--sql + SELECT COUNT(*) + FROM session_queue + {where}; + """, + (queue_id,), + ) + count = self.__cursor.fetchone()[0] + self.__cursor.execute( + f"""--sql + DELETE + FROM session_queue + {where}; + """, + (queue_id,), + ) + 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 complete_queue_item(self, item_id: int) -> SessionQueueItem: + queue_item = self._set_queue_item_status(item_id=item_id, status="completed") + return queue_item + + def fail_queue_item( + self, + item_id: int, + error_type: str, + error_message: str, + error_traceback: str, + ) -> SessionQueueItem: + queue_item = self._set_queue_item_status( + item_id=item_id, + status="failed", + error_type=error_type, + error_message=error_message, + error_traceback=error_traceback, + ) + 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]) + where = f"""--sql + WHERE + queue_id == ? + AND batch_id IN ({placeholders}) + AND status != 'canceled' + AND status != 'completed' + AND status != 'failed' + """ + params = [queue_id] + batch_ids + self.__cursor.execute( + f"""--sql + SELECT COUNT(*) + FROM session_queue + {where}; + """, + tuple(params), + ) + count = self.__cursor.fetchone()[0] + self.__cursor.execute( + f"""--sql + UPDATE session_queue + SET status = 'canceled' + {where}; + """, + tuple(params), + ) + self.__conn.commit() + if current_queue_item is not None and current_queue_item.batch_id in batch_ids: + self._set_queue_item_status(current_queue_item.item_id, "canceled") + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + return CancelByBatchIDsResult(canceled=count) + + def cancel_by_destination(self, queue_id: str, destination: str) -> CancelByDestinationResult: + try: + current_queue_item = self.get_current(queue_id) + self.__lock.acquire() + where = """--sql + WHERE + queue_id == ? + AND destination == ? + AND status != 'canceled' + AND status != 'completed' + AND status != 'failed' + """ + params = (queue_id, destination) + self.__cursor.execute( + f"""--sql + SELECT COUNT(*) + FROM session_queue + {where}; + """, + params, + ) + count = self.__cursor.fetchone()[0] + self.__cursor.execute( + f"""--sql + UPDATE session_queue + SET status = 'canceled' + {where}; + """, + params, + ) + self.__conn.commit() + if current_queue_item is not None and current_queue_item.destination == destination: + self._set_queue_item_status(current_queue_item.item_id, "canceled") + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + return CancelByDestinationResult(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 ? + AND status != 'canceled' + AND status != 'completed' + AND status != 'failed' + """ + params = [queue_id] + self.__cursor.execute( + f"""--sql + SELECT COUNT(*) + FROM session_queue + {where}; + """, + tuple(params), + ) + count = self.__cursor.fetchone()[0] + self.__cursor.execute( + f"""--sql + UPDATE session_queue + SET status = 'canceled' + {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() + return CancelByQueueIDResult(canceled=count) + + def get_queue_item(self, item_id: int) -> SessionQueueItem: + try: + self.__lock.acquire() + self.__cursor.execute( + """--sql + SELECT * FROM session_queue + WHERE + item_id = ? + """, + (item_id,), + ) + result = cast(Union[sqlite3.Row, None], self.__cursor.fetchone()) + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + 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: + # 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( + """--sql + UPDATE session_queue + SET session = ? + WHERE item_id = ? + """, + (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( + self, + queue_id: str, + limit: int, + priority: int, + cursor: Optional[int] = None, + status: Optional[QUEUE_ITEM_STATUS] = None, + ) -> CursorPaginatedResults[SessionQueueItemDTO]: + try: + 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, + origin, + destination + FROM session_queue + WHERE queue_id = ? + """ + params: list[Union[str, int]] = [queue_id] + + if status is not None: + query += """--sql + AND status = ? + """ + params.append(status) + + if item_id is not None: + query += """--sql + AND (priority < ?) OR (priority = ? AND item_id > ?) + """ + params.extend([priority, priority, item_id]) + + query += """--sql + ORDER BY + 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() + 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( + """--sql + SELECT status, count(*) + FROM session_queue + WHERE queue_id = ? + GROUP BY status + """, + (queue_id,), + ) + counts_result = cast(list[sqlite3.Row], self.__cursor.fetchall()) + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + + current_item = self.get_current(queue_id=queue_id) + total = sum(row[1] for row in counts_result) + counts: dict[str, int] = {row[0]: row[1] for row in counts_result} + 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, + 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 get_batch_status(self, queue_id: str, batch_id: str) -> BatchStatus: + try: + self.__lock.acquire() + self.__cursor.execute( + """--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} + origin = result[0]["origin"] if result else None + destination = result[0]["destination"] if result else None + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + + 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), + completed=counts.get("completed", 0), + failed=counts.get("failed", 0), + canceled=counts.get("canceled", 0), + total=total, + ) + + def get_counts_by_destination(self, queue_id: str, destination: str) -> SessionQueueCountsByDestination: + try: + self.__lock.acquire() + self.__cursor.execute( + """--sql + SELECT status, count(*) + FROM session_queue + WHERE queue_id = ? + AND destination = ? + GROUP BY status + """, + (queue_id, destination), + ) + counts_result = cast(list[sqlite3.Row], self.__cursor.fetchall()) + except Exception: + self.__conn.rollback() + raise + finally: + self.__lock.release() + + total = sum(row[1] 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, + ) diff --git a/invokeai/app/services/shared/__init__.py b/invokeai/app/services/shared/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/shared/graph.py b/invokeai/app/services/shared/graph.py new file mode 100644 index 0000000000000000000000000000000000000000..60fd909881b8ad5b5f1dbe2e4bd236f2ce12b33d --- /dev/null +++ b/invokeai/app/services/shared/graph.py @@ -0,0 +1,1122 @@ +# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) + +import copy +import itertools +from typing import Any, Optional, TypeVar, Union, get_args, get_origin, get_type_hints + +import networkx as nx +from pydantic import ( + BaseModel, + GetCoreSchemaHandler, + GetJsonSchemaHandler, + ValidationError, + field_validator, +) +from pydantic.fields import Field +from pydantic.json_schema import JsonSchemaValue +from pydantic_core import core_schema + +# Importing * is bad karma but needed here for node detection +from invokeai.app.invocations import * # noqa: F401 F403 +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + invocation, + invocation_output, +) +from invokeai.app.invocations.fields import Input, InputField, OutputField, UIType +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) + + +class EdgeConnection(BaseModel): + node_id: str = Field(description="The id of the node for this edge connection") + field: str = Field(description="The field for this connection") + + def __eq__(self, other): + return ( + isinstance(other, self.__class__) + and getattr(other, "node_id", None) == self.node_id + and getattr(other, "field", None) == self.field + ) + + def __hash__(self): + return hash(f"{self.node_id}.{self.field}") + + +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 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 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 is_union_subtype(t1, t2): + t1_args = get_args(t1) + t2_args = get_args(t2) + if not t1_args: + # t1 is a single type + return t1 in t2_args + else: + # t1 is a Union, check that all of its types are in t2_args + return all(arg in t2_args for arg in t1_args) + + +def is_list_or_contains_list(t): + t_args = get_args(t) + + # If the type is a List + if get_origin(t) is list: + return True + + # If the type is a Union + elif t_args: + # Check if any of the types in the Union is a List + for arg in t_args: + if get_origin(arg) is list: + return True + return False + + +def are_connection_types_compatible(from_type: Any, to_type: Any) -> bool: + if not from_type: + return False + if 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 + + if from_type in get_args(to_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 -> 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 + + return True + + +def are_connections_compatible( + from_node: BaseInvocation, from_field: str, to_node: BaseInvocation, to_field: str +) -> bool: + """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) + + return are_connection_types_compatible(from_node_field, to_node_field) + + +T = TypeVar("T") + + +def copydeep(obj: T) -> T: + """Deep-copies an object. If it is a pydantic model, use the model's copy method.""" + if isinstance(obj, BaseModel): + return obj.model_copy(deep=True) + return copy.deepcopy(obj) + + +class NodeAlreadyInGraphError(ValueError): + pass + + +class InvalidEdgeError(ValueError): + pass + + +class NodeNotFoundError(ValueError): + pass + + +class NodeAlreadyExecutedError(ValueError): + pass + + +class DuplicateNodeIdError(ValueError): + pass + + +class NodeFieldNotFoundError(ValueError): + pass + + +class NodeIdMismatchError(ValueError): + pass + + +class CyclicalGraphError(ValueError): + pass + + +class UnknownGraphValidationError(ValueError): + pass + + +class NodeInputError(ValueError): + """Raised when a node fails preparation. This occurs when a node's inputs are being set from its incomers, but an + input fails validation. + + Attributes: + node: The node that failed preparation. Note: only successfully set fields will be accurate. Review the error to + determine which field caused the failure. + """ + + def __init__(self, node: BaseInvocation, e: ValidationError): + self.original_error = e + self.node = node + # When preparing a node, we set each input one-at-a-time. We may thus safely assume that the first error + # represents the first input that failed. + self.failed_input = loc_to_dot_sep(e.errors()[0]["loc"]) + super().__init__(f"Node {node.id} has invalid incoming input for {self.failed_input}") + + +def loc_to_dot_sep(loc: tuple[Union[str, int], ...]) -> str: + """Helper to pretty-print pydantic error locations as dot-separated strings. + Taken from https://docs.pydantic.dev/latest/errors/errors/#customize-error-messages + """ + path = "" + for i, x in enumerate(loc): + if isinstance(x, str): + if i > 0: + path += "." + path += x + else: + path += f"[{x}]" + return path + + +@invocation_output("iterate_output") +class IterateInvocationOutput(BaseInvocationOutput): + """Used to connect iteration outputs. Will be expanded to a specific output.""" + + item: Any = OutputField( + description="The item being iterated over", title="Collection Item", ui_type=UIType._CollectionItem + ) + index: int = OutputField(description="The index of the item", title="Index") + total: int = OutputField(description="The total number of items", title="Total") + + +# TODO: Fill this out and move to invocations +@invocation("iterate", version="1.1.0") +class IterateInvocation(BaseInvocation): + """Iterates over a list of items""" + + collection: list[Any] = InputField( + description="The list of items to iterate over", default=[], ui_type=UIType._Collection + ) + index: int = InputField(description="The index, will be provided on executed iterators", default=0, ui_hidden=True) + + def invoke(self, context: InvocationContext) -> IterateInvocationOutput: + """Produces the outputs as values""" + return IterateInvocationOutput(item=self.collection[self.index], index=self.index, total=len(self.collection)) + + +@invocation_output("collect_output") +class CollectInvocationOutput(BaseInvocationOutput): + collection: list[Any] = OutputField( + description="The collection of input items", title="Collection", ui_type=UIType._Collection + ) + + +@invocation("collect", version="1.0.0") +class CollectInvocation(BaseInvocation): + """Collects values into a collection""" + + item: Optional[Any] = InputField( + default=None, + description="The item to collect (all inputs must be of the same type)", + ui_type=UIType._CollectionItem, + title="Collection Item", + input=Input.Connection, + ) + collection: list[Any] = InputField( + description="The collection, will be provided on execution", default=[], ui_hidden=True + ) + + def invoke(self, context: InvocationContext) -> CollectInvocationOutput: + """Invoke with provided services and return outputs.""" + return CollectInvocationOutput(collection=copy.copy(self.collection)) + + +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 core_schema.no_info_plain_validator_function(validate_invocation) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + # 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()] + for name in sorted(names): + oneOf.append({"$ref": f"#/components/schemas/{name}"}) + return {"oneOf": oneOf} + + +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 core_schema.no_info_plain_validator_function(validate_invocation_output) + + @classmethod + def __get_pydantic_json_schema__( + cls, core_schema: core_schema.CoreSchema, handler: GetJsonSchemaHandler + ) -> JsonSchemaValue: + # 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 BaseInvocationOutput.get_outputs()] + for name in sorted(names): + oneOf.append({"$ref": f"#/components/schemas/{name}"}) + return {"oneOf": oneOf} + + +class Graph(BaseModel): + 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) + edges: list[Edge] = Field( + description="The connections between nodes and their fields in this graph", + default_factory=list, + ) + + def add_node(self, node: BaseInvocation) -> None: + """Adds a node to a graph + + :raises NodeAlreadyInGraphError: the node is already present in the graph. + """ + + if node.id in self.nodes: + raise NodeAlreadyInGraphError() + + self.nodes[node.id] = node + + def delete_node(self, node_id: str) -> None: + """Deletes a node from a graph""" + + try: + # Delete edges for this node + input_edges = self._get_input_edges(node_id) + output_edges = self._get_output_edges(node_id) + + for edge in input_edges: + self.delete_edge(edge) + + for edge in output_edges: + self.delete_edge(edge) + + del self.nodes[node_id] + + except NodeNotFoundError: + pass # Ignore, not doesn't exist (should this throw?) + + def add_edge(self, edge: Edge) -> None: + """Adds an edge to a graph + + :raises InvalidEdgeError: the provided edge is invalid. + """ + + self._validate_edge(edge) + if edge not in self.edges: + self.edges.append(edge) + else: + raise InvalidEdgeError() + + def delete_edge(self, edge: Edge) -> None: + """Deletes an edge from a graph""" + + try: + self.edges.remove(edge) + except KeyError: + 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 + 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} + 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}") + + # Validate that all edges match nodes and fields in the graph + for edge in self.edges: + source_node = self.nodes.get(edge.source.node_id, None) + if source_node is None: + raise NodeNotFoundError(f"Edge source node {edge.source.node_id} does not exist in the graph") + + destination_node = self.nodes.get(edge.destination.node_id, 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: + 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): + raise CyclicalGraphError("Graph contains cycles") + + # Validate all edge connections are valid + for edge in self.edges: + if not are_connections_compatible( + self.get_node(edge.source.node_id), + edge.source.field, + 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}" + ) + + # Validate all iterators & collectors + # 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}") + + return None + + def is_valid(self) -> bool: + """ + Checks if the graph is valid. + + Raises `UnknownGraphValidationError` if there is a problem validating the graph (not a validation error). + """ + try: + self.validate_self() + return True + except ( + DuplicateNodeIdError, + NodeIdMismatchError, + NodeNotFoundError, + NodeFieldNotFoundError, + CyclicalGraphError, + InvalidEdgeError, + ): + return False + except Exception as e: + raise UnknownGraphValidationError(f"Problem validating graph {e}") from e + + 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 + + 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] + + def _validate_edge(self, edge: Edge): + """Validates that a new edge doesn't create a cycle in the graph""" + + # Validate that the nodes exist + try: + from_node = self.get_node(edge.source.node_id) + to_node = 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}") + + # Validate that an edge to this node+field doesn't already exist + 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}" + ) + + # 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" + 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}" + ) + + def has_node(self, node_id: str) -> bool: + """Determines whether or not a node exists in the graph.""" + try: + _ = self.get_node(node_id) + return True + except NodeNotFoundError: + return False + + def get_node(self, node_id: str) -> BaseInvocation: + """Gets a node from the graph.""" + try: + return self.nodes[node_id] + except KeyError as e: + raise NodeNotFoundError(f"Node {node_id} not found in graph") from e + + def update_node(self, node_id: str, new_node: BaseInvocation) -> None: + """Updates a node in the graph.""" + node = self.nodes[node_id] + + # Ensure the node type matches the new node + if type(node) is not type(new_node): + raise TypeError(f"Node {node_id} is type {type(node)} but new node is type {type(new_node)}") + + # Ensure the new id is either the same or is not in the graph + if new_node.id != node.id and self.has_node(new_node.id): + raise NodeAlreadyInGraphError(f"Node with id {new_node.id} already exists in graph") + + # Set the new node in the graph + self.nodes[new_node.id] = new_node + if new_node.id != node.id: + input_edges = self._get_input_edges(node_id) + output_edges = self._get_output_edges(node_id) + + # Delete node and all edges + self.delete_node(node_id) + + # Create new edges for each input and output + for edge in input_edges: + self.add_edge( + Edge( + source=edge.source, + destination=EdgeConnection(node_id=new_node.id, field=edge.destination.field), + ) + ) + + for edge in output_edges: + self.add_edge( + Edge( + source=EdgeConnection(node_id=new_node.id, field=edge.source.field), + destination=edge.destination, + ) + ) + + def _get_input_edges(self, node_id: str, field: Optional[str] = None) -> list[Edge]: + """Gets all input edges for a node. If field is provided, only edges to that field are returned.""" + + edges = [e for e in self.edges if e.destination.node_id == node_id] + + if field is None: + return edges + + filtered_edges = [e for e in edges if e.destination.field == field] + + return filtered_edges + + def _get_output_edges(self, node_id: str, field: Optional[str] = None) -> list[Edge]: + """Gets all output edges for a node. If field is provided, only edges from that field are returned.""" + edges = [e for e in self.edges if e.source.node_id == node_id] + + if field is None: + return edges + + filtered_edges = [e for e in edges if e.source.field == field] + + return filtered_edges + + def _is_iterator_connection_valid( + self, + 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")] + + 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 + if len(inputs) > 1: + return False + + # 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] + + # Input type must be a list + if get_origin(input_field) is not list: + return False + + # 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 + + return True + + def _is_collector_connection_valid( + self, + 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, "item")] + outputs = [e.destination for e in self._get_output_edges(node_id, "collection")] + + if new_input is not None: + 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 + + # Get the input root type + input_root_type = next(t[0] for t in type_degrees if t[1] == 0) # type: ignore + + # Verify that all outputs are lists + if not all(is_list_or_contains_list(f) for f in output_fields): + return False + + # 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 True + + def nx_graph(self) -> nx.DiGraph: + """Returns a NetworkX DiGraph representing the layout of this graph""" + # TODO: Cache this? + g = nx.DiGraph() + g.add_nodes_from(list(self.nodes.keys())) + 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 + + 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]) + return g + + +class GraphExecutionState(BaseModel): + """Tracks the state of a graph execution""" + + 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? + graph: Graph = Field(description="The graph being executed") + + # The graph of materialized nodes + execution_graph: Graph = Field( + description="The expanded graph of activated and executed nodes", + default_factory=Graph, + ) + + # Nodes that have been executed + executed: set[str] = Field(description="The set of node ids that have been executed", default_factory=set) + executed_history: list[str] = Field( + description="The list of node ids that have been executed, in order of execution", + default_factory=list, + ) + + # The results of executed nodes + results: dict[str, AnyInvocationOutput] = Field(description="The results of node executions", default_factory=dict) + + # Errors raised when executing nodes + errors: dict[str, str] = Field(description="Errors raised when executing nodes", default_factory=dict) + + # Map of prepared/executed nodes to their original nodes + prepared_source_mapping: dict[str, str] = Field( + description="The map of prepared nodes to original graph nodes", + default_factory=dict, + ) + + # Map of original nodes to prepared nodes + source_prepared_mapping: dict[str, set[str]] = Field( + description="The map of original graph nodes to prepared nodes", + default_factory=dict, + ) + + @field_validator("graph") + def graph_is_valid(cls, v: Graph): + """Validates that the graph is valid""" + v.validate_self() + return v + + def next(self) -> Optional[BaseInvocation]: + """Gets the next node ready to execute.""" + + # TODO: enable multiple nodes to execute simultaneously by tracking currently executing nodes + # possibly with a timeout? + + # 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() + + # Get values from edges + if next_node is not None: + try: + self._prepare_inputs(next_node) + except ValidationError as e: + raise NodeInputError(next_node, e) + + # If next is still none, there's no next node, return None + return next_node + + 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) + + def set_node_error(self, node_id: str, error: str): + """Marks a node as errored""" + self.errors[node_id] = error + + def is_complete(self) -> bool: + """Returns true if the graph is complete""" + node_ids = set(self.graph.nx_graph_flat().nodes) + return self.has_error() or all((k in self.executed for k in node_ids)) + + def has_error(self) -> bool: + """Returns true if the graph has any errors""" + 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 new_nodes + + 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) + + return next(iter(new_node_ids), None) + + def _get_iteration_node( + self, + source_node_id: str, + graph: nx.DiGraph, + 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, + ) + + 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 + + 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)), + ) + + # 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: + try: + self.graph._validate_edge(edge) + except InvalidEdgeError: + return False + + # Invalid if destination has already been prepared or executed + if edge.destination.node_id in self.source_prepared_mapping: + return False + + # Otherwise, the edge is valid + return True + + def _is_node_updatable(self, node_id: str) -> bool: + # The node is updatable as long as it hasn't been prepared or executed + return node_id not in self.source_prepared_mapping + + def add_node(self, node: BaseInvocation) -> None: + self.graph.add_node(node) + + def update_node(self, node_id: str, new_node: BaseInvocation) -> None: + if not self._is_node_updatable(node_id): + raise NodeAlreadyExecutedError( + f"Node {node_id} has already been prepared or executed and cannot be updated" + ) + self.graph.update_node(node_id, new_node) + + def delete_node(self, node_id: str) -> None: + if not self._is_node_updatable(node_id): + raise NodeAlreadyExecutedError( + f"Node {node_id} has already been prepared or executed and cannot be deleted" + ) + self.graph.delete_node(node_id) + + def add_edge(self, edge: Edge) -> None: + if not self._is_node_updatable(edge.destination.node_id): + raise NodeAlreadyExecutedError( + f"Destination node {edge.destination.node_id} has already been prepared or executed and cannot be linked to" + ) + self.graph.add_edge(edge) + + def delete_edge(self, edge: Edge) -> None: + if not self._is_node_updatable(edge.destination.node_id): + raise NodeAlreadyExecutedError( + f"Destination node {edge.destination.node_id} has already been prepared or executed and cannot have a source edge deleted" + ) + self.graph.delete_edge(edge) diff --git a/invokeai/app/services/shared/invocation_context.py b/invokeai/app/services/shared/invocation_context.py new file mode 100644 index 0000000000000000000000000000000000000000..721934cecb1416c56224af0229e25db980bc7097 --- /dev/null +++ b/invokeai/app/services/shared/invocation_context.py @@ -0,0 +1,754 @@ +from copy import deepcopy +from dataclasses import dataclass +from pathlib import Path +from typing import TYPE_CHECKING, Callable, Optional, Union + +from PIL.Image import Image +from pydantic.networks import AnyHttpUrl +from torch import Tensor + +from invokeai.app.invocations.constants import IMAGE_MODES +from invokeai.app.invocations.fields import MetadataField, WithBoard, WithMetadata +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.services.session_processor.session_processor_common import ProgressImage +from invokeai.app.util.step_callback import flux_step_callback, stable_diffusion_step_callback +from invokeai.backend.model_manager.config import ( + AnyModel, + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load.load_base import LoadedModel, LoadedModelWithoutConfig +from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData + +if TYPE_CHECKING: + from invokeai.app.invocations.baseinvocation import BaseInvocation + from invokeai.app.invocations.model import ModelIdentifierField + from invokeai.app.services.session_queue.session_queue_common import SessionQueueItem + +""" +The InvocationContext provides access to various services and data about the current invocation. + +We do not provide the invocation services directly, as their methods are both dangerous and +inconvenient to use. + +For example: +- The `images` service allows nodes to delete or unsafely modify existing images. +- The `configuration` service allows nodes to change the app's config at runtime. +- The `events` service allows nodes to emit arbitrary events. + +Wrapping these services provides a simpler and safer interface for nodes to use. + +When a node executes, a fresh `InvocationContext` is built for it, ensuring nodes cannot interfere +with each other. + +Many of the wrappers have the same signature as the methods they wrap. This allows us to write +user-facing docstrings and not need to go and update the internal services to match. + +Note: The docstrings are in weird places, but that's where they must be to get IDEs to see them. +""" + + +@dataclass +class InvocationContextData: + queue_item: "SessionQueueItem" + """The queue item that is being executed.""" + invocation: "BaseInvocation" + """The invocation that is being executed.""" + source_invocation_id: str + """The ID of the invocation from which the currently executing invocation was prepared.""" + + +class InvocationContextInterface: + def __init__(self, services: InvocationServices, data: InvocationContextData) -> None: + self._services = services + self._data = data + + +class BoardsInterface(InvocationContextInterface): + def create(self, board_name: str) -> BoardDTO: + """Creates a board. + + Args: + board_name: The name of the board to create. + + Returns: + The created board DTO. + """ + return self._services.boards.create(board_name) + + def get_dto(self, board_id: str) -> BoardDTO: + """Gets a board DTO. + + Args: + board_id: The ID of the board to get. + + Returns: + The board DTO. + """ + return self._services.boards.get_dto(board_id) + + def get_all(self) -> list[BoardDTO]: + """Gets all boards. + + Returns: + A list of all boards. + """ + return self._services.boards.get_all() + + def add_image_to_board(self, board_id: str, image_name: str) -> None: + """Adds an image to a board. + + Args: + board_id: The ID of the board to add the image to. + image_name: The name of the image to add to the board. + """ + return self._services.board_images.add_image_to_board(board_id, image_name) + + def get_all_image_names_for_board(self, board_id: str) -> list[str]: + """Gets all image names for a board. + + Args: + board_id: The ID of the board to get the image names for. + + Returns: + A list of all image names for the board. + """ + return self._services.board_images.get_all_board_image_names_for_board(board_id) + + +class LoggerInterface(InvocationContextInterface): + def debug(self, message: str) -> None: + """Logs a debug message. + + Args: + message: The message to log. + """ + self._services.logger.debug(message) + + def info(self, message: str) -> None: + """Logs an info message. + + Args: + message: The message to log. + """ + self._services.logger.info(message) + + def warning(self, message: str) -> None: + """Logs a warning message. + + Args: + message: The message to log. + """ + self._services.logger.warning(message) + + def error(self, message: str) -> None: + """Logs an error message. + + Args: + message: The message to log. + """ + self._services.logger.error(message) + + +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, + board_id: Optional[str] = None, + image_category: ImageCategory = ImageCategory.GENERAL, + metadata: Optional[MetadataField] = None, + ) -> ImageDTO: + """Saves an image, returning its DTO. + + If the current queue item has a workflow or metadata, it is automatically saved with the image. + + Args: + image: The image to save, as a PIL image. + board_id: 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!** + image_category: The category of the image. Only the GENERAL category is added \ + to the gallery. + metadata: 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!** + + Returns: + 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: + metadata_ = metadata.model_dump_json() + elif isinstance(self._data.invocation, WithMetadata) and self._data.invocation.metadata: + metadata_ = self._data.invocation.metadata.model_dump_json() + + # If `board_id` is provided directly, use that. Else, use the board provided by `WithBoard`, falling back to None. + board_id_ = None + if board_id: + board_id_ = board_id + elif isinstance(self._data.invocation, WithBoard) and self._data.invocation.board: + board_id_ = self._data.invocation.board.board_id + + workflow_ = None + if self._data.queue_item.workflow: + workflow_ = self._data.queue_item.workflow.model_dump_json() + + graph_ = None + if self._data.queue_item.session.graph: + graph_ = self._data.queue_item.session.graph.model_dump_json() + + return self._services.images.create( + image=image, + is_intermediate=self._data.invocation.is_intermediate, + image_category=image_category, + board_id=board_id_, + metadata=metadata_, + image_origin=ResourceOrigin.INTERNAL, + workflow=workflow_, + graph=graph_, + session_id=self._data.queue_item.session_id, + node_id=self._data.invocation.id, + ) + + def get_pil(self, image_name: str, mode: IMAGE_MODES | None = None) -> Image: + """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. + mode: The color mode to convert the image to. If None, the original mode is used. + + Returns: + The image as a PIL Image object. + """ + 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]: + """Gets an image's metadata, if it has any. + + Args: + image_name: The name of the image to get the metadata for. + + Returns: + The image's metadata, if it has any. + """ + return self._services.images.get_metadata(image_name) + + def get_dto(self, image_name: str) -> ImageDTO: + """Gets an image as an ImageDTO object. + + Args: + image_name: The name of the image to get. + + Returns: + The image as an ImageDTO object. + """ + return self._services.images.get_dto(image_name) + + def get_path(self, image_name: str, thumbnail: bool = False) -> Path: + """Gets the internal path to an image or thumbnail. + + Args: + image_name: The name of the image to get the path of. + thumbnail: Get the path of the thumbnail instead of the full image + + Returns: + The local path of the image or thumbnail. + """ + return self._services.images.get_path(image_name, thumbnail) + + +class TensorsInterface(InvocationContextInterface): + def save(self, tensor: Tensor) -> str: + """Saves a tensor, returning its name. + + Args: + tensor: The tensor to save. + + Returns: + The name of the saved tensor. + """ + + name = self._services.tensors.save(obj=tensor) + return name + + def load(self, name: str) -> Tensor: + """Loads a tensor by name. This method returns a copy of the tensor. + + Args: + name: The name of the tensor to load. + + Returns: + The tensor. + """ + return self._services.tensors.load(name).clone() + + +class ConditioningInterface(InvocationContextInterface): + def save(self, conditioning_data: ConditioningFieldData) -> str: + """Saves a conditioning data object, returning its name. + + Args: + conditioning_data: The conditioning data to save. + + Returns: + The name of the saved conditioning data. + """ + + name = self._services.conditioning.save(obj=conditioning_data) + return name + + def load(self, name: str) -> ConditioningFieldData: + """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 conditioning data. + """ + + 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. + + Args: + identifier: The key or ModelField representing the model. + + Returns: + True if the model exists, False if not. + """ + if isinstance(identifier, str): + return self._services.model_manager.store.exists(identifier) + else: + return self._services.model_manager.store.exists(identifier.key) + + def load( + self, identifier: Union[str, "ModelIdentifierField"], submodel_type: Optional[SubModelType] = None + ) -> LoadedModel: + """Load a model. + + Args: + identifier: The key or ModelField representing the model. + submodel_type: The submodel of the model to get. + + Returns: + An object representing the loaded model. + """ + + # The model manager emits events as it loads the model. It needs the context data to build + # the event payloads. + + if isinstance(identifier, str): + model = self._services.model_manager.store.get_model(identifier) + else: + submodel_type = submodel_type or identifier.submodel_type + model = self._services.model_manager.store.get_model(identifier.key) + + 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 + ) -> LoadedModel: + """Load a model by its attributes. + + Args: + name: Name of the model. + base: The models' base type, e.g. `BaseModelType.StableDiffusion1`, `BaseModelType.StableDiffusionXL`, etc. + type: Type of the model, e.g. `ModelType.Main`, `ModelType.Vae`, etc. + submodel_type: The type of submodel to load, e.g. `SubModelType.UNet`, `SubModelType.TextEncoder`, etc. Only main + models have submodels. + + Returns: + An object representing the loaded model. + """ + + configs = self._services.model_manager.store.search_by_attr(model_name=name, base_model=base, model_type=type) + if len(configs) == 0: + raise UnknownModelException(f"No model found with name {name}, base {base}, and type {type}") + + if len(configs) > 1: + raise ValueError(f"More than one model found with name {name}, base {base}, and type {type}") + + 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) + + def get_config(self, identifier: Union[str, "ModelIdentifierField"]) -> AnyModelConfig: + """Get a model's config. + + Args: + identifier: The key or ModelField representing the model. + + Returns: + The model's config. + """ + if isinstance(identifier, str): + return self._services.model_manager.store.get_model(identifier) + else: + return self._services.model_manager.store.get_model(identifier.key) + + def search_by_path(self, path: Path) -> list[AnyModelConfig]: + """Search for models by path. + + Args: + path: The path to search for. + + Returns: + A list of models that match the path. + """ + return self._services.model_manager.store.search_by_path(path) + + def search_by_attrs( + self, + name: Optional[str] = None, + base: Optional[BaseModelType] = None, + type: Optional[ModelType] = None, + format: Optional[ModelFormat] = None, + ) -> list[AnyModelConfig]: + """Search for models by attributes. + + Args: + name: The name to search for (exact match). + base: The base to search for, e.g. `BaseModelType.StableDiffusion1`, `BaseModelType.StableDiffusionXL`, etc. + type: Type type of model to search for, e.g. `ModelType.Main`, `ModelType.Vae`, etc. + format: The format of model to search for, e.g. `ModelFormat.Checkpoint`, `ModelFormat.Diffusers`, etc. + + Returns: + A list of models that match the attributes. + """ + + return self._services.model_manager.store.search_by_attr( + model_name=name, + base_model=base, + model_type=type, + model_format=format, + ) + + def download_and_cache_model( + self, + source: str | AnyHttpUrl, + ) -> Path: + """ + Download the model file located at source to the models cache and return its Path. + + This can be used to single-file install models and other resources of arbitrary types + which should not get registered with the database. If the model is already + installed, the cached path will be returned. Otherwise it will be downloaded. + + Args: + source: A URL that points to the model, or a huggingface repo_id. + + 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( + self, + model_path: Path, + loader: Optional[Callable[[Path], AnyModel]] = None, + ) -> LoadedModelWithoutConfig: + """ + Load the model file located at the indicated path + + If a loader callable is provided, it will be invoked to load the model. Otherwise, + `safetensors.torch.load_file()` or `torch.load()` will be called to load the model. + + Be aware that the LoadedModelWithoutConfig object has no `config` attribute + + Args: + path: A model Path + loader: A Callable that expects a Path and returns a dict[str|int, Any] + + 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( + self, + source: str | AnyHttpUrl, + loader: Optional[Callable[[Path], AnyModel]] = None, + ) -> LoadedModelWithoutConfig: + """ + Download, cache, and load the model file located at the indicated URL or repo_id. + + If the model is already downloaded, it will be loaded from the cache. + + If the a loader callable is provided, it will be invoked to load the model. Otherwise, + `safetensors.torch.load_file()` or `torch.load()` will be called to load the model. + + Be aware that the LoadedModelWithoutConfig object has no `config` attribute + + Args: + source: A URL or huggingface repoid. + loader: A Callable that expects a Path and returns a dict[str|int, Any] + + Returns: + 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) + + +class ConfigInterface(InvocationContextInterface): + def get(self) -> InvokeAIAppConfig: + """Gets the app's config. + + Returns: + The app's config. + """ + + return self._services.configuration + + +class UtilInterface(InvocationContextInterface): + def __init__( + self, services: InvocationServices, data: InvocationContextData, is_canceled: Callable[[], bool] + ) -> None: + super().__init__(services, data) + self._is_canceled = is_canceled + + def is_canceled(self) -> bool: + """Checks if the current session has been canceled. + + Returns: + True if the current session has been canceled, False if not. + """ + return self._is_canceled() + + def sd_step_callback(self, intermediate_state: PipelineIntermediateState, base_model: BaseModelType) -> 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. + base_model: The base model for the current denoising step. + """ + + stable_diffusion_step_callback( + signal_progress=self.signal_progress, + intermediate_state=intermediate_state, + base_model=base_model, + 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. + """ + + flux_step_callback( + signal_progress=self.signal_progress, + intermediate_state=intermediate_state, + 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. + + Attributes: + images (ImagesInterface): Methods to save, get and update images and their metadata. + tensors (TensorsInterface): Methods to save and get tensors, including image, noise, masks, and masked images. + conditioning (ConditioningInterface): Methods to save and get conditioning data. + models (ModelsInterface): Methods to check if a model exists, get a model, and get a model's info. + logger (LoggerInterface): The app logger. + config (ConfigInterface): The app config. + util (UtilInterface): Utility methods, including a method to check if an invocation was canceled and step callbacks. + boards (BoardsInterface): Methods to interact with boards. + """ + + def __init__( + self, + images: ImagesInterface, + tensors: TensorsInterface, + conditioning: ConditioningInterface, + models: ModelsInterface, + logger: LoggerInterface, + config: ConfigInterface, + util: UtilInterface, + boards: BoardsInterface, + data: InvocationContextData, + services: InvocationServices, + ) -> None: + self.images = images + """Methods to save, get and update images and their metadata.""" + self.tensors = tensors + """Methods to save and get tensors, including image, noise, masks, and masked images.""" + self.conditioning = conditioning + """Methods to save and get conditioning data.""" + self.models = models + """Methods to check if a model exists, get a model, and get a model's info.""" + self.logger = logger + """The app logger.""" + self.config = config + """The app config.""" + self.util = util + """Utility methods, including a method to check if an invocation was canceled and step callbacks.""" + self.boards = boards + """Methods to interact with boards.""" + self._data = data + """An internal API providing access to data about the current queue item and invocation. You probably shouldn't use this. It may change without warning.""" + self._services = services + """An internal API providing access to all application services. You probably shouldn't use this. It may change without warning.""" + + +def build_invocation_context( + services: InvocationServices, + data: InvocationContextData, + is_canceled: Callable[[], bool], +) -> InvocationContext: + """Builds the invocation context for a specific invocation execution. + + Args: + services: The invocation services to wrap. + data: The invocation context data. + + Returns: + The invocation context. + """ + + logger = LoggerInterface(services=services, data=data) + tensors = TensorsInterface(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( + images=images, + logger=logger, + config=config, + tensors=tensors, + models=models, + data=data, + util=util, + conditioning=conditioning, + services=services, + boards=boards, + ) + + return ctx diff --git a/invokeai/app/services/shared/pagination.py b/invokeai/app/services/shared/pagination.py new file mode 100644 index 0000000000000000000000000000000000000000..ea342b11013e92a4b630156beb51707a3743e230 --- /dev/null +++ b/invokeai/app/services/shared/pagination.py @@ -0,0 +1,41 @@ +from typing import Generic, TypeVar + +from pydantic import BaseModel, Field + +GenericBaseModel = TypeVar("GenericBaseModel", bound=BaseModel) + + +class CursorPaginatedResults(BaseModel, Generic[GenericBaseModel]): + """ + Cursor-paginated results + Generic must be a Pydantic model + """ + + limit: int = Field(..., description="Limit of items to get") + has_more: bool = Field(..., description="Whether there are more items available") + items: list[GenericBaseModel] = Field(..., description="Items") + + +class OffsetPaginatedResults(BaseModel, Generic[GenericBaseModel]): + """ + Offset-paginated results + Generic must be a Pydantic model + """ + + limit: int = Field(description="Limit of items to get") + offset: int = Field(description="Offset from which to retrieve items") + total: int = Field(description="Total number of items in result") + items: list[GenericBaseModel] = Field(description="Items") + + +class PaginatedResults(BaseModel, Generic[GenericBaseModel]): + """ + Paginated results + Generic must be a Pydantic model + """ + + page: int = Field(description="Current Page") + pages: int = Field(description="Total number of pages") + per_page: int = Field(description="Number of items per page") + total: int = Field(description="Total number of items in result") + items: list[GenericBaseModel] = Field(description="Items") diff --git a/invokeai/app/services/shared/sqlite/__init__.py b/invokeai/app/services/shared/sqlite/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/shared/sqlite/sqlite_common.py b/invokeai/app/services/shared/sqlite/sqlite_common.py new file mode 100644 index 0000000000000000000000000000000000000000..2520695201191126cc0b6cc8f9e3281c90122946 --- /dev/null +++ b/invokeai/app/services/shared/sqlite/sqlite_common.py @@ -0,0 +1,10 @@ +from enum import Enum + +from invokeai.app.util.metaenum import MetaEnum + +sqlite_memory = ":memory:" + + +class SQLiteDirection(str, Enum, metaclass=MetaEnum): + Ascending = "ASC" + Descending = "DESC" diff --git a/invokeai/app/services/shared/sqlite/sqlite_database.py b/invokeai/app/services/shared/sqlite/sqlite_database.py new file mode 100644 index 0000000000000000000000000000000000000000..e860160044e060836ad9f49307d5d7868d5ffc92 --- /dev/null +++ b/invokeai/app/services/shared/sqlite/sqlite_database.py @@ -0,0 +1,67 @@ +import sqlite3 +import threading +from logging import Logger +from pathlib import Path + +from invokeai.app.services.shared.sqlite.sqlite_common import sqlite_memory + + +class SqliteDatabase: + """ + Manages a connection to an SQLite database. + + :param db_path: Path to the database file. If None, an in-memory database is used. + :param logger: Logger to use for logging. + :param verbose: Whether to log SQL statements. Provides `logger.debug` as the SQLite trace callback. + + This is a light wrapper around the `sqlite3` module, providing a few conveniences: + - The database file is written to disk if it does not exist. + - Foreign key constraints are enabled by default. + - The connection is configured to use the `sqlite3.Row` row factory. + + In addition to the constructor args, the instance provides the following attributes and methods: + - `conn`: A `sqlite3.Connection` object. Note that the connection must never be closed if the database is in-memory. + - `lock`: A shared re-entrant lock, used to approximate thread safety. + - `clean()`: Runs the SQL `VACUUM;` command and reports on the freed space. + """ + + 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 + + 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.conn = sqlite3.connect(database=self.db_path or sqlite_memory, check_same_thread=False) + self.lock = threading.RLock() + self.conn.row_factory = sqlite3.Row + + if self.verbose: + self.conn.set_trace_callback(self.logger.debug) + + self.conn.execute("PRAGMA foreign_keys = ON;") + + 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: + 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 + 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}") + raise diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py new file mode 100644 index 0000000000000000000000000000000000000000..5e1df296022527b0c1c35a807e8fb379bf62980c --- /dev/null +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -0,0 +1,58 @@ +from logging import Logger + +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_1 import build_migration_1 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_2 import build_migration_2 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_3 import build_migration_3 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_4 import build_migration_4 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_5 import build_migration_5 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_6 import build_migration_6 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_7 import build_migration_7 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_8 import build_migration_8 +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.sqlite_migrator_impl import SqliteMigrator + + +def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileStorageBase) -> SqliteDatabase: + """ + Initializes the SQLite database. + + :param config: The app config + :param logger: The logger + :param image_files: The image files service (used by migration 2) + + This function: + - Instantiates a :class:`SqliteDatabase` + - Instantiates a :class:`SqliteMigrator` and registers all migrations + - Runs all migrations + """ + db_path = None if config.use_memory_db else config.db_path + db = SqliteDatabase(db_path=db_path, logger=logger, verbose=config.log_sql) + + migrator = SqliteMigrator(db=db) + migrator.register_migration(build_migration_1()) + migrator.register_migration(build_migration_2(image_files=image_files, logger=logger)) + migrator.register_migration(build_migration_3(app_config=config, logger=logger)) + migrator.register_migration(build_migration_4()) + migrator.register_migration(build_migration_5()) + migrator.register_migration(build_migration_6()) + migrator.register_migration(build_migration_7()) + migrator.register_migration(build_migration_8(app_config=config)) + 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.run_migrations() + + return db diff --git a/invokeai/app/services/shared/sqlite_migrator/__init__.py b/invokeai/app/services/shared/sqlite_migrator/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/__init__.py b/invokeai/app/services/shared/sqlite_migrator/migrations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_1.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_1.py new file mode 100644 index 0000000000000000000000000000000000000000..574afb472f68818d7ae9e5b0485c2f7add1d5cc5 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_1.py @@ -0,0 +1,372 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration1Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + """Migration callback for database version 1.""" + + self._create_board_images(cursor) + self._create_boards(cursor) + self._create_images(cursor) + self._create_model_config(cursor) + self._create_session_queue(cursor) + self._create_workflow_images(cursor) + self._create_workflows(cursor) + + def _create_board_images(self, cursor: sqlite3.Cursor) -> None: + """Creates the `board_images` table, indices and triggers.""" + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS board_images ( + board_id TEXT NOT NULL, + image_name 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')), + -- Soft delete, currently unused + deleted_at DATETIME, + -- enforce one-to-many relationship between boards and images using PK + -- (we can extend this to many-to-many later) + PRIMARY KEY (image_name), + FOREIGN KEY (board_id) REFERENCES boards (board_id) ON DELETE CASCADE, + FOREIGN KEY (image_name) REFERENCES images (image_name) ON DELETE CASCADE + ); + """ + ] + + indices = [ + "CREATE INDEX IF NOT EXISTS idx_board_images_board_id ON board_images (board_id);", + "CREATE INDEX IF NOT EXISTS idx_board_images_board_id_created_at ON board_images (board_id, created_at);", + ] + + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS tg_board_images_updated_at + AFTER UPDATE + ON board_images FOR EACH ROW + BEGIN + UPDATE board_images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE board_id = old.board_id AND image_name = old.image_name; + END; + """ + ] + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + def _create_boards(self, cursor: sqlite3.Cursor) -> None: + """Creates the `boards` table, indices and triggers.""" + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS boards ( + board_id TEXT NOT NULL PRIMARY KEY, + board_name TEXT NOT NULL, + cover_image_name TEXT, + 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')), + -- Soft delete, currently unused + deleted_at DATETIME, + FOREIGN KEY (cover_image_name) REFERENCES images (image_name) ON DELETE SET NULL + ); + """ + ] + + indices = ["CREATE INDEX IF NOT EXISTS idx_boards_created_at ON boards (created_at);"] + + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS tg_boards_updated_at + AFTER UPDATE + ON boards FOR EACH ROW + BEGIN + UPDATE boards SET updated_at = current_timestamp + WHERE board_id = old.board_id; + END; + """ + ] + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + def _create_images(self, cursor: sqlite3.Cursor) -> None: + """Creates the `images` table, indices and triggers. Adds the `starred` column.""" + + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS images ( + image_name TEXT NOT NULL PRIMARY KEY, + -- This is an enum in python, unrestricted string here for flexibility + image_origin TEXT NOT NULL, + -- This is an enum in python, unrestricted string here for flexibility + image_category TEXT NOT NULL, + width INTEGER NOT NULL, + height INTEGER NOT NULL, + session_id TEXT, + node_id TEXT, + metadata TEXT, + is_intermediate BOOLEAN DEFAULT FALSE, + 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')), + -- Soft delete, currently unused + deleted_at DATETIME + ); + """ + ] + + indices = [ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_images_image_name ON images(image_name);", + "CREATE INDEX IF NOT EXISTS idx_images_image_origin ON images(image_origin);", + "CREATE INDEX IF NOT EXISTS idx_images_image_category ON images(image_category);", + "CREATE INDEX IF NOT EXISTS idx_images_created_at ON images(created_at);", + ] + + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS tg_images_updated_at + AFTER UPDATE + ON images FOR EACH ROW + BEGIN + UPDATE images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE image_name = old.image_name; + END; + """ + ] + + # Add the 'starred' column to `images` if it doesn't exist + cursor.execute("PRAGMA table_info(images)") + columns = [column[1] for column in cursor.fetchall()] + + if "starred" not in columns: + tables.append("ALTER TABLE images ADD COLUMN starred BOOLEAN DEFAULT FALSE;") + indices.append("CREATE INDEX IF NOT EXISTS idx_images_starred ON images(starred);") + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + def _create_model_config(self, cursor: sqlite3.Cursor) -> None: + """Creates the `model_config` table, `model_manager_metadata` table, indices and triggers.""" + + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS model_config ( + id TEXT NOT NULL PRIMARY KEY, + -- The next 3 fields are enums in python, unrestricted string here + base TEXT NOT NULL, + type TEXT NOT NULL, + name TEXT NOT NULL, + path TEXT NOT NULL, + original_hash TEXT, -- could be 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')), + -- unique constraint on combo of name, base and type + UNIQUE(name, base, type) + ); + """, + """--sql + CREATE TABLE IF NOT EXISTS model_manager_metadata ( + metadata_key TEXT NOT NULL PRIMARY KEY, + metadata_value TEXT NOT NULL + ); + """, + ] + + # Add trigger for `updated_at`. + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS model_config_updated_at + AFTER UPDATE + ON model_config FOR EACH ROW + BEGIN + UPDATE model_config 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 base_index ON model_config(base);", + "CREATE INDEX IF NOT EXISTS type_index ON model_config(type);", + "CREATE INDEX IF NOT EXISTS name_index ON model_config(name);", + "CREATE UNIQUE INDEX IF NOT EXISTS path_index ON model_config(path);", + ] + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + def _create_session_queue(self, cursor: sqlite3.Cursor) -> None: + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS session_queue ( + item_id INTEGER PRIMARY KEY AUTOINCREMENT, -- used for ordering, cursor pagination + batch_id TEXT NOT NULL, -- identifier of the batch this queue item belongs to + queue_id TEXT NOT NULL, -- identifier of the queue this queue item belongs to + session_id TEXT NOT NULL UNIQUE, -- duplicated data from the session column, for ease of access + field_values TEXT, -- NULL if no values are associated with this queue item + session TEXT NOT NULL, -- the session to be executed + status TEXT NOT NULL DEFAULT 'pending', -- the status of the queue item, one of 'pending', 'in_progress', 'completed', 'failed', 'canceled' + priority INTEGER NOT NULL DEFAULT 0, -- the priority, higher is more important + error TEXT, -- any errors associated with this queue item + 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')), -- updated via trigger + started_at DATETIME, -- updated via trigger + completed_at DATETIME -- updated via trigger, completed items are cleaned up on application startup + -- Ideally this is a FK, but graph_executions uses INSERT OR REPLACE, and REPLACE triggers the ON DELETE CASCADE... + -- FOREIGN KEY (session_id) REFERENCES graph_executions (id) ON DELETE CASCADE + ); + """ + ] + + indices = [ + "CREATE UNIQUE INDEX IF NOT EXISTS idx_session_queue_item_id ON session_queue(item_id);", + "CREATE UNIQUE INDEX IF NOT EXISTS idx_session_queue_session_id ON session_queue(session_id);", + "CREATE INDEX IF NOT EXISTS idx_session_queue_batch_id ON session_queue(batch_id);", + "CREATE INDEX IF NOT EXISTS idx_session_queue_created_priority ON session_queue(priority);", + "CREATE INDEX IF NOT EXISTS idx_session_queue_created_status ON session_queue(status);", + ] + + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS tg_session_queue_completed_at + AFTER UPDATE OF status ON session_queue + FOR EACH ROW + WHEN + NEW.status = 'completed' + OR NEW.status = 'failed' + OR NEW.status = 'canceled' + BEGIN + UPDATE session_queue + SET completed_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE item_id = NEW.item_id; + END; + """, + """--sql + CREATE TRIGGER IF NOT EXISTS tg_session_queue_started_at + AFTER UPDATE OF status ON session_queue + FOR EACH ROW + WHEN + NEW.status = 'in_progress' + BEGIN + UPDATE session_queue + SET started_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE item_id = NEW.item_id; + END; + """, + """--sql + CREATE TRIGGER IF NOT EXISTS tg_session_queue_updated_at + AFTER UPDATE + ON session_queue FOR EACH ROW + BEGIN + UPDATE session_queue + SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE item_id = old.item_id; + END; + """, + ] + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + def _create_workflow_images(self, cursor: sqlite3.Cursor) -> None: + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS workflow_images ( + workflow_id TEXT NOT NULL, + image_name 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')), + -- Soft delete, currently unused + deleted_at DATETIME, + -- enforce one-to-many relationship between workflows and images using PK + -- (we can extend this to many-to-many later) + PRIMARY KEY (image_name), + FOREIGN KEY (workflow_id) REFERENCES workflows (workflow_id) ON DELETE CASCADE, + FOREIGN KEY (image_name) REFERENCES images (image_name) ON DELETE CASCADE + ); + """ + ] + + indices = [ + "CREATE INDEX IF NOT EXISTS idx_workflow_images_workflow_id ON workflow_images (workflow_id);", + "CREATE INDEX IF NOT EXISTS idx_workflow_images_workflow_id_created_at ON workflow_images (workflow_id, created_at);", + ] + + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS tg_workflow_images_updated_at + AFTER UPDATE + ON workflow_images FOR EACH ROW + BEGIN + UPDATE workflow_images SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE workflow_id = old.workflow_id AND image_name = old.image_name; + END; + """ + ] + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + def _create_workflows(self, cursor: sqlite3.Cursor) -> None: + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS workflows ( + workflow TEXT NOT NULL, + workflow_id TEXT GENERATED ALWAYS AS (json_extract(workflow, '$.id')) VIRTUAL NOT NULL UNIQUE, -- gets implicit index + 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')) -- updated via trigger + ); + """ + ] + + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS tg_workflows_updated_at + AFTER UPDATE + ON workflows FOR EACH ROW + BEGIN + UPDATE workflows + SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE workflow_id = old.workflow_id; + END; + """ + ] + + for stmt in tables + triggers: + cursor.execute(stmt) + + +def build_migration_1() -> Migration: + """ + Builds the migration from database version 0 (init) to 1. + + This migration represents the state of the database circa InvokeAI v3.4.0, which was the last + version to not use migrations to manage the database. + + As such, this migration does include some ALTER statements, and the SQL statements are written + to be idempotent. + + - Create `board_images` junction table + - Create `boards` table + - Create `images` table, add `starred` column + - Create `model_config` table + - Create `session_queue` table + - Create `workflow_images` junction table + - Create `workflows` table + """ + + migration_1 = Migration( + from_version=0, + to_version=1, + callback=Migration1Callback(), + ) + + return migration_1 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_10.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_10.py new file mode 100644 index 0000000000000000000000000000000000000000..ce2cd2e965ee7fc1d45ae6e04e250eedff8f5754 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_10.py @@ -0,0 +1,35 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration10Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._update_error_cols(cursor) + + def _update_error_cols(self, cursor: sqlite3.Cursor) -> None: + """ + - Adds `error_type` and `error_message` columns to the session queue table. + - Renames the `error` column to `error_traceback`. + """ + + cursor.execute("ALTER TABLE session_queue ADD COLUMN error_type TEXT;") + cursor.execute("ALTER TABLE session_queue ADD COLUMN error_message TEXT;") + cursor.execute("ALTER TABLE session_queue RENAME COLUMN error TO error_traceback;") + + +def build_migration_10() -> Migration: + """ + Build the migration from database version 9 to 10. + + This migration does the following: + - Adds `error_type` and `error_message` columns to the session queue table. + - Renames the `error` column to `error_traceback`. + """ + migration_10 = Migration( + from_version=9, + to_version=10, + callback=Migration10Callback(), + ) + + return migration_10 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_11.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_11.py new file mode 100644 index 0000000000000000000000000000000000000000..f66374e0b1edd77fb95bbfbd264a91163c7ab884 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_11.py @@ -0,0 +1,75 @@ +import shutil +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 + +LEGACY_CORE_MODELS = [ + # OpenPose + "any/annotators/dwpose/yolox_l.onnx", + "any/annotators/dwpose/dw-ll_ucoco_384.onnx", + # DepthAnything + "any/annotators/depth_anything/depth_anything_vitl14.pth", + "any/annotators/depth_anything/depth_anything_vitb14.pth", + "any/annotators/depth_anything/depth_anything_vits14.pth", + # Lama inpaint + "core/misc/lama/lama.pt", + # RealESRGAN upscale + "core/upscaling/realesrgan/RealESRGAN_x4plus.pth", + "core/upscaling/realesrgan/RealESRGAN_x4plus_anime_6B.pth", + "core/upscaling/realesrgan/ESRGAN_SRx4_DF2KOST_official-ff704c30.pth", + "core/upscaling/realesrgan/RealESRGAN_x2plus.pth", +] + + +class Migration11Callback: + def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None: + self._app_config = app_config + self._logger = logger + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._remove_convert_cache() + self._remove_downloaded_models() + self._remove_unused_core_models() + + 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.") + legacy_convert_path = self._app_config.root_path / "models" / ".cache" + shutil.rmtree(legacy_convert_path, ignore_errors=True) + + def _remove_downloaded_models(self) -> None: + """Remove models from their old locations; they will re-download when needed.""" + self._logger.info( + "Removing legacy just-in-time models. Downloaded models will now be cached in .download_cache." + ) + for model_path in LEGACY_CORE_MODELS: + legacy_dest_path = self._app_config.models_path / model_path + legacy_dest_path.unlink(missing_ok=True) + + def _remove_unused_core_models(self) -> None: + """Remove unused core models and their directories.""" + self._logger.info("Removing defunct core models.") + for dir in ["face_restoration", "misc", "upscaling"]: + path_to_remove = self._app_config.models_path / "core" / dir + shutil.rmtree(path_to_remove, ignore_errors=True) + shutil.rmtree(self._app_config.models_path / "any" / "annotators", ignore_errors=True) + + +def build_migration_11(app_config: InvokeAIAppConfig, logger: Logger) -> Migration: + """ + Build the migration from database version 10 to 11. + + This migration does the following: + - Moves "core" models previously downloaded with download_with_progress_bar() into new + "models/.download_cache" directory. + - Renames "models/.cache" to "models/.convert_cache". + """ + migration_11 = Migration( + from_version=10, + to_version=11, + callback=Migration11Callback(app_config=app_config, logger=logger), + ) + + return migration_11 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 0000000000000000000000000000000000000000..f81632445c83ba2a6ac40b59f3349a10c2921845 --- /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 0000000000000000000000000000000000000000..401c0a4866aef97ee79f4cb49c3cb89c657c76f9 --- /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 0000000000000000000000000000000000000000..399f5a71d208c42489323360213ef16931926779 --- /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 0000000000000000000000000000000000000000..455ff71ab5b3f3bb7cd2d1c6162fdca821303432 --- /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_2.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2.py new file mode 100644 index 0000000000000000000000000000000000000000..f290fe6159446fdd6953cd97ec812a66b4a9768c --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_2.py @@ -0,0 +1,166 @@ +import sqlite3 +from logging import Logger + +from pydantic import ValidationError +from tqdm import tqdm + +from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase +from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration +from invokeai.app.services.workflow_records.workflow_records_common import ( + UnsafeWorkflowWithVersionValidator, +) + + +class Migration2Callback: + def __init__(self, image_files: ImageFileStorageBase, logger: Logger): + self._image_files = image_files + self._logger = logger + + def __call__(self, cursor: sqlite3.Cursor): + self._add_images_has_workflow(cursor) + self._add_session_queue_workflow(cursor) + self._drop_old_workflow_tables(cursor) + self._add_workflow_library(cursor) + self._drop_model_manager_metadata(cursor) + self._migrate_embedded_workflows(cursor) + + def _add_images_has_workflow(self, cursor: sqlite3.Cursor) -> None: + """Add the `has_workflow` column to `images` table.""" + cursor.execute("PRAGMA table_info(images)") + columns = [column[1] for column in cursor.fetchall()] + + if "has_workflow" not in columns: + cursor.execute("ALTER TABLE images ADD COLUMN has_workflow BOOLEAN DEFAULT FALSE;") + + def _add_session_queue_workflow(self, cursor: sqlite3.Cursor) -> None: + """Add the `workflow` column to `session_queue` table.""" + + cursor.execute("PRAGMA table_info(session_queue)") + columns = [column[1] for column in cursor.fetchall()] + + if "workflow" not in columns: + cursor.execute("ALTER TABLE session_queue ADD COLUMN workflow TEXT;") + + def _drop_old_workflow_tables(self, cursor: sqlite3.Cursor) -> None: + """Drops the `workflows` and `workflow_images` tables.""" + cursor.execute("DROP TABLE IF EXISTS workflow_images;") + cursor.execute("DROP TABLE IF EXISTS workflows;") + + def _add_workflow_library(self, cursor: sqlite3.Cursor) -> None: + """Adds the `workflow_library` table and drops the `workflows` and `workflow_images` tables.""" + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS workflow_library ( + workflow_id TEXT NOT NULL PRIMARY KEY, + workflow 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')), + -- updated manually when retrieving workflow + opened_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + -- Generated columns, needed for indexing and searching + category TEXT GENERATED ALWAYS as (json_extract(workflow, '$.meta.category')) VIRTUAL NOT NULL, + name TEXT GENERATED ALWAYS as (json_extract(workflow, '$.name')) VIRTUAL NOT NULL, + description TEXT GENERATED ALWAYS as (json_extract(workflow, '$.description')) VIRTUAL NOT NULL + ); + """, + ] + + indices = [ + "CREATE INDEX IF NOT EXISTS idx_workflow_library_created_at ON workflow_library(created_at);", + "CREATE INDEX IF NOT EXISTS idx_workflow_library_updated_at ON workflow_library(updated_at);", + "CREATE INDEX IF NOT EXISTS idx_workflow_library_opened_at ON workflow_library(opened_at);", + "CREATE INDEX IF NOT EXISTS idx_workflow_library_category ON workflow_library(category);", + "CREATE INDEX IF NOT EXISTS idx_workflow_library_name ON workflow_library(name);", + "CREATE INDEX IF NOT EXISTS idx_workflow_library_description ON workflow_library(description);", + ] + + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS tg_workflow_library_updated_at + AFTER UPDATE + ON workflow_library FOR EACH ROW + BEGIN + UPDATE workflow_library + SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE workflow_id = old.workflow_id; + END; + """ + ] + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + def _drop_model_manager_metadata(self, cursor: sqlite3.Cursor) -> None: + """Drops the `model_manager_metadata` table.""" + cursor.execute("DROP TABLE IF EXISTS model_manager_metadata;") + + def _migrate_embedded_workflows(self, cursor: sqlite3.Cursor) -> None: + """ + In the v3.5.0 release, InvokeAI changed how it handles embedded workflows. The `images` table in + the database now has a `has_workflow` column, indicating if an image has a workflow embedded. + + This migrate callback checks each image for the presence of an embedded workflow, then updates its entry + in the database accordingly. + """ + # Get all image names + cursor.execute("SELECT image_name FROM images") + image_names: list[str] = [image[0] for image in cursor.fetchall()] + total_image_names = len(image_names) + + if not total_image_names: + return + + self._logger.info(f"Migrating workflows for {total_image_names} images") + + # Migrate the images + to_migrate: list[tuple[bool, str]] = [] + pbar = tqdm(image_names) + for idx, image_name in enumerate(pbar): + pbar.set_description(f"Checking image {idx + 1}/{total_image_names} for workflow") + try: + pil_image = self._image_files.get(image_name) + except ImageFileNotFoundException: + self._logger.warning(f"Image {image_name} not found, skipping") + continue + except Exception as e: + self._logger.warning(f"Error while checking image {image_name}, skipping: {e}") + continue + if "invokeai_workflow" in pil_image.info: + try: + UnsafeWorkflowWithVersionValidator.validate_json(pil_image.info.get("invokeai_workflow", "")) + except ValidationError: + self._logger.warning(f"Image {image_name} has invalid embedded workflow, skipping") + continue + to_migrate.append((True, image_name)) + + self._logger.info(f"Adding {len(to_migrate)} embedded workflows to database") + cursor.executemany("UPDATE images SET has_workflow = ? WHERE image_name = ?", to_migrate) + + +def build_migration_2(image_files: ImageFileStorageBase, logger: Logger) -> Migration: + """ + Builds the migration from database version 1 to 2. + + Introduced in v3.5.0 for the new workflow library. + + :param image_files: The image files service, used to check for embedded workflows + :param logger: The logger, used to log progress during embedded workflows handling + + This migration does the following: + - Add `has_workflow` column to `images` table + - Add `workflow` column to `session_queue` table + - Drop `workflows` and `workflow_images` tables + - Add `workflow_library` table + - Drops the `model_manager_metadata` table + - Drops the `model_config` table, recreating it (at this point, there is no user data in this table) + - Populates the `has_workflow` column in the `images` table (requires `image_files` & `logger` dependencies) + """ + migration_2 = Migration( + from_version=1, + to_version=2, + callback=Migration2Callback(image_files=image_files, logger=logger), + ) + + return migration_2 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_3.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_3.py new file mode 100644 index 0000000000000000000000000000000000000000..48eb1db8541099ddaead701d7c9e9623e4a7cf2f --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_3.py @@ -0,0 +1,70 @@ +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 Migration3Callback: + def __init__(self, app_config: InvokeAIAppConfig, logger: Logger) -> None: + self._app_config = app_config + self._logger = logger + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._drop_model_manager_metadata(cursor) + self._recreate_model_config(cursor) + + def _drop_model_manager_metadata(self, cursor: sqlite3.Cursor) -> None: + """Drops the `model_manager_metadata` table.""" + cursor.execute("DROP TABLE IF EXISTS model_manager_metadata;") + + def _recreate_model_config(self, cursor: sqlite3.Cursor) -> None: + """ + Drops the `model_config` table, recreating it. + + In 3.4.0, this table used explicit columns but was changed to use json_extract 3.5.0. + + Because this table is not used in production, we are able to simply drop it and recreate it. + """ + + cursor.execute("DROP TABLE IF EXISTS model_config;") + + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS model_config ( + id TEXT NOT NULL PRIMARY KEY, + -- The next 3 fields are enums in python, unrestricted string here + base TEXT GENERATED ALWAYS as (json_extract(config, '$.base')) VIRTUAL NOT NULL, + type TEXT GENERATED ALWAYS as (json_extract(config, '$.type')) VIRTUAL NOT NULL, + name TEXT GENERATED ALWAYS as (json_extract(config, '$.name')) 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, + original_hash TEXT, -- could be 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')), + -- unique constraint on combo of name, base and type + UNIQUE(name, base, type) + ); + """ + ) + + +def build_migration_3(app_config: InvokeAIAppConfig, logger: Logger) -> Migration: + """ + Build the migration from database version 2 to 3. + + This migration does the following: + - Drops the `model_config` table, recreating it + - Migrates data from `models.yaml` into the `model_config` table + """ + migration_3 = Migration( + from_version=2, + to_version=3, + callback=Migration3Callback(app_config=app_config, logger=logger), + ) + + return migration_3 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_4.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_4.py new file mode 100644 index 0000000000000000000000000000000000000000..b8dc4dd83b43eb518e0505cfeb98f7dbaebc348a --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_4.py @@ -0,0 +1,83 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration4Callback: + """Callback to do step 4 of migration.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: # noqa D102 + self._create_model_metadata(cursor) + self._create_model_tags(cursor) + self._create_tags(cursor) + self._create_triggers(cursor) + + def _create_model_metadata(self, cursor: sqlite3.Cursor) -> None: + """Create the table used to store model metadata downloaded from remote sources.""" + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS model_metadata ( + id TEXT NOT NULL PRIMARY KEY, + name TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.name')) VIRTUAL NOT NULL, + author TEXT GENERATED ALWAYS AS (json_extract(metadata, '$.author')) VIRTUAL NOT NULL, + -- Serialized JSON representation of the whole metadata object, + -- which will contain additional fields from subclasses + metadata 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')), + FOREIGN KEY(id) REFERENCES model_config(id) ON DELETE CASCADE + ); + """ + ) + + def _create_model_tags(self, cursor: sqlite3.Cursor) -> None: + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS model_tags ( + model_id TEXT NOT NULL, + tag_id INTEGER NOT NULL, + FOREIGN KEY(model_id) REFERENCES model_config(id) ON DELETE CASCADE, + FOREIGN KEY(tag_id) REFERENCES tags(tag_id) ON DELETE CASCADE, + UNIQUE(model_id,tag_id) + ); + """ + ) + + def _create_tags(self, cursor: sqlite3.Cursor) -> None: + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS tags ( + tag_id INTEGER NOT NULL PRIMARY KEY, + tag_text TEXT NOT NULL UNIQUE + ); + """ + ) + + def _create_triggers(self, cursor: sqlite3.Cursor) -> None: + cursor.execute( + """--sql + CREATE TRIGGER IF NOT EXISTS model_metadata_updated_at + AFTER UPDATE + ON model_metadata FOR EACH ROW + BEGIN + UPDATE model_metadata SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE id = old.id; + END; + """ + ) + + +def build_migration_4() -> Migration: + """ + Build the migration from database version 3 to 4. + + Adds the tables needed to store model metadata and tags. + """ + migration_4 = Migration( + from_version=3, + to_version=4, + callback=Migration4Callback(), + ) + + return migration_4 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_5.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_5.py new file mode 100644 index 0000000000000000000000000000000000000000..b2e8c206d8d36905df2d7da1161108a9eb631626 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_5.py @@ -0,0 +1,34 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration5Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._drop_graph_executions(cursor) + + def _drop_graph_executions(self, cursor: sqlite3.Cursor) -> None: + """Drops the `graph_executions` table.""" + + cursor.execute( + """--sql + DROP TABLE IF EXISTS graph_executions; + """ + ) + + +def build_migration_5() -> Migration: + """ + Build the migration from database version 4 to 5. + + Introduced in v3.6.3, this migration: + - Drops the `graph_executions` table. We are able to do this because we are moving the graph storage + to be purely in-memory. + """ + migration_5 = Migration( + from_version=4, + to_version=5, + callback=Migration5Callback(), + ) + + return migration_5 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_6.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_6.py new file mode 100644 index 0000000000000000000000000000000000000000..1f9ac56518ce73f44c0805a4ba28d826dc443e29 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_6.py @@ -0,0 +1,62 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration6Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._recreate_model_triggers(cursor) + self._delete_ip_adapters(cursor) + + def _recreate_model_triggers(self, cursor: sqlite3.Cursor) -> None: + """ + Adds the timestamp trigger to the model_config table. + + This trigger was inadvertently dropped in earlier migration scripts. + """ + + cursor.execute( + """--sql + CREATE TRIGGER IF NOT EXISTS model_config_updated_at + AFTER UPDATE + ON model_config FOR EACH ROW + BEGIN + UPDATE model_config SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE id = old.id; + END; + """ + ) + + def _delete_ip_adapters(self, cursor: sqlite3.Cursor) -> None: + """ + Delete all the IP adapters. + + The model manager will automatically find and re-add them after the migration + is done. This allows the manager to add the correct image encoder to their + configuration records. + """ + + cursor.execute( + """--sql + DELETE FROM model_config + WHERE type='ip_adapter'; + """ + ) + + +def build_migration_6() -> Migration: + """ + Build the migration from database version 5 to 6. + + This migration does the following: + - Adds the model_config_updated_at trigger if it does not exist + - Delete all ip_adapter models so that the model prober can find and + update with the correct image processor model. + """ + migration_6 = Migration( + from_version=5, + to_version=6, + callback=Migration6Callback(), + ) + + return migration_6 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_7.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_7.py new file mode 100644 index 0000000000000000000000000000000000000000..fa573d63a63f6a2d9d21eb5a03b1301c0ce6e946 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_7.py @@ -0,0 +1,88 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration7Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._create_models_table(cursor) + self._drop_old_models_tables(cursor) + + def _drop_old_models_tables(self, cursor: sqlite3.Cursor) -> None: + """Drops the old model_records, model_metadata, model_tags and tags tables.""" + + tables = ["model_config", "model_metadata", "model_tags", "tags"] + + for table in tables: + cursor.execute(f"DROP TABLE IF EXISTS {table};") + + def _create_models_table(self, cursor: sqlite3.Cursor) -> None: + """Creates the v4.0.0 models table.""" + + tables = [ + """--sql + CREATE TABLE IF NOT EXISTS 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, + -- 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')), + -- unique constraint on combo of name, base and type + UNIQUE(name, base, type) + ); + """ + ] + + # Add trigger for `updated_at`. + triggers = [ + """--sql + CREATE TRIGGER IF NOT EXISTS 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; + """ + ] + + # Add indexes for searchable fields + indices = [ + "CREATE INDEX IF NOT EXISTS base_index ON models(base);", + "CREATE INDEX IF NOT EXISTS type_index ON models(type);", + "CREATE INDEX IF NOT EXISTS name_index ON models(name);", + "CREATE UNIQUE INDEX IF NOT EXISTS path_index ON models(path);", + ] + + for stmt in tables + indices + triggers: + cursor.execute(stmt) + + +def build_migration_7() -> Migration: + """ + Build the migration from database version 6 to 7. + + This migration does the following: + - Adds the new models table + - Drops the old model_records, model_metadata, model_tags and tags tables. + - TODO(MM2): Migrates model names and descriptions from `models.yaml` to the new table (?). + """ + migration_7 = Migration( + from_version=6, + to_version=7, + callback=Migration7Callback(), + ) + + return migration_7 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_8.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_8.py new file mode 100644 index 0000000000000000000000000000000000000000..154a5236caea7485bf1c33abc0b54bdbeedc6b8f --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_8.py @@ -0,0 +1,91 @@ +import sqlite3 +from pathlib import Path + +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration8Callback: + def __init__(self, app_config: InvokeAIAppConfig) -> None: + self._app_config = app_config + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._drop_model_config_table(cursor) + self._migrate_abs_models_to_rel(cursor) + + def _drop_model_config_table(self, cursor: sqlite3.Cursor) -> None: + """Drops the old model_config table. This was missed in a previous migration.""" + + cursor.execute("DROP TABLE IF EXISTS model_config;") + + def _migrate_abs_models_to_rel(self, cursor: sqlite3.Cursor) -> None: + """Check all model paths & legacy config paths to determine if they are inside Invoke-managed directories. If + they are, update the paths to be relative to the managed directories. + + This migration is a no-op for normal users (their paths will already be relative), but is necessary for users + who have been testing the RCs with their live databases. The paths were made absolute in the initial RC, but this + change was reverted. To smooth over the revert for our tests, we can migrate the paths back to relative. + """ + + models_path = self._app_config.models_path + legacy_conf_path = self._app_config.legacy_conf_path + legacy_conf_dir = self._app_config.legacy_conf_dir + + stmt = """---sql + SELECT + id, + path, + json_extract(config, '$.config_path') as config_path + FROM models; + """ + + all_models = cursor.execute(stmt).fetchall() + + for model_id, model_path, model_config_path in all_models: + # If the model path is inside the models directory, update it to be relative to the models directory. + if Path(model_path).is_relative_to(models_path): + new_path = Path(model_path).relative_to(models_path) + cursor.execute( + """--sql + UPDATE models + SET config = json_set(config, '$.path', ?) + WHERE id = ?; + """, + (str(new_path), model_id), + ) + # If the model has a legacy config path and it is inside the legacy conf directory, update it to be + # relative to the legacy conf directory. This also fixes up cases in which the config path was + # incorrectly relativized to the root directory. It will now be relativized to the legacy conf directory. + if model_config_path: + if Path(model_config_path).is_relative_to(legacy_conf_path): + new_config_path = Path(model_config_path).relative_to(legacy_conf_path) + elif Path(model_config_path).is_relative_to(legacy_conf_dir): + new_config_path = Path(*Path(model_config_path).parts[1:]) + else: + new_config_path = None + if new_config_path: + cursor.execute( + """--sql + UPDATE models + SET config = json_set(config, '$.config_path', ?) + WHERE id = ?; + """, + (str(new_config_path), model_id), + ) + + +def build_migration_8(app_config: InvokeAIAppConfig) -> Migration: + """ + Build the migration from database version 7 to 8. + + This migration does the following: + - Removes the `model_config` table. + - Migrates absolute model & legacy config paths to be relative to the models directory. + """ + migration_8 = Migration( + from_version=7, + to_version=8, + callback=Migration8Callback(app_config), + ) + + return migration_8 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_9.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_9.py new file mode 100644 index 0000000000000000000000000000000000000000..acc4ef5017d50c779fe305fa22f360b61fe3743d --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_9.py @@ -0,0 +1,29 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration9Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._empty_session_queue(cursor) + + def _empty_session_queue(self, cursor: sqlite3.Cursor) -> None: + """Empties the session queue. This is done to prevent any lingering session queue items from causing pydantic errors due to changed schemas.""" + + cursor.execute("DELETE FROM session_queue;") + + +def build_migration_9() -> Migration: + """ + Build the migration from database version 8 to 9. + + This migration does the following: + - Empties the session queue. This is done to prevent any lingering session queue items from causing pydantic errors due to changed schemas. + """ + migration_9 = Migration( + from_version=8, + to_version=9, + callback=Migration9Callback(), + ) + + return migration_9 diff --git a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_common.py b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_common.py new file mode 100644 index 0000000000000000000000000000000000000000..9b2444dae4ba057e4d21ec8b7c2b5720cfe60e99 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_common.py @@ -0,0 +1,163 @@ +import sqlite3 +from typing import Optional, Protocol, runtime_checkable + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +@runtime_checkable +class MigrateCallback(Protocol): + """ + A callback that performs a migration. + + Migrate callbacks are provided an open cursor to the database. They should not commit their + transaction; this is handled by the migrator. + + If the callback needs to access additional dependencies, will be provided to the callback at runtime. + + See :class:`Migration` for an example. + """ + + def __call__(self, cursor: sqlite3.Cursor) -> None: ... + + +class MigrationError(RuntimeError): + """Raised when a migration fails.""" + + +class MigrationVersionError(ValueError): + """Raised when a migration version is invalid.""" + + +class Migration(BaseModel): + """ + Represents a migration for a SQLite database. + + :param from_version: The database version on which this migration may be run + :param to_version: The database version that results from this migration + :param migrate_callback: The callback to run to perform the migration + + Migration callbacks will be provided an open cursor to the database. They should not commit their + transaction; this is handled by the migrator. + + It is suggested to use a class to define the migration callback and a builder function to create + the :class:`Migration`. This allows the callback to be provided with additional dependencies and + keeps things tidy, as all migration logic is self-contained. + + Example: + ```py + # Define the migration callback class + class Migration1Callback: + # This migration needs a logger, so we define a class that accepts a logger in its constructor. + def __init__(self, image_files: ImageFileStorageBase) -> None: + self._image_files = ImageFileStorageBase + + # This dunder method allows the instance of the class to be called like a function. + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._add_with_banana_column(cursor) + self._do_something_with_images(cursor) + + def _add_with_banana_column(self, cursor: sqlite3.Cursor) -> None: + \"""Adds the with_banana column to the sushi table.\""" + # Execute SQL using the cursor, taking care to *not commit* a transaction + cursor.execute('ALTER TABLE sushi ADD COLUMN with_banana BOOLEAN DEFAULT TRUE;') + + def _do_something_with_images(self, cursor: sqlite3.Cursor) -> None: + \"""Does something with the image files service.\""" + self._image_files.get(...) + + # Define the migration builder function. This function creates an instance of the migration callback + # class and returns a Migration. + def build_migration_1(image_files: ImageFileStorageBase) -> Migration: + \"""Builds the migration from database version 0 to 1. + Requires the image files service to... + \""" + + migration_1 = Migration( + from_version=0, + to_version=1, + migrate_callback=Migration1Callback(image_files=image_files), + ) + + return migration_1 + + # Register the migration after all dependencies have been initialized + db = SqliteDatabase(db_path, logger) + migrator = SqliteMigrator(db) + migrator.register_migration(build_migration_1(image_files)) + migrator.run_migrations() + ``` + """ + + from_version: int = Field(ge=0, strict=True, description="The database version on which this migration may be run") + to_version: int = Field(ge=1, strict=True, description="The database version that results from this migration") + callback: MigrateCallback = Field(description="The callback to run to perform the migration") + + @model_validator(mode="after") + def validate_to_version(self) -> "Migration": + """Validates that to_version is one greater than from_version.""" + if self.to_version != self.from_version + 1: + raise MigrationVersionError("to_version must be one greater than from_version") + return self + + def __hash__(self) -> int: + # Callables are not hashable, so we need to implement our own __hash__ function to use this class in a set. + return hash((self.from_version, self.to_version)) + + model_config = ConfigDict(arbitrary_types_allowed=True) + + +class MigrationSet: + """ + A set of Migrations. Performs validation during migration registration and provides utility methods. + + Migrations should be registered with `register()`. Once all are registered, `validate_migration_chain()` + should be called to ensure that the migrations form a single chain of migrations from version 0 to the latest version. + """ + + def __init__(self) -> None: + self._migrations: set[Migration] = set() + + def register(self, migration: Migration) -> None: + """Registers a migration.""" + migration_from_already_registered = any(m.from_version == migration.from_version for m in self._migrations) + migration_to_already_registered = any(m.to_version == migration.to_version for m in self._migrations) + if migration_from_already_registered or migration_to_already_registered: + raise MigrationVersionError("Migration with from_version or to_version already registered") + self._migrations.add(migration) + + def get(self, from_version: int) -> Optional[Migration]: + """Gets the migration that may be run on the given database version.""" + # register() ensures that there is only one migration with a given from_version, so this is safe. + return next((m for m in self._migrations if m.from_version == from_version), None) + + def validate_migration_chain(self) -> None: + """ + Validates that the migrations form a single chain of migrations from version 0 to the latest version, + Raises a MigrationError if there is a problem. + """ + if self.count == 0: + return + if self.latest_version == 0: + return + next_migration = self.get(from_version=0) + if next_migration is None: + raise MigrationError("Migration chain is fragmented") + touched_count = 1 + while next_migration is not None: + next_migration = self.get(next_migration.to_version) + if next_migration is not None: + touched_count += 1 + if touched_count != self.count: + raise MigrationError("Migration chain is fragmented") + + @property + def count(self) -> int: + """The count of registered migrations.""" + return len(self._migrations) + + @property + def latest_version(self) -> int: + """Gets latest to_version among registered migrations. Returns 0 if there are no migrations registered.""" + if self.count == 0: + return 0 + return sorted(self._migrations, key=lambda m: m.to_version)[-1].to_version diff --git a/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py new file mode 100644 index 0000000000000000000000000000000000000000..5d78d55818c4a5c0a1d756d2e9adc8714f67803c --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/sqlite_migrator_impl.py @@ -0,0 +1,145 @@ +import sqlite3 +from contextlib import closing +from datetime import datetime +from pathlib import Path +from typing import Optional + +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration, MigrationError, MigrationSet + + +class SqliteMigrator: + """ + Manages migrations for a SQLite database. + + :param db: The instance of :class:`SqliteDatabase` to migrate. + + Migrations should be registered with :meth:`register_migration`. + + Each migration is run in a transaction. If a migration fails, the transaction is rolled back. + + Example Usage: + ```py + db = SqliteDatabase(db_path="my_db.db", logger=logger) + migrator = SqliteMigrator(db=db) + migrator.register_migration(build_migration_1()) + migrator.register_migration(build_migration_2()) + migrator.run_migrations() + ``` + """ + + backup_path: Optional[Path] = None + + def __init__(self, db: SqliteDatabase) -> None: + self._db = db + self._logger = db.logger + self._migration_set = MigrationSet() + self._backup_path: Optional[Path] = None + + def register_migration(self, migration: Migration) -> None: + """Registers a migration.""" + self._migration_set.register(migration) + self._logger.debug(f"Registered migration {migration.from_version} -> {migration.to_version}") + + 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 + + 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: + cursor = conn.cursor() + if self._get_current_version(cursor) != migration.from_version: + raise MigrationError( + f"Database is at version {self._get_current_version(cursor)}, expected {migration.from_version}" + ) + self._logger.debug(f"Running migration from {migration.from_version} to {migration.to_version}") + + # Run the actual migration + migration.callback(cursor) + + # Update the version + cursor.execute("INSERT INTO migrations (version) VALUES (?);", (migration.to_version,)) + + self._logger.debug( + f"Successfully migrated database from {migration.from_version} to {migration.to_version}" + ) + # We want to catch *any* error, mirroring the behaviour of the sqlite3 module. + except Exception as e: + # The connection context manager has already rolled back the migration, so we don't need to do anything. + msg = f"Error migrating database from {migration.from_version} to {migration.to_version}: {e}" + self._logger.error(msg) + raise MigrationError(msg) from e + + 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 + + @classmethod + def _get_current_version(cls, cursor: sqlite3.Cursor) -> int: + """Gets the current version of the database, or 0 if the migrations table does not exist.""" + try: + cursor.execute("SELECT MAX(version) FROM migrations;") + version: int = cursor.fetchone()[0] + if version is None: + return 0 + return version + except sqlite3.OperationalError as e: + if "no such table" in str(e): + return 0 + raise 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 0000000000000000000000000000000000000000..def6dce25920db16faa56fd383817e13020bdcd4 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 0000000000000000000000000000000000000000..97a2e74772f4d4656dfc6e5948209b90ccb434a9 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 0000000000000000000000000000000000000000..5db78ce086f6841a3e5781afe8f775aa1867c55b 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 0000000000000000000000000000000000000000..93c3c5c301a548566aa58a4ca1f46f243f334555 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 0000000000000000000000000000000000000000..5d3d0c4af6e7fe981da2041715f348c25b1c061e 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 0000000000000000000000000000000000000000..3f287fc335943bdf2be76d8a8bb61b897505dd6f 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 0000000000000000000000000000000000000000..a0e1cbfb4234a4af6e440cd0a1bb193a6e934ca1 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 0000000000000000000000000000000000000000..5b5976c4f953714be0b128f8bd64f2d89a4b1e87 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 0000000000000000000000000000000000000000..5c7841037719c2e0525268adc699ab47411e89bc 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 0000000000000000000000000000000000000000..b8cdfea030f231259111c78cfe5d58fcc1dc5665 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 0000000000000000000000000000000000000000..b47da9fb9413c6446bc8a1057ae4246acbcf7aec 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 0000000000000000000000000000000000000000..a034cd197bc4af50143df36238a5a7c2228bd0cd 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 0000000000000000000000000000000000000000..5985fb6c4b21327ff81aa244d0bd7fd0f7224985 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 0000000000000000000000000000000000000000..7718735b23f984cc126f5375b8525d3dcef1649b 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 0000000000000000000000000000000000000000..60bd40b1fa85e5a8db061911f80ec67c1f715ca5 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 0000000000000000000000000000000000000000..4a426f476928834bfc10536ed2b435eb4aebf75f 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 0000000000000000000000000000000000000000..08d240a29e67f2037b409e69cf779222a19d0f3f 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 0000000000000000000000000000000000000000..73c4c8db08723fe4a34f0358f3591092cd1bf501 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Vehicles.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/__init__.py b/invokeai/app/services/style_preset_images/default_style_preset_images/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 0000000000000000000000000000000000000000..d8158ad2ae2b6e6b9e0dc9521ce3b2b22d11128c --- /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 0000000000000000000000000000000000000000..054a12b82b7d1b7c4af8247d2f11bfac48e88a0b --- /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 0000000000000000000000000000000000000000..cd2b29efd2ab8af29800e7241999dfa0bd1dbafb --- /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/invokeai/app/services/style_preset_records/__init__.py b/invokeai/app/services/style_preset_records/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 0000000000000000000000000000000000000000..1daadfa8ff7eb349e12abd84bd0588f19ce1732f --- /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 0000000000000000000000000000000000000000..a4dee2fbbd6bff490af03dd04814fbbed12fefe5 --- /dev/null +++ b/invokeai/app/services/style_preset_records/style_preset_records_base.py @@ -0,0 +1,42 @@ +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.""" + pass + + @abstractmethod + def create(self, style_preset: StylePresetWithoutId) -> StylePresetRecordDTO: + """Creates a style preset.""" + pass + + @abstractmethod + def create_many(self, style_presets: list[StylePresetWithoutId]) -> None: + """Creates many style presets.""" + pass + + @abstractmethod + def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO: + """Updates a style preset.""" + pass + + @abstractmethod + def delete(self, style_preset_id: str) -> None: + """Deletes a style preset.""" + pass + + @abstractmethod + def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]: + """Gets many workflows.""" + 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 0000000000000000000000000000000000000000..36153d002d0f7bbb05aaa2dfc8d612b2c8fa1a52 --- /dev/null +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -0,0 +1,139 @@ +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" + Project = "project" + + +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") + + +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") + + +class StylePresetRecordDTO(StylePresetWithoutId): + id: str = Field(description="The style preset ID.") + + @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 0000000000000000000000000000000000000000..657d73b3bda55340df40389831da21891abb26c0 --- /dev/null +++ b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py @@ -0,0 +1,215 @@ +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 + + +class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase): + def __init__(self, db: SqliteDatabase) -> None: + super().__init__() + self._lock = db.lock + self._conn = db.conn + self._cursor = self._conn.cursor() + + 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.""" + try: + self._lock.acquire() + self._cursor.execute( + """--sql + SELECT * + FROM style_presets + WHERE id = ?; + """, + (style_preset_id,), + ) + row = self._cursor.fetchone() + if row is None: + raise StylePresetNotFoundError(f"Style preset with id {style_preset_id} not found") + return StylePresetRecordDTO.from_dict(dict(row)) + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + + def create(self, style_preset: StylePresetWithoutId) -> StylePresetRecordDTO: + style_preset_id = uuid_string() + try: + self._lock.acquire() + self._cursor.execute( + """--sql + INSERT OR IGNORE INTO style_presets ( + id, + name, + preset_data, + type + ) + VALUES (?, ?, ?, ?); + """, + ( + style_preset_id, + style_preset.name, + style_preset.preset_data.model_dump_json(), + style_preset.type, + ), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + return self.get(style_preset_id) + + def create_many(self, style_presets: list[StylePresetWithoutId]) -> None: + style_preset_ids = [] + try: + self._lock.acquire() + for style_preset in style_presets: + style_preset_id = uuid_string() + style_preset_ids.append(style_preset_id) + self._cursor.execute( + """--sql + INSERT OR IGNORE INTO style_presets ( + id, + name, + preset_data, + type + ) + VALUES (?, ?, ?, ?); + """, + ( + style_preset_id, + style_preset.name, + style_preset.preset_data.model_dump_json(), + style_preset.type, + ), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + + return None + + def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO: + try: + self._lock.acquire() + # Change the name of a style preset + if changes.name is not None: + self._cursor.execute( + """--sql + UPDATE style_presets + SET name = ? + WHERE id = ?; + """, + (changes.name, style_preset_id), + ) + + # Change the preset data for a style preset + if changes.preset_data is not None: + self._cursor.execute( + """--sql + UPDATE style_presets + SET preset_data = ? + WHERE id = ?; + """, + (changes.preset_data.model_dump_json(), style_preset_id), + ) + + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + return self.get(style_preset_id) + + def delete(self, style_preset_id: str) -> None: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + DELETE from style_presets + WHERE id = ?; + """, + (style_preset_id,), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + return None + + def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]: + try: + self._lock.acquire() + main_query = """ + SELECT + * + FROM style_presets + """ + + if type is not None: + main_query += "WHERE type = ? " + + main_query += "ORDER BY LOWER(name) ASC" + + if type is not None: + self._cursor.execute(main_query, (type,)) + else: + self._cursor.execute(main_query) + + rows = self._cursor.fetchall() + style_presets = [StylePresetRecordDTO.from_dict(dict(row)) for row in rows] + + return style_presets + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + + def _sync_default_style_presets(self) -> None: + """Syncs default style presets to the database. Internal use only.""" + + # First delete all existing default style presets + try: + self._lock.acquire() + self._cursor.execute( + """--sql + DELETE FROM style_presets + WHERE type = "default"; + """ + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + # Next, parse and create the default style presets + with self._lock, 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) diff --git a/invokeai/app/services/urls/__init__.py b/invokeai/app/services/urls/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/services/urls/urls_base.py b/invokeai/app/services/urls/urls_base.py new file mode 100644 index 0000000000000000000000000000000000000000..b2e41db3e4e4df71aa96d7d5270572f9e1d80d70 --- /dev/null +++ b/invokeai/app/services/urls/urls_base.py @@ -0,0 +1,20 @@ +from abc import ABC, abstractmethod + + +class UrlServiceBase(ABC): + """Responsible for building URLs for resources.""" + + @abstractmethod + def get_image_url(self, image_name: str, thumbnail: bool = False) -> str: + """Gets the URL for an image or thumbnail.""" + pass + + @abstractmethod + 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 diff --git a/invokeai/app/services/urls/urls_default.py b/invokeai/app/services/urls/urls_default.py new file mode 100644 index 0000000000000000000000000000000000000000..f62bebe9013384e0cb473a36953e22833ee2900c --- /dev/null +++ b/invokeai/app/services/urls/urls_default.py @@ -0,0 +1,24 @@ +import os + +from invokeai.app.services.urls.urls_base import UrlServiceBase + + +class LocalUrlService(UrlServiceBase): + def __init__(self, base_url: str = "api/v1", base_url_v2: str = "api/v2"): + self._base_url = base_url + self._base_url_v2 = base_url_v2 + + def get_image_url(self, image_name: str, thumbnail: bool = False) -> str: + image_basename = os.path.basename(image_name) + + # These paths are determined by the routes in invokeai/app/api/routers/images.py + if thumbnail: + return f"{self._base_url}/images/i/{image_basename}/thumbnail" + + return f"{self._base_url}/images/i/{image_basename}/full" + + 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" diff --git a/invokeai/app/services/workflow_records/__init__.py b/invokeai/app/services/workflow_records/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 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 new file mode 100644 index 0000000000000000000000000000000000000000..2cadcae961789d8363440b892e29dec89884fb59 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/ESRGAN Upscaling with Canny ControlNet.json @@ -0,0 +1,851 @@ +{ + "name": "ESRGAN Upscaling with Canny ControlNet", + "author": "InvokeAI", + "description": "Sample workflow for using Upscaling with ControlNet with SD1.5", + "version": "2.1.0", + "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" + }, + { + "nodeId": "f7564dd2-9539-47f2-ac13-190804461f4e", + "fieldName": "model_name" + }, + { + "nodeId": "ca1d020c-89a8-4958-880a-016d28775cfa", + "fieldName": "control_model" + }, + { + "nodeId": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0", + "fieldName": "board" + } + ], + "meta": { + "version": "3.0.0", + "category": "default" + }, + "nodes": [ + { + "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b", + "type": "invocation", + "data": { + "id": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b", + "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": 1250, + "y": 1200 + } + }, + { + "id": "5ca498a4-c8c8-4580-a396-0c984317205d", + "type": "invocation", + "data": { + "id": "5ca498a4-c8c8-4580-a396-0c984317205d", + "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": false + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1650, + "y": 1675 + } + }, + { + "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0", + "type": "invocation", + "data": { + "id": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0", + "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": false, + "useCache": true + }, + "position": { + "x": 2559.4751127537957, + "y": 1246.6000376741406 + } + }, + { + "id": "ca1d020c-89a8-4958-880a-016d28775cfa", + "type": "invocation", + "data": { + "id": "ca1d020c-89a8-4958-880a-016d28775cfa", + "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)", + "value": { + "key": "a7b9c76f-4bc5-42aa-b918-c1c458a5bb24", + "hash": "blake3:260c7f8e10aefea9868cfc68d89970e91033bd37132b14b903e70ee05ebf530e", + "name": "sd-controlnet-canny", + "base": "sd-1", + "type": "controlnet" + } + }, + "control_weight": { + "name": "control_weight", + "label": "", + "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, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1624.7980608333519, + "y": 1902.9649340196056 + } + }, + { + "id": "1d887701-df21-4966-ae6e-a7d82307d7bd", + "type": "invocation", + "data": { + "id": "1d887701-df21-4966-ae6e-a7d82307d7bd", + "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": 1200, + "y": 1900 + } + }, + { + "id": "d8ace142-c05f-4f1d-8982-88dc7473958d", + "type": "invocation", + "data": { + "id": "d8ace142-c05f-4f1d-8982-88dc7473958d", + "version": "1.0.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "main_model_loader", + "inputs": { + "model": { + "name": "model", + "label": "", + "value": { + "key": "5cd43ca0-dd0a-418d-9f7e-35b2b9d5e106", + "hash": "blake3:6987f323017f597213cc3264250edf57056d21a40a0a85d83a1a33a7d44dc41a", + "name": "Deliberate_v5", + "base": "sd-1", + "type": "main" + } + } + }, + "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": "" + }, + "clip": { + "name": "clip", + "label": "" + }, + "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": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "model_name": { + "name": "model_name", + "label": "Upscaler Model", + "value": "RealESRGAN_x2plus.pth" + }, + "tile_size": { + "name": "tile_size", + "label": "", + "value": 400 + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 717.3863693661265, + "y": 1721.9215053134815 + } + }, + { + "id": "f50624ce-82bf-41d0-bdf7-8aab11a80d48", + "type": "invocation", + "data": { + "id": "f50624ce-82bf-41d0-bdf7-8aab11a80d48", + "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": 512 + }, + "use_cpu": { + "name": "use_cpu", + "label": "", + "value": true + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1650, + "y": 1775 + } + }, + { + "id": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "type": "invocation", + "data": { + "id": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "version": "1.5.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 30 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 7.5 + }, + "denoising_start": { + "name": "denoising_start", + "label": "", + "value": 0.65 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "dpmpp_sde_k" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 2128.740065979906, + "y": 1232.6219060454753 + } + }, + { + "id": "eb8f6f8a-c7b1-4914-806e-045ee2717a35", + "type": "invocation", + "data": { + "id": "eb8f6f8a-c7b1-4914-806e-045ee2717a35", + "version": "1.0.1", + "label": "", + "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": 1650, + "y": 1600 + } + }, + { + "id": "9ba14a1f-1675-4118-8b75-81c66c4b9d3a", + "type": "invocation", + "data": { + "id": "9ba14a1f-1675-4118-8b75-81c66c4b9d3a", + "type": "integer_math", + "version": "1.0.1", + "label": "Get Min of Width & Height", + "notes": "", + "isOpen": false, + "isIntermediate": true, + "useCache": true, + "inputs": { + "operation": { + "name": "operation", + "label": "", + "value": "MIN" + }, + "a": { + "name": "a", + "label": "", + "value": 1 + }, + "b": { + "name": "b", + "label": "", + "value": 1 + } + } + }, + "position": { + "x": 722.6636820159035, + "y": 2088.414119794122 + } + }, + { + "id": "aa9bcef8-aa90-49ea-b162-4bd613f5ea52", + "type": "invocation", + "data": { + "id": "aa9bcef8-aa90-49ea-b162-4bd613f5ea52", + "type": "float_to_int", + "version": "1.0.1", + "label": "To Multiple of 8", + "notes": "", + "isOpen": false, + "isIntermediate": true, + "useCache": true, + "inputs": { + "value": { + "name": "value", + "label": "", + "value": 0 + }, + "multiple": { + "name": "multiple", + "label": "", + "value": 8 + }, + "method": { + "name": "method", + "label": "", + "value": "Nearest" + } + } + }, + "position": { + "x": 724.1719300146672, + "y": 2135.1501652410816 + } + } + ], + "edges": [ + { + "id": "5ca498a4-c8c8-4580-a396-0c984317205d-f50624ce-82bf-41d0-bdf7-8aab11a80d48-collapsed", + "type": "collapsed", + "source": "5ca498a4-c8c8-4580-a396-0c984317205d", + "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48" + }, + { + "id": "9ba14a1f-1675-4118-8b75-81c66c4b9d3a-aa9bcef8-aa90-49ea-b162-4bd613f5ea52-collapsed", + "type": "collapsed", + "source": "9ba14a1f-1675-4118-8b75-81c66c4b9d3a", + "target": "aa9bcef8-aa90-49ea-b162-4bd613f5ea52" + }, + { + "id": "eb8f6f8a-c7b1-4914-806e-045ee2717a35-f50624ce-82bf-41d0-bdf7-8aab11a80d48-collapsed", + "type": "collapsed", + "source": "eb8f6f8a-c7b1-4914-806e-045ee2717a35", + "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48" + }, + { + "id": "reactflow__edge-771bdf6a-0813-4099-a5d8-921a138754d4image-f7564dd2-9539-47f2-ac13-190804461f4eimage", + "type": "default", + "source": "771bdf6a-0813-4099-a5d8-921a138754d4", + "target": "f7564dd2-9539-47f2-ac13-190804461f4e", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-f7564dd2-9539-47f2-ac13-190804461f4eimage-1d887701-df21-4966-ae6e-a7d82307d7bdimage", + "type": "default", + "source": "f7564dd2-9539-47f2-ac13-190804461f4e", + "target": "1d887701-df21-4966-ae6e-a7d82307d7bd", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-5ca498a4-c8c8-4580-a396-0c984317205dwidth-f50624ce-82bf-41d0-bdf7-8aab11a80d48width", + "type": "default", + "source": "5ca498a4-c8c8-4580-a396-0c984317205d", + "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48", + "sourceHandle": "width", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-5ca498a4-c8c8-4580-a396-0c984317205dheight-f50624ce-82bf-41d0-bdf7-8aab11a80d48height", + "type": "default", + "source": "5ca498a4-c8c8-4580-a396-0c984317205d", + "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48", + "sourceHandle": "height", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-f50624ce-82bf-41d0-bdf7-8aab11a80d48noise-c3737554-8d87-48ff-a6f8-e71d2867f434noise", + "type": "default", + "source": "f50624ce-82bf-41d0-bdf7-8aab11a80d48", + "target": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "sourceHandle": "noise", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-5ca498a4-c8c8-4580-a396-0c984317205dlatents-c3737554-8d87-48ff-a6f8-e71d2867f434latents", + "type": "default", + "source": "5ca498a4-c8c8-4580-a396-0c984317205d", + "target": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-e8bf67fe-67de-4227-87eb-79e86afdfc74conditioning-c3737554-8d87-48ff-a6f8-e71d2867f434negative_conditioning", + "type": "default", + "source": "e8bf67fe-67de-4227-87eb-79e86afdfc74", + "target": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16bconditioning-c3737554-8d87-48ff-a6f8-e71d2867f434positive_conditioning", + "type": "default", + "source": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b", + "target": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dclip-63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16bclip", + "type": "default", + "source": "d8ace142-c05f-4f1d-8982-88dc7473958d", + "target": "63b6ab7e-5b05-4d1b-a3b1-42d8e53ce16b", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dclip-e8bf67fe-67de-4227-87eb-79e86afdfc74clip", + "type": "default", + "source": "d8ace142-c05f-4f1d-8982-88dc7473958d", + "target": "e8bf67fe-67de-4227-87eb-79e86afdfc74", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-1d887701-df21-4966-ae6e-a7d82307d7bdimage-ca1d020c-89a8-4958-880a-016d28775cfaimage", + "type": "default", + "source": "1d887701-df21-4966-ae6e-a7d82307d7bd", + "target": "ca1d020c-89a8-4958-880a-016d28775cfa", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-ca1d020c-89a8-4958-880a-016d28775cfacontrol-c3737554-8d87-48ff-a6f8-e71d2867f434control", + "type": "default", + "source": "ca1d020c-89a8-4958-880a-016d28775cfa", + "target": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "sourceHandle": "control", + "targetHandle": "control" + }, + { + "id": "reactflow__edge-c3737554-8d87-48ff-a6f8-e71d2867f434latents-3ed9b2ef-f4ec-40a7-94db-92e63b583ec0latents", + "type": "default", + "source": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "target": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dvae-3ed9b2ef-f4ec-40a7-94db-92e63b583ec0vae", + "type": "default", + "source": "d8ace142-c05f-4f1d-8982-88dc7473958d", + "target": "3ed9b2ef-f4ec-40a7-94db-92e63b583ec0", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-f7564dd2-9539-47f2-ac13-190804461f4eimage-5ca498a4-c8c8-4580-a396-0c984317205dimage", + "type": "default", + "source": "f7564dd2-9539-47f2-ac13-190804461f4e", + "target": "5ca498a4-c8c8-4580-a396-0c984317205d", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dunet-c3737554-8d87-48ff-a6f8-e71d2867f434unet", + "type": "default", + "source": "d8ace142-c05f-4f1d-8982-88dc7473958d", + "target": "c3737554-8d87-48ff-a6f8-e71d2867f434", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-d8ace142-c05f-4f1d-8982-88dc7473958dvae-5ca498a4-c8c8-4580-a396-0c984317205dvae", + "type": "default", + "source": "d8ace142-c05f-4f1d-8982-88dc7473958d", + "target": "5ca498a4-c8c8-4580-a396-0c984317205d", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-eb8f6f8a-c7b1-4914-806e-045ee2717a35value-f50624ce-82bf-41d0-bdf7-8aab11a80d48seed", + "type": "default", + "source": "eb8f6f8a-c7b1-4914-806e-045ee2717a35", + "target": "f50624ce-82bf-41d0-bdf7-8aab11a80d48", + "sourceHandle": "value", + "targetHandle": "seed" + }, + { + "id": "reactflow__edge-f7564dd2-9539-47f2-ac13-190804461f4ewidth-9ba14a1f-1675-4118-8b75-81c66c4b9d3aa", + "type": "default", + "source": "f7564dd2-9539-47f2-ac13-190804461f4e", + "target": "9ba14a1f-1675-4118-8b75-81c66c4b9d3a", + "sourceHandle": "width", + "targetHandle": "a" + }, + { + "id": "reactflow__edge-f7564dd2-9539-47f2-ac13-190804461f4eheight-9ba14a1f-1675-4118-8b75-81c66c4b9d3ab", + "type": "default", + "source": "f7564dd2-9539-47f2-ac13-190804461f4e", + "target": "9ba14a1f-1675-4118-8b75-81c66c4b9d3a", + "sourceHandle": "height", + "targetHandle": "b" + }, + { + "id": "reactflow__edge-9ba14a1f-1675-4118-8b75-81c66c4b9d3avalue-aa9bcef8-aa90-49ea-b162-4bd613f5ea52value", + "type": "default", + "source": "9ba14a1f-1675-4118-8b75-81c66c4b9d3a", + "target": "aa9bcef8-aa90-49ea-b162-4bd613f5ea52", + "sourceHandle": "value", + "targetHandle": "value" + }, + { + "id": "reactflow__edge-aa9bcef8-aa90-49ea-b162-4bd613f5ea52value-1d887701-df21-4966-ae6e-a7d82307d7bddetect_resolution", + "type": "default", + "source": "aa9bcef8-aa90-49ea-b162-4bd613f5ea52", + "target": "1d887701-df21-4966-ae6e-a7d82307d7bd", + "sourceHandle": "value", + "targetHandle": "detect_resolution" + }, + { + "id": "reactflow__edge-aa9bcef8-aa90-49ea-b162-4bd613f5ea52value-1d887701-df21-4966-ae6e-a7d82307d7bdimage_resolution", + "type": "default", + "source": "aa9bcef8-aa90-49ea-b162-4bd613f5ea52", + "target": "1d887701-df21-4966-ae6e-a7d82307d7bd", + "sourceHandle": "value", + "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 0000000000000000000000000000000000000000..1500f04af2bca2b9e1e0f8032cc6e687cefa08ec --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/FLUX Image to Image.json @@ -0,0 +1,408 @@ +{ + "name": "FLUX Image to Image", + "author": "InvokeAI", + "description": "A simple image-to-image workflow using a FLUX dev model. ", + "version": "1.1.0", + "contact": "", + "tags": "image2image, 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": "", + "value": { + "key": "d18d5575-96b6-4da3-b3d8-eb58308d6705", + "hash": "random:f2f9ed74acdfb4bf6fec200e780f6c25f8dd8764a35e65d425d606912fdf573a", + "name": "t5_bnb_int8_quantized_encoder", + "base": "any", + "type": "t5_encoder" + } + }, + "clip_embed_model": { + "name": "clip_embed_model", + "label": "", + "value": { + "key": "5a19d7e5-8d98-43cd-8a81-87515e4b3b4e", + "hash": "random:4bd08514c08fb6ff04088db9aeb45def3c488e8b5fd09a35f2cc4f2dc346f99f", + "name": "clip-vit-large-patch14", + "base": "any", + "type": "clip_embed" + } + }, + "vae_model": { + "name": "vae_model", + "label": "", + "value": { + "key": "9172beab-5c1d-43f0-b2f0-6e0b956710d9", + "hash": "random:c54dde288e5fa2e6137f1c92e9d611f598049e6f16e360207b6d96c9f5a67ba0", + "name": "FLUX.1-schnell_ae", + "base": "flux", + "type": "vae" + } + } + } + }, + "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 new file mode 100644 index 0000000000000000000000000000000000000000..481ba85e64e3948e0a1359f2ac15080af1d106f8 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Face Detailer with IP-Adapter & Canny (See Note in Details).json @@ -0,0 +1,1448 @@ +{ + "name": "Face Detailer with IP-Adapter & Canny (See Note in Details)", + "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.1.0", + "contact": "invoke@invoke.ai", + "tags": "face detailer, IP-Adapter, Canny", + "notes": "Set this image as the blur mask: https://i.imgur.com/Gxi61zP.png", + "exposedFields": [ + { + "nodeId": "c6359181-6479-40ec-bf3a-b7e8451683b8", + "fieldName": "model" + }, + { + "nodeId": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05", + "fieldName": "value" + }, + { + "nodeId": "f0de6c44-4515-4f79-bcc0-dee111bcfe31", + "fieldName": "value" + }, + { + "nodeId": "2c9bc2a6-6c03-4861-aad4-db884a7682f8", + "fieldName": "image" + }, + { + "nodeId": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955", + "fieldName": "image" + }, + { + "nodeId": "f60b6161-8f26-42f6-89ff-545e6011e501", + "fieldName": "control_model" + }, + { + "nodeId": "22b750db-b85e-486b-b278-ac983e329813", + "fieldName": "ip_adapter_model" + } + ], + "meta": { + "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)", + "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": "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)", + "value": { + "key": "1cc210bb-4d0a-4312-b36c-b5d46c43768e", + "hash": "blake3:3d669dffa7471b357b4df088b99ffb6bf4d4383d5e0ef1de5ec1c89728a3d5a5", + "name": "ip_adapter_sd15", + "base": "sd-1", + "type": "ip_adapter" + } + }, + "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.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": 2575, + "y": -250 + } + }, + { + "id": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955", + "type": "invocation", + "data": { + "id": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "img_resize", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "Blur Mask (see notes!)" + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "name": "height", + "label": "", + "value": 512 + }, + "resample_mode": { + "name": "resample_mode", + "label": "", + "value": "lanczos" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4423.179487179487, + "y": 482.66666666666674 + } + }, + { + "id": "2c9bc2a6-6c03-4861-aad4-db884a7682f8", + "type": "invocation", + "data": { + "id": "2c9bc2a6-6c03-4861-aad4-db884a7682f8", + "version": "1.0.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "image", + "inputs": { + "image": { + "name": "image", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 2050, + "y": -75 + } + }, + { + "id": "9ae34718-a17d-401d-9859-086896c29fca", + "type": "invocation", + "data": { + "id": "9ae34718-a17d-401d-9859-086896c29fca", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "face_off", + "inputs": { + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "face_id": { + "name": "face_id", + "label": "", + "value": 0 + }, + "minimum_confidence": { + "name": "minimum_confidence", + "label": "", + "value": 0.5 + }, + "x_offset": { + "name": "x_offset", + "label": "", + "value": 0 + }, + "y_offset": { + "name": "y_offset", + "label": "", + "value": 0 + }, + "padding": { + "name": "padding", + "label": "", + "value": 64 + }, + "chunk": { + "name": "chunk", + "label": "", + "value": false + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 2575, + "y": 200 + } + }, + { + "id": "50a8db6a-3796-4522-8547-53275efa4e7d", + "type": "invocation", + "data": { + "id": "50a8db6a-3796-4522-8547-53275efa4e7d", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "img_resize", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "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": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "type": "invocation", + "data": { + "id": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "version": "1.5.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 40 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 3 + }, + "denoising_start": { + "name": "denoising_start", + "label": "Original Image Percent", + "value": 0.2 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "dpmpp_2m_sde_k" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4597.554345564559, + "y": -265.6421598623905 + } + }, + { + "id": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42", + "type": "invocation", + "data": { + "id": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42", + "version": "1.0.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "noise", + "inputs": { + "seed": { + "name": "seed", + "label": "", + "value": 123451234 + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "name": "height", + "label": "", + "value": 512 + }, + "use_cpu": { + "name": "use_cpu", + "label": "", + "value": true + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4025, + "y": -175 + } + }, + { + "id": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "type": "invocation", + "data": { + "id": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "version": "1.0.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "lscale", + "inputs": { + "latents": { + "name": "latents", + "label": "" + }, + "scale_factor": { + "name": "scale_factor", + "label": "", + "value": 1.5 + }, + "mode": { + "name": "mode", + "label": "", + "value": "bilinear" + }, + "antialias": { + "name": "antialias", + "label": "", + "value": true + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 3075, + "y": -175 + } + }, + { + "id": "a7d14545-aa09-4b96-bfc5-40c009af9110", + "type": "invocation", + "data": { + "id": "a7d14545-aa09-4b96-bfc5-40c009af9110", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "img_paste", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "base_image": { + "name": "base_image", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "mask": { + "name": "mask", + "label": "" + }, + "x": { + "name": "x", + "label": "", + "value": 0 + }, + "y": { + "name": "y", + "label": "", + "value": 0 + }, + "crop": { + "name": "crop", + "label": "", + "value": false + } + }, + "isOpen": true, + "isIntermediate": false, + "useCache": true + }, + "position": { + "x": 6000, + "y": -200 + } + }, + { + "id": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef", + "type": "invocation", + "data": { + "id": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "img_resize", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "name": "height", + "label": "", + "value": 512 + }, + "resample_mode": { + "name": "resample_mode", + "label": "", + "value": "lanczos" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 5500, + "y": -225 + } + }, + { + "id": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05", + "type": "invocation", + "data": { + "id": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05", + "version": "1.0.1", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "float", + "inputs": { + "value": { + "name": "value", + "label": "Orignal Image Percentage", + "value": 0.4 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4025, + "y": -75 + } + }, + { + "id": "64712037-92e8-483f-9f6e-87588539c1b8", + "type": "invocation", + "data": { + "id": "64712037-92e8-483f-9f6e-87588539c1b8", + "version": "1.0.1", + "nodePack": "invokeai", + "label": "CFG Main", + "notes": "", + "type": "float", + "inputs": { + "value": { + "name": "value", + "label": "CFG Main", + "value": 6 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4035.2678120778373, + "y": 13.393127532980124 + } + }, + { + "id": "c865f39f-f830-4ed7-88a5-e935cfe050a9", + "type": "invocation", + "data": { + "id": "c865f39f-f830-4ed7-88a5-e935cfe050a9", + "version": "1.0.1", + "nodePack": "invokeai", + "label": "", + "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": 4025, + "y": -275 + } + }, + { + "id": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a", + "type": "invocation", + "data": { + "id": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "img_scale", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "scale_factor": { + "name": "scale_factor", + "label": "", + "value": 1.5 + }, + "resample_mode": { + "name": "resample_mode", + "label": "", + "value": "bicubic" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 3079.916484101321, + "y": 151.0148192064986 + } + }, + { + "id": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc", + "type": "invocation", + "data": { + "id": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "mask_combine", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "mask1": { + "name": "mask1", + "label": "" + }, + "mask2": { + "name": "mask2", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 5450, + "y": 250 + } + }, + { + "id": "77da4e4d-5778-4469-8449-ffed03d54bdb", + "type": "invocation", + "data": { + "id": "77da4e4d-5778-4469-8449-ffed03d54bdb", + "version": "1.2.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "img_blur", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "radius": { + "name": "radius", + "label": "Mask Blue", + "value": 150 + }, + "blur_type": { + "name": "blur_type", + "label": "", + "value": "gaussian" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 5000, + "y": 300 + } + }, + { + "id": "f0de6c44-4515-4f79-bcc0-dee111bcfe31", + "type": "invocation", + "data": { + "id": "f0de6c44-4515-4f79-bcc0-dee111bcfe31", + "version": "1.0.1", + "nodePack": "invokeai", + "label": "Face Detail Scale", + "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", + "type": "float", + "inputs": { + "value": { + "name": "value", + "label": "Face Detail Scale", + "value": 1.5 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 2578.2364832140506, + "y": 78.7948456497351 + } + } + ], + "edges": [ + { + "id": "f0de6c44-4515-4f79-bcc0-dee111bcfe31-2974e5b3-3d41-4b6f-9953-cd21e8f3a323-collapsed", + "type": "collapsed", + "source": "f0de6c44-4515-4f79-bcc0-dee111bcfe31", + "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323" + }, + { + "id": "de8b1a48-a2e4-42ca-90bb-66058bffd534-2974e5b3-3d41-4b6f-9953-cd21e8f3a323-collapsed", + "type": "collapsed", + "source": "de8b1a48-a2e4-42ca-90bb-66058bffd534", + "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323" + }, + { + "id": "50a8db6a-3796-4522-8547-53275efa4e7d-de8b1a48-a2e4-42ca-90bb-66058bffd534-collapsed", + "type": "collapsed", + "source": "50a8db6a-3796-4522-8547-53275efa4e7d", + "target": "de8b1a48-a2e4-42ca-90bb-66058bffd534" + }, + { + "id": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323-35623411-ba3a-4eaa-91fd-1e0fda0a5b42-collapsed", + "type": "collapsed", + "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42" + }, + { + "id": "c865f39f-f830-4ed7-88a5-e935cfe050a9-35623411-ba3a-4eaa-91fd-1e0fda0a5b42-collapsed", + "type": "collapsed", + "source": "c865f39f-f830-4ed7-88a5-e935cfe050a9", + "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42" + }, + { + "id": "reactflow__edge-2c9bc2a6-6c03-4861-aad4-db884a7682f8image-9ae34718-a17d-401d-9859-086896c29fcaimage", + "type": "default", + "source": "2c9bc2a6-6c03-4861-aad4-db884a7682f8", + "target": "9ae34718-a17d-401d-9859-086896c29fca", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcaimage-50a8db6a-3796-4522-8547-53275efa4e7dimage", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "50a8db6a-3796-4522-8547-53275efa4e7d", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-35623411-ba3a-4eaa-91fd-1e0fda0a5b42noise-bd06261d-a74a-4d1f-8374-745ed6194bc2noise", + "type": "default", + "source": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "noise", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-de8b1a48-a2e4-42ca-90bb-66058bffd534latents-2974e5b3-3d41-4b6f-9953-cd21e8f3a323latents", + "type": "default", + "source": "de8b1a48-a2e4-42ca-90bb-66058bffd534", + "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-2974e5b3-3d41-4b6f-9953-cd21e8f3a323latents-bd06261d-a74a-4d1f-8374-745ed6194bc2latents", + "type": "default", + "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-2974e5b3-3d41-4b6f-9953-cd21e8f3a323width-35623411-ba3a-4eaa-91fd-1e0fda0a5b42width", + "type": "default", + "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42", + "sourceHandle": "width", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-2974e5b3-3d41-4b6f-9953-cd21e8f3a323height-35623411-ba3a-4eaa-91fd-1e0fda0a5b42height", + "type": "default", + "source": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42", + "sourceHandle": "height", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-2c9bc2a6-6c03-4861-aad4-db884a7682f8image-a7d14545-aa09-4b96-bfc5-40c009af9110base_image", + "type": "default", + "source": "2c9bc2a6-6c03-4861-aad4-db884a7682f8", + "target": "a7d14545-aa09-4b96-bfc5-40c009af9110", + "sourceHandle": "image", + "targetHandle": "base_image" + }, + { + "id": "reactflow__edge-2224ed72-2453-4252-bd89-3085240e0b6fimage-ff8c23dc-da7c-45b7-b5c9-d984b12f02efimage", + "type": "default", + "source": "2224ed72-2453-4252-bd89-3085240e0b6f", + "target": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcawidth-ff8c23dc-da7c-45b7-b5c9-d984b12f02efwidth", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef", + "sourceHandle": "width", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcaheight-ff8c23dc-da7c-45b7-b5c9-d984b12f02efheight", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef", + "sourceHandle": "height", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcax-a7d14545-aa09-4b96-bfc5-40c009af9110x", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "a7d14545-aa09-4b96-bfc5-40c009af9110", + "sourceHandle": "x", + "targetHandle": "x" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcay-a7d14545-aa09-4b96-bfc5-40c009af9110y", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "a7d14545-aa09-4b96-bfc5-40c009af9110", + "sourceHandle": "y", + "targetHandle": "y" + }, + { + "id": "reactflow__edge-50a8db6a-3796-4522-8547-53275efa4e7dimage-de8b1a48-a2e4-42ca-90bb-66058bffd534image", + "type": "default", + "source": "50a8db6a-3796-4522-8547-53275efa4e7d", + "target": "de8b1a48-a2e4-42ca-90bb-66058bffd534", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05value-bd06261d-a74a-4d1f-8374-745ed6194bc2denoising_start", + "type": "default", + "source": "cdfa5ab0-b3e2-43ed-85bb-2ac4aa83bc05", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "value", + "targetHandle": "denoising_start" + }, + { + "id": "reactflow__edge-64712037-92e8-483f-9f6e-87588539c1b8value-bd06261d-a74a-4d1f-8374-745ed6194bc2cfg_scale", + "type": "default", + "source": "64712037-92e8-483f-9f6e-87588539c1b8", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "value", + "targetHandle": "cfg_scale" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcawidth-c59e815c-1f3a-4e2b-b6b8-66f4b005e955width", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955", + "sourceHandle": "width", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcaheight-c59e815c-1f3a-4e2b-b6b8-66f4b005e955height", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955", + "sourceHandle": "height", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-ff8c23dc-da7c-45b7-b5c9-d984b12f02efimage-a7d14545-aa09-4b96-bfc5-40c009af9110image", + "type": "default", + "source": "ff8c23dc-da7c-45b7-b5c9-d984b12f02ef", + "target": "a7d14545-aa09-4b96-bfc5-40c009af9110", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-bd06261d-a74a-4d1f-8374-745ed6194bc2latents-2224ed72-2453-4252-bd89-3085240e0b6flatents", + "type": "default", + "source": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "target": "2224ed72-2453-4252-bd89-3085240e0b6f", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-c865f39f-f830-4ed7-88a5-e935cfe050a9value-35623411-ba3a-4eaa-91fd-1e0fda0a5b42seed", + "type": "default", + "source": "c865f39f-f830-4ed7-88a5-e935cfe050a9", + "target": "35623411-ba3a-4eaa-91fd-1e0fda0a5b42", + "sourceHandle": "value", + "targetHandle": "seed" + }, + { + "id": "reactflow__edge-f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65conditioning-bd06261d-a74a-4d1f-8374-745ed6194bc2positive_conditioning", + "type": "default", + "source": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-44f2c190-eb03-460d-8d11-a94d13b33f19conditioning-bd06261d-a74a-4d1f-8374-745ed6194bc2negative_conditioning", + "type": "default", + "source": "44f2c190-eb03-460d-8d11-a94d13b33f19", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-22b750db-b85e-486b-b278-ac983e329813ip_adapter-bd06261d-a74a-4d1f-8374-745ed6194bc2ip_adapter", + "type": "default", + "source": "22b750db-b85e-486b-b278-ac983e329813", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "ip_adapter", + "targetHandle": "ip_adapter" + }, + { + "id": "reactflow__edge-50a8db6a-3796-4522-8547-53275efa4e7dimage-4bd4ae80-567f-4366-b8c6-3bb06f4fb46aimage", + "type": "default", + "source": "50a8db6a-3796-4522-8547-53275efa4e7d", + "target": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-4bd4ae80-567f-4366-b8c6-3bb06f4fb46aimage-22b750db-b85e-486b-b278-ac983e329813image", + "type": "default", + "source": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a", + "target": "22b750db-b85e-486b-b278-ac983e329813", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-8fe598c6-d447-44fa-a165-4975af77d080image-f60b6161-8f26-42f6-89ff-545e6011e501image", + "type": "default", + "source": "8fe598c6-d447-44fa-a165-4975af77d080", + "target": "f60b6161-8f26-42f6-89ff-545e6011e501", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-4bd4ae80-567f-4366-b8c6-3bb06f4fb46aimage-8fe598c6-d447-44fa-a165-4975af77d080image", + "type": "default", + "source": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a", + "target": "8fe598c6-d447-44fa-a165-4975af77d080", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-f60b6161-8f26-42f6-89ff-545e6011e501control-bd06261d-a74a-4d1f-8374-745ed6194bc2control", + "type": "default", + "source": "f60b6161-8f26-42f6-89ff-545e6011e501", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "control", + "targetHandle": "control" + }, + { + "id": "reactflow__edge-c59e815c-1f3a-4e2b-b6b8-66f4b005e955image-381d5b6a-f044-48b0-bc07-6138fbfa8dfcmask2", + "type": "default", + "source": "c59e815c-1f3a-4e2b-b6b8-66f4b005e955", + "target": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc", + "sourceHandle": "image", + "targetHandle": "mask2" + }, + { + "id": "reactflow__edge-381d5b6a-f044-48b0-bc07-6138fbfa8dfcimage-a7d14545-aa09-4b96-bfc5-40c009af9110mask", + "type": "default", + "source": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc", + "target": "a7d14545-aa09-4b96-bfc5-40c009af9110", + "sourceHandle": "image", + "targetHandle": "mask" + }, + { + "id": "reactflow__edge-77da4e4d-5778-4469-8449-ffed03d54bdbimage-381d5b6a-f044-48b0-bc07-6138fbfa8dfcmask1", + "type": "default", + "source": "77da4e4d-5778-4469-8449-ffed03d54bdb", + "target": "381d5b6a-f044-48b0-bc07-6138fbfa8dfc", + "sourceHandle": "image", + "targetHandle": "mask1" + }, + { + "id": "reactflow__edge-9ae34718-a17d-401d-9859-086896c29fcamask-77da4e4d-5778-4469-8449-ffed03d54bdbimage", + "type": "default", + "source": "9ae34718-a17d-401d-9859-086896c29fca", + "target": "77da4e4d-5778-4469-8449-ffed03d54bdb", + "sourceHandle": "mask", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-f0de6c44-4515-4f79-bcc0-dee111bcfe31value-2974e5b3-3d41-4b6f-9953-cd21e8f3a323scale_factor", + "type": "default", + "source": "f0de6c44-4515-4f79-bcc0-dee111bcfe31", + "target": "2974e5b3-3d41-4b6f-9953-cd21e8f3a323", + "sourceHandle": "value", + "targetHandle": "scale_factor" + }, + { + "id": "reactflow__edge-f0de6c44-4515-4f79-bcc0-dee111bcfe31value-4bd4ae80-567f-4366-b8c6-3bb06f4fb46ascale_factor", + "type": "default", + "source": "f0de6c44-4515-4f79-bcc0-dee111bcfe31", + "target": "4bd4ae80-567f-4366-b8c6-3bb06f4fb46a", + "sourceHandle": "value", + "targetHandle": "scale_factor" + }, + { + "id": "reactflow__edge-c6359181-6479-40ec-bf3a-b7e8451683b8vae-2224ed72-2453-4252-bd89-3085240e0b6fvae", + "type": "default", + "source": "c6359181-6479-40ec-bf3a-b7e8451683b8", + "target": "2224ed72-2453-4252-bd89-3085240e0b6f", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-c6359181-6479-40ec-bf3a-b7e8451683b8clip-44f2c190-eb03-460d-8d11-a94d13b33f19clip", + "type": "default", + "source": "c6359181-6479-40ec-bf3a-b7e8451683b8", + "target": "44f2c190-eb03-460d-8d11-a94d13b33f19", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-c6359181-6479-40ec-bf3a-b7e8451683b8clip-f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65clip", + "type": "default", + "source": "c6359181-6479-40ec-bf3a-b7e8451683b8", + "target": "f4d15b64-c4a6-42a5-90fc-e4ed07a0ca65", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-c6359181-6479-40ec-bf3a-b7e8451683b8unet-bd06261d-a74a-4d1f-8374-745ed6194bc2unet", + "type": "default", + "source": "c6359181-6479-40ec-bf3a-b7e8451683b8", + "target": "bd06261d-a74a-4d1f-8374-745ed6194bc2", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-c6359181-6479-40ec-bf3a-b7e8451683b8vae-de8b1a48-a2e4-42ca-90bb-66058bffd534vae", + "type": "default", + "source": "c6359181-6479-40ec-bf3a-b7e8451683b8", + "target": "de8b1a48-a2e4-42ca-90bb-66058bffd534", + "sourceHandle": "vae", + "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 0000000000000000000000000000000000000000..0320cfd30dcbb676bbf74145719c0f5820ed0a1f --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Flux Text to Image.json @@ -0,0 +1,344 @@ +{ + "name": "FLUX Text to Image", + "author": "InvokeAI", + "description": "A simple text-to-image workflow using FLUX dev or schnell models.", + "version": "1.1.0", + "contact": "", + "tags": "text2image, flux", + "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": "", + "value": { + "key": "d18d5575-96b6-4da3-b3d8-eb58308d6705", + "hash": "random:f2f9ed74acdfb4bf6fec200e780f6c25f8dd8764a35e65d425d606912fdf573a", + "name": "t5_bnb_int8_quantized_encoder", + "base": "any", + "type": "t5_encoder" + } + }, + "clip_embed_model": { + "name": "clip_embed_model", + "label": "", + "value": { + "key": "5a19d7e5-8d98-43cd-8a81-87515e4b3b4e", + "hash": "random:4bd08514c08fb6ff04088db9aeb45def3c488e8b5fd09a35f2cc4f2dc346f99f", + "name": "clip-vit-large-patch14", + "base": "any", + "type": "clip_embed" + } + }, + "vae_model": { + "name": "vae_model", + "label": "", + "value": { + "key": "9172beab-5c1d-43f0-b2f0-6e0b956710d9", + "hash": "random:c54dde288e5fa2e6137f1c92e9d611f598049e6f16e360207b6d96c9f5a67ba0", + "name": "FLUX.1-schnell_ae", + "base": "flux", + "type": "vae" + } + } + } + }, + "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 new file mode 100644 index 0000000000000000000000000000000000000000..3ff99b5eb3638a47f71e35f70ab454cae221bf45 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Multi ControlNet (Canny & Depth).json @@ -0,0 +1,1017 @@ +{ + "name": "Multi ControlNet (Canny & Depth)", + "author": "InvokeAI", + "description": "A sample workflow using canny & depth ControlNets to guide the generation process. ", + "version": "2.1.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" + }, + { + "nodeId": "d204d184-f209-4fae-a0a1-d152800844e1", + "fieldName": "control_model" + }, + { + "nodeId": "a33199c2-8340-401e-b8a2-42ffa875fc1c", + "fieldName": "control_model" + } + ], + "meta": { + "version": "3.0.0", + "category": "default" + }, + "nodes": [ + { + "id": "9db25398-c869-4a63-8815-c6559341ef12", + "type": "invocation", + "data": { + "id": "9db25398-c869-4a63-8815-c6559341ef12", + "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": 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": "" + }, + "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": 4095.757337055795, + "y": -455.63440891935863 + } + }, + { + "id": "018b1214-c2af-43a7-9910-fb687c6726d7", + "type": "invocation", + "data": { + "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": "", + "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": 1 + }, + "begin_step_percent": { + "name": "begin_step_percent", + "label": "", + "value": 0 + }, + "end_step_percent": { + "name": "end_step_percent", + "label": "", + "value": 1 + }, + "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": 4479.68542130465, + "y": -618.4221638099414 + } + }, + { + "id": "7ce68934-3419-42d4-ac70-82cfc9397306", + "type": "invocation", + "data": { + "id": "7ce68934-3419-42d4-ac70-82cfc9397306", + "version": "1.2.0", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "compel", + "inputs": { + "prompt": { + "name": "prompt", + "label": "Positive Prompt", + "value": "" + }, + "clip": { + "name": "clip", + "label": "" + }, + "mask": { + "name": "mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4075, + "y": -1125 + } + }, + { + "id": "54486974-835b-4d81-8f82-05f9f32ce9e9", + "type": "invocation", + "data": { + "id": "54486974-835b-4d81-8f82-05f9f32ce9e9", + "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": 3600, + "y": -1000 + } + }, + { + "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1", + "type": "invocation", + "data": { + "id": "273e3f96-49ea-4dc5-9d5b-9660390f14e1", + "version": "1.2.0", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "compel", + "inputs": { + "prompt": { + "name": "prompt", + "label": "Negative Prompt", + "value": "" + }, + "clip": { + "name": "clip", + "label": "" + }, + "mask": { + "name": "mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4075, + "y": -825 + } + }, + { + "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c", + "type": "invocation", + "data": { + "id": "a33199c2-8340-401e-b8a2-42ffa875fc1c", + "version": "1.1.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "controlnet", + "inputs": { + "image": { + "name": "image", + "label": "" + }, + "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" + } + }, + "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": "", + "value": 1 + }, + "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": 4477.604342844504, + "y": -49.39005411272677 + } + }, + { + "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e", + "type": "invocation", + "data": { + "id": "8e860e51-5045-456e-bf04-9a62a2a5c49e", + "version": "1.0.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "image", + "inputs": { + "image": { + "name": "image", + "label": "Depth Input Image" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 3666.135718057363, + "y": 186.66887319822808 + } + }, + { + "id": "c4b23e64-7986-40c4-9cad-46327b12e204", + "type": "invocation", + "data": { + "id": "c4b23e64-7986-40c4-9cad-46327b12e204", + "version": "1.0.2", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "image", + "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": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4875, + "y": -575 + } + }, + { + "id": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "type": "invocation", + "data": { + "id": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "version": "1.5.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 10 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 7.5 + }, + "denoising_start": { + "name": "denoising_start", + "label": "", + "value": 0 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "euler" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 5274.672987098195, + "y": -823.0752416664332 + } + }, + { + "id": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b", + "type": "invocation", + "data": { + "id": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b", + "version": "1.0.2", + "label": "", + "notes": "", + "type": "noise", + "inputs": { + "seed": { + "name": "seed", + "label": "", + "value": 0 + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "name": "height", + "label": "", + "value": 512 + }, + "use_cpu": { + "name": "use_cpu", + "label": "", + "value": true + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 4875, + "y": -675 + } + }, + { + "id": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce", + "type": "invocation", + "data": { + "id": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce", + "version": "1.0.1", + "label": "", + "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": 4875, + "y": -750 + } + }, + { + "id": "5d675ae3-e9c7-418d-96fe-09cd8763f2a2", + "type": "invocation", + "data": { + "id": "5d675ae3-e9c7-418d-96fe-09cd8763f2a2", + "type": "integer_math", + "version": "1.0.1", + "label": "", + "notes": "", + "isOpen": false, + "isIntermediate": true, + "useCache": true, + "inputs": { + "operation": { + "name": "operation", + "label": "", + "value": "MIN" + }, + "a": { + "name": "a", + "label": "", + "value": 1 + }, + "b": { + "name": "b", + "label": "", + "value": 1 + } + } + }, + "position": { + "x": 3673.795544334132, + "y": 402.7899296636469 + } + }, + { + "id": "1170017d-4c61-496f-897e-07e44725fc66", + "type": "invocation", + "data": { + "id": "1170017d-4c61-496f-897e-07e44725fc66", + "type": "float_to_int", + "version": "1.0.1", + "label": "", + "notes": "", + "isOpen": false, + "isIntermediate": true, + "useCache": true, + "inputs": { + "value": { + "name": "value", + "label": "", + "value": 0 + }, + "multiple": { + "name": "multiple", + "label": "", + "value": 8 + }, + "method": { + "name": "method", + "label": "", + "value": "Nearest" + } + } + }, + "position": { + "x": 3672.6528854992052, + "y": 451.92425956549766 + } + }, + { + "id": "6ff9f8b4-20e4-4230-8a38-37de9f756e8c", + "type": "invocation", + "data": { + "id": "6ff9f8b4-20e4-4230-8a38-37de9f756e8c", + "type": "integer_math", + "version": "1.0.1", + "label": "", + "notes": "", + "isOpen": false, + "isIntermediate": true, + "useCache": true, + "inputs": { + "operation": { + "name": "operation", + "label": "", + "value": "MIN" + }, + "a": { + "name": "a", + "label": "", + "value": 1 + }, + "b": { + "name": "b", + "label": "", + "value": 1 + } + } + }, + "position": { + "x": 3638.3731204514042, + "y": -199.39127634275573 + } + }, + { + "id": "8d481737-42b5-48d5-9ab4-2e18bf3116e2", + "type": "invocation", + "data": { + "id": "8d481737-42b5-48d5-9ab4-2e18bf3116e2", + "type": "float_to_int", + "version": "1.0.1", + "label": "", + "notes": "", + "isOpen": false, + "isIntermediate": true, + "useCache": true, + "inputs": { + "value": { + "name": "value", + "label": "", + "value": 0 + }, + "multiple": { + "name": "multiple", + "label": "", + "value": 8 + }, + "method": { + "name": "method", + "label": "", + "value": "Nearest" + } + } + }, + "position": { + "x": 3640.658438121258, + "y": -144.5436522662713 + } + } + ], + "edges": [ + { + "id": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce-2e77a0a1-db6a-47a2-a8bf-1e003be6423b-collapsed", + "type": "collapsed", + "source": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce", + "target": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b" + }, + { + "id": "6ff9f8b4-20e4-4230-8a38-37de9f756e8c-8d481737-42b5-48d5-9ab4-2e18bf3116e2-collapsed", + "type": "collapsed", + "source": "6ff9f8b4-20e4-4230-8a38-37de9f756e8c", + "target": "8d481737-42b5-48d5-9ab4-2e18bf3116e2" + }, + { + "id": "5d675ae3-e9c7-418d-96fe-09cd8763f2a2-1170017d-4c61-496f-897e-07e44725fc66-collapsed", + "type": "collapsed", + "source": "5d675ae3-e9c7-418d-96fe-09cd8763f2a2", + "target": "1170017d-4c61-496f-897e-07e44725fc66" + }, + { + "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9clip-7ce68934-3419-42d4-ac70-82cfc9397306clip", + "type": "default", + "source": "54486974-835b-4d81-8f82-05f9f32ce9e9", + "target": "7ce68934-3419-42d4-ac70-82cfc9397306", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9clip-273e3f96-49ea-4dc5-9d5b-9660390f14e1clip", + "type": "default", + "source": "54486974-835b-4d81-8f82-05f9f32ce9e9", + "target": "273e3f96-49ea-4dc5-9d5b-9660390f14e1", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-a33199c2-8340-401e-b8a2-42ffa875fc1ccontrol-ca4d5059-8bfb-447f-b415-da0faba5a143item", + "type": "default", + "source": "a33199c2-8340-401e-b8a2-42ffa875fc1c", + "target": "ca4d5059-8bfb-447f-b415-da0faba5a143", + "sourceHandle": "control", + "targetHandle": "item" + }, + { + "id": "reactflow__edge-d204d184-f209-4fae-a0a1-d152800844e1control-ca4d5059-8bfb-447f-b415-da0faba5a143item", + "type": "default", + "source": "d204d184-f209-4fae-a0a1-d152800844e1", + "target": "ca4d5059-8bfb-447f-b415-da0faba5a143", + "sourceHandle": "control", + "targetHandle": "item" + }, + { + "id": "reactflow__edge-8e860e51-5045-456e-bf04-9a62a2a5c49eimage-018b1214-c2af-43a7-9910-fb687c6726d7image", + "type": "default", + "source": "8e860e51-5045-456e-bf04-9a62a2a5c49e", + "target": "018b1214-c2af-43a7-9910-fb687c6726d7", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-018b1214-c2af-43a7-9910-fb687c6726d7image-a33199c2-8340-401e-b8a2-42ffa875fc1cimage", + "type": "default", + "source": "018b1214-c2af-43a7-9910-fb687c6726d7", + "target": "a33199c2-8340-401e-b8a2-42ffa875fc1c", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-c4b23e64-7986-40c4-9cad-46327b12e204image-c826ba5e-9676-4475-b260-07b85e88753cimage", + "type": "default", + "source": "c4b23e64-7986-40c4-9cad-46327b12e204", + "target": "c826ba5e-9676-4475-b260-07b85e88753c", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-c826ba5e-9676-4475-b260-07b85e88753cimage-d204d184-f209-4fae-a0a1-d152800844e1image", + "type": "default", + "source": "c826ba5e-9676-4475-b260-07b85e88753c", + "target": "d204d184-f209-4fae-a0a1-d152800844e1", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9vae-9db25398-c869-4a63-8815-c6559341ef12vae", + "type": "default", + "source": "54486974-835b-4d81-8f82-05f9f32ce9e9", + "target": "9db25398-c869-4a63-8815-c6559341ef12", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-ac481b7f-08bf-4a9d-9e0c-3a82ea5243celatents-9db25398-c869-4a63-8815-c6559341ef12latents", + "type": "default", + "source": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "target": "9db25398-c869-4a63-8815-c6559341ef12", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-ca4d5059-8bfb-447f-b415-da0faba5a143collection-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cecontrol", + "type": "default", + "source": "ca4d5059-8bfb-447f-b415-da0faba5a143", + "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "sourceHandle": "collection", + "targetHandle": "control" + }, + { + "id": "reactflow__edge-54486974-835b-4d81-8f82-05f9f32ce9e9unet-ac481b7f-08bf-4a9d-9e0c-3a82ea5243ceunet", + "type": "default", + "source": "54486974-835b-4d81-8f82-05f9f32ce9e9", + "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-273e3f96-49ea-4dc5-9d5b-9660390f14e1conditioning-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cenegative_conditioning", + "type": "default", + "source": "273e3f96-49ea-4dc5-9d5b-9660390f14e1", + "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-7ce68934-3419-42d4-ac70-82cfc9397306conditioning-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cepositive_conditioning", + "type": "default", + "source": "7ce68934-3419-42d4-ac70-82cfc9397306", + "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-2e77a0a1-db6a-47a2-a8bf-1e003be6423bnoise-ac481b7f-08bf-4a9d-9e0c-3a82ea5243cenoise", + "type": "default", + "source": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b", + "target": "ac481b7f-08bf-4a9d-9e0c-3a82ea5243ce", + "sourceHandle": "noise", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-8b260b4d-3fd6-44d4-b1be-9f0e43c628cevalue-2e77a0a1-db6a-47a2-a8bf-1e003be6423bseed", + "type": "default", + "source": "8b260b4d-3fd6-44d4-b1be-9f0e43c628ce", + "target": "2e77a0a1-db6a-47a2-a8bf-1e003be6423b", + "sourceHandle": "value", + "targetHandle": "seed" + }, + { + "id": "reactflow__edge-8e860e51-5045-456e-bf04-9a62a2a5c49ewidth-5d675ae3-e9c7-418d-96fe-09cd8763f2a2a", + "type": "default", + "source": "8e860e51-5045-456e-bf04-9a62a2a5c49e", + "target": "5d675ae3-e9c7-418d-96fe-09cd8763f2a2", + "sourceHandle": "width", + "targetHandle": "a" + }, + { + "id": "reactflow__edge-8e860e51-5045-456e-bf04-9a62a2a5c49eheight-5d675ae3-e9c7-418d-96fe-09cd8763f2a2b", + "type": "default", + "source": "8e860e51-5045-456e-bf04-9a62a2a5c49e", + "target": "5d675ae3-e9c7-418d-96fe-09cd8763f2a2", + "sourceHandle": "height", + "targetHandle": "b" + }, + { + "id": "reactflow__edge-5d675ae3-e9c7-418d-96fe-09cd8763f2a2value-1170017d-4c61-496f-897e-07e44725fc66value", + "type": "default", + "source": "5d675ae3-e9c7-418d-96fe-09cd8763f2a2", + "target": "1170017d-4c61-496f-897e-07e44725fc66", + "sourceHandle": "value", + "targetHandle": "value" + }, + { + "id": "reactflow__edge-1170017d-4c61-496f-897e-07e44725fc66value-018b1214-c2af-43a7-9910-fb687c6726d7detect_resolution", + "type": "default", + "source": "1170017d-4c61-496f-897e-07e44725fc66", + "target": "018b1214-c2af-43a7-9910-fb687c6726d7", + "sourceHandle": "value", + "targetHandle": "detect_resolution" + }, + { + "id": "reactflow__edge-1170017d-4c61-496f-897e-07e44725fc66value-018b1214-c2af-43a7-9910-fb687c6726d7image_resolution", + "type": "default", + "source": "1170017d-4c61-496f-897e-07e44725fc66", + "target": "018b1214-c2af-43a7-9910-fb687c6726d7", + "sourceHandle": "value", + "targetHandle": "image_resolution" + }, + { + "id": "reactflow__edge-c4b23e64-7986-40c4-9cad-46327b12e204width-6ff9f8b4-20e4-4230-8a38-37de9f756e8ca", + "type": "default", + "source": "c4b23e64-7986-40c4-9cad-46327b12e204", + "target": "6ff9f8b4-20e4-4230-8a38-37de9f756e8c", + "sourceHandle": "width", + "targetHandle": "a" + }, + { + "id": "reactflow__edge-c4b23e64-7986-40c4-9cad-46327b12e204height-6ff9f8b4-20e4-4230-8a38-37de9f756e8cb", + "type": "default", + "source": "c4b23e64-7986-40c4-9cad-46327b12e204", + "target": "6ff9f8b4-20e4-4230-8a38-37de9f756e8c", + "sourceHandle": "height", + "targetHandle": "b" + }, + { + "id": "reactflow__edge-6ff9f8b4-20e4-4230-8a38-37de9f756e8cvalue-8d481737-42b5-48d5-9ab4-2e18bf3116e2value", + "type": "default", + "source": "6ff9f8b4-20e4-4230-8a38-37de9f756e8c", + "target": "8d481737-42b5-48d5-9ab4-2e18bf3116e2", + "sourceHandle": "value", + "targetHandle": "value" + }, + { + "id": "reactflow__edge-8d481737-42b5-48d5-9ab4-2e18bf3116e2value-c826ba5e-9676-4475-b260-07b85e88753cdetect_resolution", + "type": "default", + "source": "8d481737-42b5-48d5-9ab4-2e18bf3116e2", + "target": "c826ba5e-9676-4475-b260-07b85e88753c", + "sourceHandle": "value", + "targetHandle": "detect_resolution" + }, + { + "id": "reactflow__edge-8d481737-42b5-48d5-9ab4-2e18bf3116e2value-c826ba5e-9676-4475-b260-07b85e88753cimage_resolution", + "type": "default", + "source": "8d481737-42b5-48d5-9ab4-2e18bf3116e2", + "target": "c826ba5e-9676-4475-b260-07b85e88753c", + "sourceHandle": "value", + "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 0000000000000000000000000000000000000000..7bc2810c75b57fcf3091f6b572f1490018154a51 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SD1.5.json @@ -0,0 +1,1430 @@ +{ + "name": "MultiDiffusion SD1.5", + "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": "tiled, upscaling, sdxl", + "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" + }, + "id": "e5b5fb01-8906-463a-963a-402dbc42f79b", + "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": "", + "value": { + "key": "e7b402e5-62e5-4acb-8c39-bee6bdb758ab", + "hash": "c8659e796168d076368256b57edbc1b48d6dafc1712f1bb37cc57c7c06889a6b", + "name": "526mix", + "base": "sd-1", + "type": "main" + } + } + } + }, + "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", + "value": { + "image_name": "ee7009f7-a35d-488b-a2a6-21237ef5ae05.png" + } + }, + "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", + "value": { + "key": "20645e4d-ef97-4c5a-9243-b834a3483925", + "hash": "f0812e13758f91baf4e54b7dbb707b70642937d3b2098cd2b94cc36d3eba308e", + "name": "tile", + "base": "sd-1", + "type": "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" + } + ] +} \ No newline at end of file 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 0000000000000000000000000000000000000000..876ca6f8e6f6a030ded067020cb7f50e40b581a9 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/MultiDiffusion SDXL.json @@ -0,0 +1,1645 @@ +{ + "name": "MultiDiffusion SDXL", + "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": "tiled, upscaling, sdxl", + "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" + }, + "id": "dd607062-9e1b-48b9-89ad-9762cdfbb8f4", + "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", + "value": { + "key": "74f4651f-0ace-4b7b-b616-e98360257797", + "hash": "blake3:167a5b84583aaed3e5c8d660b45830e82e1c602743c689d3c27773c6c8b85b4a", + "name": "controlnet-tile-sdxl-1.0", + "base": "sdxl", + "type": "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": "", + "value": { + "key": "ff926845-090e-4d46-b81e-30289ee47474", + "hash": "9705ab1c31fa96b308734214fb7571a958621c7a9247eed82b7d277145f8d9fa", + "name": "VAEFix", + "base": "sdxl", + "type": "vae" + } + } + } + }, + "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", + "value": { + "key": "ab191f73-68d2-492c-8aec-b438a8cf0f45", + "hash": "blake3:2d50e940627e3bf555f015280ec0976d5c1fa100f7bc94e95ffbfc770e98b6fe", + "name": "CustomXLv7", + "base": "sdxl", + "type": "main" + } + } + } + }, + "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" + } + ] +} \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..de902bc77eed16ae33bc874f26faaeb286f64f52 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Prompt from File.json @@ -0,0 +1,515 @@ +{ + "name": "Prompt from File", + "author": "InvokeAI", + "description": "Sample workflow using Prompt from File node", + "version": "2.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" + }, + { + "nodeId": "1b7e0df8-8589-4915-a4ea-c0088f15d642", + "fieldName": "pre_prompt" + }, + { + "nodeId": "1b7e0df8-8589-4915-a4ea-c0088f15d642", + "fieldName": "post_prompt" + }, + { + "nodeId": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77", + "fieldName": "width" + }, + { + "nodeId": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77", + "fieldName": "height" + }, + { + "nodeId": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1", + "fieldName": "board" + } + ], + "meta": { + "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.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": -200 + } + }, + { + "id": "1b7e0df8-8589-4915-a4ea-c0088f15d642", + "type": "invocation", + "data": { + "id": "1b7e0df8-8589-4915-a4ea-c0088f15d642", + "version": "1.0.2", + "nodePack": "invokeai", + "label": "Prompts from File", + "notes": "", + "type": "prompt_from_file", + "inputs": { + "file_path": { + "name": "file_path", + "label": "Prompts File Path", + "value": "" + }, + "pre_prompt": { + "name": "pre_prompt", + "label": "", + "value": "" + }, + "post_prompt": { + "name": "post_prompt", + "label": "", + "value": "" + }, + "start_line": { + "name": "start_line", + "label": "", + "value": 1 + }, + "max_prompts": { + "name": "max_prompts", + "label": "", + "value": 1 + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 475, + "y": -400 + } + }, + { + "id": "1b89067c-3f6b-42c8-991f-e3055789b251", + "type": "invocation", + "data": { + "id": "1b89067c-3f6b-42c8-991f-e3055789b251", + "version": "1.1.0", + "label": "", + "notes": "", + "type": "iterate", + "inputs": { + "collection": { + "name": "collection", + "label": "" + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 925, + "y": -400 + } + }, + { + "id": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77", + "type": "invocation", + "data": { + "id": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77", + "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": 512 + }, + "use_cpu": { + "name": "use_cpu", + "label": "", + "value": true + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 925, + "y": 25 + } + }, + { + "id": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5", + "type": "invocation", + "data": { + "id": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5", + "version": "1.0.1", + "nodePack": "invokeai", + "label": "", + "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": 925, + "y": -50 + } + }, + { + "id": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e", + "type": "invocation", + "data": { + "id": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e", + "version": "1.5.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 30 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 7.5 + }, + "denoising_start": { + "name": "denoising_start", + "label": "", + "value": 0 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "euler" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1570.9941088179146, + "y": -407.6505491604564 + } + } + ], + "edges": [ + { + "id": "1b89067c-3f6b-42c8-991f-e3055789b251-fc9d0e35-a6de-4a19-84e1-c72497c823f6-collapsed", + "type": "collapsed", + "source": "1b89067c-3f6b-42c8-991f-e3055789b251", + "target": "fc9d0e35-a6de-4a19-84e1-c72497c823f6" + }, + { + "id": "reactflow__edge-1b7e0df8-8589-4915-a4ea-c0088f15d642collection-1b89067c-3f6b-42c8-991f-e3055789b251collection", + "type": "default", + "source": "1b7e0df8-8589-4915-a4ea-c0088f15d642", + "target": "1b89067c-3f6b-42c8-991f-e3055789b251", + "sourceHandle": "collection", + "targetHandle": "collection" + }, + { + "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426clip-fc9d0e35-a6de-4a19-84e1-c72497c823f6clip", + "type": "default", + "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426", + "target": "fc9d0e35-a6de-4a19-84e1-c72497c823f6", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-1b89067c-3f6b-42c8-991f-e3055789b251item-fc9d0e35-a6de-4a19-84e1-c72497c823f6prompt", + "type": "default", + "source": "1b89067c-3f6b-42c8-991f-e3055789b251", + "target": "fc9d0e35-a6de-4a19-84e1-c72497c823f6", + "sourceHandle": "item", + "targetHandle": "prompt" + }, + { + "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426clip-c2eaf1ba-5708-4679-9e15-945b8b432692clip", + "type": "default", + "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426", + "target": "c2eaf1ba-5708-4679-9e15-945b8b432692", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5value-0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77seed", + "type": "default", + "source": "dfc20e07-7aef-4fc0-a3a1-7bf68ec6a4e5", + "target": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77", + "sourceHandle": "value", + "targetHandle": "seed" + }, + { + "id": "reactflow__edge-fc9d0e35-a6de-4a19-84e1-c72497c823f6conditioning-2fb1577f-0a56-4f12-8711-8afcaaaf1d5epositive_conditioning", + "type": "default", + "source": "fc9d0e35-a6de-4a19-84e1-c72497c823f6", + "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-c2eaf1ba-5708-4679-9e15-945b8b432692conditioning-2fb1577f-0a56-4f12-8711-8afcaaaf1d5enegative_conditioning", + "type": "default", + "source": "c2eaf1ba-5708-4679-9e15-945b8b432692", + "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77noise-2fb1577f-0a56-4f12-8711-8afcaaaf1d5enoise", + "type": "default", + "source": "0eb5f3f5-1b91-49eb-9ef0-41d67c7eae77", + "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e", + "sourceHandle": "noise", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426unet-2fb1577f-0a56-4f12-8711-8afcaaaf1d5eunet", + "type": "default", + "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426", + "target": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-2fb1577f-0a56-4f12-8711-8afcaaaf1d5elatents-491ec988-3c77-4c37-af8a-39a0c4e7a2a1latents", + "type": "default", + "source": "2fb1577f-0a56-4f12-8711-8afcaaaf1d5e", + "target": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-d6353b7f-b447-4e17-8f2e-80a88c91d426vae-491ec988-3c77-4c37-af8a-39a0c4e7a2a1vae", + "type": "default", + "source": "d6353b7f-b447-4e17-8f2e-80a88c91d426", + "target": "491ec988-3c77-4c37-af8a-39a0c4e7a2a1", + "sourceHandle": "vae", + "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 new file mode 100644 index 0000000000000000000000000000000000000000..3901ead1cd5bca1afec44a133c9a0cd81b378af6 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/README.md @@ -0,0 +1,17 @@ +# Default Workflows + +Workflows placed in this directory will be synced to the `workflow_library` as +_default workflows_ on app startup. + +- 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. + +After adding or updating default workflows, you **must** start the app up and +load them to ensure: + +- The workflow loads without warning or errors +- The workflow runs successfully 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 0000000000000000000000000000000000000000..8d038283758bed613f98f9cc5ce71bb36f1bc024 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/SD3.5 Text to Image.json @@ -0,0 +1,382 @@ +{ + "name": "SD3.5 Text to Image", + "author": "InvokeAI", + "description": "Sample text to image workflow for Stable Diffusion 3.5", + "version": "1.0.0", + "contact": "invoke@invoke.ai", + "tags": "text2image, SD3.5, default", + "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" + }, + "id": "e3a51d6b-8208-4d6d-b187-fcfe8b32934c", + "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": "", + "value": { + "key": "f7b20be9-92a8-4cfb-bca4-6c3b5535c10b", + "hash": "placeholder", + "name": "stable-diffusion-3.5-medium", + "base": "sd-3", + "type": "main" + } + }, + "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" + } + ] + } \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..65f894724c76272979fe408bccdff1fa43c903ff --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SD1.5.json @@ -0,0 +1,419 @@ +{ + "name": "Text to Image - SD1.5", + "author": "InvokeAI", + "description": "Sample text to image workflow for Stable Diffusion 1.5/2", + "version": "2.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" + }, + { + "nodeId": "58c957f5-0d01-41fc-a803-b2bbf0413d4f", + "fieldName": "board" + } + ], + "meta": { + "version": "3.0.0", + "category": "default" + }, + "nodes": [ + { + "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f", + "type": "invocation", + "data": { + "id": "58c957f5-0d01-41fc-a803-b2bbf0413d4f", + "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": 1800, + "y": 25 + } + }, + { + "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c", + "type": "invocation", + "data": { + "id": "7d8bf987-284f-413a-b2fd-d825445a5d6c", + "version": "1.2.0", + "nodePack": "invokeai", + "label": "Positive Compel Prompt", + "notes": "", + "type": "compel", + "inputs": { + "prompt": { + "name": "prompt", + "label": "Positive Prompt", + "value": "Super cute tiger cub, national geographic award-winning photograph" + }, + "clip": { + "name": "clip", + "label": "" + }, + "mask": { + "name": "mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1000, + "y": 25 + } + }, + { + "id": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8", + "type": "invocation", + "data": { + "id": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8", + "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": 600, + "y": 25 + } + }, + { + "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402", + "type": "invocation", + "data": { + "id": "93dc02a4-d05b-48ed-b99c-c9b616af3402", + "version": "1.2.0", + "nodePack": "invokeai", + "label": "Negative Compel Prompt", + "notes": "", + "type": "compel", + "inputs": { + "prompt": { + "name": "prompt", + "label": "Negative Prompt", + "value": "" + }, + "clip": { + "name": "clip", + "label": "" + }, + "mask": { + "name": "mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1000, + "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 + } + }, + { + "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": 600, + "y": 275 + } + }, + { + "id": "eea2702a-19fb-45b5-9d75-56b4211ec03c", + "type": "invocation", + "data": { + "id": "eea2702a-19fb-45b5-9d75-56b4211ec03c", + "version": "1.5.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 30 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 7.5 + }, + "denoising_start": { + "name": "denoising_start", + "label": "", + "value": 0 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "dpmpp_sde_k" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1400, + "y": 25 + } + } + ], + "edges": [ + { + "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-c8d55139-f380-4695-b7f2-8b3d1e1e3db8clip-7d8bf987-284f-413a-b2fd-d825445a5d6cclip", + "type": "default", + "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8", + "target": "7d8bf987-284f-413a-b2fd-d825445a5d6c", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-c8d55139-f380-4695-b7f2-8b3d1e1e3db8clip-93dc02a4-d05b-48ed-b99c-c9b616af3402clip", + "type": "default", + "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8", + "target": "93dc02a4-d05b-48ed-b99c-c9b616af3402", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-55705012-79b9-4aac-9f26-c0b10309785bnoise-eea2702a-19fb-45b5-9d75-56b4211ec03cnoise", + "type": "default", + "source": "55705012-79b9-4aac-9f26-c0b10309785b", + "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c", + "sourceHandle": "noise", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-7d8bf987-284f-413a-b2fd-d825445a5d6cconditioning-eea2702a-19fb-45b5-9d75-56b4211ec03cpositive_conditioning", + "type": "default", + "source": "7d8bf987-284f-413a-b2fd-d825445a5d6c", + "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-93dc02a4-d05b-48ed-b99c-c9b616af3402conditioning-eea2702a-19fb-45b5-9d75-56b4211ec03cnegative_conditioning", + "type": "default", + "source": "93dc02a4-d05b-48ed-b99c-c9b616af3402", + "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-c8d55139-f380-4695-b7f2-8b3d1e1e3db8unet-eea2702a-19fb-45b5-9d75-56b4211ec03cunet", + "type": "default", + "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8", + "target": "eea2702a-19fb-45b5-9d75-56b4211ec03c", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-eea2702a-19fb-45b5-9d75-56b4211ec03clatents-58c957f5-0d01-41fc-a803-b2bbf0413d4flatents", + "type": "default", + "source": "eea2702a-19fb-45b5-9d75-56b4211ec03c", + "target": "58c957f5-0d01-41fc-a803-b2bbf0413d4f", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-c8d55139-f380-4695-b7f2-8b3d1e1e3db8vae-58c957f5-0d01-41fc-a803-b2bbf0413d4fvae", + "type": "default", + "source": "c8d55139-f380-4695-b7f2-8b3d1e1e3db8", + "target": "58c957f5-0d01-41fc-a803-b2bbf0413d4f", + "sourceHandle": "vae", + "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 new file mode 100644 index 0000000000000000000000000000000000000000..0f4777169e28493d1892ac0d0f39946fd03c3f06 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image - SDXL.json @@ -0,0 +1,717 @@ +{ + "name": "Text to Image - SDXL", + "author": "InvokeAI", + "description": "Sample text to image workflow for SDXL", + "version": "2.1.0", + "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" + }, + { + "nodeId": "63e91020-83b2-4f35-b174-ad9692aabb48", + "fieldName": "board" + } + ], + "meta": { + "version": "3.0.0", + "category": "default" + }, + "nodes": [ + { + "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8", + "type": "invocation", + "data": { + "id": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8", + "version": "1.0.3", + "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": "63e91020-83b2-4f35-b174-ad9692aabb48", + "type": "invocation", + "data": { + "id": "63e91020-83b2-4f35-b174-ad9692aabb48", + "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": false, + "useCache": false + }, + "position": { + "x": 1475, + "y": -500 + } + }, + { + "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08", + "type": "invocation", + "data": { + "id": "faf965a4-7530-427b-b1f3-4ba6505c2a08", + "version": "1.2.0", + "nodePack": "invokeai", + "label": "SDXL Positive Compel Prompt", + "notes": "", + "type": "sdxl_compel_prompt", + "inputs": { + "prompt": { + "name": "prompt", + "label": "Positive Prompt", + "value": "" + }, + "style": { + "name": "style", + "label": "Positive Style", + "value": "" + }, + "original_width": { + "name": "original_width", + "label": "", + "value": 1024 + }, + "original_height": { + "name": "original_height", + "label": "", + "value": 1024 + }, + "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": "" + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 750, + "y": -175 + } + }, + { + "id": "30d3289c-773c-4152-a9d2-bd8a99c8fd22", + "type": "invocation", + "data": { + "id": "30d3289c-773c-4152-a9d2-bd8a99c8fd22", + "version": "1.0.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "sdxl_model_loader", + "inputs": { + "model": { + "name": "model", + "label": "", + "value": { + "key": "4a63b226-e8ff-4da4-854e-0b9f04b562ba", + "hash": "blake3:d279309ea6e5ee6e8fd52504275865cc280dac71cbf528c5b07c98b888bddaba", + "name": "dreamshaper-xl-v2-turbo", + "base": "sdxl", + "type": "main" + } + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 375, + "y": -500 + } + }, + { + "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204", + "type": "invocation", + "data": { + "id": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204", + "version": "1.2.0", + "nodePack": "invokeai", + "label": "SDXL Negative Compel Prompt", + "notes": "", + "type": "sdxl_compel_prompt", + "inputs": { + "prompt": { + "name": "prompt", + "label": "Negative Prompt", + "value": "" + }, + "style": { + "name": "style", + "label": "Negative Style", + "value": "" + }, + "original_width": { + "name": "original_width", + "label": "", + "value": 1024 + }, + "original_height": { + "name": "original_height", + "label": "", + "value": 1024 + }, + "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": "" + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 750, + "y": 200 + } + }, + { + "id": "3774ec24-a69e-4254-864c-097d07a6256f", + "type": "invocation", + "data": { + "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": "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 + } + }, + { + "id": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb", + "type": "invocation", + "data": { + "id": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb", + "version": "1.5.3", + "nodePack": "invokeai", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 32 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 6 + }, + "denoising_start": { + "name": "denoising_start", + "label": "", + "value": 0 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "dpmpp_2m_sde_k" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 1125, + "y": -500 + } + }, + { + "id": "ade2c0d3-0384-4157-b39b-29ce429cfa15", + "type": "invocation", + "data": { + "id": "ade2c0d3-0384-4157-b39b-29ce429cfa15", + "version": "1.0.1", + "label": "Positive Prompt", + "notes": "", + "type": "string", + "inputs": { + "value": { + "name": "value", + "label": "Positive Prompt", + "value": "Super cute tiger cub, fierce, traditional chinese watercolor" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 750, + "y": -500 + } + }, + { + "id": "ad8fa655-3a76-43d0-9c02-4d7644dea650", + "type": "invocation", + "data": { + "id": "ad8fa655-3a76-43d0-9c02-4d7644dea650", + "version": "1.0.1", + "label": "Negative Style Concat", + "notes": "", + "type": "string_join", + "inputs": { + "string_left": { + "name": "string_left", + "label": "", + "value": "" + }, + "string_right": { + "name": "string_right", + "label": "Negative Style Prompt", + "value": "" + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 750, + "y": 150 + } + } + ], + "edges": [ + { + "id": "3774ec24-a69e-4254-864c-097d07a6256f-faf965a4-7530-427b-b1f3-4ba6505c2a08-collapsed", + "type": "collapsed", + "source": "3774ec24-a69e-4254-864c-097d07a6256f", + "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08" + }, + { + "id": "ad8fa655-3a76-43d0-9c02-4d7644dea650-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204-collapsed", + "type": "collapsed", + "source": "ad8fa655-3a76-43d0-9c02-4d7644dea650", + "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204" + }, + { + "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-30d3289c-773c-4152-a9d2-bd8a99c8fd22unet-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbunet", + "type": "default", + "source": "30d3289c-773c-4152-a9d2-bd8a99c8fd22", + "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-faf965a4-7530-427b-b1f3-4ba6505c2a08conditioning-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbpositive_conditioning", + "type": "default", + "source": "faf965a4-7530-427b-b1f3-4ba6505c2a08", + "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204conditioning-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbnegative_conditioning", + "type": "default", + "source": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204", + "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-55705012-79b9-4aac-9f26-c0b10309785bnoise-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfbnoise", + "type": "default", + "source": "55705012-79b9-4aac-9f26-c0b10309785b", + "target": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb", + "sourceHandle": "noise", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-50a36525-3c0a-4cc5-977c-e4bfc3fd6dfblatents-63e91020-83b2-4f35-b174-ad9692aabb48latents", + "type": "default", + "source": "50a36525-3c0a-4cc5-977c-e4bfc3fd6dfb", + "target": "63e91020-83b2-4f35-b174-ad9692aabb48", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-0093692f-9cf4-454d-a5b8-62f0e3eb3bb8vae-63e91020-83b2-4f35-b174-ad9692aabb48vae", + "type": "default", + "source": "0093692f-9cf4-454d-a5b8-62f0e3eb3bb8", + "target": "63e91020-83b2-4f35-b174-ad9692aabb48", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-ade2c0d3-0384-4157-b39b-29ce429cfa15value-faf965a4-7530-427b-b1f3-4ba6505c2a08prompt", + "type": "default", + "source": "ade2c0d3-0384-4157-b39b-29ce429cfa15", + "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08", + "sourceHandle": "value", + "targetHandle": "prompt" + }, + { + "id": "reactflow__edge-719dabe8-8297-4749-aea1-37be301cd425value-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204prompt", + "type": "default", + "source": "719dabe8-8297-4749-aea1-37be301cd425", + "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204", + "sourceHandle": "value", + "targetHandle": "prompt" + }, + { + "id": "reactflow__edge-719dabe8-8297-4749-aea1-37be301cd425value-ad8fa655-3a76-43d0-9c02-4d7644dea650string_left", + "type": "default", + "source": "719dabe8-8297-4749-aea1-37be301cd425", + "target": "ad8fa655-3a76-43d0-9c02-4d7644dea650", + "sourceHandle": "value", + "targetHandle": "string_left" + }, + { + "id": "reactflow__edge-ad8fa655-3a76-43d0-9c02-4d7644dea650value-3193ad09-a7c2-4bf4-a3a9-1c61cc33a204style", + "type": "default", + "source": "ad8fa655-3a76-43d0-9c02-4d7644dea650", + "target": "3193ad09-a7c2-4bf4-a3a9-1c61cc33a204", + "sourceHandle": "value", + "targetHandle": "style" + }, + { + "id": "reactflow__edge-ade2c0d3-0384-4157-b39b-29ce429cfa15value-3774ec24-a69e-4254-864c-097d07a6256fstring_left", + "type": "default", + "source": "ade2c0d3-0384-4157-b39b-29ce429cfa15", + "target": "3774ec24-a69e-4254-864c-097d07a6256f", + "sourceHandle": "value", + "targetHandle": "string_left" + }, + { + "id": "reactflow__edge-3774ec24-a69e-4254-864c-097d07a6256fvalue-faf965a4-7530-427b-b1f3-4ba6505c2a08style", + "type": "default", + "source": "3774ec24-a69e-4254-864c-097d07a6256f", + "target": "faf965a4-7530-427b-b1f3-4ba6505c2a08", + "sourceHandle": "value", + "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 new file mode 100644 index 0000000000000000000000000000000000000000..b4df4b921ce3e36199d30ae0b01163ab5b943c81 --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Text to Image with LoRA.json @@ -0,0 +1,475 @@ +{ + "name": "Text to Image with LoRA", + "author": "InvokeAI", + "description": "Simple text to image workflow with a LoRA", + "version": "2.1.0", + "contact": "invoke@invoke.ai", + "tags": "text to image, lora, default", + "notes": "", + "exposedFields": [ + { + "nodeId": "24e9d7ed-4836-4ec4-8f9e-e747721f9818", + "fieldName": "model" + }, + { + "nodeId": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "fieldName": "lora" + }, + { + "nodeId": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "fieldName": "weight" + }, + { + "nodeId": "c3fa6872-2599-4a82-a596-b3446a66cf8b", + "fieldName": "prompt" + }, + { + "nodeId": "ea18915f-2c5b-4569-b725-8e9e9122e8d3", + "fieldName": "width" + }, + { + "nodeId": "ea18915f-2c5b-4569-b725-8e9e9122e8d3", + "fieldName": "height" + }, + { + "nodeId": "a9683c0a-6b1f-4a5e-8187-c57e764b3400", + "fieldName": "board" + } + ], + "meta": { + "version": "3.0.0", + "category": "default" + }, + "nodes": [ + { + "id": "a9683c0a-6b1f-4a5e-8187-c57e764b3400", + "type": "invocation", + "data": { + "id": "a9683c0a-6b1f-4a5e-8187-c57e764b3400", + "version": "1.3.0", + "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": false, + "useCache": true + }, + "position": { + "x": 4450, + "y": -550 + } + }, + { + "id": "c3fa6872-2599-4a82-a596-b3446a66cf8b", + "type": "invocation", + "data": { + "id": "c3fa6872-2599-4a82-a596-b3446a66cf8b", + "version": "1.2.0", + "label": "", + "notes": "", + "type": "compel", + "inputs": { + "prompt": { + "name": "prompt", + "label": "Positive Prompt", + "value": "super cute tiger cub" + }, + "clip": { + "name": "clip", + "label": "" + }, + "mask": { + "name": "mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 3425, + "y": -575 + } + }, + { + "id": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "type": "invocation", + "data": { + "id": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "version": "1.0.3", + "label": "", + "notes": "", + "type": "lora_loader", + "inputs": { + "lora": { + "name": "lora", + "label": "" + }, + "weight": { + "name": "weight", + "label": "LoRA Weight", + "value": 1 + }, + "unet": { + "name": "unet", + "label": "" + }, + "clip": { + "name": "clip", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 2975, + "y": -600 + } + }, + { + "id": "24e9d7ed-4836-4ec4-8f9e-e747721f9818", + "type": "invocation", + "data": { + "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": "Negative Prompt", + "value": "" + }, + "clip": { + "name": "clip", + "label": "" + }, + "mask": { + "name": "mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 3425, + "y": -300 + } + }, + { + "id": "ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63", + "type": "invocation", + "data": { + "id": "ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63", + "version": "1.5.3", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 30 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 7.5 + }, + "denoising_start": { + "name": "denoising_start", + "label": "", + "value": 0 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "euler" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 3975, + "y": -575 + } + }, + { + "id": "ea18915f-2c5b-4569-b725-8e9e9122e8d3", + "type": "invocation", + "data": { + "id": "ea18915f-2c5b-4569-b725-8e9e9122e8d3", + "version": "1.0.2", + "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": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": 3425, + "y": 75 + } + }, + { + "id": "6fd74a17-6065-47a5-b48b-f4e2b8fa7953", + "type": "invocation", + "data": { + "id": "6fd74a17-6065-47a5-b48b-f4e2b8fa7953", + "version": "1.0.1", + "label": "", + "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": 3425, + "y": 0 + } + } + ], + "edges": [ + { + "id": "6fd74a17-6065-47a5-b48b-f4e2b8fa7953-ea18915f-2c5b-4569-b725-8e9e9122e8d3-collapsed", + "type": "collapsed", + "source": "6fd74a17-6065-47a5-b48b-f4e2b8fa7953", + "target": "ea18915f-2c5b-4569-b725-8e9e9122e8d3" + }, + { + "id": "reactflow__edge-24e9d7ed-4836-4ec4-8f9e-e747721f9818clip-c41e705b-f2e3-4d1a-83c4-e34bb9344966clip", + "type": "default", + "source": "24e9d7ed-4836-4ec4-8f9e-e747721f9818", + "target": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-c41e705b-f2e3-4d1a-83c4-e34bb9344966clip-c3fa6872-2599-4a82-a596-b3446a66cf8bclip", + "type": "default", + "source": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "target": "c3fa6872-2599-4a82-a596-b3446a66cf8b", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-24e9d7ed-4836-4ec4-8f9e-e747721f9818unet-c41e705b-f2e3-4d1a-83c4-e34bb9344966unet", + "type": "default", + "source": "24e9d7ed-4836-4ec4-8f9e-e747721f9818", + "target": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-c41e705b-f2e3-4d1a-83c4-e34bb9344966unet-ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63unet", + "type": "default", + "source": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "target": "ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63", + "sourceHandle": "unet", + "targetHandle": "unet" + }, + { + "id": "reactflow__edge-85b77bb2-c67a-416a-b3e8-291abe746c44conditioning-ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63negative_conditioning", + "type": "default", + "source": "85b77bb2-c67a-416a-b3e8-291abe746c44", + "target": "ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-c3fa6872-2599-4a82-a596-b3446a66cf8bconditioning-ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63positive_conditioning", + "type": "default", + "source": "c3fa6872-2599-4a82-a596-b3446a66cf8b", + "target": "ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-ea18915f-2c5b-4569-b725-8e9e9122e8d3noise-ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63noise", + "type": "default", + "source": "ea18915f-2c5b-4569-b725-8e9e9122e8d3", + "target": "ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63", + "sourceHandle": "noise", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-6fd74a17-6065-47a5-b48b-f4e2b8fa7953value-ea18915f-2c5b-4569-b725-8e9e9122e8d3seed", + "type": "default", + "source": "6fd74a17-6065-47a5-b48b-f4e2b8fa7953", + "target": "ea18915f-2c5b-4569-b725-8e9e9122e8d3", + "sourceHandle": "value", + "targetHandle": "seed" + }, + { + "id": "reactflow__edge-ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63latents-a9683c0a-6b1f-4a5e-8187-c57e764b3400latents", + "type": "default", + "source": "ad487d0c-dcbb-49c5-bb8e-b28d4cbc5a63", + "target": "a9683c0a-6b1f-4a5e-8187-c57e764b3400", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-24e9d7ed-4836-4ec4-8f9e-e747721f9818vae-a9683c0a-6b1f-4a5e-8187-c57e764b3400vae", + "type": "default", + "source": "24e9d7ed-4836-4ec4-8f9e-e747721f9818", + "target": "a9683c0a-6b1f-4a5e-8187-c57e764b3400", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-c41e705b-f2e3-4d1a-83c4-e34bb9344966clip-85b77bb2-c67a-416a-b3e8-291abe746c44clip", + "type": "default", + "source": "c41e705b-f2e3-4d1a-83c4-e34bb9344966", + "target": "85b77bb2-c67a-416a-b3e8-291abe746c44", + "sourceHandle": "clip", + "targetHandle": "clip" + } + ] +} \ No newline at end of file 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 new file mode 100644 index 0000000000000000000000000000000000000000..426fe49c419b156bba22500aaee20f049ff900df --- /dev/null +++ b/invokeai/app/services/workflow_records/default_workflows/Tiled Upscaling (Beta).json @@ -0,0 +1,1818 @@ +{ + "name": "Tiled Upscaling (Beta)", + "author": "Invoke", + "description": "A workflow to upscale an input image with tiled upscaling. ", + "version": "2.1.0", + "contact": "invoke@invoke.ai", + "tags": "tiled, upscaling, sd1.5", + "notes": "", + "exposedFields": [ + { + "nodeId": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "fieldName": "model" + }, + { + "nodeId": "5ca87ace-edf9-49c7-a424-cd42416b86a7", + "fieldName": "image" + }, + { + "nodeId": "86fce904-9dc2-466f-837a-92fe15969b51", + "fieldName": "value" + }, + { + "nodeId": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a", + "fieldName": "a" + }, + { + "nodeId": "3f99d25c-6b43-44ec-a61a-c7ff91712621", + "fieldName": "strength" + }, + { + "nodeId": "d334f2da-016a-4524-9911-bdab85546888", + "fieldName": "end_step_percent" + }, + { + "nodeId": "287f134f-da8d-41d1-884e-5940e8f7b816", + "fieldName": "ip_adapter_model" + }, + { + "nodeId": "d334f2da-016a-4524-9911-bdab85546888", + "fieldName": "control_model" + } + ], + "meta": { + "version": "3.0.0", + "category": "default" + }, + "nodes": [ + { + "id": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "type": "invocation", + "data": { + "id": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "version": "1.0.3", + "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": "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)", + "value": { + "key": "1cc210bb-4d0a-4312-b36c-b5d46c43768e", + "hash": "blake3:3d669dffa7471b357b4df088b99ffb6bf4d4383d5e0ef1de5ec1c89728a3d5a5", + "name": "ip_adapter_sd15", + "base": "sd-1", + "type": "ip_adapter" + } + }, + "clip_vision_model": { + "name": "clip_vision_model", + "label": "", + "value": "ViT-H" + }, + "weight": { + "name": "weight", + "label": "", + "value": 0.2 + }, + "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": 1 + }, + "mask": { + "name": "mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -2855.8555540799207, + "y": -183.58854843775742 + } + }, + { + "id": "b76fe66f-7884-43ad-b72c-fadc81d7a73c", + "type": "invocation", + "data": { + "id": "b76fe66f-7884-43ad-b72c-fadc81d7a73c", + "version": "1.3.0", + "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": -1999.770193862987, + "y": -1075 + } + }, + { + "id": "d334f2da-016a-4524-9911-bdab85546888", + "type": "invocation", + "data": { + "id": "d334f2da-016a-4524-9911-bdab85546888", + "version": "1.1.2", + "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": "338b883c-3728-4f18-b3a6-6e7190c2f850", + "type": "invocation", + "data": { + "id": "338b883c-3728-4f18-b3a6-6e7190c2f850", + "version": "1.1.0", + "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": false + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "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 + } + }, + { + "id": "9b2d8c58-ce8f-4162-a5a1-48de854040d6", + "type": "invocation", + "data": { + "id": "9b2d8c58-ce8f-4162-a5a1-48de854040d6", + "version": "1.2.0", + "label": "", + "notes": "", + "type": "compel", + "inputs": { + "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, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4485.384246996007, + "y": -977.6662925348955 + } + }, + { + "id": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "type": "invocation", + "data": { + "id": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "version": "1.2.2", + "label": "", + "notes": "", + "type": "img_scale", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "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": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4478.200192078582, + "y": 3.422855503409039 + } + }, + { + "id": "b3513fed-ed42-408d-b382-128fdb0de523", + "type": "invocation", + "data": { + "id": "b3513fed-ed42-408d-b382-128fdb0de523", + "version": "1.0.2", + "label": "", + "notes": "", + "type": "noise", + "inputs": { + "seed": { + "name": "seed", + "label": "", + "value": 1 + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "name": "height", + "label": "", + "value": 512 + }, + "use_cpu": { + "name": "use_cpu", + "label": "", + "value": true + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -3661.44600187038, + "y": -86.98974389852648 + } + }, + { + "id": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5", + "type": "invocation", + "data": { + "id": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5", + "version": "1.1.0", + "label": "", + "notes": "", + "type": "iterate", + "inputs": { + "collection": { + "name": "collection", + "label": "" + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -3651.5370216396627, + "y": 81.15992554066929 + } + }, + { + "id": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "type": "invocation", + "data": { + "id": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "version": "1.0.1", + "label": "", + "notes": "", + "type": "tile_to_properties", + "inputs": { + "tile": { + "name": "tile", + "label": "" + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -3653.3418661289197, + "y": 134.9675219108736 + } + }, + { + "id": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "type": "invocation", + "data": { + "id": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "version": "1.2.2", + "label": "", + "notes": "", + "type": "img_crop", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "x": { + "name": "x", + "label": "", + "value": 0 + }, + "y": { + "name": "y", + "label": "", + "value": 0 + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "name": "height", + "label": "", + "value": 512 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -3253.380472583465, + "y": -29.08699277598673 + } + }, + { + "id": "1011539e-85de-4e02-a003-0b22358491b8", + "type": "invocation", + "data": { + "id": "1011539e-85de-4e02-a003-0b22358491b8", + "version": "1.5.3", + "label": "", + "notes": "", + "type": "denoise_latents", + "inputs": { + "positive_conditioning": { + "name": "positive_conditioning", + "label": "" + }, + "negative_conditioning": { + "name": "negative_conditioning", + "label": "" + }, + "noise": { + "name": "noise", + "label": "" + }, + "steps": { + "name": "steps", + "label": "", + "value": 35 + }, + "cfg_scale": { + "name": "cfg_scale", + "label": "", + "value": 4 + }, + "denoising_start": { + "name": "denoising_start", + "label": "", + "value": 0.75 + }, + "denoising_end": { + "name": "denoising_end", + "label": "", + "value": 1 + }, + "scheduler": { + "name": "scheduler", + "label": "", + "value": "unipc" + }, + "unet": { + "name": "unet", + "label": "" + }, + "control": { + "name": "control", + "label": "" + }, + "ip_adapter": { + "name": "ip_adapter", + "label": "" + }, + "t2i_adapter": { + "name": "t2i_adapter", + "label": "" + }, + "cfg_rescale_multiplier": { + "name": "cfg_rescale_multiplier", + "label": "", + "value": 0 + }, + "latents": { + "name": "latents", + "label": "" + }, + "denoise_mask": { + "name": "denoise_mask", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -2493.8519134413505, + "y": -1006.415909408244 + } + }, + { + "id": "ab6f5dda-4b60-4ddf-99f2-f61fb5937527", + "type": "invocation", + "data": { + "id": "ab6f5dda-4b60-4ddf-99f2-f61fb5937527", + "version": "1.0.1", + "label": "", + "notes": "", + "type": "pair_tile_image", + "inputs": { + "image": { + "name": "image", + "label": "" + }, + "tile": { + "name": "tile", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -1528.3086883131245, + "y": -847.9775129915614 + } + }, + { + "id": "ca0d20d1-918f-44e0-8fc3-4704dc41f4da", + "type": "invocation", + "data": { + "id": "ca0d20d1-918f-44e0-8fc3-4704dc41f4da", + "version": "1.0.0", + "label": "", + "notes": "", + "type": "collect", + "inputs": { + "item": { + "name": "item", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -1528.3086883131245, + "y": -647.9775129915615 + } + }, + { + "id": "7cedc866-2095-4bda-aa15-23f15d6273cb", + "type": "invocation", + "data": { + "id": "7cedc866-2095-4bda-aa15-23f15d6273cb", + "version": "1.1.1", + "label": "", + "notes": "", + "type": "merge_tiles_to_image", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "tiles_with_images": { + "name": "tiles_with_images", + "label": "" + }, + "blend_mode": { + "name": "blend_mode", + "label": "", + "value": "Seam" + }, + "blend_amount": { + "name": "blend_amount", + "label": "", + "value": 32 + } + }, + "isOpen": true, + "isIntermediate": true, + "useCache": false + }, + "position": { + "x": -1528.3086883131245, + "y": -522.9775129915615 + } + }, + { + "id": "234192f1-ee96-49be-a5d1-bad4c52a9012", + "type": "invocation", + "data": { + "id": "234192f1-ee96-49be-a5d1-bad4c52a9012", + "version": "1.2.2", + "label": "", + "notes": "", + "type": "save_image", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + } + }, + "isOpen": true, + "isIntermediate": false, + "useCache": false + }, + "position": { + "x": -1128.3086883131245, + "y": -522.9775129915615 + } + }, + { + "id": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "type": "invocation", + "data": { + "id": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "version": "1.0.2", + "label": "", + "notes": "", + "type": "crop_latents", + "inputs": { + "latents": { + "name": "latents", + "label": "" + }, + "x": { + "name": "x", + "label": "", + "value": 0 + }, + "y": { + "name": "y", + "label": "", + "value": 0 + }, + "width": { + "name": "width", + "label": "", + "value": 0 + }, + "height": { + "name": "height", + "label": "", + "value": 0 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -3253.7161754850986, + "y": -78.2819050861178 + } + }, + { + "id": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "type": "invocation", + "data": { + "id": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "version": "1.1.1", + "label": "", + "notes": "", + "type": "calculate_image_tiles_even_split", + "inputs": { + "image_width": { + "name": "image_width", + "label": "", + "value": 1024 + }, + "image_height": { + "name": "image_height", + "label": "", + "value": 1024 + }, + "num_tiles_x": { + "name": "num_tiles_x", + "label": "", + "value": 2 + }, + "num_tiles_y": { + "name": "num_tiles_y", + "label": "", + "value": 2 + }, + "overlap": { + "name": "overlap", + "label": "", + "value": 128 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4101.266011341878, + "y": -49.381989859546415 + } + }, + { + "id": "86fce904-9dc2-466f-837a-92fe15969b51", + "type": "invocation", + "data": { + "id": "86fce904-9dc2-466f-837a-92fe15969b51", + "version": "1.0.1", + "label": "Scale Factor", + "notes": "", + "type": "integer", + "inputs": { + "value": { + "name": "value", + "label": "Scale Factor", + "value": 2 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4476.853041598589, + "y": -41.810810454906914 + } + }, + { + "id": "f5d9bf3b-2646-4b17-9894-20fd2b4218ea", + "type": "invocation", + "data": { + "id": "f5d9bf3b-2646-4b17-9894-20fd2b4218ea", + "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": -4472.251829335153, + "y": -287.93974602686 + } + }, + { + "id": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "type": "invocation", + "data": { + "id": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "version": "1.2.2", + "label": "Compatibility Cropping Mo8", + "notes": "", + "type": "img_crop", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "x": { + "name": "x", + "label": "", + "value": 0 + }, + "y": { + "name": "y", + "label": "", + "value": 0 + }, + "width": { + "name": "width", + "label": "", + "value": 512 + }, + "height": { + "name": "height", + "label": "", + "value": 512 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4470.138475621539, + "y": -201.36850691108262 + } + }, + { + "id": "3f99d25c-6b43-44ec-a61a-c7ff91712621", + "type": "invocation", + "data": { + "id": "3f99d25c-6b43-44ec-a61a-c7ff91712621", + "version": "1.2.2", + "label": "Sharpening", + "notes": "", + "type": "unsharp_mask", + "inputs": { + "board": { + "name": "board", + "label": "" + }, + "metadata": { + "name": "metadata", + "label": "" + }, + "image": { + "name": "image", + "label": "" + }, + "radius": { + "name": "radius", + "label": "", + "value": 2 + }, + "strength": { + "name": "strength", + "label": "Sharpen Strength", + "value": 50 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -2904.1636287554056, + "y": -339.7161193204281 + } + }, + { + "id": "157d5318-fbc1-43e5-9ed4-5bbeda0594b0", + "type": "invocation", + "data": { + "id": "157d5318-fbc1-43e5-9ed4-5bbeda0594b0", + "version": "1.0.1", + "label": "", + "notes": "", + "type": "float_math", + "inputs": { + "operation": { + "name": "operation", + "label": "", + "value": "SUB" + }, + "a": { + "name": "a", + "label": "", + "value": 0.8 + }, + "b": { + "name": "b", + "label": "", + "value": 1 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4009.026283214496, + "y": -574.9200068395512 + } + }, + { + "id": "43515ab9-b46b-47db-bb46-7e0273c01d1a", + "type": "invocation", + "data": { + "id": "43515ab9-b46b-47db-bb46-7e0273c01d1a", + "version": "1.0.1", + "label": "", + "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": -3658.0647708234524, + "y": -136.19433892512953 + } + }, + { + "id": "e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb", + "type": "invocation", + "data": { + "id": "e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb", + "version": "1.0.1", + "label": "Multiple Check", + "notes": "", + "type": "float_to_int", + "inputs": { + "value": { + "name": "value", + "label": "", + "value": 0 + }, + "multiple": { + "name": "multiple", + "label": "", + "value": 8 + }, + "method": { + "name": "method", + "label": "", + "value": "Nearest" + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4092.2410416963758, + "y": -180.31086509172079 + } + }, + { + "id": "f87a3783-ac5c-43f8-8f97-6688a2aefba5", + "type": "invocation", + "data": { + "id": "f87a3783-ac5c-43f8-8f97-6688a2aefba5", + "version": "1.0.1", + "label": "Pixel Summation", + "notes": "", + "type": "float_math", + "inputs": { + "operation": { + "name": "operation", + "label": "", + "value": "ADD" + }, + "a": { + "name": "a", + "label": "", + "value": 1 + }, + "b": { + "name": "b", + "label": "", + "value": 1 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4096.902679890686, + "y": -279.75914657034684 + } + }, + { + "id": "d62d4d15-e03a-4c10-86ba-3e58da98d2a4", + "type": "invocation", + "data": { + "id": "d62d4d15-e03a-4c10-86ba-3e58da98d2a4", + "version": "1.0.1", + "label": "Overlap Calc", + "notes": "", + "type": "float_math", + "inputs": { + "operation": { + "name": "operation", + "label": "", + "value": "MUL" + }, + "a": { + "name": "a", + "label": "", + "value": 1 + }, + "b": { + "name": "b", + "label": "", + "value": 0.075 + } + }, + "isOpen": false, + "isIntermediate": true, + "useCache": true + }, + "position": { + "x": -4095.348800492582, + "y": -230.03500583103383 + } + } + ], + "edges": [ + { + "id": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a-157d5318-fbc1-43e5-9ed4-5bbeda0594b0-collapsed", + "type": "collapsed", + "source": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a", + "target": "157d5318-fbc1-43e5-9ed4-5bbeda0594b0" + }, + { + "id": "fad15012-0787-43a8-99dd-27f1518b5bc7-36d25df7-6408-442b-89e2-b9aba11a72c3-collapsed", + "type": "collapsed", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "36d25df7-6408-442b-89e2-b9aba11a72c3" + }, + { + "id": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b-36d25df7-6408-442b-89e2-b9aba11a72c3-collapsed", + "type": "collapsed", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "36d25df7-6408-442b-89e2-b9aba11a72c3" + }, + { + "id": "36d25df7-6408-442b-89e2-b9aba11a72c3-3f99d25c-6b43-44ec-a61a-c7ff91712621-collapsed", + "type": "collapsed", + "source": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "target": "3f99d25c-6b43-44ec-a61a-c7ff91712621" + }, + { + "id": "3f99d25c-6b43-44ec-a61a-c7ff91712621-338b883c-3728-4f18-b3a6-6e7190c2f850-collapsed", + "type": "collapsed", + "source": "3f99d25c-6b43-44ec-a61a-c7ff91712621", + "target": "338b883c-3728-4f18-b3a6-6e7190c2f850" + }, + { + "id": "fad15012-0787-43a8-99dd-27f1518b5bc7-b3513fed-ed42-408d-b382-128fdb0de523-collapsed", + "type": "collapsed", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "b3513fed-ed42-408d-b382-128fdb0de523" + }, + { + "id": "fad15012-0787-43a8-99dd-27f1518b5bc7-1f86c8bf-06f9-4e28-abee-02f46f445ac4-collapsed", + "type": "collapsed", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4" + }, + { + "id": "86fce904-9dc2-466f-837a-92fe15969b51-fad15012-0787-43a8-99dd-27f1518b5bc7-collapsed", + "type": "collapsed", + "source": "86fce904-9dc2-466f-837a-92fe15969b51", + "target": "fad15012-0787-43a8-99dd-27f1518b5bc7" + }, + { + "id": "23546dd5-a0ec-4842-9ad0-3857899b607a-fad15012-0787-43a8-99dd-27f1518b5bc7-collapsed", + "type": "collapsed", + "source": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "target": "fad15012-0787-43a8-99dd-27f1518b5bc7" + }, + { + "id": "1f86c8bf-06f9-4e28-abee-02f46f445ac4-40de95ee-ebb5-43f7-a31a-299e76c8a5d5-collapsed", + "type": "collapsed", + "source": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "target": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5" + }, + { + "id": "86fce904-9dc2-466f-837a-92fe15969b51-1f86c8bf-06f9-4e28-abee-02f46f445ac4-collapsed", + "type": "collapsed", + "source": "86fce904-9dc2-466f-837a-92fe15969b51", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4" + }, + { + "id": "e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb-1f86c8bf-06f9-4e28-abee-02f46f445ac4-collapsed", + "type": "collapsed", + "source": "e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4" + }, + { + "id": "f5d9bf3b-2646-4b17-9894-20fd2b4218ea-23546dd5-a0ec-4842-9ad0-3857899b607a-collapsed", + "type": "collapsed", + "source": "f5d9bf3b-2646-4b17-9894-20fd2b4218ea", + "target": "23546dd5-a0ec-4842-9ad0-3857899b607a" + }, + { + "id": "7dbb756b-7d79-431c-a46d-d8f7b082c127-23546dd5-a0ec-4842-9ad0-3857899b607a-collapsed", + "type": "collapsed", + "source": "7dbb756b-7d79-431c-a46d-d8f7b082c127", + "target": "23546dd5-a0ec-4842-9ad0-3857899b607a" + }, + { + "id": "23546dd5-a0ec-4842-9ad0-3857899b607a-f87a3783-ac5c-43f8-8f97-6688a2aefba5-collapsed", + "type": "collapsed", + "source": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "target": "f87a3783-ac5c-43f8-8f97-6688a2aefba5" + }, + { + "id": "f87a3783-ac5c-43f8-8f97-6688a2aefba5-d62d4d15-e03a-4c10-86ba-3e58da98d2a4-collapsed", + "type": "collapsed", + "source": "f87a3783-ac5c-43f8-8f97-6688a2aefba5", + "target": "d62d4d15-e03a-4c10-86ba-3e58da98d2a4" + }, + { + "id": "d62d4d15-e03a-4c10-86ba-3e58da98d2a4-e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb-collapsed", + "type": "collapsed", + "source": "d62d4d15-e03a-4c10-86ba-3e58da98d2a4", + "target": "e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb" + }, + { + "id": "b3513fed-ed42-408d-b382-128fdb0de523-54dd79ec-fb65-45a6-a5d7-f20109f88b49-collapsed", + "type": "collapsed", + "source": "b3513fed-ed42-408d-b382-128fdb0de523", + "target": "54dd79ec-fb65-45a6-a5d7-f20109f88b49" + }, + { + "id": "43515ab9-b46b-47db-bb46-7e0273c01d1a-b3513fed-ed42-408d-b382-128fdb0de523-collapsed", + "type": "collapsed", + "source": "43515ab9-b46b-47db-bb46-7e0273c01d1a", + "target": "b3513fed-ed42-408d-b382-128fdb0de523" + }, + { + "id": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b-54dd79ec-fb65-45a6-a5d7-f20109f88b49-collapsed", + "type": "collapsed", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "54dd79ec-fb65-45a6-a5d7-f20109f88b49" + }, + { + "id": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5-857eb5ce-8e5e-4bda-8a33-3e52e57db67b-collapsed", + "type": "collapsed", + "source": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5", + "target": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b" + }, + { + "id": "reactflow__edge-fad15012-0787-43a8-99dd-27f1518b5bc7width-b3513fed-ed42-408d-b382-128fdb0de523width", + "type": "default", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "b3513fed-ed42-408d-b382-128fdb0de523", + "sourceHandle": "width", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-fad15012-0787-43a8-99dd-27f1518b5bc7height-b3513fed-ed42-408d-b382-128fdb0de523height", + "type": "default", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "b3513fed-ed42-408d-b382-128fdb0de523", + "sourceHandle": "height", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-40de95ee-ebb5-43f7-a31a-299e76c8a5d5item-857eb5ce-8e5e-4bda-8a33-3e52e57db67btile", + "type": "default", + "source": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5", + "target": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "sourceHandle": "item", + "targetHandle": "tile" + }, + { + "id": "reactflow__edge-fad15012-0787-43a8-99dd-27f1518b5bc7image-36d25df7-6408-442b-89e2-b9aba11a72c3image", + "type": "default", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bcoords_top-36d25df7-6408-442b-89e2-b9aba11a72c3y", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "sourceHandle": "coords_top", + "targetHandle": "y" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bcoords_left-36d25df7-6408-442b-89e2-b9aba11a72c3x", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "sourceHandle": "coords_left", + "targetHandle": "x" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bwidth-36d25df7-6408-442b-89e2-b9aba11a72c3width", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "sourceHandle": "width", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bheight-36d25df7-6408-442b-89e2-b9aba11a72c3height", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "sourceHandle": "height", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-9b2d8c58-ce8f-4162-a5a1-48de854040d6conditioning-1011539e-85de-4e02-a003-0b22358491b8positive_conditioning", + "type": "default", + "source": "9b2d8c58-ce8f-4162-a5a1-48de854040d6", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "conditioning", + "targetHandle": "positive_conditioning" + }, + { + "id": "reactflow__edge-947c3f88-0305-4695-8355-df4abac64b1cconditioning-1011539e-85de-4e02-a003-0b22358491b8negative_conditioning", + "type": "default", + "source": "947c3f88-0305-4695-8355-df4abac64b1c", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "conditioning", + "targetHandle": "negative_conditioning" + }, + { + "id": "reactflow__edge-338b883c-3728-4f18-b3a6-6e7190c2f850latents-1011539e-85de-4e02-a003-0b22358491b8latents", + "type": "default", + "source": "338b883c-3728-4f18-b3a6-6e7190c2f850", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-1011539e-85de-4e02-a003-0b22358491b8latents-b76fe66f-7884-43ad-b72c-fadc81d7a73clatents", + "type": "default", + "source": "1011539e-85de-4e02-a003-0b22358491b8", + "target": "b76fe66f-7884-43ad-b72c-fadc81d7a73c", + "sourceHandle": "latents", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-b76fe66f-7884-43ad-b72c-fadc81d7a73cimage-ab6f5dda-4b60-4ddf-99f2-f61fb5937527image", + "type": "default", + "source": "b76fe66f-7884-43ad-b72c-fadc81d7a73c", + "target": "ab6f5dda-4b60-4ddf-99f2-f61fb5937527", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-40de95ee-ebb5-43f7-a31a-299e76c8a5d5item-ab6f5dda-4b60-4ddf-99f2-f61fb5937527tile", + "type": "default", + "source": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5", + "target": "ab6f5dda-4b60-4ddf-99f2-f61fb5937527", + "sourceHandle": "item", + "targetHandle": "tile" + }, + { + "id": "reactflow__edge-ab6f5dda-4b60-4ddf-99f2-f61fb5937527tile_with_image-ca0d20d1-918f-44e0-8fc3-4704dc41f4daitem", + "type": "default", + "source": "ab6f5dda-4b60-4ddf-99f2-f61fb5937527", + "target": "ca0d20d1-918f-44e0-8fc3-4704dc41f4da", + "sourceHandle": "tile_with_image", + "targetHandle": "item" + }, + { + "id": "reactflow__edge-ca0d20d1-918f-44e0-8fc3-4704dc41f4dacollection-7cedc866-2095-4bda-aa15-23f15d6273cbtiles_with_images", + "type": "default", + "source": "ca0d20d1-918f-44e0-8fc3-4704dc41f4da", + "target": "7cedc866-2095-4bda-aa15-23f15d6273cb", + "sourceHandle": "collection", + "targetHandle": "tiles_with_images" + }, + { + "id": "reactflow__edge-7cedc866-2095-4bda-aa15-23f15d6273cbimage-234192f1-ee96-49be-a5d1-bad4c52a9012image", + "type": "default", + "source": "7cedc866-2095-4bda-aa15-23f15d6273cb", + "target": "234192f1-ee96-49be-a5d1-bad4c52a9012", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-b3513fed-ed42-408d-b382-128fdb0de523noise-54dd79ec-fb65-45a6-a5d7-f20109f88b49latents", + "type": "default", + "source": "b3513fed-ed42-408d-b382-128fdb0de523", + "target": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "sourceHandle": "noise", + "targetHandle": "latents" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bwidth-54dd79ec-fb65-45a6-a5d7-f20109f88b49width", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "sourceHandle": "width", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bheight-54dd79ec-fb65-45a6-a5d7-f20109f88b49height", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "sourceHandle": "height", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bcoords_left-54dd79ec-fb65-45a6-a5d7-f20109f88b49x", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "sourceHandle": "coords_left", + "targetHandle": "x" + }, + { + "id": "reactflow__edge-857eb5ce-8e5e-4bda-8a33-3e52e57db67bcoords_top-54dd79ec-fb65-45a6-a5d7-f20109f88b49y", + "type": "default", + "source": "857eb5ce-8e5e-4bda-8a33-3e52e57db67b", + "target": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "sourceHandle": "coords_top", + "targetHandle": "y" + }, + { + "id": "reactflow__edge-54dd79ec-fb65-45a6-a5d7-f20109f88b49latents-1011539e-85de-4e02-a003-0b22358491b8noise", + "type": "default", + "source": "54dd79ec-fb65-45a6-a5d7-f20109f88b49", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "latents", + "targetHandle": "noise" + }, + { + "id": "reactflow__edge-287f134f-da8d-41d1-884e-5940e8f7b816ip_adapter-1011539e-85de-4e02-a003-0b22358491b8ip_adapter", + "type": "default", + "source": "287f134f-da8d-41d1-884e-5940e8f7b816", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "ip_adapter", + "targetHandle": "ip_adapter" + }, + { + "id": "reactflow__edge-36d25df7-6408-442b-89e2-b9aba11a72c3image-287f134f-da8d-41d1-884e-5940e8f7b816image", + "type": "default", + "source": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "target": "287f134f-da8d-41d1-884e-5940e8f7b816", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-1f86c8bf-06f9-4e28-abee-02f46f445ac4tiles-40de95ee-ebb5-43f7-a31a-299e76c8a5d5collection", + "type": "default", + "source": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "target": "40de95ee-ebb5-43f7-a31a-299e76c8a5d5", + "sourceHandle": "tiles", + "targetHandle": "collection" + }, + { + "id": "reactflow__edge-fad15012-0787-43a8-99dd-27f1518b5bc7width-1f86c8bf-06f9-4e28-abee-02f46f445ac4image_width", + "type": "default", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "sourceHandle": "width", + "targetHandle": "image_width" + }, + { + "id": "reactflow__edge-fad15012-0787-43a8-99dd-27f1518b5bc7height-1f86c8bf-06f9-4e28-abee-02f46f445ac4image_height", + "type": "default", + "source": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "sourceHandle": "height", + "targetHandle": "image_height" + }, + { + "id": "reactflow__edge-86fce904-9dc2-466f-837a-92fe15969b51value-fad15012-0787-43a8-99dd-27f1518b5bc7scale_factor", + "type": "default", + "source": "86fce904-9dc2-466f-837a-92fe15969b51", + "target": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "sourceHandle": "value", + "targetHandle": "scale_factor" + }, + { + "id": "reactflow__edge-86fce904-9dc2-466f-837a-92fe15969b51value-1f86c8bf-06f9-4e28-abee-02f46f445ac4num_tiles_x", + "type": "default", + "source": "86fce904-9dc2-466f-837a-92fe15969b51", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "sourceHandle": "value", + "targetHandle": "num_tiles_x" + }, + { + "id": "reactflow__edge-86fce904-9dc2-466f-837a-92fe15969b51value-1f86c8bf-06f9-4e28-abee-02f46f445ac4num_tiles_y", + "type": "default", + "source": "86fce904-9dc2-466f-837a-92fe15969b51", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "sourceHandle": "value", + "targetHandle": "num_tiles_y" + }, + { + "id": "reactflow__edge-2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5clip-9b2d8c58-ce8f-4162-a5a1-48de854040d6clip", + "type": "default", + "source": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "target": "9b2d8c58-ce8f-4162-a5a1-48de854040d6", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5clip-947c3f88-0305-4695-8355-df4abac64b1cclip", + "type": "default", + "source": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "target": "947c3f88-0305-4695-8355-df4abac64b1c", + "sourceHandle": "clip", + "targetHandle": "clip" + }, + { + "id": "reactflow__edge-5ca87ace-edf9-49c7-a424-cd42416b86a7width-f5d9bf3b-2646-4b17-9894-20fd2b4218eavalue", + "type": "default", + "source": "5ca87ace-edf9-49c7-a424-cd42416b86a7", + "target": "f5d9bf3b-2646-4b17-9894-20fd2b4218ea", + "sourceHandle": "width", + "targetHandle": "value" + }, + { + "id": "reactflow__edge-5ca87ace-edf9-49c7-a424-cd42416b86a7height-7dbb756b-7d79-431c-a46d-d8f7b082c127value", + "type": "default", + "source": "5ca87ace-edf9-49c7-a424-cd42416b86a7", + "target": "7dbb756b-7d79-431c-a46d-d8f7b082c127", + "sourceHandle": "height", + "targetHandle": "value" + }, + { + "id": "reactflow__edge-f5d9bf3b-2646-4b17-9894-20fd2b4218eavalue-23546dd5-a0ec-4842-9ad0-3857899b607awidth", + "type": "default", + "source": "f5d9bf3b-2646-4b17-9894-20fd2b4218ea", + "target": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "sourceHandle": "value", + "targetHandle": "width" + }, + { + "id": "reactflow__edge-7dbb756b-7d79-431c-a46d-d8f7b082c127value-23546dd5-a0ec-4842-9ad0-3857899b607aheight", + "type": "default", + "source": "7dbb756b-7d79-431c-a46d-d8f7b082c127", + "target": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "sourceHandle": "value", + "targetHandle": "height" + }, + { + "id": "reactflow__edge-23546dd5-a0ec-4842-9ad0-3857899b607aimage-fad15012-0787-43a8-99dd-27f1518b5bc7image", + "type": "default", + "source": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "target": "fad15012-0787-43a8-99dd-27f1518b5bc7", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-5ca87ace-edf9-49c7-a424-cd42416b86a7image-23546dd5-a0ec-4842-9ad0-3857899b607aimage", + "type": "default", + "source": "5ca87ace-edf9-49c7-a424-cd42416b86a7", + "target": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-d334f2da-016a-4524-9911-bdab85546888control-1011539e-85de-4e02-a003-0b22358491b8control", + "type": "default", + "source": "d334f2da-016a-4524-9911-bdab85546888", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "control", + "targetHandle": "control" + }, + { + "id": "reactflow__edge-36d25df7-6408-442b-89e2-b9aba11a72c3image-3f99d25c-6b43-44ec-a61a-c7ff91712621image", + "type": "default", + "source": "36d25df7-6408-442b-89e2-b9aba11a72c3", + "target": "3f99d25c-6b43-44ec-a61a-c7ff91712621", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-3f99d25c-6b43-44ec-a61a-c7ff91712621image-338b883c-3728-4f18-b3a6-6e7190c2f850image", + "type": "default", + "source": "3f99d25c-6b43-44ec-a61a-c7ff91712621", + "target": "338b883c-3728-4f18-b3a6-6e7190c2f850", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-3f99d25c-6b43-44ec-a61a-c7ff91712621image-d334f2da-016a-4524-9911-bdab85546888image", + "type": "default", + "source": "3f99d25c-6b43-44ec-a61a-c7ff91712621", + "target": "d334f2da-016a-4524-9911-bdab85546888", + "sourceHandle": "image", + "targetHandle": "image" + }, + { + "id": "reactflow__edge-b875cae6-d8a3-4fdc-b969-4d53cbd03f9avalue-157d5318-fbc1-43e5-9ed4-5bbeda0594b0b", + "type": "default", + "source": "b875cae6-d8a3-4fdc-b969-4d53cbd03f9a", + "target": "157d5318-fbc1-43e5-9ed4-5bbeda0594b0", + "sourceHandle": "value", + "targetHandle": "b" + }, + { + "id": "reactflow__edge-157d5318-fbc1-43e5-9ed4-5bbeda0594b0value-1011539e-85de-4e02-a003-0b22358491b8denoising_start", + "type": "default", + "source": "157d5318-fbc1-43e5-9ed4-5bbeda0594b0", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "value", + "targetHandle": "denoising_start" + }, + { + "id": "reactflow__edge-43515ab9-b46b-47db-bb46-7e0273c01d1avalue-b3513fed-ed42-408d-b382-128fdb0de523seed", + "type": "default", + "source": "43515ab9-b46b-47db-bb46-7e0273c01d1a", + "target": "b3513fed-ed42-408d-b382-128fdb0de523", + "sourceHandle": "value", + "targetHandle": "seed" + }, + { + "id": "reactflow__edge-e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bbvalue-1f86c8bf-06f9-4e28-abee-02f46f445ac4overlap", + "type": "default", + "source": "e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb", + "target": "1f86c8bf-06f9-4e28-abee-02f46f445ac4", + "sourceHandle": "value", + "targetHandle": "overlap" + }, + { + "id": "reactflow__edge-23546dd5-a0ec-4842-9ad0-3857899b607awidth-f87a3783-ac5c-43f8-8f97-6688a2aefba5a", + "type": "default", + "source": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "target": "f87a3783-ac5c-43f8-8f97-6688a2aefba5", + "sourceHandle": "width", + "targetHandle": "a" + }, + { + "id": "reactflow__edge-23546dd5-a0ec-4842-9ad0-3857899b607aheight-f87a3783-ac5c-43f8-8f97-6688a2aefba5b", + "type": "default", + "source": "23546dd5-a0ec-4842-9ad0-3857899b607a", + "target": "f87a3783-ac5c-43f8-8f97-6688a2aefba5", + "sourceHandle": "height", + "targetHandle": "b" + }, + { + "id": "reactflow__edge-f87a3783-ac5c-43f8-8f97-6688a2aefba5value-d62d4d15-e03a-4c10-86ba-3e58da98d2a4a", + "type": "default", + "source": "f87a3783-ac5c-43f8-8f97-6688a2aefba5", + "target": "d62d4d15-e03a-4c10-86ba-3e58da98d2a4", + "sourceHandle": "value", + "targetHandle": "a" + }, + { + "id": "reactflow__edge-d62d4d15-e03a-4c10-86ba-3e58da98d2a4value-e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bbvalue", + "type": "default", + "source": "d62d4d15-e03a-4c10-86ba-3e58da98d2a4", + "target": "e9b5a7e1-6e8a-4b95-aa7c-c92ba15080bb", + "sourceHandle": "value", + "targetHandle": "value" + }, + { + "id": "reactflow__edge-2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5vae-b76fe66f-7884-43ad-b72c-fadc81d7a73cvae", + "type": "default", + "source": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "target": "b76fe66f-7884-43ad-b72c-fadc81d7a73c", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5vae-338b883c-3728-4f18-b3a6-6e7190c2f850vae", + "type": "default", + "source": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "target": "338b883c-3728-4f18-b3a6-6e7190c2f850", + "sourceHandle": "vae", + "targetHandle": "vae" + }, + { + "id": "reactflow__edge-2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5unet-1011539e-85de-4e02-a003-0b22358491b8unet", + "type": "default", + "source": "2ff466b8-5e2a-4d8f-923a-a3884c7ecbc5", + "target": "1011539e-85de-4e02-a003-0b22358491b8", + "sourceHandle": "unet", + "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 new file mode 100644 index 0000000000000000000000000000000000000000..9da830eaafe8602afd4549b19a9bfc34f5675b25 --- /dev/null +++ b/invokeai/app/services/workflow_records/workflow_records_base.py @@ -0,0 +1,50 @@ +from abc import ABC, abstractmethod +from typing import Optional + +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, + WorkflowCategory, + WorkflowRecordDTO, + WorkflowRecordListItemDTO, + WorkflowRecordOrderBy, + WorkflowWithoutID, +) + + +class WorkflowRecordsStorageBase(ABC): + """Base class for workflow storage services.""" + + @abstractmethod + def get(self, workflow_id: str) -> WorkflowRecordDTO: + """Get workflow by id.""" + pass + + @abstractmethod + def create(self, workflow: WorkflowWithoutID) -> WorkflowRecordDTO: + """Creates a workflow.""" + pass + + @abstractmethod + def update(self, workflow: Workflow) -> WorkflowRecordDTO: + """Updates a workflow.""" + pass + + @abstractmethod + def delete(self, workflow_id: str) -> None: + """Deletes a workflow.""" + pass + + @abstractmethod + def get_many( + self, + order_by: WorkflowRecordOrderBy, + direction: SQLiteDirection, + category: WorkflowCategory, + page: int, + per_page: Optional[int], + query: Optional[str], + ) -> PaginatedResults[WorkflowRecordListItemDTO]: + """Gets many workflows.""" + pass diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py new file mode 100644 index 0000000000000000000000000000000000000000..e02600a0c35e7b2a4f92b95953bdadc5caffc425 --- /dev/null +++ b/invokeai/app/services/workflow_records/workflow_records_common.py @@ -0,0 +1,119 @@ +import datetime +from enum import Enum +from typing import Any, Union + +import semver +from pydantic import BaseModel, ConfigDict, Field, JsonValue, TypeAdapter, field_validator + +from invokeai.app.util.metaenum import MetaEnum + +__workflow_meta_version__ = semver.Version.parse("1.0.0") + + +class ExposedField(BaseModel): + nodeId: str + fieldName: str + + +class WorkflowNotFoundError(Exception): + """Raised when a workflow is not found""" + + +class WorkflowRecordOrderBy(str, Enum, metaclass=MetaEnum): + """The order by options for workflow records""" + + CreatedAt = "created_at" + UpdatedAt = "updated_at" + OpenedAt = "opened_at" + Name = "name" + + +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)." + ) + + @field_validator("version") + def validate_version(cls, version: str): + try: + semver.Version.parse(version) + return version + except Exception: + raise ValueError(f"Invalid workflow meta version: {version}") + + def to_semver(self) -> semver.Version: + return semver.Version.parse(self.version) + + +class WorkflowWithoutID(BaseModel): + name: str = Field(description="The name of the workflow.") + author: str = Field(description="The author of the workflow.") + description: str = Field(description="The description of the workflow.") + version: str = Field(description="The version of the workflow.") + contact: str = Field(description="The contact of the workflow.") + tags: str = Field(description="The tags of the workflow.") + 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 + nodes: list[dict[str, JsonValue]] = Field(description="The nodes of the workflow.") + edges: list[dict[str, JsonValue]] = Field(description="The edges of the workflow.") + + model_config = ConfigDict(extra="ignore") + + +WorkflowWithoutIDValidator = TypeAdapter(WorkflowWithoutID) + + +class UnsafeWorkflowWithVersion(BaseModel): + """ + This utility model only requires a workflow to have a valid version string. + It is used to validate a workflow version without having to validate the entire workflow. + """ + + meta: WorkflowMeta = Field(description="The meta of the workflow.") + + +UnsafeWorkflowWithVersionValidator = TypeAdapter(UnsafeWorkflowWithVersion) + + +class Workflow(WorkflowWithoutID): + id: str = Field(description="The id of the workflow.") + + +WorkflowValidator = TypeAdapter(Workflow) + + +class WorkflowRecordDTOBase(BaseModel): + workflow_id: str = Field(description="The id of the workflow.") + 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.") + + +class WorkflowRecordDTO(WorkflowRecordDTOBase): + workflow: Workflow = Field(description="The workflow.") + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "WorkflowRecordDTO": + data["workflow"] = WorkflowValidator.validate_json(data.get("workflow", "")) + return WorkflowRecordDTOValidator.validate_python(data) + + +WorkflowRecordDTOValidator = TypeAdapter(WorkflowRecordDTO) + + +class WorkflowRecordListItemDTO(WorkflowRecordDTOBase): + description: str = Field(description="The description of the workflow.") + category: WorkflowCategory = Field(description="The description of the workflow.") + + +WorkflowRecordListItemDTOValidator = TypeAdapter(WorkflowRecordListItemDTO) diff --git a/invokeai/app/services/workflow_records/workflow_records_sqlite.py b/invokeai/app/services/workflow_records/workflow_records_sqlite.py new file mode 100644 index 0000000000000000000000000000000000000000..d08107705a415d207c27d6e1b5c95d4c2924d0cc --- /dev/null +++ b/invokeai/app/services/workflow_records/workflow_records_sqlite.py @@ -0,0 +1,243 @@ +from pathlib import Path +from typing import Optional + +from invokeai.app.services.invoker import Invoker +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.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase +from invokeai.app.services.workflow_records.workflow_records_common import ( + Workflow, + WorkflowCategory, + WorkflowNotFoundError, + WorkflowRecordDTO, + WorkflowRecordListItemDTO, + WorkflowRecordListItemDTOValidator, + WorkflowRecordOrderBy, + WorkflowWithoutID, + WorkflowWithoutIDValidator, +) +from invokeai.app.util.misc import uuid_string + + +class SqliteWorkflowRecordsStorage(WorkflowRecordsStorageBase): + def __init__(self, db: SqliteDatabase) -> None: + super().__init__() + self._lock = db.lock + self._conn = db.conn + self._cursor = self._conn.cursor() + + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + self._sync_default_workflows() + + def get(self, workflow_id: str) -> WorkflowRecordDTO: + """Gets a workflow by ID. Updates the opened_at column.""" + try: + self._lock.acquire() + self._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 + 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 + workflow_with_id = Workflow(**workflow.model_dump(), id=uuid_string()) + self._lock.acquire() + self._cursor.execute( + """--sql + INSERT OR IGNORE INTO workflow_library ( + workflow_id, + workflow + ) + VALUES (?, ?); + """, + (workflow_with_id.id, workflow_with_id.model_dump_json()), + ) + 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() + 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() + return None + + def get_many( + self, + order_by: WorkflowRecordOrderBy, + direction: SQLiteDirection, + category: WorkflowCategory, + page: int = 0, + per_page: Optional[int] = None, + query: Optional[str] = None, + ) -> PaginatedResults[WorkflowRecordListItemDTO]: + try: + self._lock.acquire() + # sanitize! + assert order_by in WorkflowRecordOrderBy + assert direction in SQLiteDirection + assert category in WorkflowCategory + count_query = "SELECT COUNT(*) FROM workflow_library WHERE category = ?" + 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] + + stripped_query = query.strip() if query else None + if stripped_query: + 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}" + + if per_page: + main_query += " LIMIT ? OFFSET ?" + main_params.extend([per_page, page * per_page]) + + self._cursor.execute(main_query, main_params) + rows = self._cursor.fetchall() + workflows = [WorkflowRecordListItemDTOValidator.validate_python(dict(row)) for row in rows] + + self._cursor.execute(count_query, count_params) + total = self._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 if per_page else total, + pages=pages, + total=total, + ) + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + + def _sync_default_workflows(self) -> None: + """Syncs default workflows to the database. Internal use only.""" + + """ + An enhancement might be to only update workflows that have changed. This would require stable + default workflow IDs, and properly incrementing the workflow version. + + It's much simpler to just replace them all with whichever workflows are in the directory. + + The downside is that the `updated_at` and `opened_at` timestamps for default workflows are + meaningless, as they are overwritten every time the server starts. + """ + + try: + self._lock.acquire() + workflows: 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( + """--sql + INSERT OR REPLACE INTO workflow_library ( + workflow_id, + workflow + ) + VALUES (?, ?); + """, + (w.id, w.model_dump_json()), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() diff --git a/invokeai/app/shared/__init__.py b/invokeai/app/shared/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..3f50fd9fbc2037feef8a4de358874f14c24ffbc7 --- /dev/null +++ b/invokeai/app/shared/__init__.py @@ -0,0 +1,5 @@ +""" +This module contains various classes, functions and models which are shared across the app, particularly by invocations. + +Lifting these classes, functions and models into this shared module helps to reduce circular imports. +""" diff --git a/invokeai/app/shared/models.py b/invokeai/app/shared/models.py new file mode 100644 index 0000000000000000000000000000000000000000..1a11b480cc529ed69ec595e0898463f0af75730a --- /dev/null +++ b/invokeai/app/shared/models.py @@ -0,0 +1,16 @@ +from pydantic import BaseModel, Field + +from invokeai.app.invocations.fields import FieldDescriptions + + +class FreeUConfig(BaseModel): + """ + Configuration for the FreeU hyperparameters. + - https://huggingface.co/docs/diffusers/main/en/using-diffusers/freeu + - https://github.com/ChenyangSi/FreeU + """ + + s1: float = Field(ge=-1, le=3, description=FieldDescriptions.freeu_s1) + s2: float = Field(ge=-1, le=3, description=FieldDescriptions.freeu_s2) + b1: float = Field(ge=-1, le=3, description=FieldDescriptions.freeu_b1) + b2: float = Field(ge=-1, le=3, description=FieldDescriptions.freeu_b2) diff --git a/invokeai/app/util/__init__.py b/invokeai/app/util/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..f92823a27fdc0549d50ba33a1b55a8b656b90ed0 --- /dev/null +++ b/invokeai/app/util/controlnet_utils.py @@ -0,0 +1,353 @@ +from typing import Any, Literal, Union + +import cv2 +import numpy as np +import torch +from einops import rearrange +from PIL import Image + +from invokeai.backend.image_util.util import nms, normalize_image_channel_count + +CONTROLNET_RESIZE_VALUES = Literal[ + "just_resize", + "crop_resize", + "fill_resize", + "just_resize_simple", +] +CONTROLNET_MODE_VALUES = Literal["balanced", "more_prompt", "more_control", "unbalanced"] + +################################################################### +# Copy of scripts/lvminthin.py from Mikubill/sd-webui-controlnet +################################################################### +# High Quality Edge Thinning using Pure Python +# Written by Lvmin Zhangu +# 2023 April +# Stanford University +# If you use this, please Cite "High Quality Edge Thinning using Pure Python", Lvmin Zhang, In Mikubill/sd-webui-controlnet. + +lvmin_kernels_raw = [ + np.array([[-1, -1, -1], [0, 1, 0], [1, 1, 1]], dtype=np.int32), + np.array([[0, -1, -1], [1, 1, -1], [0, 1, 0]], dtype=np.int32), +] + +lvmin_kernels = [] +lvmin_kernels += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_kernels_raw] + +lvmin_prunings_raw = [ + np.array([[-1, -1, -1], [-1, 1, -1], [0, 0, -1]], dtype=np.int32), + np.array([[-1, -1, -1], [-1, 1, -1], [-1, 0, 0]], dtype=np.int32), +] + +lvmin_prunings = [] +lvmin_prunings += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_prunings_raw] + + +def remove_pattern(x, kernel): + objects = cv2.morphologyEx(x, cv2.MORPH_HITMISS, kernel) + objects = np.where(objects > 127) + x[objects] = 0 + return x, objects[0].shape[0] > 0 + + +def thin_one_time(x, kernels): + y = x + is_done = True + for k in kernels: + y, has_update = remove_pattern(y, k) + if has_update: + is_done = False + return y, is_done + + +def lvmin_thin(x, prunings=True): + y = x + for _i in range(32): + y, is_done = thin_one_time(y, lvmin_kernels) + if is_done: + break + if prunings: + y, _ = thin_one_time(y, lvmin_prunings) + return y + + +################################################################################ +# copied from Mikubill/sd-webui-controlnet external_code.py and modified for InvokeAI +################################################################################ +# FIXME: not using yet, if used in the future will most likely require modification of preprocessors +def pixel_perfect_resolution( + image: np.ndarray, + target_H: int, + target_W: int, + resize_mode: str, +) -> int: + """ + Calculate the estimated resolution for resizing an image while preserving aspect ratio. + + The function first calculates scaling factors for height and width of the image based on the target + height and width. Then, based on the chosen resize mode, it either takes the smaller or the larger + scaling factor to estimate the new resolution. + + If the resize mode is OUTER_FIT, the function uses the smaller scaling factor, ensuring the whole image + fits within the target dimensions, potentially leaving some empty space. + + If the resize mode is not OUTER_FIT, the function uses the larger scaling factor, ensuring the target + dimensions are fully filled, potentially cropping the image. + + After calculating the estimated resolution, the function prints some debugging information. + + Args: + image (np.ndarray): A 3D numpy array representing an image. The dimensions represent [height, width, channels]. + target_H (int): The target height for the image. + target_W (int): The target width for the image. + resize_mode (ResizeMode): The mode for resizing. + + Returns: + int: The estimated resolution after resizing. + """ + raw_H, raw_W, _ = image.shape + + k0 = float(target_H) / float(raw_H) + k1 = float(target_W) / float(raw_W) + + if resize_mode == "fill_resize": + estimation = min(k0, k1) * float(min(raw_H, raw_W)) + else: # "crop_resize" or "just_resize" (or possibly "just_resize_simple"?) + estimation = max(k0, k1) * float(min(raw_H, raw_W)) + + # print(f"Pixel Perfect Computation:") + # print(f"resize_mode = {resize_mode}") + # print(f"raw_H = {raw_H}") + # print(f"raw_W = {raw_W}") + # print(f"target_H = {target_H}") + # print(f"target_W = {target_W}") + # print(f"estimation = {estimation}") + + return int(np.round(estimation)) + + +def clone_contiguous(x: np.ndarray[Any, Any]) -> np.ndarray[Any, Any]: + """Get a memory-contiguous clone of the given numpy array, as a safety measure and to improve computation efficiency.""" + return np.ascontiguousarray(x).copy() + + +def np_img_to_torch(np_img: np.ndarray[Any, Any], device: torch.device) -> torch.Tensor: + """Convert a numpy image to a PyTorch tensor. The image is normalized to 0-1, rearranged to BCHW format and sent to + the specified device.""" + + torch_img = torch.from_numpy(np_img) + normalized = torch_img.float() / 255.0 + bchw = rearrange(normalized, "h w c -> 1 c h w") + on_device = bchw.to(device) + return on_device.clone() + + +def heuristic_resize(np_img: np.ndarray[Any, Any], size: tuple[int, int]) -> np.ndarray[Any, Any]: + """Resizes an image using a heuristic to choose the best resizing strategy. + + - If the image appears to be an edge map, special handling will be applied to ensure the edges are not distorted. + - Single-pixel edge maps use NMS and thinning to keep the edges as single-pixel lines. + - Low-color-count images are resized with nearest-neighbor to preserve color information (for e.g. segmentation maps). + - The alpha channel is handled separately to ensure it is resized correctly. + + Args: + np_img (np.ndarray): The input image. + size (tuple[int, int]): The target size for the image. + + Returns: + np.ndarray: The resized image. + + Adapted from https://github.com/Mikubill/sd-webui-controlnet. + """ + + # Return early if the image is already at the requested size + if np_img.shape[0] == size[1] and np_img.shape[1] == size[0]: + return np_img + + # If the image has an alpha channel, separate it for special handling later. + inpaint_mask = None + if np_img.ndim == 3 and np_img.shape[2] == 4: + inpaint_mask = np_img[:, :, 3] + np_img = np_img[:, :, 0:3] + + new_size_is_smaller = (size[0] * size[1]) < (np_img.shape[0] * np_img.shape[1]) + new_size_is_bigger = (size[0] * size[1]) > (np_img.shape[0] * np_img.shape[1]) + unique_color_count = np.unique(np_img.reshape(-1, np_img.shape[2]), axis=0).shape[0] + is_one_pixel_edge = False + is_binary = False + + if unique_color_count == 2: + # If the image has only two colors, it is likely binary. Check if the image has one-pixel edges. + is_binary = np.min(np_img) < 16 and np.max(np_img) > 240 + if is_binary: + eroded = cv2.erode(np_img, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + dilated = cv2.dilate(eroded, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + one_pixel_edge_count = np.where(dilated < np_img)[0].shape[0] + all_edge_count = np.where(np_img > 127)[0].shape[0] + is_one_pixel_edge = one_pixel_edge_count * 2 > all_edge_count + + if 2 < unique_color_count < 200: + # With a low color count, we assume this is a map where exact colors are important. Near-neighbor preserves + # the colors as needed. + interpolation = cv2.INTER_NEAREST + elif new_size_is_smaller: + # This works best for downscaling + interpolation = cv2.INTER_AREA + else: + # Fall back for other cases + interpolation = cv2.INTER_CUBIC # Must be CUBIC because we now use nms. NEVER CHANGE THIS + + # This may be further transformed depending on the binary nature of the image. + resized = cv2.resize(np_img, size, interpolation=interpolation) + + if inpaint_mask is not None: + # Resize the inpaint mask to match the resized image using the same interpolation method. + inpaint_mask = cv2.resize(inpaint_mask, size, interpolation=interpolation) + + # If the image is binary, we will perform some additional processing to ensure the edges are preserved. + if is_binary: + resized = np.mean(resized.astype(np.float32), axis=2).clip(0, 255).astype(np.uint8) + if is_one_pixel_edge: + # Use NMS and thinning to keep the edges as single-pixel lines. + resized = nms(resized) + _, resized = cv2.threshold(resized, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + resized = lvmin_thin(resized, prunings=new_size_is_bigger) + else: + _, resized = cv2.threshold(resized, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + resized = np.stack([resized] * 3, axis=2) + + # Restore the alpha channel if it was present. + if inpaint_mask is not None: + inpaint_mask = (inpaint_mask > 127).astype(np.float32) * 255.0 + inpaint_mask = inpaint_mask[:, :, None].clip(0, 255).astype(np.uint8) + resized = np.concatenate([resized, inpaint_mask], axis=2) + + return resized + + +########################################################################### +# Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet +# modified for InvokeAI +########################################################################### +def np_img_resize( + np_img: np.ndarray, + resize_mode: CONTROLNET_RESIZE_VALUES, + h: int, + w: int, + device: torch.device = torch.device("cpu"), +) -> tuple[torch.Tensor, np.ndarray[Any, Any]]: + np_img = normalize_image_channel_count(np_img) + + if resize_mode == "just_resize": # RESIZE + np_img = heuristic_resize(np_img, (w, h)) + np_img = clone_contiguous(np_img) + return np_img_to_torch(np_img, device), np_img + + old_h, old_w, _ = np_img.shape + old_w = float(old_w) + old_h = float(old_h) + k0 = float(h) / old_h + k1 = float(w) / old_w + + def safeint(x: Union[int, float]) -> int: + return int(np.round(x)) + + if resize_mode == "fill_resize": # OUTER_FIT + k = min(k0, k1) + borders = np.concatenate([np_img[0, :, :], np_img[-1, :, :], np_img[:, 0, :], np_img[:, -1, :]], axis=0) + high_quality_border_color = np.median(borders, axis=0).astype(np_img.dtype) + if len(high_quality_border_color) == 4: + # 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))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (h - new_h) // 2) + pad_w = max(0, (w - new_w) // 2) + high_quality_background[pad_h : pad_h + new_h, pad_w : pad_w + new_w] = np_img + np_img = high_quality_background + np_img = clone_contiguous(np_img) + 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))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (new_h - h) // 2) + pad_w = max(0, (new_w - w) // 2) + np_img = np_img[pad_h : pad_h + h, pad_w : pad_w + w] + np_img = clone_contiguous(np_img) + return np_img_to_torch(np_img, device), np_img + + +def prepare_control_image( + image: Image.Image, + width: int, + height: int, + num_channels: int = 3, + device: str | torch.device = "cuda", + dtype: torch.dtype = torch.float16, + control_mode: CONTROLNET_MODE_VALUES = "balanced", + resize_mode: CONTROLNET_RESIZE_VALUES = "just_resize_simple", + do_classifier_free_guidance: bool = True, +) -> torch.Tensor: + """Pre-process images for ControlNets or T2I-Adapters. + + Args: + image (Image): The PIL image to pre-process. + width (int): The target width in pixels. + height (int): The target height in pixels. + 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 | 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. + control_mode (str, optional): Defaults to "balanced". + resize_mode (str, optional): Defaults to "just_resize_simple". + + Raises: + ValueError: If `resize_mode` is not recognized. + ValueError: If `num_channels` is out of range. + + Returns: + torch.Tensor: The pre-processed input tensor. + """ + if resize_mode == "just_resize_simple": + image = image.convert("RGB") + image = image.resize((width, height), resample=Image.LANCZOS) + nimage = np.array(image) + nimage = nimage[None, :] + nimage = np.concatenate([nimage], axis=0) + # normalizing RGB values to [0,1] range (in PIL.Image they are [0-255]) + nimage = np.array(nimage).astype(np.float32) / 255.0 + nimage = nimage.transpose(0, 3, 1, 2) + timage = torch.from_numpy(nimage) + + # use fancy lvmin controlnet resizing + elif resize_mode == "just_resize" or resize_mode == "crop_resize" or resize_mode == "fill_resize": + nimage = np.array(image) + timage, nimage = np_img_resize( + np_img=nimage, + resize_mode=resize_mode, + h=height, + w=width, + device=torch.device(device), + ) + else: + raise ValueError(f"Unsupported resize_mode: '{resize_mode}'.") + + if timage.shape[1] < num_channels or num_channels <= 0: + raise ValueError(f"Cannot achieve the target of num_channels={num_channels}.") + timage = timage[:, :num_channels, :, :] + + timage = timage.to(device=device, dtype=dtype) + cfg_injection = control_mode == "more_control" or control_mode == "unbalanced" + if do_classifier_free_guidance and not cfg_injection: + timage = torch.cat([timage] * 2) + return timage diff --git a/invokeai/app/util/custom_openapi.py b/invokeai/app/util/custom_openapi.py new file mode 100644 index 0000000000000000000000000000000000000000..e52028d7721a3559187c19a8a738070004054345 --- /dev/null +++ b/invokeai/app/util/custom_openapi.py @@ -0,0 +1,116 @@ +from typing import Any, Callable, Optional + +from fastapi import FastAPI +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.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 + + +def move_defs_to_top_level(openapi_schema: dict[str, Any], component_schema: dict[str, Any]) -> None: + """Moves a component schema's $defs to the top level of the openapi schema. Useful when generating a schema + for a single model that needs to be added back to the top level of the schema. Mutates openapi_schema and + component_schema.""" + + defs = component_schema.pop("$defs", {}) + for schema_key, json_schema in defs.items(): + if schema_key in openapi_schema["components"]["schemas"]: + continue + openapi_schema["components"]["schemas"][schema_key] = json_schema + + +def get_openapi_func( + app: FastAPI, post_transform: Optional[Callable[[dict[str, Any]], dict[str, Any]]] = None +) -> Callable[[], dict[str, Any]]: + """Gets the OpenAPI schema generator function. + + Args: + app (FastAPI): The FastAPI app to generate the schema for. + post_transform (Optional[Callable[[dict[str, Any]], dict[str, Any]]], optional): A function to apply to the + generated schema before returning it. Defaults to None. + + Returns: + Callable[[], dict[str, Any]]: The OpenAPI schema generator function. When first called, the generated schema is + cached in `app.openapi_schema`. On subsequent calls, the cached schema is returned. This caching behaviour + matches FastAPI's default schema generation caching. + """ + + def openapi() -> dict[str, Any]: + if app.openapi_schema: + return app.openapi_schema + + openapi_schema = get_openapi( + title=app.title, + description="An API for invoking AI image operations", + version="1.0.0", + routes=app.routes, + separate_input_output_schemas=False, # https://fastapi.tiangolo.com/how-to/separate-openapi-schemas/ + ) + + # We'll create a map of invocation type to output schema to make some types simpler on the client. + invocation_output_map_properties: 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(): + json_schema = output.model_json_schema(mode="serialization", ref_template="#/components/schemas/{model}") + 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(): + json_schema = invocation.model_json_schema( + mode="serialization", ref_template="#/components/schemas/{model}" + ) + move_defs_to_top_level(openapi_schema, json_schema) + output_title = invocation.get_output_annotation().__name__ + outputs_ref = {"$ref": f"#/components/schemas/{output_title}"} + json_schema["output"] = outputs_ref + openapi_schema["components"]["schemas"][invocation.__name__] = json_schema + + # Add this invocation and its output to the output map + invocation_type = invocation.get_type() + invocation_output_map_properties[invocation_type] = json_schema["output"] + invocation_output_map_required.append(invocation_type) + + # Add the output map to the schema + openapi_schema["components"]["schemas"]["InvocationOutputMap"] = { + "type": "object", + "properties": dict(sorted(invocation_output_map_properties.items())), + "required": 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. + # We need to add them manually here. WARNING: Pydantic can choke if you call `model.model_json_schema()` to get + # a schema. This has something to do with schema refs - not totally clear. For whatever reason, using + # `models_json_schema` seems to work fine. + additional_models = [ + *EventBase.get_events(), + UIConfigBase, + InputFieldJSONSchemaExtra, + OutputFieldJSONSchemaExtra, + ModelIdentifierField, + ProgressImage, + ] + + additional_schemas = models_json_schema( + [(m, "serialization") for m in additional_models], + ref_template="#/components/schemas/{model}", + ) + # 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]) + + if post_transform is not None: + openapi_schema = post_transform(openapi_schema) + + openapi_schema["components"]["schemas"] = dict(sorted(openapi_schema["components"]["schemas"].items())) + + app.openapi_schema = openapi_schema + return app.openapi_schema + + return openapi diff --git a/invokeai/app/util/metaenum.py b/invokeai/app/util/metaenum.py new file mode 100644 index 0000000000000000000000000000000000000000..462238f775ea17b0861ff9eab26affc08f85f67f --- /dev/null +++ b/invokeai/app/util/metaenum.py @@ -0,0 +1,15 @@ +from enum import EnumMeta + + +class MetaEnum(EnumMeta): + """Metaclass to support additional features in Enums. + + - `in` operator support: `'value' in MyEnum -> bool` + """ + + def __contains__(cls, item): + try: + cls(item) + except ValueError: + return False + return True diff --git a/invokeai/app/util/misc.py b/invokeai/app/util/misc.py new file mode 100644 index 0000000000000000000000000000000000000000..da431929dbe4f0fff871e674356dda9e0e838195 --- /dev/null +++ b/invokeai/app/util/misc.py @@ -0,0 +1,35 @@ +import datetime +import typing +import uuid + +import numpy as np + + +def get_timestamp() -> int: + return int(datetime.datetime.now(datetime.timezone.utc).timestamp()) + + +def get_iso_timestamp() -> str: + return datetime.datetime.utcnow().isoformat() + + +def get_datetime_from_iso_timestamp(iso_timestamp: str) -> datetime.datetime: + return datetime.datetime.fromisoformat(iso_timestamp) + + +SEED_MAX = np.iinfo(np.uint32).max + + +def get_random_seed() -> int: + rng = np.random.default_rng(seed=None) + return int(rng.integers(0, SEED_MAX)) + + +def uuid_string() -> str: + res = uuid.uuid4() + return str(res) + + +def is_optional(value: typing.Any) -> bool: + """Checks if a value is typed as Optional. Note that Optional is sugar for Union[x, None].""" + return typing.get_origin(value) is typing.Union and type(None) in typing.get_args(value) diff --git a/invokeai/app/util/model_exclude_null.py b/invokeai/app/util/model_exclude_null.py new file mode 100644 index 0000000000000000000000000000000000000000..6da41039b4510d04392768235ddd710d122f3395 --- /dev/null +++ b/invokeai/app/util/model_exclude_null.py @@ -0,0 +1,23 @@ +from typing import Any + +from pydantic import BaseModel + +""" +We want to exclude null values from objects that make their way to the client. + +Unfortunately there is no built-in way to do this in pydantic, so we need to override the default +dict method to do this. + +From https://github.com/tiangolo/fastapi/discussions/8882#discussioncomment-5154541 +""" + + +class BaseModelExcludeNull(BaseModel): + def model_dump(self, *args, **kwargs) -> dict[str, Any]: + """ + Override the default dict method to exclude None values in the response + """ + kwargs.pop("exclude_none", None) + return super().model_dump(*args, exclude_none=True, **kwargs) + + pass diff --git a/invokeai/app/util/profiler.py b/invokeai/app/util/profiler.py new file mode 100644 index 0000000000000000000000000000000000000000..d1ce126b04942737577155684d50a2797da8e2e3 --- /dev/null +++ b/invokeai/app/util/profiler.py @@ -0,0 +1,67 @@ +import cProfile +from logging import Logger +from pathlib import Path +from typing import Optional + + +class Profiler: + """ + Simple wrapper around cProfile. + + Usage + ``` + # Create a profiler + profiler = Profiler(logger, output_dir, "sql_query_perf") + # Start a new profile + profiler.start("my_profile") + # Do stuff + profiler.stop() + ``` + + Visualize a profile as a flamegraph with [snakeviz](https://jiffyclub.github.io/snakeviz/) + ```sh + snakeviz my_profile.prof + ``` + + Visualize a profile as directed graph with [graphviz](https://graphviz.org/download/) & [gprof2dot](https://github.com/jrfonseca/gprof2dot) + ```sh + gprof2dot -f pstats my_profile.prof | dot -Tpng -o my_profile.png + # SVG or PDF may be nicer - you can search for function names + gprof2dot -f pstats my_profile.prof | dot -Tsvg -o my_profile.svg + gprof2dot -f pstats my_profile.prof | dot -Tpdf -o my_profile.pdf + ``` + """ + + def __init__(self, logger: Logger, output_dir: Path, prefix: Optional[str] = None) -> None: + self._logger = logger.getChild(f"profiler.{prefix}" if prefix else "profiler") + self._output_dir = output_dir + self._output_dir.mkdir(parents=True, exist_ok=True) + self._profiler: Optional[cProfile.Profile] = None + self._prefix = prefix + + self.profile_id: Optional[str] = None + + def start(self, profile_id: str) -> None: + if self._profiler: + self.stop() + + self.profile_id = profile_id + + self._profiler = cProfile.Profile() + self._profiler.enable() + self._logger.info(f"Started profiling {self.profile_id}.") + + def stop(self) -> Path: + if not self._profiler: + raise RuntimeError("Profiler not initialized. Call start() first.") + self._profiler.disable() + + filename = f"{self._prefix}_{self.profile_id}.prof" if self._prefix else f"{self.profile_id}.prof" + path = Path(self._output_dir, filename) + + self._profiler.dump_stats(path) + self._logger.info(f"Stopped profiling, profile dumped to {path}.") + self._profiler = None + self.profile_id = None + + return path diff --git a/invokeai/app/util/step_callback.py b/invokeai/app/util/step_callback.py new file mode 100644 index 0000000000000000000000000000000000000000..fd7d6f67eab1e3ec23374dda53829fa0672b3bff --- /dev/null +++ b/invokeai/app/util/step_callback.py @@ -0,0 +1,166 @@ +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 +from invokeai.backend.model_manager.config import BaseModelType +from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState + +# fast latents preview matrix for sdxl +# generated by @StAlKeR7779 +SDXL_LATENT_RGB_FACTORS = [ + # R G B + [0.3816, 0.4930, 0.5320], + [-0.3753, 0.1631, 0.1739], + [0.1770, 0.3588, -0.2048], + [-0.4350, -0.2644, -0.4289], +] +SDXL_SMOOTH_MATRIX = [ + [0.0358, 0.0964, 0.0358], + [0.0964, 0.4711, 0.0964], + [0.0358, 0.0964, 0.0358], +] + +# origingally adapted from code by @erucipe and @keturn here: +# https://discuss.huggingface.co/t/decoding-latents-to-rgb-without-upscaling/23204/7 +# these updated numbers for v1.5 are from @torridgristle +SD1_5_LATENT_RGB_FACTORS = [ + # R G B + [0.3444, 0.1385, 0.0670], # L1 + [0.1247, 0.4027, 0.1494], # L2 + [-0.3192, 0.2513, 0.2103], # L3 + [-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], +] + + +def sample_to_lowres_estimated_image( + samples: torch.Tensor, latent_rgb_factors: torch.Tensor, smooth_matrix: Optional[torch.Tensor] = None +): + latent_image = samples[0].permute(1, 2, 0) @ latent_rgb_factors + + if smooth_matrix is not None: + latent_image = latent_image.unsqueeze(0).permute(3, 0, 1, 2) + latent_image = torch.nn.functional.conv2d(latent_image, smooth_matrix.reshape((1, 1, 3, 3)), padding=1) + latent_image = latent_image.permute(1, 2, 3, 0).squeeze(0) + + latents_ubyte = ( + ((latent_image + 1) / 2).clamp(0, 1).mul(0xFF).byte() # change scale from -1..1 to 0..1 # to 0..255 + ).cpu() + + return Image.fromarray(latents_ubyte.numpy()) + + +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: + return floor(step / 2) / floor(total_steps / 2) + # order == 1 + return step / total_steps + + +SignalProgressFunc: TypeAlias = Callable[[str, float | None, Image.Image | None, tuple[int, int] | None], None] + + +def stable_diffusion_step_callback( + signal_progress: SignalProgressFunc, + intermediate_state: PipelineIntermediateState, + base_model: BaseModelType, + is_canceled: Callable[[], bool], +) -> None: + if is_canceled(): + raise CanceledException + + # Some schedulers report not only the noisy latents at the current timestep, + # but also their estimate so far of what the de-noised latents will be. Use + # that estimate if it is available. + if intermediate_state.predicted_original is not None: + sample = intermediate_state.predicted_original + 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) + elif base_model == BaseModelType.StableDiffusion3: + sd3_latent_rgb_factors = torch.tensor(SD3_5_LATENT_RGB_FACTORS, dtype=sample.dtype, device=sample.device) + image = sample_to_lowres_estimated_image(sample, sd3_latent_rgb_factors) + 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) + + width = image.width * 8 + height = image.height * 8 + percentage = calc_percentage(intermediate_state) + + signal_progress("Denoising", percentage, image, (width, height)) + + +def flux_step_callback( + signal_progress: SignalProgressFunc, + intermediate_state: PipelineIntermediateState, + is_canceled: Callable[[], bool], +) -> None: + if is_canceled(): + raise CanceledException + sample = intermediate_state.latents + latent_rgb_factors = torch.tensor(FLUX_LATENT_RGB_FACTORS, dtype=sample.dtype, device=sample.device) + latent_image_perm = sample.permute(1, 2, 0).to(dtype=sample.dtype, device=sample.device) + latent_image = latent_image_perm @ latent_rgb_factors + latents_ubyte = ( + ((latent_image + 1) / 2).clamp(0, 1).mul(0xFF) # change scale from -1..1 to 0..1 # to 0..255 + ).to(device="cpu", dtype=torch.uint8) + image = Image.fromarray(latents_ubyte.cpu().numpy()) + + width = image.width * 8 + height = image.height * 8 + percentage = calc_percentage(intermediate_state) + + signal_progress("Denoising", percentage, image, (width, height)) diff --git a/invokeai/app/util/suppress_output.py b/invokeai/app/util/suppress_output.py new file mode 100644 index 0000000000000000000000000000000000000000..d5e69460e276223a116a48b7dca64354cb045d6b --- /dev/null +++ b/invokeai/app/util/suppress_output.py @@ -0,0 +1,24 @@ +import io +import sys +from typing import Any + + +class SuppressOutput: + """Context manager to suppress stdout. + + Example: + ``` + with SuppressOutput(): + print("This will not be printed") + ``` + """ + + def __enter__(self): + # Save the original stdout + self._original_stdout = sys.stdout + # Redirect stdout to a dummy StringIO object + sys.stdout = io.StringIO() + + def __exit__(self, *args: Any, **kwargs: Any): + # Restore stdout + sys.stdout = self._original_stdout diff --git a/invokeai/app/util/thumbnails.py b/invokeai/app/util/thumbnails.py new file mode 100644 index 0000000000000000000000000000000000000000..ad722f197e42d4fb980b6906161a8adade94e986 --- /dev/null +++ b/invokeai/app/util/thumbnails.py @@ -0,0 +1,16 @@ +import os + +from PIL import Image + + +def get_thumbnail_name(image_name: str) -> str: + """Formats given an image name, returns the appropriate thumbnail image name""" + thumbnail_name = os.path.splitext(image_name)[0] + ".webp" + return thumbnail_name + + +def make_thumbnail(image: Image.Image, size: int = 256) -> Image.Image: + """Makes a thumbnail from a PIL Image""" + thumbnail = image.copy() + thumbnail.thumbnail(size=(size, size)) + return thumbnail diff --git a/invokeai/app/util/ti_utils.py b/invokeai/app/util/ti_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..34669fe64ef55324b3aae2fd99844b94d7da6b2c --- /dev/null +++ b/invokeai/app/util/ti_utils.py @@ -0,0 +1,47 @@ +import re +from typing import List, Tuple + +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.textual_inversion import TextualInversionModelRaw + + +def extract_ti_triggers_from_prompt(prompt: str) -> List[str]: + ti_triggers: List[str] = [] + for trigger in re.findall(r"<[a-zA-Z0-9., _-]+>", prompt): + ti_triggers.append(str(trigger)) + return ti_triggers + + +def generate_ti_list( + prompt: str, base: BaseModelType, context: InvocationContext +) -> List[Tuple[str, TextualInversionModelRaw]]: + ti_list: List[Tuple[str, TextualInversionModelRaw]] = [] + for trigger in extract_ti_triggers_from_prompt(prompt): + name_or_key = trigger[1:-1] + try: + loaded_model = context.models.load(name_or_key) + model = loaded_model.model + assert isinstance(model, TextualInversionModelRaw) + assert loaded_model.config.base == base + ti_list.append((name_or_key, model)) + except UnknownModelException: + try: + loaded_model = context.models.load_by_attrs( + name=name_or_key, base=base, type=ModelType.TextualInversion + ) + model = loaded_model.model + assert isinstance(model, TextualInversionModelRaw) + assert loaded_model.config.base == base + ti_list.append((name_or_key, model)) + except UnknownModelException: + pass + except ValueError: + logger.warning(f'trigger: "{trigger}" more than one similarly-named textual inversion models') + except AssertionError: + logger.warning(f'trigger: "{trigger}" not a valid textual inversion model for this graph') + except Exception: + logger.warning(f'Failed to load TI model for trigger: "{trigger}"') + return ti_list diff --git a/invokeai/assets/__init__.py b/invokeai/assets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/assets/a-painting-of-a-fire.png b/invokeai/assets/a-painting-of-a-fire.png new file mode 100644 index 0000000000000000000000000000000000000000..3d3b9bde4852fbd1b6a0dd88b53a2d7611c2ec73 Binary files /dev/null and b/invokeai/assets/a-painting-of-a-fire.png differ diff --git a/invokeai/assets/a-photograph-of-a-fire.png b/invokeai/assets/a-photograph-of-a-fire.png new file mode 100644 index 0000000000000000000000000000000000000000..e246bc1a0d7b1261be9dac7fb8f5e1f7f487fcd0 Binary files /dev/null and b/invokeai/assets/a-photograph-of-a-fire.png differ diff --git a/invokeai/assets/a-shirt-with-a-fire-printed-on-it.png b/invokeai/assets/a-shirt-with-a-fire-printed-on-it.png new file mode 100644 index 0000000000000000000000000000000000000000..aa68f188a8ab325808b44077526e5b3d44209eaf Binary files /dev/null and b/invokeai/assets/a-shirt-with-a-fire-printed-on-it.png differ diff --git a/invokeai/assets/a-shirt-with-the-inscription-'fire'.png b/invokeai/assets/a-shirt-with-the-inscription-'fire'.png new file mode 100644 index 0000000000000000000000000000000000000000..f058b97df860faeccc661cc6209d862872487887 Binary files /dev/null and b/invokeai/assets/a-shirt-with-the-inscription-'fire'.png differ diff --git a/invokeai/assets/a-watercolor-painting-of-a-fire.png b/invokeai/assets/a-watercolor-painting-of-a-fire.png new file mode 100644 index 0000000000000000000000000000000000000000..e4ebe136773e12fe35a9d010ad2541bccd670afc Binary files /dev/null and b/invokeai/assets/a-watercolor-painting-of-a-fire.png differ diff --git a/invokeai/assets/birdhouse.png b/invokeai/assets/birdhouse.png new file mode 100644 index 0000000000000000000000000000000000000000..872d49c0e2c16de6b2c303cedf4b5ec85851aeae Binary files /dev/null and b/invokeai/assets/birdhouse.png differ diff --git a/invokeai/assets/data/DejaVuSans.ttf b/invokeai/assets/data/DejaVuSans.ttf new file mode 100644 index 0000000000000000000000000000000000000000..e5f7eecce43be41ff0703ed99e1553029b849f14 Binary files /dev/null and b/invokeai/assets/data/DejaVuSans.ttf differ diff --git a/invokeai/assets/data/example_conditioning/superresolution/sample_0.jpg b/invokeai/assets/data/example_conditioning/superresolution/sample_0.jpg new file mode 100644 index 0000000000000000000000000000000000000000..09abe80ae7cf61f3071882e5eea6d15c730ab0cc Binary files /dev/null and b/invokeai/assets/data/example_conditioning/superresolution/sample_0.jpg differ diff --git a/invokeai/assets/data/example_conditioning/text_conditional/sample_0.txt b/invokeai/assets/data/example_conditioning/text_conditional/sample_0.txt new file mode 100644 index 0000000000000000000000000000000000000000..de60c5c9a38bbead2956aa0c60129a26985cbf6d --- /dev/null +++ b/invokeai/assets/data/example_conditioning/text_conditional/sample_0.txt @@ -0,0 +1 @@ +A basket of cerries diff --git a/invokeai/assets/data/imagenet_clsidx_to_label.txt b/invokeai/assets/data/imagenet_clsidx_to_label.txt new file mode 100644 index 0000000000000000000000000000000000000000..e2fe435526be7e0dd6675885c6c74b2f9276459b --- /dev/null +++ b/invokeai/assets/data/imagenet_clsidx_to_label.txt @@ -0,0 +1,1000 @@ + 0: 'tench, Tinca tinca', + 1: 'goldfish, Carassius auratus', + 2: 'great white shark, white shark, man-eater, man-eating shark, Carcharodon carcharias', + 3: 'tiger shark, Galeocerdo cuvieri', + 4: 'hammerhead, hammerhead shark', + 5: 'electric ray, crampfish, numbfish, torpedo', + 6: 'stingray', + 7: 'cock', + 8: 'hen', + 9: 'ostrich, Struthio camelus', + 10: 'brambling, Fringilla montifringilla', + 11: 'goldfinch, Carduelis carduelis', + 12: 'house finch, linnet, Carpodacus mexicanus', + 13: 'junco, snowbird', + 14: 'indigo bunting, indigo finch, indigo bird, Passerina cyanea', + 15: 'robin, American robin, Turdus migratorius', + 16: 'bulbul', + 17: 'jay', + 18: 'magpie', + 19: 'chickadee', + 20: 'water ouzel, dipper', + 21: 'kite', + 22: 'bald eagle, American eagle, Haliaeetus leucocephalus', + 23: 'vulture', + 24: 'great grey owl, great gray owl, Strix nebulosa', + 25: 'European fire salamander, Salamandra salamandra', + 26: 'common newt, Triturus vulgaris', + 27: 'eft', + 28: 'spotted salamander, Ambystoma maculatum', + 29: 'axolotl, mud puppy, Ambystoma mexicanum', + 30: 'bullfrog, Rana catesbeiana', + 31: 'tree frog, tree-frog', + 32: 'tailed frog, bell toad, ribbed toad, tailed toad, Ascaphus trui', + 33: 'loggerhead, loggerhead turtle, Caretta caretta', + 34: 'leatherback turtle, leatherback, leathery turtle, Dermochelys coriacea', + 35: 'mud turtle', + 36: 'terrapin', + 37: 'box turtle, box tortoise', + 38: 'banded gecko', + 39: 'common iguana, iguana, Iguana iguana', + 40: 'American chameleon, anole, Anolis carolinensis', + 41: 'whiptail, whiptail lizard', + 42: 'agama', + 43: 'frilled lizard, Chlamydosaurus kingi', + 44: 'alligator lizard', + 45: 'Gila monster, Heloderma suspectum', + 46: 'green lizard, Lacerta viridis', + 47: 'African chameleon, Chamaeleo chamaeleon', + 48: 'Komodo dragon, Komodo lizard, dragon lizard, giant lizard, Varanus komodoensis', + 49: 'African crocodile, Nile crocodile, Crocodylus niloticus', + 50: 'American alligator, Alligator mississipiensis', + 51: 'triceratops', + 52: 'thunder snake, worm snake, Carphophis amoenus', + 53: 'ringneck snake, ring-necked snake, ring snake', + 54: 'hognose snake, puff adder, sand viper', + 55: 'green snake, grass snake', + 56: 'king snake, kingsnake', + 57: 'garter snake, grass snake', + 58: 'water snake', + 59: 'vine snake', + 60: 'night snake, Hypsiglena torquata', + 61: 'boa constrictor, Constrictor constrictor', + 62: 'rock python, rock snake, Python sebae', + 63: 'Indian cobra, Naja naja', + 64: 'green mamba', + 65: 'sea snake', + 66: 'horned viper, cerastes, sand viper, horned asp, Cerastes cornutus', + 67: 'diamondback, diamondback rattlesnake, Crotalus adamanteus', + 68: 'sidewinder, horned rattlesnake, Crotalus cerastes', + 69: 'trilobite', + 70: 'harvestman, daddy longlegs, Phalangium opilio', + 71: 'scorpion', + 72: 'black and gold garden spider, Argiope aurantia', + 73: 'barn spider, Araneus cavaticus', + 74: 'garden spider, Aranea diademata', + 75: 'black widow, Latrodectus mactans', + 76: 'tarantula', + 77: 'wolf spider, hunting spider', + 78: 'tick', + 79: 'centipede', + 80: 'black grouse', + 81: 'ptarmigan', + 82: 'ruffed grouse, partridge, Bonasa umbellus', + 83: 'prairie chicken, prairie grouse, prairie fowl', + 84: 'peacock', + 85: 'quail', + 86: 'partridge', + 87: 'African grey, African gray, Psittacus erithacus', + 88: 'macaw', + 89: 'sulphur-crested cockatoo, Kakatoe galerita, Cacatua galerita', + 90: 'lorikeet', + 91: 'coucal', + 92: 'bee eater', + 93: 'hornbill', + 94: 'hummingbird', + 95: 'jacamar', + 96: 'toucan', + 97: 'drake', + 98: 'red-breasted merganser, Mergus serrator', + 99: 'goose', + 100: 'black swan, Cygnus atratus', + 101: 'tusker', + 102: 'echidna, spiny anteater, anteater', + 103: 'platypus, duckbill, duckbilled platypus, duck-billed platypus, Ornithorhynchus anatinus', + 104: 'wallaby, brush kangaroo', + 105: 'koala, koala bear, kangaroo bear, native bear, Phascolarctos cinereus', + 106: 'wombat', + 107: 'jellyfish', + 108: 'sea anemone, anemone', + 109: 'brain coral', + 110: 'flatworm, platyhelminth', + 111: 'nematode, nematode worm, roundworm', + 112: 'conch', + 113: 'snail', + 114: 'slug', + 115: 'sea slug, nudibranch', + 116: 'chiton, coat-of-mail shell, sea cradle, polyplacophore', + 117: 'chambered nautilus, pearly nautilus, nautilus', + 118: 'Dungeness crab, Cancer magister', + 119: 'rock crab, Cancer irroratus', + 120: 'fiddler crab', + 121: 'king crab, Alaska crab, Alaskan king crab, Alaska king crab, Paralithodes camtschatica', + 122: 'American lobster, Northern lobster, Maine lobster, Homarus americanus', + 123: 'spiny lobster, langouste, rock lobster, crawfish, crayfish, sea crawfish', + 124: 'crayfish, crawfish, crawdad, crawdaddy', + 125: 'hermit crab', + 126: 'isopod', + 127: 'white stork, Ciconia ciconia', + 128: 'black stork, Ciconia nigra', + 129: 'spoonbill', + 130: 'flamingo', + 131: 'little blue heron, Egretta caerulea', + 132: 'American egret, great white heron, Egretta albus', + 133: 'bittern', + 134: 'crane', + 135: 'limpkin, Aramus pictus', + 136: 'European gallinule, Porphyrio porphyrio', + 137: 'American coot, marsh hen, mud hen, water hen, Fulica americana', + 138: 'bustard', + 139: 'ruddy turnstone, Arenaria interpres', + 140: 'red-backed sandpiper, dunlin, Erolia alpina', + 141: 'redshank, Tringa totanus', + 142: 'dowitcher', + 143: 'oystercatcher, oyster catcher', + 144: 'pelican', + 145: 'king penguin, Aptenodytes patagonica', + 146: 'albatross, mollymawk', + 147: 'grey whale, gray whale, devilfish, Eschrichtius gibbosus, Eschrichtius robustus', + 148: 'killer whale, killer, orca, grampus, sea wolf, Orcinus orca', + 149: 'dugong, Dugong dugon', + 150: 'sea lion', + 151: 'Chihuahua', + 152: 'Japanese spaniel', + 153: 'Maltese dog, Maltese terrier, Maltese', + 154: 'Pekinese, Pekingese, Peke', + 155: 'Shih-Tzu', + 156: 'Blenheim spaniel', + 157: 'papillon', + 158: 'toy terrier', + 159: 'Rhodesian ridgeback', + 160: 'Afghan hound, Afghan', + 161: 'basset, basset hound', + 162: 'beagle', + 163: 'bloodhound, sleuthhound', + 164: 'bluetick', + 165: 'black-and-tan coonhound', + 166: 'Walker hound, Walker foxhound', + 167: 'English foxhound', + 168: 'redbone', + 169: 'borzoi, Russian wolfhound', + 170: 'Irish wolfhound', + 171: 'Italian greyhound', + 172: 'whippet', + 173: 'Ibizan hound, Ibizan Podenco', + 174: 'Norwegian elkhound, elkhound', + 175: 'otterhound, otter hound', + 176: 'Saluki, gazelle hound', + 177: 'Scottish deerhound, deerhound', + 178: 'Weimaraner', + 179: 'Staffordshire bullterrier, Staffordshire bull terrier', + 180: 'American Staffordshire terrier, Staffordshire terrier, American pit bull terrier, pit bull terrier', + 181: 'Bedlington terrier', + 182: 'Border terrier', + 183: 'Kerry blue terrier', + 184: 'Irish terrier', + 185: 'Norfolk terrier', + 186: 'Norwich terrier', + 187: 'Yorkshire terrier', + 188: 'wire-haired fox terrier', + 189: 'Lakeland terrier', + 190: 'Sealyham terrier, Sealyham', + 191: 'Airedale, Airedale terrier', + 192: 'cairn, cairn terrier', + 193: 'Australian terrier', + 194: 'Dandie Dinmont, Dandie Dinmont terrier', + 195: 'Boston bull, Boston terrier', + 196: 'miniature schnauzer', + 197: 'giant schnauzer', + 198: 'standard schnauzer', + 199: 'Scotch terrier, Scottish terrier, Scottie', + 200: 'Tibetan terrier, chrysanthemum dog', + 201: 'silky terrier, Sydney silky', + 202: 'soft-coated wheaten terrier', + 203: 'West Highland white terrier', + 204: 'Lhasa, Lhasa apso', + 205: 'flat-coated retriever', + 206: 'curly-coated retriever', + 207: 'golden retriever', + 208: 'Labrador retriever', + 209: 'Chesapeake Bay retriever', + 210: 'German short-haired pointer', + 211: 'vizsla, Hungarian pointer', + 212: 'English setter', + 213: 'Irish setter, red setter', + 214: 'Gordon setter', + 215: 'Brittany spaniel', + 216: 'clumber, clumber spaniel', + 217: 'English springer, English springer spaniel', + 218: 'Welsh springer spaniel', + 219: 'cocker spaniel, English cocker spaniel, cocker', + 220: 'Sussex spaniel', + 221: 'Irish water spaniel', + 222: 'kuvasz', + 223: 'schipperke', + 224: 'groenendael', + 225: 'malinois', + 226: 'briard', + 227: 'kelpie', + 228: 'komondor', + 229: 'Old English sheepdog, bobtail', + 230: 'Shetland sheepdog, Shetland sheep dog, Shetland', + 231: 'collie', + 232: 'Border collie', + 233: 'Bouvier des Flandres, Bouviers des Flandres', + 234: 'Rottweiler', + 235: 'German shepherd, German shepherd dog, German police dog, alsatian', + 236: 'Doberman, Doberman pinscher', + 237: 'miniature pinscher', + 238: 'Greater Swiss Mountain dog', + 239: 'Bernese mountain dog', + 240: 'Appenzeller', + 241: 'EntleBucher', + 242: 'boxer', + 243: 'bull mastiff', + 244: 'Tibetan mastiff', + 245: 'French bulldog', + 246: 'Great Dane', + 247: 'Saint Bernard, St Bernard', + 248: 'Eskimo dog, husky', + 249: 'malamute, malemute, Alaskan malamute', + 250: 'Siberian husky', + 251: 'dalmatian, coach dog, carriage dog', + 252: 'affenpinscher, monkey pinscher, monkey dog', + 253: 'basenji', + 254: 'pug, pug-dog', + 255: 'Leonberg', + 256: 'Newfoundland, Newfoundland dog', + 257: 'Great Pyrenees', + 258: 'Samoyed, Samoyede', + 259: 'Pomeranian', + 260: 'chow, chow chow', + 261: 'keeshond', + 262: 'Brabancon griffon', + 263: 'Pembroke, Pembroke Welsh corgi', + 264: 'Cardigan, Cardigan Welsh corgi', + 265: 'toy poodle', + 266: 'miniature poodle', + 267: 'standard poodle', + 268: 'Mexican hairless', + 269: 'timber wolf, grey wolf, gray wolf, Canis lupus', + 270: 'white wolf, Arctic wolf, Canis lupus tundrarum', + 271: 'red wolf, maned wolf, Canis rufus, Canis niger', + 272: 'coyote, prairie wolf, brush wolf, Canis latrans', + 273: 'dingo, warrigal, warragal, Canis dingo', + 274: 'dhole, Cuon alpinus', + 275: 'African hunting dog, hyena dog, Cape hunting dog, Lycaon pictus', + 276: 'hyena, hyaena', + 277: 'red fox, Vulpes vulpes', + 278: 'kit fox, Vulpes macrotis', + 279: 'Arctic fox, white fox, Alopex lagopus', + 280: 'grey fox, gray fox, Urocyon cinereoargenteus', + 281: 'tabby, tabby cat', + 282: 'tiger cat', + 283: 'Persian cat', + 284: 'Siamese cat, Siamese', + 285: 'Egyptian cat', + 286: 'cougar, puma, catamount, mountain lion, painter, panther, Felis concolor', + 287: 'lynx, catamount', + 288: 'leopard, Panthera pardus', + 289: 'snow leopard, ounce, Panthera uncia', + 290: 'jaguar, panther, Panthera onca, Felis onca', + 291: 'lion, king of beasts, Panthera leo', + 292: 'tiger, Panthera tigris', + 293: 'cheetah, chetah, Acinonyx jubatus', + 294: 'brown bear, bruin, Ursus arctos', + 295: 'American black bear, black bear, Ursus americanus, Euarctos americanus', + 296: 'ice bear, polar bear, Ursus Maritimus, Thalarctos maritimus', + 297: 'sloth bear, Melursus ursinus, Ursus ursinus', + 298: 'mongoose', + 299: 'meerkat, mierkat', + 300: 'tiger beetle', + 301: 'ladybug, ladybeetle, lady beetle, ladybird, ladybird beetle', + 302: 'ground beetle, carabid beetle', + 303: 'long-horned beetle, longicorn, longicorn beetle', + 304: 'leaf beetle, chrysomelid', + 305: 'dung beetle', + 306: 'rhinoceros beetle', + 307: 'weevil', + 308: 'fly', + 309: 'bee', + 310: 'ant, emmet, pismire', + 311: 'grasshopper, hopper', + 312: 'cricket', + 313: 'walking stick, walkingstick, stick insect', + 314: 'cockroach, roach', + 315: 'mantis, mantid', + 316: 'cicada, cicala', + 317: 'leafhopper', + 318: 'lacewing, lacewing fly', + 319: "dragonfly, darning needle, devil's darning needle, sewing needle, snake feeder, snake doctor, mosquito hawk, skeeter hawk", + 320: 'damselfly', + 321: 'admiral', + 322: 'ringlet, ringlet butterfly', + 323: 'monarch, monarch butterfly, milkweed butterfly, Danaus plexippus', + 324: 'cabbage butterfly', + 325: 'sulphur butterfly, sulfur butterfly', + 326: 'lycaenid, lycaenid butterfly', + 327: 'starfish, sea star', + 328: 'sea urchin', + 329: 'sea cucumber, holothurian', + 330: 'wood rabbit, cottontail, cottontail rabbit', + 331: 'hare', + 332: 'Angora, Angora rabbit', + 333: 'hamster', + 334: 'porcupine, hedgehog', + 335: 'fox squirrel, eastern fox squirrel, Sciurus niger', + 336: 'marmot', + 337: 'beaver', + 338: 'guinea pig, Cavia cobaya', + 339: 'sorrel', + 340: 'zebra', + 341: 'hog, pig, grunter, squealer, Sus scrofa', + 342: 'wild boar, boar, Sus scrofa', + 343: 'warthog', + 344: 'hippopotamus, hippo, river horse, Hippopotamus amphibius', + 345: 'ox', + 346: 'water buffalo, water ox, Asiatic buffalo, Bubalus bubalis', + 347: 'bison', + 348: 'ram, tup', + 349: 'bighorn, bighorn sheep, cimarron, Rocky Mountain bighorn, Rocky Mountain sheep, Ovis canadensis', + 350: 'ibex, Capra ibex', + 351: 'hartebeest', + 352: 'impala, Aepyceros melampus', + 353: 'gazelle', + 354: 'Arabian camel, dromedary, Camelus dromedarius', + 355: 'llama', + 356: 'weasel', + 357: 'mink', + 358: 'polecat, fitch, foulmart, foumart, Mustela putorius', + 359: 'black-footed ferret, ferret, Mustela nigripes', + 360: 'otter', + 361: 'skunk, polecat, wood pussy', + 362: 'badger', + 363: 'armadillo', + 364: 'three-toed sloth, ai, Bradypus tridactylus', + 365: 'orangutan, orang, orangutang, Pongo pygmaeus', + 366: 'gorilla, Gorilla gorilla', + 367: 'chimpanzee, chimp, Pan troglodytes', + 368: 'gibbon, Hylobates lar', + 369: 'siamang, Hylobates syndactylus, Symphalangus syndactylus', + 370: 'guenon, guenon monkey', + 371: 'patas, hussar monkey, Erythrocebus patas', + 372: 'baboon', + 373: 'macaque', + 374: 'langur', + 375: 'colobus, colobus monkey', + 376: 'proboscis monkey, Nasalis larvatus', + 377: 'marmoset', + 378: 'capuchin, ringtail, Cebus capucinus', + 379: 'howler monkey, howler', + 380: 'titi, titi monkey', + 381: 'spider monkey, Ateles geoffroyi', + 382: 'squirrel monkey, Saimiri sciureus', + 383: 'Madagascar cat, ring-tailed lemur, Lemur catta', + 384: 'indri, indris, Indri indri, Indri brevicaudatus', + 385: 'Indian elephant, Elephas maximus', + 386: 'African elephant, Loxodonta africana', + 387: 'lesser panda, red panda, panda, bear cat, cat bear, Ailurus fulgens', + 388: 'giant panda, panda, panda bear, coon bear, Ailuropoda melanoleuca', + 389: 'barracouta, snoek', + 390: 'eel', + 391: 'coho, cohoe, coho salmon, blue jack, silver salmon, Oncorhynchus kisutch', + 392: 'rock beauty, Holocanthus tricolor', + 393: 'anemone fish', + 394: 'sturgeon', + 395: 'gar, garfish, garpike, billfish, Lepisosteus osseus', + 396: 'lionfish', + 397: 'puffer, pufferfish, blowfish, globefish', + 398: 'abacus', + 399: 'abaya', + 400: "academic gown, academic robe, judge's robe", + 401: 'accordion, piano accordion, squeeze box', + 402: 'acoustic guitar', + 403: 'aircraft carrier, carrier, flattop, attack aircraft carrier', + 404: 'airliner', + 405: 'airship, dirigible', + 406: 'altar', + 407: 'ambulance', + 408: 'amphibian, amphibious vehicle', + 409: 'analog clock', + 410: 'apiary, bee house', + 411: 'apron', + 412: 'ashcan, trash can, garbage can, wastebin, ash bin, ash-bin, ashbin, dustbin, trash barrel, trash bin', + 413: 'assault rifle, assault gun', + 414: 'backpack, back pack, knapsack, packsack, rucksack, haversack', + 415: 'bakery, bakeshop, bakehouse', + 416: 'balance beam, beam', + 417: 'balloon', + 418: 'ballpoint, ballpoint pen, ballpen, Biro', + 419: 'Band Aid', + 420: 'banjo', + 421: 'bannister, banister, balustrade, balusters, handrail', + 422: 'barbell', + 423: 'barber chair', + 424: 'barbershop', + 425: 'barn', + 426: 'barometer', + 427: 'barrel, cask', + 428: 'barrow, garden cart, lawn cart, wheelbarrow', + 429: 'baseball', + 430: 'basketball', + 431: 'bassinet', + 432: 'bassoon', + 433: 'bathing cap, swimming cap', + 434: 'bath towel', + 435: 'bathtub, bathing tub, bath, tub', + 436: 'beach wagon, station wagon, wagon, estate car, beach waggon, station waggon, waggon', + 437: 'beacon, lighthouse, beacon light, pharos', + 438: 'beaker', + 439: 'bearskin, busby, shako', + 440: 'beer bottle', + 441: 'beer glass', + 442: 'bell cote, bell cot', + 443: 'bib', + 444: 'bicycle-built-for-two, tandem bicycle, tandem', + 445: 'bikini, two-piece', + 446: 'binder, ring-binder', + 447: 'binoculars, field glasses, opera glasses', + 448: 'birdhouse', + 449: 'boathouse', + 450: 'bobsled, bobsleigh, bob', + 451: 'bolo tie, bolo, bola tie, bola', + 452: 'bonnet, poke bonnet', + 453: 'bookcase', + 454: 'bookshop, bookstore, bookstall', + 455: 'bottlecap', + 456: 'bow', + 457: 'bow tie, bow-tie, bowtie', + 458: 'brass, memorial tablet, plaque', + 459: 'brassiere, bra, bandeau', + 460: 'breakwater, groin, groyne, mole, bulwark, seawall, jetty', + 461: 'breastplate, aegis, egis', + 462: 'broom', + 463: 'bucket, pail', + 464: 'buckle', + 465: 'bulletproof vest', + 466: 'bullet train, bullet', + 467: 'butcher shop, meat market', + 468: 'cab, hack, taxi, taxicab', + 469: 'caldron, cauldron', + 470: 'candle, taper, wax light', + 471: 'cannon', + 472: 'canoe', + 473: 'can opener, tin opener', + 474: 'cardigan', + 475: 'car mirror', + 476: 'carousel, carrousel, merry-go-round, roundabout, whirligig', + 477: "carpenter's kit, tool kit", + 478: 'carton', + 479: 'car wheel', + 480: 'cash machine, cash dispenser, automated teller machine, automatic teller machine, automated teller, automatic teller, ATM', + 481: 'cassette', + 482: 'cassette player', + 483: 'castle', + 484: 'catamaran', + 485: 'CD player', + 486: 'cello, violoncello', + 487: 'cellular telephone, cellular phone, cellphone, cell, mobile phone', + 488: 'chain', + 489: 'chainlink fence', + 490: 'chain mail, ring mail, mail, chain armor, chain armour, ring armor, ring armour', + 491: 'chain saw, chainsaw', + 492: 'chest', + 493: 'chiffonier, commode', + 494: 'chime, bell, gong', + 495: 'china cabinet, china closet', + 496: 'Christmas stocking', + 497: 'church, church building', + 498: 'cinema, movie theater, movie theatre, movie house, picture palace', + 499: 'cleaver, meat cleaver, chopper', + 500: 'cliff dwelling', + 501: 'cloak', + 502: 'clog, geta, patten, sabot', + 503: 'cocktail shaker', + 504: 'coffee mug', + 505: 'coffeepot', + 506: 'coil, spiral, volute, whorl, helix', + 507: 'combination lock', + 508: 'computer keyboard, keypad', + 509: 'confectionery, confectionary, candy store', + 510: 'container ship, containership, container vessel', + 511: 'convertible', + 512: 'corkscrew, bottle screw', + 513: 'cornet, horn, trumpet, trump', + 514: 'cowboy boot', + 515: 'cowboy hat, ten-gallon hat', + 516: 'cradle', + 517: 'crane', + 518: 'crash helmet', + 519: 'crate', + 520: 'crib, cot', + 521: 'Crock Pot', + 522: 'croquet ball', + 523: 'crutch', + 524: 'cuirass', + 525: 'dam, dike, dyke', + 526: 'desk', + 527: 'desktop computer', + 528: 'dial telephone, dial phone', + 529: 'diaper, nappy, napkin', + 530: 'digital clock', + 531: 'digital watch', + 532: 'dining table, board', + 533: 'dishrag, dishcloth', + 534: 'dishwasher, dish washer, dishwashing machine', + 535: 'disk brake, disc brake', + 536: 'dock, dockage, docking facility', + 537: 'dogsled, dog sled, dog sleigh', + 538: 'dome', + 539: 'doormat, welcome mat', + 540: 'drilling platform, offshore rig', + 541: 'drum, membranophone, tympan', + 542: 'drumstick', + 543: 'dumbbell', + 544: 'Dutch oven', + 545: 'electric fan, blower', + 546: 'electric guitar', + 547: 'electric locomotive', + 548: 'entertainment center', + 549: 'envelope', + 550: 'espresso maker', + 551: 'face powder', + 552: 'feather boa, boa', + 553: 'file, file cabinet, filing cabinet', + 554: 'fireboat', + 555: 'fire engine, fire truck', + 556: 'fire screen, fireguard', + 557: 'flagpole, flagstaff', + 558: 'flute, transverse flute', + 559: 'folding chair', + 560: 'football helmet', + 561: 'forklift', + 562: 'fountain', + 563: 'fountain pen', + 564: 'four-poster', + 565: 'freight car', + 566: 'French horn, horn', + 567: 'frying pan, frypan, skillet', + 568: 'fur coat', + 569: 'garbage truck, dustcart', + 570: 'gasmask, respirator, gas helmet', + 571: 'gas pump, gasoline pump, petrol pump, island dispenser', + 572: 'goblet', + 573: 'go-kart', + 574: 'golf ball', + 575: 'golfcart, golf cart', + 576: 'gondola', + 577: 'gong, tam-tam', + 578: 'gown', + 579: 'grand piano, grand', + 580: 'greenhouse, nursery, glasshouse', + 581: 'grille, radiator grille', + 582: 'grocery store, grocery, food market, market', + 583: 'guillotine', + 584: 'hair slide', + 585: 'hair spray', + 586: 'half track', + 587: 'hammer', + 588: 'hamper', + 589: 'hand blower, blow dryer, blow drier, hair dryer, hair drier', + 590: 'hand-held computer, hand-held microcomputer', + 591: 'handkerchief, hankie, hanky, hankey', + 592: 'hard disc, hard disk, fixed disk', + 593: 'harmonica, mouth organ, harp, mouth harp', + 594: 'harp', + 595: 'harvester, reaper', + 596: 'hatchet', + 597: 'holster', + 598: 'home theater, home theatre', + 599: 'honeycomb', + 600: 'hook, claw', + 601: 'hoopskirt, crinoline', + 602: 'horizontal bar, high bar', + 603: 'horse cart, horse-cart', + 604: 'hourglass', + 605: 'iPod', + 606: 'iron, smoothing iron', + 607: "jack-o'-lantern", + 608: 'jean, blue jean, denim', + 609: 'jeep, landrover', + 610: 'jersey, T-shirt, tee shirt', + 611: 'jigsaw puzzle', + 612: 'jinrikisha, ricksha, rickshaw', + 613: 'joystick', + 614: 'kimono', + 615: 'knee pad', + 616: 'knot', + 617: 'lab coat, laboratory coat', + 618: 'ladle', + 619: 'lampshade, lamp shade', + 620: 'laptop, laptop computer', + 621: 'lawn mower, mower', + 622: 'lens cap, lens cover', + 623: 'letter opener, paper knife, paperknife', + 624: 'library', + 625: 'lifeboat', + 626: 'lighter, light, igniter, ignitor', + 627: 'limousine, limo', + 628: 'liner, ocean liner', + 629: 'lipstick, lip rouge', + 630: 'Loafer', + 631: 'lotion', + 632: 'loudspeaker, speaker, speaker unit, loudspeaker system, speaker system', + 633: "loupe, jeweler's loupe", + 634: 'lumbermill, sawmill', + 635: 'magnetic compass', + 636: 'mailbag, postbag', + 637: 'mailbox, letter box', + 638: 'maillot', + 639: 'maillot, tank suit', + 640: 'manhole cover', + 641: 'maraca', + 642: 'marimba, xylophone', + 643: 'mask', + 644: 'matchstick', + 645: 'maypole', + 646: 'maze, labyrinth', + 647: 'measuring cup', + 648: 'medicine chest, medicine cabinet', + 649: 'megalith, megalithic structure', + 650: 'microphone, mike', + 651: 'microwave, microwave oven', + 652: 'military uniform', + 653: 'milk can', + 654: 'minibus', + 655: 'miniskirt, mini', + 656: 'minivan', + 657: 'missile', + 658: 'mitten', + 659: 'mixing bowl', + 660: 'mobile home, manufactured home', + 661: 'Model T', + 662: 'modem', + 663: 'monastery', + 664: 'monitor', + 665: 'moped', + 666: 'mortar', + 667: 'mortarboard', + 668: 'mosque', + 669: 'mosquito net', + 670: 'motor scooter, scooter', + 671: 'mountain bike, all-terrain bike, off-roader', + 672: 'mountain tent', + 673: 'mouse, computer mouse', + 674: 'mousetrap', + 675: 'moving van', + 676: 'muzzle', + 677: 'nail', + 678: 'neck brace', + 679: 'necklace', + 680: 'nipple', + 681: 'notebook, notebook computer', + 682: 'obelisk', + 683: 'oboe, hautboy, hautbois', + 684: 'ocarina, sweet potato', + 685: 'odometer, hodometer, mileometer, milometer', + 686: 'oil filter', + 687: 'organ, pipe organ', + 688: 'oscilloscope, scope, cathode-ray oscilloscope, CRO', + 689: 'overskirt', + 690: 'oxcart', + 691: 'oxygen mask', + 692: 'packet', + 693: 'paddle, boat paddle', + 694: 'paddlewheel, paddle wheel', + 695: 'padlock', + 696: 'paintbrush', + 697: "pajama, pyjama, pj's, jammies", + 698: 'palace', + 699: 'panpipe, pandean pipe, syrinx', + 700: 'paper towel', + 701: 'parachute, chute', + 702: 'parallel bars, bars', + 703: 'park bench', + 704: 'parking meter', + 705: 'passenger car, coach, carriage', + 706: 'patio, terrace', + 707: 'pay-phone, pay-station', + 708: 'pedestal, plinth, footstall', + 709: 'pencil box, pencil case', + 710: 'pencil sharpener', + 711: 'perfume, essence', + 712: 'Petri dish', + 713: 'photocopier', + 714: 'pick, plectrum, plectron', + 715: 'pickelhaube', + 716: 'picket fence, paling', + 717: 'pickup, pickup truck', + 718: 'pier', + 719: 'piggy bank, penny bank', + 720: 'pill bottle', + 721: 'pillow', + 722: 'ping-pong ball', + 723: 'pinwheel', + 724: 'pirate, pirate ship', + 725: 'pitcher, ewer', + 726: "plane, carpenter's plane, woodworking plane", + 727: 'planetarium', + 728: 'plastic bag', + 729: 'plate rack', + 730: 'plow, plough', + 731: "plunger, plumber's helper", + 732: 'Polaroid camera, Polaroid Land camera', + 733: 'pole', + 734: 'police van, police wagon, paddy wagon, patrol wagon, wagon, black Maria', + 735: 'poncho', + 736: 'pool table, billiard table, snooker table', + 737: 'pop bottle, soda bottle', + 738: 'pot, flowerpot', + 739: "potter's wheel", + 740: 'power drill', + 741: 'prayer rug, prayer mat', + 742: 'printer', + 743: 'prison, prison house', + 744: 'projectile, missile', + 745: 'projector', + 746: 'puck, hockey puck', + 747: 'punching bag, punch bag, punching ball, punchball', + 748: 'purse', + 749: 'quill, quill pen', + 750: 'quilt, comforter, comfort, puff', + 751: 'racer, race car, racing car', + 752: 'racket, racquet', + 753: 'radiator', + 754: 'radio, wireless', + 755: 'radio telescope, radio reflector', + 756: 'rain barrel', + 757: 'recreational vehicle, RV, R.V.', + 758: 'reel', + 759: 'reflex camera', + 760: 'refrigerator, icebox', + 761: 'remote control, remote', + 762: 'restaurant, eating house, eating place, eatery', + 763: 'revolver, six-gun, six-shooter', + 764: 'rifle', + 765: 'rocking chair, rocker', + 766: 'rotisserie', + 767: 'rubber eraser, rubber, pencil eraser', + 768: 'rugby ball', + 769: 'rule, ruler', + 770: 'running shoe', + 771: 'safe', + 772: 'safety pin', + 773: 'saltshaker, salt shaker', + 774: 'sandal', + 775: 'sarong', + 776: 'sax, saxophone', + 777: 'scabbard', + 778: 'scale, weighing machine', + 779: 'school bus', + 780: 'schooner', + 781: 'scoreboard', + 782: 'screen, CRT screen', + 783: 'screw', + 784: 'screwdriver', + 785: 'seat belt, seatbelt', + 786: 'sewing machine', + 787: 'shield, buckler', + 788: 'shoe shop, shoe-shop, shoe store', + 789: 'shoji', + 790: 'shopping basket', + 791: 'shopping cart', + 792: 'shovel', + 793: 'shower cap', + 794: 'shower curtain', + 795: 'ski', + 796: 'ski mask', + 797: 'sleeping bag', + 798: 'slide rule, slipstick', + 799: 'sliding door', + 800: 'slot, one-armed bandit', + 801: 'snorkel', + 802: 'snowmobile', + 803: 'snowplow, snowplough', + 804: 'soap dispenser', + 805: 'soccer ball', + 806: 'sock', + 807: 'solar dish, solar collector, solar furnace', + 808: 'sombrero', + 809: 'soup bowl', + 810: 'space bar', + 811: 'space heater', + 812: 'space shuttle', + 813: 'spatula', + 814: 'speedboat', + 815: "spider web, spider's web", + 816: 'spindle', + 817: 'sports car, sport car', + 818: 'spotlight, spot', + 819: 'stage', + 820: 'steam locomotive', + 821: 'steel arch bridge', + 822: 'steel drum', + 823: 'stethoscope', + 824: 'stole', + 825: 'stone wall', + 826: 'stopwatch, stop watch', + 827: 'stove', + 828: 'strainer', + 829: 'streetcar, tram, tramcar, trolley, trolley car', + 830: 'stretcher', + 831: 'studio couch, day bed', + 832: 'stupa, tope', + 833: 'submarine, pigboat, sub, U-boat', + 834: 'suit, suit of clothes', + 835: 'sundial', + 836: 'sunglass', + 837: 'sunglasses, dark glasses, shades', + 838: 'sunscreen, sunblock, sun blocker', + 839: 'suspension bridge', + 840: 'swab, swob, mop', + 841: 'sweatshirt', + 842: 'swimming trunks, bathing trunks', + 843: 'swing', + 844: 'switch, electric switch, electrical switch', + 845: 'syringe', + 846: 'table lamp', + 847: 'tank, army tank, armored combat vehicle, armoured combat vehicle', + 848: 'tape player', + 849: 'teapot', + 850: 'teddy, teddy bear', + 851: 'television, television system', + 852: 'tennis ball', + 853: 'thatch, thatched roof', + 854: 'theater curtain, theatre curtain', + 855: 'thimble', + 856: 'thresher, thrasher, threshing machine', + 857: 'throne', + 858: 'tile roof', + 859: 'toaster', + 860: 'tobacco shop, tobacconist shop, tobacconist', + 861: 'toilet seat', + 862: 'torch', + 863: 'totem pole', + 864: 'tow truck, tow car, wrecker', + 865: 'toyshop', + 866: 'tractor', + 867: 'trailer truck, tractor trailer, trucking rig, rig, articulated lorry, semi', + 868: 'tray', + 869: 'trench coat', + 870: 'tricycle, trike, velocipede', + 871: 'trimaran', + 872: 'tripod', + 873: 'triumphal arch', + 874: 'trolleybus, trolley coach, trackless trolley', + 875: 'trombone', + 876: 'tub, vat', + 877: 'turnstile', + 878: 'typewriter keyboard', + 879: 'umbrella', + 880: 'unicycle, monocycle', + 881: 'upright, upright piano', + 882: 'vacuum, vacuum cleaner', + 883: 'vase', + 884: 'vault', + 885: 'velvet', + 886: 'vending machine', + 887: 'vestment', + 888: 'viaduct', + 889: 'violin, fiddle', + 890: 'volleyball', + 891: 'waffle iron', + 892: 'wall clock', + 893: 'wallet, billfold, notecase, pocketbook', + 894: 'wardrobe, closet, press', + 895: 'warplane, military plane', + 896: 'washbasin, handbasin, washbowl, lavabo, wash-hand basin', + 897: 'washer, automatic washer, washing machine', + 898: 'water bottle', + 899: 'water jug', + 900: 'water tower', + 901: 'whiskey jug', + 902: 'whistle', + 903: 'wig', + 904: 'window screen', + 905: 'window shade', + 906: 'Windsor tie', + 907: 'wine bottle', + 908: 'wing', + 909: 'wok', + 910: 'wooden spoon', + 911: 'wool, woolen, woollen', + 912: 'worm fence, snake fence, snake-rail fence, Virginia fence', + 913: 'wreck', + 914: 'yawl', + 915: 'yurt', + 916: 'web site, website, internet site, site', + 917: 'comic book', + 918: 'crossword puzzle, crossword', + 919: 'street sign', + 920: 'traffic light, traffic signal, stoplight', + 921: 'book jacket, dust cover, dust jacket, dust wrapper', + 922: 'menu', + 923: 'plate', + 924: 'guacamole', + 925: 'consomme', + 926: 'hot pot, hotpot', + 927: 'trifle', + 928: 'ice cream, icecream', + 929: 'ice lolly, lolly, lollipop, popsicle', + 930: 'French loaf', + 931: 'bagel, beigel', + 932: 'pretzel', + 933: 'cheeseburger', + 934: 'hotdog, hot dog, red hot', + 935: 'mashed potato', + 936: 'head cabbage', + 937: 'broccoli', + 938: 'cauliflower', + 939: 'zucchini, courgette', + 940: 'spaghetti squash', + 941: 'acorn squash', + 942: 'butternut squash', + 943: 'cucumber, cuke', + 944: 'artichoke, globe artichoke', + 945: 'bell pepper', + 946: 'cardoon', + 947: 'mushroom', + 948: 'Granny Smith', + 949: 'strawberry', + 950: 'orange', + 951: 'lemon', + 952: 'fig', + 953: 'pineapple, ananas', + 954: 'banana', + 955: 'jackfruit, jak, jack', + 956: 'custard apple', + 957: 'pomegranate', + 958: 'hay', + 959: 'carbonara', + 960: 'chocolate sauce, chocolate syrup', + 961: 'dough', + 962: 'meat loaf, meatloaf', + 963: 'pizza, pizza pie', + 964: 'potpie', + 965: 'burrito', + 966: 'red wine', + 967: 'espresso', + 968: 'cup', + 969: 'eggnog', + 970: 'alp', + 971: 'bubble', + 972: 'cliff, drop, drop-off', + 973: 'coral reef', + 974: 'geyser', + 975: 'lakeside, lakeshore', + 976: 'promontory, headland, head, foreland', + 977: 'sandbar, sand bar', + 978: 'seashore, coast, seacoast, sea-coast', + 979: 'valley, vale', + 980: 'volcano', + 981: 'ballplayer, baseball player', + 982: 'groom, bridegroom', + 983: 'scuba diver', + 984: 'rapeseed', + 985: 'daisy', + 986: "yellow lady's slipper, yellow lady-slipper, Cypripedium calceolus, Cypripedium parviflorum", + 987: 'corn', + 988: 'acorn', + 989: 'hip, rose hip, rosehip', + 990: 'buckeye, horse chestnut, conker', + 991: 'coral fungus', + 992: 'agaric', + 993: 'gyromitra', + 994: 'stinkhorn, carrion fungus', + 995: 'earthstar', + 996: 'hen-of-the-woods, hen of the woods, Polyporus frondosus, Grifola frondosa', + 997: 'bolete', + 998: 'ear, spike, capitulum', + 999: 'toilet tissue, toilet paper, bathroom tissue' \ No newline at end of file diff --git a/invokeai/assets/data/imagenet_train_hr_indices.p b/invokeai/assets/data/imagenet_train_hr_indices.p new file mode 100644 index 0000000000000000000000000000000000000000..1b3c399e00da86eae76b157451be629063bbf31d --- /dev/null +++ b/invokeai/assets/data/imagenet_train_hr_indices.p @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:9116decb10fae4d546892a551bf71865423b48ffccda61a38e52c59ad8d49d67 +size 5641045 diff --git a/invokeai/assets/data/imagenet_val_hr_indices.p b/invokeai/assets/data/imagenet_val_hr_indices.p new file mode 100644 index 0000000000000000000000000000000000000000..744ad64b430f82b6fac24d3015a147ba1436b408 Binary files /dev/null and b/invokeai/assets/data/imagenet_val_hr_indices.p differ diff --git a/invokeai/assets/data/index_synset.yaml b/invokeai/assets/data/index_synset.yaml new file mode 100644 index 0000000000000000000000000000000000000000..635ea71a0da40d42072fee110143520daa203ce6 --- /dev/null +++ b/invokeai/assets/data/index_synset.yaml @@ -0,0 +1,1000 @@ +0: n01440764 +1: n01443537 +2: n01484850 +3: n01491361 +4: n01494475 +5: n01496331 +6: n01498041 +7: n01514668 +8: n07646067 +9: n01518878 +10: n01530575 +11: n01531178 +12: n01532829 +13: n01534433 +14: n01537544 +15: n01558993 +16: n01560419 +17: n01580077 +18: n01582220 +19: n01592084 +20: n01601694 +21: n13382471 +22: n01614925 +23: n01616318 +24: n01622779 +25: n01629819 +26: n01630670 +27: n01631663 +28: n01632458 +29: n01632777 +30: n01641577 +31: n01644373 +32: n01644900 +33: n01664065 +34: n01665541 +35: n01667114 +36: n01667778 +37: n01669191 +38: n01675722 +39: n01677366 +40: n01682714 +41: n01685808 +42: n01687978 +43: n01688243 +44: n01689811 +45: n01692333 +46: n01693334 +47: n01694178 +48: n01695060 +49: n01697457 +50: n01698640 +51: n01704323 +52: n01728572 +53: n01728920 +54: n01729322 +55: n01729977 +56: n01734418 +57: n01735189 +58: n01737021 +59: n01739381 +60: n01740131 +61: n01742172 +62: n01744401 +63: n01748264 +64: n01749939 +65: n01751748 +66: n01753488 +67: n01755581 +68: n01756291 +69: n01768244 +70: n01770081 +71: n01770393 +72: n01773157 +73: n01773549 +74: n01773797 +75: n01774384 +76: n01774750 +77: n01775062 +78: n04432308 +79: n01784675 +80: n01795545 +81: n01796340 +82: n01797886 +83: n01798484 +84: n01806143 +85: n07647321 +86: n07647496 +87: n01817953 +88: n01818515 +89: n01819313 +90: n01820546 +91: n01824575 +92: n01828970 +93: n01829413 +94: n01833805 +95: n01843065 +96: n01843383 +97: n01847000 +98: n01855032 +99: n07646821 +100: n01860187 +101: n01871265 +102: n01872772 +103: n01873310 +104: n01877812 +105: n01882714 +106: n01883070 +107: n01910747 +108: n01914609 +109: n01917289 +110: n01924916 +111: n01930112 +112: n01943899 +113: n01944390 +114: n13719102 +115: n01950731 +116: n01955084 +117: n01968897 +118: n01978287 +119: n01978455 +120: n01980166 +121: n01981276 +122: n01983481 +123: n01984695 +124: n01985128 +125: n01986214 +126: n01990800 +127: n02002556 +128: n02002724 +129: n02006656 +130: n02007558 +131: n02009229 +132: n02009912 +133: n02011460 +134: n03126707 +135: n02013706 +136: n02017213 +137: n02018207 +138: n02018795 +139: n02025239 +140: n02027492 +141: n02028035 +142: n02033041 +143: n02037110 +144: n02051845 +145: n02056570 +146: n02058221 +147: n02066245 +148: n02071294 +149: n02074367 +150: n02077923 +151: n08742578 +152: n02085782 +153: n02085936 +154: n02086079 +155: n02086240 +156: n02086646 +157: n02086910 +158: n02087046 +159: n02087394 +160: n02088094 +161: n02088238 +162: n02088364 +163: n02088466 +164: n02088632 +165: n02089078 +166: n02089867 +167: n02089973 +168: n02090379 +169: n02090622 +170: n02090721 +171: n02091032 +172: n02091134 +173: n02091244 +174: n02091467 +175: n02091635 +176: n02091831 +177: n02092002 +178: n02092339 +179: n02093256 +180: n02093428 +181: n02093647 +182: n02093754 +183: n02093859 +184: n02093991 +185: n02094114 +186: n02094258 +187: n02094433 +188: n02095314 +189: n02095570 +190: n02095889 +191: n02096051 +192: n02096177 +193: n02096294 +194: n02096437 +195: n02096585 +196: n02097047 +197: n02097130 +198: n02097209 +199: n02097298 +200: n02097474 +201: n02097658 +202: n02098105 +203: n02098286 +204: n02098413 +205: n02099267 +206: n02099429 +207: n02099601 +208: n02099712 +209: n02099849 +210: n02100236 +211: n02100583 +212: n02100735 +213: n02100877 +214: n02101006 +215: n02101388 +216: n02101556 +217: n02102040 +218: n02102177 +219: n02102318 +220: n02102480 +221: n02102973 +222: n02104029 +223: n02104365 +224: n02105056 +225: n02105162 +226: n02105251 +227: n02105412 +228: n02105505 +229: n02105641 +230: n02105855 +231: n02106030 +232: n02106166 +233: n02106382 +234: n02106550 +235: n02106662 +236: n02107142 +237: n02107312 +238: n02107574 +239: n02107683 +240: n02107908 +241: n02108000 +242: n02108089 +243: n02108422 +244: n02108551 +245: n02108915 +246: n02109047 +247: n02109525 +248: n02109961 +249: n02110063 +250: n02110185 +251: n02110341 +252: n02110627 +253: n02110806 +254: n02110958 +255: n02111129 +256: n02111277 +257: n02111500 +258: n02111889 +259: n02112018 +260: n02112137 +261: n02112350 +262: n02112706 +263: n02113023 +264: n02113186 +265: n02113624 +266: n02113712 +267: n02113799 +268: n02113978 +269: n02114367 +270: n02114548 +271: n02114712 +272: n02114855 +273: n02115641 +274: n02115913 +275: n02116738 +276: n02117135 +277: n02119022 +278: n02119789 +279: n02120079 +280: n02120505 +281: n02123045 +282: n02123159 +283: n02123394 +284: n02123597 +285: n02124075 +286: n02125311 +287: n02127052 +288: n02128385 +289: n02128757 +290: n02128925 +291: n02129165 +292: n02129604 +293: n02130308 +294: n02132136 +295: n02133161 +296: n02134084 +297: n02134418 +298: n02137549 +299: n02138441 +300: n02165105 +301: n02165456 +302: n02167151 +303: n02168699 +304: n02169497 +305: n02172182 +306: n02174001 +307: n02177972 +308: n03373237 +309: n07975909 +310: n02219486 +311: n02226429 +312: n02229544 +313: n02231487 +314: n02233338 +315: n02236044 +316: n02256656 +317: n02259212 +318: n02264363 +319: n02268443 +320: n02268853 +321: n02276258 +322: n02277742 +323: n02279972 +324: n02280649 +325: n02281406 +326: n02281787 +327: n02317335 +328: n02319095 +329: n02321529 +330: n02325366 +331: n02326432 +332: n02328150 +333: n02342885 +334: n02346627 +335: n02356798 +336: n02361337 +337: n05262120 +338: n02364673 +339: n02389026 +340: n02391049 +341: n02395406 +342: n02396427 +343: n02397096 +344: n02398521 +345: n02403003 +346: n02408429 +347: n02410509 +348: n02412080 +349: n02415577 +350: n02417914 +351: n02422106 +352: n02422699 +353: n02423022 +354: n02437312 +355: n02437616 +356: n10771990 +357: n14765497 +358: n02443114 +359: n02443484 +360: n14765785 +361: n02445715 +362: n02447366 +363: n02454379 +364: n02457408 +365: n02480495 +366: n02480855 +367: n02481823 +368: n02483362 +369: n02483708 +370: n02484975 +371: n02486261 +372: n02486410 +373: n02487347 +374: n02488291 +375: n02488702 +376: n02489166 +377: n02490219 +378: n02492035 +379: n02492660 +380: n02493509 +381: n02493793 +382: n02494079 +383: n02497673 +384: n02500267 +385: n02504013 +386: n02504458 +387: n02509815 +388: n02510455 +389: n02514041 +390: n07783967 +391: n02536864 +392: n02606052 +393: n02607072 +394: n02640242 +395: n02641379 +396: n02643566 +397: n02655020 +398: n02666347 +399: n02667093 +400: n02669723 +401: n02672831 +402: n02676566 +403: n02687172 +404: n02690373 +405: n02692877 +406: n02699494 +407: n02701002 +408: n02704792 +409: n02708093 +410: n02727426 +411: n08496334 +412: n02747177 +413: n02749479 +414: n02769748 +415: n02776631 +416: n02777292 +417: n02782329 +418: n02783161 +419: n02786058 +420: n02787622 +421: n02788148 +422: n02790996 +423: n02791124 +424: n02791270 +425: n02793495 +426: n02794156 +427: n02795169 +428: n02797295 +429: n02799071 +430: n02802426 +431: n02804515 +432: n02804610 +433: n02807133 +434: n02808304 +435: n02808440 +436: n02814533 +437: n02814860 +438: n02815834 +439: n02817516 +440: n02823428 +441: n02823750 +442: n02825657 +443: n02834397 +444: n02835271 +445: n02837789 +446: n02840245 +447: n02841315 +448: n02843684 +449: n02859443 +450: n02860847 +451: n02865351 +452: n02869837 +453: n02870880 +454: n02871525 +455: n02877765 +456: n02880308 +457: n02883205 +458: n02892201 +459: n02892767 +460: n02894605 +461: n02895154 +462: n12520864 +463: n02909870 +464: n02910353 +465: n02916936 +466: n02917067 +467: n02927161 +468: n02930766 +469: n02939185 +470: n02948072 +471: n02950826 +472: n02951358 +473: n02951585 +474: n02963159 +475: n02965783 +476: n02966193 +477: n02966687 +478: n02971356 +479: n02974003 +480: n02977058 +481: n02978881 +482: n02979186 +483: n02980441 +484: n02981792 +485: n02988304 +486: n02992211 +487: n02992529 +488: n13652994 +489: n03000134 +490: n03000247 +491: n03000684 +492: n03014705 +493: n03016953 +494: n03017168 +495: n03018349 +496: n03026506 +497: n03028079 +498: n03032252 +499: n03041632 +500: n03042490 +501: n03045698 +502: n03047690 +503: n03062245 +504: n03063599 +505: n03063689 +506: n03065424 +507: n03075370 +508: n03085013 +509: n03089624 +510: n03095699 +511: n03100240 +512: n03109150 +513: n03110669 +514: n03124043 +515: n03124170 +516: n15142452 +517: n03126707 +518: n03127747 +519: n03127925 +520: n03131574 +521: n03133878 +522: n03134739 +523: n03141823 +524: n03146219 +525: n03160309 +526: n03179701 +527: n03180011 +528: n03187595 +529: n03188531 +530: n03196217 +531: n03197337 +532: n03201208 +533: n03207743 +534: n03207941 +535: n03208938 +536: n03216828 +537: n03218198 +538: n13872072 +539: n03223299 +540: n03240683 +541: n03249569 +542: n07647870 +543: n03255030 +544: n03259401 +545: n03271574 +546: n03272010 +547: n03272562 +548: n03290653 +549: n13869788 +550: n03297495 +551: n03314780 +552: n03325584 +553: n03337140 +554: n03344393 +555: n03345487 +556: n03347037 +557: n03355925 +558: n03372029 +559: n03376595 +560: n03379051 +561: n03384352 +562: n03388043 +563: n03388183 +564: n03388549 +565: n03393912 +566: n03394916 +567: n03400231 +568: n03404251 +569: n03417042 +570: n03424325 +571: n03425413 +572: n03443371 +573: n03444034 +574: n03445777 +575: n03445924 +576: n03447447 +577: n03447721 +578: n08286342 +579: n03452741 +580: n03457902 +581: n03459775 +582: n03461385 +583: n03467068 +584: n03476684 +585: n03476991 +586: n03478589 +587: n03482001 +588: n03482405 +589: n03483316 +590: n03485407 +591: n03485794 +592: n03492542 +593: n03494278 +594: n03495570 +595: n10161363 +596: n03498962 +597: n03527565 +598: n03529860 +599: n09218315 +600: n03532672 +601: n03534580 +602: n03535780 +603: n03538406 +604: n03544143 +605: n03584254 +606: n03584829 +607: n03590841 +608: n03594734 +609: n03594945 +610: n03595614 +611: n03598930 +612: n03599486 +613: n03602883 +614: n03617480 +615: n03623198 +616: n15102712 +617: n03630383 +618: n03633091 +619: n03637318 +620: n03642806 +621: n03649909 +622: n03657121 +623: n03658185 +624: n07977870 +625: n03662601 +626: n03666591 +627: n03670208 +628: n03673027 +629: n03676483 +630: n03680355 +631: n03690938 +632: n03691459 +633: n03692522 +634: n03697007 +635: n03706229 +636: n03709823 +637: n03710193 +638: n03710637 +639: n03710721 +640: n03717622 +641: n03720891 +642: n03721384 +643: n03725035 +644: n03729826 +645: n03733131 +646: n03733281 +647: n03733805 +648: n03742115 +649: n03743016 +650: n03759954 +651: n03761084 +652: n03763968 +653: n03764736 +654: n03769881 +655: n03770439 +656: n03770679 +657: n03773504 +658: n03775071 +659: n03775546 +660: n03776460 +661: n03777568 +662: n03777754 +663: n03781244 +664: n03782006 +665: n03785016 +666: n14955889 +667: n03787032 +668: n03788195 +669: n03788365 +670: n03791053 +671: n03792782 +672: n03792972 +673: n03793489 +674: n03794056 +675: n03796401 +676: n03803284 +677: n13652335 +678: n03814639 +679: n03814906 +680: n03825788 +681: n03832673 +682: n03837869 +683: n03838899 +684: n03840681 +685: n03841143 +686: n03843555 +687: n03854065 +688: n03857828 +689: n03866082 +690: n03868242 +691: n03868863 +692: n07281099 +693: n03873416 +694: n03874293 +695: n03874599 +696: n03876231 +697: n03877472 +698: n08053121 +699: n03884397 +700: n03887697 +701: n03888257 +702: n03888605 +703: n03891251 +704: n03891332 +705: n03895866 +706: n03899768 +707: n03902125 +708: n03903868 +709: n03908618 +710: n03908714 +711: n03916031 +712: n03920288 +713: n03924679 +714: n03929660 +715: n03929855 +716: n03930313 +717: n03930630 +718: n03934042 +719: n03935335 +720: n03937543 +721: n03938244 +722: n03942813 +723: n03944341 +724: n03947888 +725: n03950228 +726: n03954731 +727: n03956157 +728: n03958227 +729: n03961711 +730: n03967562 +731: n03970156 +732: n03976467 +733: n08620881 +734: n03977966 +735: n03980874 +736: n03982430 +737: n03983396 +738: n03991062 +739: n03992509 +740: n03995372 +741: n03998194 +742: n04004767 +743: n13937284 +744: n04008634 +745: n04009801 +746: n04019541 +747: n04023962 +748: n13413294 +749: n04033901 +750: n04033995 +751: n04037443 +752: n04039381 +753: n09403211 +754: n04041544 +755: n04044716 +756: n04049303 +757: n04065272 +758: n07056680 +759: n04069434 +760: n04070727 +761: n04074963 +762: n04081281 +763: n04086273 +764: n04090263 +765: n04099969 +766: n04111531 +767: n04116512 +768: n04118538 +769: n04118776 +770: n04120489 +771: n04125116 +772: n04127249 +773: n04131690 +774: n04133789 +775: n04136333 +776: n04141076 +777: n04141327 +778: n04141975 +779: n04146614 +780: n04147291 +781: n04149813 +782: n04152593 +783: n04154340 +784: n07917272 +785: n04162706 +786: n04179913 +787: n04192698 +788: n04200800 +789: n04201297 +790: n04204238 +791: n04204347 +792: n04208427 +793: n04209133 +794: n04209239 +795: n04228054 +796: n04229816 +797: n04235860 +798: n04238763 +799: n04239074 +800: n04243546 +801: n04251144 +802: n04252077 +803: n04252225 +804: n04254120 +805: n04254680 +806: n04254777 +807: n04258138 +808: n04259630 +809: n04263257 +810: n04264628 +811: n04265275 +812: n04266014 +813: n04270147 +814: n04273569 +815: n04275363 +816: n05605498 +817: n04285008 +818: n04286575 +819: n08646566 +820: n04310018 +821: n04311004 +822: n04311174 +823: n04317175 +824: n04325704 +825: n04326547 +826: n04328186 +827: n04330267 +828: n04332243 +829: n04335435 +830: n04337157 +831: n04344873 +832: n04346328 +833: n04347754 +834: n04350905 +835: n04355338 +836: n04355933 +837: n04356056 +838: n04357314 +839: n04366367 +840: n04367480 +841: n04370456 +842: n04371430 +843: n14009946 +844: n04372370 +845: n04376876 +846: n04380533 +847: n04389033 +848: n04392985 +849: n04398044 +850: n04399382 +851: n04404412 +852: n04409515 +853: n04417672 +854: n04418357 +855: n04423845 +856: n04428191 +857: n04429376 +858: n04435653 +859: n04442312 +860: n04443257 +861: n04447861 +862: n04456115 +863: n04458633 +864: n04461696 +865: n04462240 +866: n04465666 +867: n04467665 +868: n04476259 +869: n04479046 +870: n04482393 +871: n04483307 +872: n04485082 +873: n04486054 +874: n04487081 +875: n04487394 +876: n04493381 +877: n04501370 +878: n04505470 +879: n04507155 +880: n04509417 +881: n04515003 +882: n04517823 +883: n04522168 +884: n04523525 +885: n04525038 +886: n04525305 +887: n04532106 +888: n04532670 +889: n04536866 +890: n04540053 +891: n04542943 +892: n04548280 +893: n04548362 +894: n04550184 +895: n04552348 +896: n04553703 +897: n04554684 +898: n04557648 +899: n04560804 +900: n04562935 +901: n04579145 +902: n04579667 +903: n04584207 +904: n04589890 +905: n04590129 +906: n04591157 +907: n04591713 +908: n10782135 +909: n04596742 +910: n04598010 +911: n04599235 +912: n04604644 +913: n14423870 +914: n04612504 +915: n04613696 +916: n06359193 +917: n06596364 +918: n06785654 +919: n06794110 +920: n06874185 +921: n07248320 +922: n07565083 +923: n07657664 +924: n07583066 +925: n07584110 +926: n07590611 +927: n07613480 +928: n07614500 +929: n07615774 +930: n07684084 +931: n07693725 +932: n07695742 +933: n07697313 +934: n07697537 +935: n07711569 +936: n07714571 +937: n07714990 +938: n07715103 +939: n12159804 +940: n12160303 +941: n12160857 +942: n07717556 +943: n07718472 +944: n07718747 +945: n07720875 +946: n07730033 +947: n13001041 +948: n07742313 +949: n12630144 +950: n14991210 +951: n07749582 +952: n07753113 +953: n07753275 +954: n07753592 +955: n07754684 +956: n07760859 +957: n07768694 +958: n07802026 +959: n07831146 +960: n07836838 +961: n07860988 +962: n07871810 +963: n07873807 +964: n07875152 +965: n07880968 +966: n07892512 +967: n07920052 +968: n13904665 +969: n07932039 +970: n09193705 +971: n09229709 +972: n09246464 +973: n09256479 +974: n09288635 +975: n09332890 +976: n09399592 +977: n09421951 +978: n09428293 +979: n09468604 +980: n09472597 +981: n09835506 +982: n10148035 +983: n10565667 +984: n11879895 +985: n11939491 +986: n12057211 +987: n12144580 +988: n12267677 +989: n12620546 +990: n12768682 +991: n12985857 +992: n12998815 +993: n13037406 +994: n13040303 +995: n13044778 +996: n13052670 +997: n13054560 +998: n13133613 +999: n15075141 diff --git a/invokeai/assets/data/inpainting_examples/6458524847_2f4c361183_k.png b/invokeai/assets/data/inpainting_examples/6458524847_2f4c361183_k.png new file mode 100644 index 0000000000000000000000000000000000000000..3eb5a2242e6ae0e59fb25bcc0265aa86b6435855 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/6458524847_2f4c361183_k.png differ diff --git a/invokeai/assets/data/inpainting_examples/6458524847_2f4c361183_k_mask.png b/invokeai/assets/data/inpainting_examples/6458524847_2f4c361183_k_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..6c77130e04992544d717cd5d76856a3206237f89 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/6458524847_2f4c361183_k_mask.png differ diff --git a/invokeai/assets/data/inpainting_examples/8399166846_f6fb4e4b8e_k.png b/invokeai/assets/data/inpainting_examples/8399166846_f6fb4e4b8e_k.png new file mode 100644 index 0000000000000000000000000000000000000000..63ac9891e4646fda9e990cee2efca75493038913 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/8399166846_f6fb4e4b8e_k.png differ diff --git a/invokeai/assets/data/inpainting_examples/8399166846_f6fb4e4b8e_k_mask.png b/invokeai/assets/data/inpainting_examples/8399166846_f6fb4e4b8e_k_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..7eb67e45fabccd74bb98863ebc8d383966fc62cb Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/8399166846_f6fb4e4b8e_k_mask.png differ diff --git a/invokeai/assets/data/inpainting_examples/alex-iby-G_Pk4D9rMLs.png b/invokeai/assets/data/inpainting_examples/alex-iby-G_Pk4D9rMLs.png new file mode 100644 index 0000000000000000000000000000000000000000..7714a1f7d51998e85c83c857c858e47b97f389f3 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/alex-iby-G_Pk4D9rMLs.png differ diff --git a/invokeai/assets/data/inpainting_examples/alex-iby-G_Pk4D9rMLs_mask.png b/invokeai/assets/data/inpainting_examples/alex-iby-G_Pk4D9rMLs_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..0324f677ebb0b2e335143397c2cda462c3ff3024 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/alex-iby-G_Pk4D9rMLs_mask.png differ diff --git a/invokeai/assets/data/inpainting_examples/bench2.png b/invokeai/assets/data/inpainting_examples/bench2.png new file mode 100644 index 0000000000000000000000000000000000000000..09be46d1ba6ea47ac8cdadb854a0cf9b135195d4 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/bench2.png differ diff --git a/invokeai/assets/data/inpainting_examples/bench2_mask.png b/invokeai/assets/data/inpainting_examples/bench2_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..bacadfa5da74fc69d0da7b698561e1d5d7e161ed Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/bench2_mask.png differ diff --git a/invokeai/assets/data/inpainting_examples/bertrand-gabioud-CpuFzIsHYJ0.png b/invokeai/assets/data/inpainting_examples/bertrand-gabioud-CpuFzIsHYJ0.png new file mode 100644 index 0000000000000000000000000000000000000000..618f200e56f4dea89bf4a4afe7cadba247b0bb7f Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/bertrand-gabioud-CpuFzIsHYJ0.png differ diff --git a/invokeai/assets/data/inpainting_examples/bertrand-gabioud-CpuFzIsHYJ0_mask.png b/invokeai/assets/data/inpainting_examples/bertrand-gabioud-CpuFzIsHYJ0_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..fd18be987f8667748b005782815a51b3cc4772f9 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/bertrand-gabioud-CpuFzIsHYJ0_mask.png differ diff --git a/invokeai/assets/data/inpainting_examples/billow926-12-Wc-Zgx6Y.png b/invokeai/assets/data/inpainting_examples/billow926-12-Wc-Zgx6Y.png new file mode 100644 index 0000000000000000000000000000000000000000..cbd246e906b74c6b1f735e4433d60914ac60c0d5 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/billow926-12-Wc-Zgx6Y.png differ diff --git a/invokeai/assets/data/inpainting_examples/billow926-12-Wc-Zgx6Y_mask.png b/invokeai/assets/data/inpainting_examples/billow926-12-Wc-Zgx6Y_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..7e5121400687e553d9ed306e245f3b7810c7aeaa Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/billow926-12-Wc-Zgx6Y_mask.png differ diff --git a/invokeai/assets/data/inpainting_examples/overture-creations-5sI6fQgYIuo.png b/invokeai/assets/data/inpainting_examples/overture-creations-5sI6fQgYIuo.png new file mode 100644 index 0000000000000000000000000000000000000000..e84dfc8554d344a69afb1fe8c7b8b2997d4e5e11 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/overture-creations-5sI6fQgYIuo.png differ diff --git a/invokeai/assets/data/inpainting_examples/overture-creations-5sI6fQgYIuo_mask.png b/invokeai/assets/data/inpainting_examples/overture-creations-5sI6fQgYIuo_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..7f3c753058c58ee3b96814722562b42af3588be2 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/overture-creations-5sI6fQgYIuo_mask.png differ diff --git a/invokeai/assets/data/inpainting_examples/photo-1583445095369-9c651e7e5d34.png b/invokeai/assets/data/inpainting_examples/photo-1583445095369-9c651e7e5d34.png new file mode 100644 index 0000000000000000000000000000000000000000..e8999de888b2f57c7af9db2f858fce7bfee1e1d1 Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/photo-1583445095369-9c651e7e5d34.png differ diff --git a/invokeai/assets/data/inpainting_examples/photo-1583445095369-9c651e7e5d34_mask.png b/invokeai/assets/data/inpainting_examples/photo-1583445095369-9c651e7e5d34_mask.png new file mode 100644 index 0000000000000000000000000000000000000000..093d0c1819aa6056a993897c9b0dce51cba7d96e Binary files /dev/null and b/invokeai/assets/data/inpainting_examples/photo-1583445095369-9c651e7e5d34_mask.png differ diff --git a/invokeai/assets/fire.png b/invokeai/assets/fire.png new file mode 100644 index 0000000000000000000000000000000000000000..64c24feae84669ce3c0df564ca44c25d2b40ea13 Binary files /dev/null and b/invokeai/assets/fire.png differ diff --git a/invokeai/assets/fonts/inter/Inter-Regular.ttf b/invokeai/assets/fonts/inter/Inter-Regular.ttf new file mode 100644 index 0000000000000000000000000000000000000000..012d1b470d92db48d6f45478f9711f088a6c7359 Binary files /dev/null and b/invokeai/assets/fonts/inter/Inter-Regular.ttf differ diff --git a/invokeai/assets/fonts/inter/LICENSE.txt b/invokeai/assets/fonts/inter/LICENSE.txt new file mode 100644 index 0000000000000000000000000000000000000000..ff80f8c615684e796c37ab5ff82a9b31d7390d6e --- /dev/null +++ b/invokeai/assets/fonts/inter/LICENSE.txt @@ -0,0 +1,94 @@ +Copyright (c) 2016-2020 The Inter Project Authors. +"Inter" is trademark of Rasmus Andersson. +https://github.com/rsms/inter + +This Font Software is licensed under the SIL Open Font License, Version 1.1. +This license is copied below, and is also available with a FAQ at: +http://scripts.sil.org/OFL + +----------------------------------------------------------- +SIL OPEN FONT LICENSE Version 1.1 - 26 February 2007 +----------------------------------------------------------- + +PREAMBLE +The goals of the Open Font License (OFL) are to stimulate worldwide +development of collaborative font projects, to support the font creation +efforts of academic and linguistic communities, and to provide a free and +open framework in which fonts may be shared and improved in partnership +with others. + +The OFL allows the licensed fonts to be used, studied, modified and +redistributed freely as long as they are not sold by themselves. The +fonts, including any derivative works, can be bundled, embedded, +redistributed and/or sold with any software provided that any reserved +names are not used by derivative works. The fonts and derivatives, +however, cannot be released under any other type of license. The +requirement for fonts to remain under this license does not apply +to any document created using the fonts or their derivatives. + +DEFINITIONS +"Font Software" refers to the set of files released by the Copyright +Holder(s) under this license and clearly marked as such. This may +include source files, build scripts and documentation. + +"Reserved Font Name" refers to any names specified as such after the +copyright statement(s). + +"Original Version" refers to the collection of Font Software components as +distributed by the Copyright Holder(s). + +"Modified Version" refers to any derivative made by adding to, deleting, +or substituting -- in part or in whole -- any of the components of the +Original Version, by changing formats or by porting the Font Software to a +new environment. + +"Author" refers to any designer, engineer, programmer, technical +writer or other person who contributed to the Font Software. + +PERMISSION AND CONDITIONS +Permission is hereby granted, free of charge, to any person obtaining +a copy of the Font Software, to use, study, copy, merge, embed, modify, +redistribute, and sell modified and unmodified copies of the Font +Software, subject to the following conditions: + +1) Neither the Font Software nor any of its individual components, +in Original or Modified Versions, may be sold by itself. + +2) Original or Modified Versions of the Font Software may be bundled, +redistributed and/or sold with any software, provided that each copy +contains the above copyright notice and this license. These can be +included either as stand-alone text files, human-readable headers or +in the appropriate machine-readable metadata fields within text or +binary files as long as those fields can be easily viewed by the user. + +3) No Modified Version of the Font Software may use the Reserved Font +Name(s) unless explicit written permission is granted by the corresponding +Copyright Holder. This restriction only applies to the primary font name as +presented to the users. + +4) The name(s) of the Copyright Holder(s) or the Author(s) of the Font +Software shall not be used to promote, endorse or advertise any +Modified Version, except to acknowledge the contribution(s) of the +Copyright Holder(s) and the Author(s) or with their explicit written +permission. + +5) The Font Software, modified or unmodified, in part or in whole, +must be distributed entirely under this license, and must not be +distributed under any other license. The requirement for fonts to +remain under this license does not apply to any document created +using the Font Software. + +TERMINATION +This license becomes null and void if any of the above conditions are +not met. + +DISCLAIMER +THE FONT SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, +EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED TO ANY WARRANTIES OF +MERCHANTABILITY, FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT +OF COPYRIGHT, PATENT, TRADEMARK, OR OTHER RIGHT. IN NO EVENT SHALL THE +COPYRIGHT HOLDER BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER LIABILITY, +INCLUDING ANY GENERAL, SPECIAL, INDIRECT, INCIDENTAL, OR CONSEQUENTIAL +DAMAGES, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING +FROM, OUT OF THE USE OR INABILITY TO USE THE FONT SOFTWARE OR FROM +OTHER DEALINGS IN THE FONT SOFTWARE. diff --git a/invokeai/assets/inpainting.png b/invokeai/assets/inpainting.png new file mode 100644 index 0000000000000000000000000000000000000000..d6b9ef85fe7cc74c258b40e2f0be4932f93cbee9 Binary files /dev/null and b/invokeai/assets/inpainting.png differ diff --git a/invokeai/assets/modelfigure.png b/invokeai/assets/modelfigure.png new file mode 100644 index 0000000000000000000000000000000000000000..6b1d3e6b9d59fd8d38468e7bce47c903a4e1c932 Binary files /dev/null and b/invokeai/assets/modelfigure.png differ diff --git a/invokeai/assets/rdm-preview.jpg b/invokeai/assets/rdm-preview.jpg new file mode 100644 index 0000000000000000000000000000000000000000..3838b0f6bb71a092c8ea1739c5d577fd4ba4ac0a Binary files /dev/null and b/invokeai/assets/rdm-preview.jpg differ diff --git a/invokeai/assets/reconstruction1.png b/invokeai/assets/reconstruction1.png new file mode 100644 index 0000000000000000000000000000000000000000..0752799ccc98c2555218d48ede6fcc42bc4693e2 Binary files /dev/null and b/invokeai/assets/reconstruction1.png differ diff --git a/invokeai/assets/reconstruction2.png b/invokeai/assets/reconstruction2.png new file mode 100644 index 0000000000000000000000000000000000000000..b8e7a36c1f00bf60ce0e7e24c2a1f8e0e04b23d7 Binary files /dev/null and b/invokeai/assets/reconstruction2.png differ diff --git a/invokeai/assets/results.gif b/invokeai/assets/results.gif new file mode 100644 index 0000000000000000000000000000000000000000..d03a52925e0a3ee70262e52891886fa2b03c2a2f --- /dev/null +++ b/invokeai/assets/results.gif @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:d0739cc1ece5ba1b9b66a42073786b7ac22b97050d134806f5cce9d6d3d0e0e6 +size 9849833 diff --git a/invokeai/assets/stable-samples/img2img/mountains-1.png b/invokeai/assets/stable-samples/img2img/mountains-1.png new file mode 100644 index 0000000000000000000000000000000000000000..d01b8350743e3bd4fdf653d1563ee7d5c2153323 Binary files /dev/null and b/invokeai/assets/stable-samples/img2img/mountains-1.png differ diff --git a/invokeai/assets/stable-samples/img2img/upscaling-in.png b/invokeai/assets/stable-samples/img2img/upscaling-in.png new file mode 100644 index 0000000000000000000000000000000000000000..6a16bf53a95850a4eb7730105cea61af7c435c5e --- /dev/null +++ b/invokeai/assets/stable-samples/img2img/upscaling-in.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:16e043b62bdfcc5be7d0eca5c747878b78e4e6ffaeb3cd1257568cbc2b5e6f7a +size 1167237 diff --git a/invokeai/assets/stable-samples/img2img/upscaling-out.png b/invokeai/assets/stable-samples/img2img/upscaling-out.png new file mode 100644 index 0000000000000000000000000000000000000000..b7926bc81099736f7c8df32cadc4481c07eddbd6 --- /dev/null +++ b/invokeai/assets/stable-samples/img2img/upscaling-out.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:c171218814d141f87884672cb00ae07c3ed0e14ce7f7023f2041678e01d93f59 +size 1317941 diff --git a/invokeai/assets/stable-samples/txt2img/000002025.png b/invokeai/assets/stable-samples/txt2img/000002025.png new file mode 100644 index 0000000000000000000000000000000000000000..66891c142e9cfe3e0b0193d16b5a45d7b72a7d8a Binary files /dev/null and b/invokeai/assets/stable-samples/txt2img/000002025.png differ diff --git a/invokeai/assets/stable-samples/txt2img/000002035.png b/invokeai/assets/stable-samples/txt2img/000002035.png new file mode 100644 index 0000000000000000000000000000000000000000..c707c130932a13fae76584324368fedc33faeba4 Binary files /dev/null and b/invokeai/assets/stable-samples/txt2img/000002035.png differ diff --git a/invokeai/assets/the-earth-is-on-fire,-oil-on-canvas.png b/invokeai/assets/the-earth-is-on-fire,-oil-on-canvas.png new file mode 100644 index 0000000000000000000000000000000000000000..90797208da649c0fd48825800411a0a99161128c Binary files /dev/null and b/invokeai/assets/the-earth-is-on-fire,-oil-on-canvas.png differ diff --git a/invokeai/assets/txt2img-convsample.png b/invokeai/assets/txt2img-convsample.png new file mode 100644 index 0000000000000000000000000000000000000000..255c265864692d1b65cbd1aae84c5e0590a38e71 Binary files /dev/null and b/invokeai/assets/txt2img-convsample.png differ diff --git a/invokeai/assets/txt2img-preview.png b/invokeai/assets/txt2img-preview.png new file mode 100644 index 0000000000000000000000000000000000000000..6cf0e234ee623d4a1942ad4382e10165727a2418 --- /dev/null +++ b/invokeai/assets/txt2img-preview.png @@ -0,0 +1,3 @@ +version https://git-lfs.github.com/spec/v1 +oid sha256:15a3eff624b02579aebb930d6c9318fc89c11c51dd22dcb85266e0403402b68e +size 2256167 diff --git a/invokeai/backend/__init__.py b/invokeai/backend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..9fe97ee525e242f25ce3f5fc93ddddf68066d25d --- /dev/null +++ b/invokeai/backend/__init__.py @@ -0,0 +1,3 @@ +""" +Initialization file for invokeai.backend +""" diff --git a/invokeai/backend/flux/controlnet/__init__.py b/invokeai/backend/flux/controlnet/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/flux/controlnet/controlnet_flux_output.py b/invokeai/backend/flux/controlnet/controlnet_flux_output.py new file mode 100644 index 0000000000000000000000000000000000000000..55940460c34715763ea7e79fab118a91b28007e9 --- /dev/null +++ b/invokeai/backend/flux/controlnet/controlnet_flux_output.py @@ -0,0 +1,58 @@ +from dataclasses import dataclass + +import torch + + +@dataclass +class ControlNetFluxOutput: + single_block_residuals: list[torch.Tensor] | None + double_block_residuals: list[torch.Tensor] | None + + def apply_weight(self, weight: float): + if self.single_block_residuals is not None: + for i in range(len(self.single_block_residuals)): + self.single_block_residuals[i] = self.single_block_residuals[i] * weight + if self.double_block_residuals is not None: + for i in range(len(self.double_block_residuals)): + self.double_block_residuals[i] = self.double_block_residuals[i] * weight + + +def add_tensor_lists_elementwise( + list1: list[torch.Tensor] | None, list2: list[torch.Tensor] | None +) -> list[torch.Tensor] | None: + """Add two tensor lists elementwise that could be None.""" + if list1 is None and list2 is None: + return None + if list1 is None: + return list2 + if list2 is None: + return list1 + + new_list: list[torch.Tensor] = [] + for list1_tensor, list2_tensor in zip(list1, list2, strict=True): + new_list.append(list1_tensor + list2_tensor) + return new_list + + +def add_controlnet_flux_outputs( + controlnet_output_1: ControlNetFluxOutput, controlnet_output_2: ControlNetFluxOutput +) -> ControlNetFluxOutput: + return ControlNetFluxOutput( + single_block_residuals=add_tensor_lists_elementwise( + controlnet_output_1.single_block_residuals, controlnet_output_2.single_block_residuals + ), + double_block_residuals=add_tensor_lists_elementwise( + controlnet_output_1.double_block_residuals, controlnet_output_2.double_block_residuals + ), + ) + + +def sum_controlnet_flux_outputs( + controlnet_outputs: list[ControlNetFluxOutput], +) -> ControlNetFluxOutput: + controlnet_output_sum = ControlNetFluxOutput(single_block_residuals=None, double_block_residuals=None) + + for controlnet_output in controlnet_outputs: + controlnet_output_sum = add_controlnet_flux_outputs(controlnet_output_sum, controlnet_output) + + return controlnet_output_sum diff --git a/invokeai/backend/flux/controlnet/instantx_controlnet_flux.py b/invokeai/backend/flux/controlnet/instantx_controlnet_flux.py new file mode 100644 index 0000000000000000000000000000000000000000..1af5fbdfc09368581e8c9901139a3b432e914f4f --- /dev/null +++ b/invokeai/backend/flux/controlnet/instantx_controlnet_flux.py @@ -0,0 +1,180 @@ +# This file was initially copied from: +# https://github.com/huggingface/diffusers/blob/99f608218caa069a2f16dcf9efab46959b15aec0/src/diffusers/models/controlnet_flux.py + + +from dataclasses import dataclass + +import torch +import torch.nn as nn + +from invokeai.backend.flux.controlnet.zero_module import zero_module +from invokeai.backend.flux.model import FluxParams +from invokeai.backend.flux.modules.layers import ( + DoubleStreamBlock, + EmbedND, + MLPEmbedder, + SingleStreamBlock, + timestep_embedding, +) + + +@dataclass +class InstantXControlNetFluxOutput: + controlnet_block_samples: list[torch.Tensor] | None + controlnet_single_block_samples: list[torch.Tensor] | None + + +# NOTE(ryand): Mapping between diffusers FLUX transformer params and BFL FLUX transformer params: +# - Diffusers: BFL +# - in_channels: in_channels +# - num_layers: depth +# - num_single_layers: depth_single_blocks +# - attention_head_dim: hidden_size // num_heads +# - num_attention_heads: num_heads +# - joint_attention_dim: context_in_dim +# - pooled_projection_dim: vec_in_dim +# - guidance_embeds: guidance_embed +# - axes_dims_rope: axes_dim + + +class InstantXControlNetFlux(torch.nn.Module): + def __init__(self, params: FluxParams, num_control_modes: int | None = None): + """ + Args: + params (FluxParams): The parameters for the FLUX model. + num_control_modes (int | None, optional): The number of controlnet modes. If non-None, then the model is a + 'union controlnet' model and expects a mode conditioning input at runtime. + """ + super().__init__() + + # The following modules mirror the base FLUX transformer model. + # ------------------------------------------------------------- + self.params = params + self.in_channels = params.in_channels + self.out_channels = self.in_channels + if params.hidden_size % params.num_heads != 0: + raise ValueError(f"Hidden size {params.hidden_size} must be divisible by num_heads {params.num_heads}") + pe_dim = params.hidden_size // params.num_heads + if sum(params.axes_dim) != pe_dim: + raise ValueError(f"Got {params.axes_dim} but expected positional dim {pe_dim}") + self.hidden_size = params.hidden_size + self.num_heads = params.num_heads + self.pe_embedder = EmbedND(dim=pe_dim, theta=params.theta, axes_dim=params.axes_dim) + self.img_in = nn.Linear(self.in_channels, self.hidden_size, bias=True) + self.time_in = MLPEmbedder(in_dim=256, hidden_dim=self.hidden_size) + self.vector_in = MLPEmbedder(params.vec_in_dim, self.hidden_size) + self.guidance_in = ( + MLPEmbedder(in_dim=256, hidden_dim=self.hidden_size) if params.guidance_embed else nn.Identity() + ) + self.txt_in = nn.Linear(params.context_in_dim, self.hidden_size) + + self.double_blocks = nn.ModuleList( + [ + DoubleStreamBlock( + self.hidden_size, + self.num_heads, + mlp_ratio=params.mlp_ratio, + qkv_bias=params.qkv_bias, + ) + for _ in range(params.depth) + ] + ) + + self.single_blocks = nn.ModuleList( + [ + SingleStreamBlock(self.hidden_size, self.num_heads, mlp_ratio=params.mlp_ratio) + for _ in range(params.depth_single_blocks) + ] + ) + + # The following modules are specific to the ControlNet model. + # ----------------------------------------------------------- + self.controlnet_blocks = nn.ModuleList([]) + for _ in range(len(self.double_blocks)): + self.controlnet_blocks.append(zero_module(nn.Linear(self.hidden_size, self.hidden_size))) + + self.controlnet_single_blocks = nn.ModuleList([]) + for _ in range(len(self.single_blocks)): + self.controlnet_single_blocks.append(zero_module(nn.Linear(self.hidden_size, self.hidden_size))) + + self.is_union = False + if num_control_modes is not None: + self.is_union = True + self.controlnet_mode_embedder = nn.Embedding(num_control_modes, self.hidden_size) + + self.controlnet_x_embedder = zero_module(torch.nn.Linear(self.in_channels, self.hidden_size)) + + def forward( + self, + controlnet_cond: torch.Tensor, + controlnet_mode: torch.Tensor | None, + img: torch.Tensor, + img_ids: torch.Tensor, + txt: torch.Tensor, + txt_ids: torch.Tensor, + timesteps: torch.Tensor, + y: torch.Tensor, + guidance: torch.Tensor | None = None, + ) -> InstantXControlNetFluxOutput: + if img.ndim != 3 or txt.ndim != 3: + raise ValueError("Input img and txt tensors must have 3 dimensions.") + + img = self.img_in(img) + + # Add controlnet_cond embedding. + img = img + self.controlnet_x_embedder(controlnet_cond) + + vec = self.time_in(timestep_embedding(timesteps, 256)) + if self.params.guidance_embed: + if guidance is None: + raise ValueError("Didn't get guidance strength for guidance distilled model.") + vec = vec + self.guidance_in(timestep_embedding(guidance, 256)) + vec = vec + self.vector_in(y) + txt = self.txt_in(txt) + + # If this is a union ControlNet, then concat the control mode embedding to the T5 text embedding. + if self.is_union: + if controlnet_mode is None: + # We allow users to enter 'None' as the controlnet_mode if they don't want to worry about this input. + # We've chosen to use a zero-embedding in this case. + zero_index = torch.zeros([1, 1], dtype=torch.long, device=txt.device) + controlnet_mode_emb = torch.zeros_like(self.controlnet_mode_embedder(zero_index)) + else: + controlnet_mode_emb = self.controlnet_mode_embedder(controlnet_mode) + txt = torch.cat([controlnet_mode_emb, txt], dim=1) + txt_ids = torch.cat([txt_ids[:, :1, :], txt_ids], dim=1) + else: + assert controlnet_mode is None + + ids = torch.cat((txt_ids, img_ids), dim=1) + pe = self.pe_embedder(ids) + + double_block_samples: list[torch.Tensor] = [] + for block in self.double_blocks: + img, txt = block(img=img, txt=txt, vec=vec, pe=pe) + double_block_samples.append(img) + + img = torch.cat((txt, img), 1) + + single_block_samples: list[torch.Tensor] = [] + for block in self.single_blocks: + img = block(img, vec=vec, pe=pe) + single_block_samples.append(img[:, txt.shape[1] :]) + + # ControlNet Block + controlnet_double_block_samples: list[torch.Tensor] = [] + for double_block_sample, controlnet_block in zip(double_block_samples, self.controlnet_blocks, strict=True): + double_block_sample = controlnet_block(double_block_sample) + controlnet_double_block_samples.append(double_block_sample) + + controlnet_single_block_samples: list[torch.Tensor] = [] + for single_block_sample, controlnet_block in zip( + single_block_samples, self.controlnet_single_blocks, strict=True + ): + single_block_sample = controlnet_block(single_block_sample) + controlnet_single_block_samples.append(single_block_sample) + + return InstantXControlNetFluxOutput( + controlnet_block_samples=controlnet_double_block_samples or None, + controlnet_single_block_samples=controlnet_single_block_samples or None, + ) diff --git a/invokeai/backend/flux/controlnet/state_dict_utils.py b/invokeai/backend/flux/controlnet/state_dict_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..aa44e6c10f0115f6ef9a56b05a6d952e552a4ef1 --- /dev/null +++ b/invokeai/backend/flux/controlnet/state_dict_utils.py @@ -0,0 +1,295 @@ +from typing import Any, Dict + +import torch + +from invokeai.backend.flux.model import FluxParams + + +def is_state_dict_xlabs_controlnet(sd: Dict[str, Any]) -> bool: + """Is the state dict for an XLabs ControlNet model? + + This is intended to be a reasonably high-precision detector, but it is not guaranteed to have perfect precision. + """ + # If all of the expected keys are present, then this is very likely an XLabs ControlNet model. + expected_keys = { + "controlnet_blocks.0.bias", + "controlnet_blocks.0.weight", + "input_hint_block.0.bias", + "input_hint_block.0.weight", + "pos_embed_input.bias", + "pos_embed_input.weight", + } + + if expected_keys.issubset(sd.keys()): + return True + return False + + +def is_state_dict_instantx_controlnet(sd: Dict[str, Any]) -> bool: + """Is the state dict for an InstantX ControlNet model? + + This is intended to be a reasonably high-precision detector, but it is not guaranteed to have perfect precision. + """ + # If all of the expected keys are present, then this is very likely an InstantX ControlNet model. + expected_keys = { + "controlnet_blocks.0.bias", + "controlnet_blocks.0.weight", + "controlnet_x_embedder.bias", + "controlnet_x_embedder.weight", + } + + if expected_keys.issubset(sd.keys()): + return True + return False + + +def _fuse_weights(*t: torch.Tensor) -> torch.Tensor: + """Fuse weights along dimension 0. + + Used to fuse q, k, v attention weights into a single qkv tensor when converting from diffusers to BFL format. + """ + # TODO(ryand): Double check dim=0 is correct. + return torch.cat(t, dim=0) + + +def _convert_flux_double_block_sd_from_diffusers_to_bfl_format( + sd: Dict[str, torch.Tensor], double_block_index: int +) -> Dict[str, torch.Tensor]: + """Convert the state dict for a double block from diffusers format to BFL format.""" + to_prefix = f"double_blocks.{double_block_index}" + from_prefix = f"transformer_blocks.{double_block_index}" + + new_sd: dict[str, torch.Tensor] = {} + + # Check one key to determine if this block exists. + if f"{from_prefix}.attn.add_q_proj.bias" not in sd: + return new_sd + + # txt_attn.qkv + new_sd[f"{to_prefix}.txt_attn.qkv.bias"] = _fuse_weights( + sd.pop(f"{from_prefix}.attn.add_q_proj.bias"), + sd.pop(f"{from_prefix}.attn.add_k_proj.bias"), + sd.pop(f"{from_prefix}.attn.add_v_proj.bias"), + ) + new_sd[f"{to_prefix}.txt_attn.qkv.weight"] = _fuse_weights( + sd.pop(f"{from_prefix}.attn.add_q_proj.weight"), + sd.pop(f"{from_prefix}.attn.add_k_proj.weight"), + sd.pop(f"{from_prefix}.attn.add_v_proj.weight"), + ) + + # img_attn.qkv + new_sd[f"{to_prefix}.img_attn.qkv.bias"] = _fuse_weights( + sd.pop(f"{from_prefix}.attn.to_q.bias"), + sd.pop(f"{from_prefix}.attn.to_k.bias"), + sd.pop(f"{from_prefix}.attn.to_v.bias"), + ) + new_sd[f"{to_prefix}.img_attn.qkv.weight"] = _fuse_weights( + sd.pop(f"{from_prefix}.attn.to_q.weight"), + sd.pop(f"{from_prefix}.attn.to_k.weight"), + sd.pop(f"{from_prefix}.attn.to_v.weight"), + ) + + # Handle basic 1-to-1 key conversions. + key_map = { + # img_attn + "attn.norm_k.weight": "img_attn.norm.key_norm.scale", + "attn.norm_q.weight": "img_attn.norm.query_norm.scale", + "attn.to_out.0.weight": "img_attn.proj.weight", + "attn.to_out.0.bias": "img_attn.proj.bias", + # img_mlp + "ff.net.0.proj.weight": "img_mlp.0.weight", + "ff.net.0.proj.bias": "img_mlp.0.bias", + "ff.net.2.weight": "img_mlp.2.weight", + "ff.net.2.bias": "img_mlp.2.bias", + # img_mod + "norm1.linear.weight": "img_mod.lin.weight", + "norm1.linear.bias": "img_mod.lin.bias", + # txt_attn + "attn.norm_added_q.weight": "txt_attn.norm.query_norm.scale", + "attn.norm_added_k.weight": "txt_attn.norm.key_norm.scale", + "attn.to_add_out.weight": "txt_attn.proj.weight", + "attn.to_add_out.bias": "txt_attn.proj.bias", + # txt_mlp + "ff_context.net.0.proj.weight": "txt_mlp.0.weight", + "ff_context.net.0.proj.bias": "txt_mlp.0.bias", + "ff_context.net.2.weight": "txt_mlp.2.weight", + "ff_context.net.2.bias": "txt_mlp.2.bias", + # txt_mod + "norm1_context.linear.weight": "txt_mod.lin.weight", + "norm1_context.linear.bias": "txt_mod.lin.bias", + } + for from_key, to_key in key_map.items(): + new_sd[f"{to_prefix}.{to_key}"] = sd.pop(f"{from_prefix}.{from_key}") + + return new_sd + + +def _convert_flux_single_block_sd_from_diffusers_to_bfl_format( + sd: Dict[str, torch.Tensor], single_block_index: int +) -> Dict[str, torch.Tensor]: + """Convert the state dict for a single block from diffusers format to BFL format.""" + to_prefix = f"single_blocks.{single_block_index}" + from_prefix = f"single_transformer_blocks.{single_block_index}" + + new_sd: dict[str, torch.Tensor] = {} + + # Check one key to determine if this block exists. + if f"{from_prefix}.attn.to_q.bias" not in sd: + return new_sd + + # linear1 (qkv) + new_sd[f"{to_prefix}.linear1.bias"] = _fuse_weights( + sd.pop(f"{from_prefix}.attn.to_q.bias"), + sd.pop(f"{from_prefix}.attn.to_k.bias"), + sd.pop(f"{from_prefix}.attn.to_v.bias"), + sd.pop(f"{from_prefix}.proj_mlp.bias"), + ) + new_sd[f"{to_prefix}.linear1.weight"] = _fuse_weights( + sd.pop(f"{from_prefix}.attn.to_q.weight"), + sd.pop(f"{from_prefix}.attn.to_k.weight"), + sd.pop(f"{from_prefix}.attn.to_v.weight"), + sd.pop(f"{from_prefix}.proj_mlp.weight"), + ) + + # Handle basic 1-to-1 key conversions. + key_map = { + # linear2 + "proj_out.weight": "linear2.weight", + "proj_out.bias": "linear2.bias", + # modulation + "norm.linear.weight": "modulation.lin.weight", + "norm.linear.bias": "modulation.lin.bias", + # norm + "attn.norm_k.weight": "norm.key_norm.scale", + "attn.norm_q.weight": "norm.query_norm.scale", + } + for from_key, to_key in key_map.items(): + new_sd[f"{to_prefix}.{to_key}"] = sd.pop(f"{from_prefix}.{from_key}") + + return new_sd + + +def convert_diffusers_instantx_state_dict_to_bfl_format(sd: Dict[str, torch.Tensor]) -> Dict[str, torch.Tensor]: + """Convert an InstantX ControlNet state dict to the format that can be loaded by our internal + InstantXControlNetFlux model. + + The original InstantX ControlNet model was developed to be used in diffusers. We have ported the original + implementation to InstantXControlNetFlux to make it compatible with BFL-style models. This function converts the + original state dict to the format expected by InstantXControlNetFlux. + """ + # Shallow copy sd so that we can pop keys from it without modifying the original. + sd = sd.copy() + + new_sd: dict[str, torch.Tensor] = {} + + # Handle basic 1-to-1 key conversions. + basic_key_map = { + # Base model keys. + # ---------------- + # txt_in keys. + "context_embedder.bias": "txt_in.bias", + "context_embedder.weight": "txt_in.weight", + # guidance_in MLPEmbedder keys. + "time_text_embed.guidance_embedder.linear_1.bias": "guidance_in.in_layer.bias", + "time_text_embed.guidance_embedder.linear_1.weight": "guidance_in.in_layer.weight", + "time_text_embed.guidance_embedder.linear_2.bias": "guidance_in.out_layer.bias", + "time_text_embed.guidance_embedder.linear_2.weight": "guidance_in.out_layer.weight", + # vector_in MLPEmbedder keys. + "time_text_embed.text_embedder.linear_1.bias": "vector_in.in_layer.bias", + "time_text_embed.text_embedder.linear_1.weight": "vector_in.in_layer.weight", + "time_text_embed.text_embedder.linear_2.bias": "vector_in.out_layer.bias", + "time_text_embed.text_embedder.linear_2.weight": "vector_in.out_layer.weight", + # time_in MLPEmbedder keys. + "time_text_embed.timestep_embedder.linear_1.bias": "time_in.in_layer.bias", + "time_text_embed.timestep_embedder.linear_1.weight": "time_in.in_layer.weight", + "time_text_embed.timestep_embedder.linear_2.bias": "time_in.out_layer.bias", + "time_text_embed.timestep_embedder.linear_2.weight": "time_in.out_layer.weight", + # img_in keys. + "x_embedder.bias": "img_in.bias", + "x_embedder.weight": "img_in.weight", + } + for old_key, new_key in basic_key_map.items(): + v = sd.pop(old_key, None) + if v is not None: + new_sd[new_key] = v + + # Handle the double_blocks. + block_index = 0 + while True: + converted_double_block_sd = _convert_flux_double_block_sd_from_diffusers_to_bfl_format(sd, block_index) + if len(converted_double_block_sd) == 0: + break + new_sd.update(converted_double_block_sd) + block_index += 1 + + # Handle the single_blocks. + block_index = 0 + while True: + converted_singe_block_sd = _convert_flux_single_block_sd_from_diffusers_to_bfl_format(sd, block_index) + if len(converted_singe_block_sd) == 0: + break + new_sd.update(converted_singe_block_sd) + block_index += 1 + + # Transfer controlnet keys as-is. + for k in list(sd.keys()): + if k.startswith("controlnet_"): + new_sd[k] = sd.pop(k) + + # Assert that all keys have been handled. + assert len(sd) == 0 + return new_sd + + +def infer_flux_params_from_state_dict(sd: Dict[str, torch.Tensor]) -> FluxParams: + """Infer the FluxParams from the shape of a FLUX state dict. When a model is distributed in diffusers format, this + information is all contained in the config.json file that accompanies the model. However, being apple to infer the + params from the state dict enables us to load models (e.g. an InstantX ControlNet) from a single weight file. + """ + hidden_size = sd["img_in.weight"].shape[0] + mlp_hidden_dim = sd["double_blocks.0.img_mlp.0.weight"].shape[0] + # mlp_ratio is a float, but we treat it as an int here to avoid having to think about possible float precision + # issues. In practice, mlp_ratio is usually 4. + mlp_ratio = mlp_hidden_dim // hidden_size + + head_dim = sd["double_blocks.0.img_attn.norm.query_norm.scale"].shape[0] + num_heads = hidden_size // head_dim + + # Count the number of double blocks. + double_block_index = 0 + while f"double_blocks.{double_block_index}.img_attn.qkv.weight" in sd: + double_block_index += 1 + + # Count the number of single blocks. + single_block_index = 0 + while f"single_blocks.{single_block_index}.linear1.weight" in sd: + single_block_index += 1 + + return FluxParams( + in_channels=sd["img_in.weight"].shape[1], + vec_in_dim=sd["vector_in.in_layer.weight"].shape[1], + context_in_dim=sd["txt_in.weight"].shape[1], + hidden_size=hidden_size, + mlp_ratio=mlp_ratio, + num_heads=num_heads, + depth=double_block_index, + depth_single_blocks=single_block_index, + # axes_dim cannot be inferred from the state dict. The hard-coded value is correct for dev/schnell models. + axes_dim=[16, 56, 56], + # theta cannot be inferred from the state dict. The hard-coded value is correct for dev/schnell models. + theta=10_000, + qkv_bias="double_blocks.0.img_attn.qkv.bias" in sd, + guidance_embed="guidance_in.in_layer.weight" in sd, + ) + + +def infer_instantx_num_control_modes_from_state_dict(sd: Dict[str, torch.Tensor]) -> int | None: + """Infer the number of ControlNet Union modes from the shape of a InstantX ControlNet state dict. + + Returns None if the model is not a ControlNet Union model. Otherwise returns the number of modes. + """ + mode_embedder_key = "controlnet_mode_embedder.weight" + if mode_embedder_key not in sd: + return None + + return sd[mode_embedder_key].shape[0] diff --git a/invokeai/backend/flux/controlnet/xlabs_controlnet_flux.py b/invokeai/backend/flux/controlnet/xlabs_controlnet_flux.py new file mode 100644 index 0000000000000000000000000000000000000000..c7d3a4675d0d2a67324c61b0212b13b7cf70673e --- /dev/null +++ b/invokeai/backend/flux/controlnet/xlabs_controlnet_flux.py @@ -0,0 +1,130 @@ +# This file was initially based on: +# https://github.com/XLabs-AI/x-flux/blob/47495425dbed499be1e8e5a6e52628b07349cba2/src/flux/controlnet.py + + +from dataclasses import dataclass + +import torch +from einops import rearrange + +from invokeai.backend.flux.controlnet.zero_module import zero_module +from invokeai.backend.flux.model import FluxParams +from invokeai.backend.flux.modules.layers import DoubleStreamBlock, EmbedND, MLPEmbedder, timestep_embedding + + +@dataclass +class XLabsControlNetFluxOutput: + controlnet_double_block_residuals: list[torch.Tensor] | None + + +class XLabsControlNetFlux(torch.nn.Module): + """A ControlNet model for FLUX. + + The architecture is very similar to the base FLUX model, with the following differences: + - A `controlnet_depth` parameter is passed to control the number of double_blocks that the ControlNet is applied to. + In order to keep the ControlNet small, this is typically much less than the depth of the base FLUX model. + - There is a set of `controlnet_blocks` that are applied to the output of each double_block. + """ + + def __init__(self, params: FluxParams, controlnet_depth: int = 2): + super().__init__() + + self.params = params + self.in_channels = params.in_channels + self.out_channels = self.in_channels + if params.hidden_size % params.num_heads != 0: + raise ValueError(f"Hidden size {params.hidden_size} must be divisible by num_heads {params.num_heads}") + pe_dim = params.hidden_size // params.num_heads + if sum(params.axes_dim) != pe_dim: + raise ValueError(f"Got {params.axes_dim} but expected positional dim {pe_dim}") + self.hidden_size = params.hidden_size + self.num_heads = params.num_heads + self.pe_embedder = EmbedND(dim=pe_dim, theta=params.theta, axes_dim=params.axes_dim) + self.img_in = torch.nn.Linear(self.in_channels, self.hidden_size, bias=True) + self.time_in = MLPEmbedder(in_dim=256, hidden_dim=self.hidden_size) + self.vector_in = MLPEmbedder(params.vec_in_dim, self.hidden_size) + self.guidance_in = ( + MLPEmbedder(in_dim=256, hidden_dim=self.hidden_size) if params.guidance_embed else torch.nn.Identity() + ) + self.txt_in = torch.nn.Linear(params.context_in_dim, self.hidden_size) + + self.double_blocks = torch.nn.ModuleList( + [ + DoubleStreamBlock( + self.hidden_size, + self.num_heads, + mlp_ratio=params.mlp_ratio, + qkv_bias=params.qkv_bias, + ) + for _ in range(controlnet_depth) + ] + ) + + # Add ControlNet blocks. + self.controlnet_blocks = torch.nn.ModuleList([]) + for _ in range(controlnet_depth): + controlnet_block = torch.nn.Linear(self.hidden_size, self.hidden_size) + controlnet_block = zero_module(controlnet_block) + self.controlnet_blocks.append(controlnet_block) + self.pos_embed_input = torch.nn.Linear(self.in_channels, self.hidden_size, bias=True) + self.input_hint_block = torch.nn.Sequential( + torch.nn.Conv2d(3, 16, 3, padding=1), + torch.nn.SiLU(), + torch.nn.Conv2d(16, 16, 3, padding=1), + torch.nn.SiLU(), + torch.nn.Conv2d(16, 16, 3, padding=1, stride=2), + torch.nn.SiLU(), + torch.nn.Conv2d(16, 16, 3, padding=1), + torch.nn.SiLU(), + torch.nn.Conv2d(16, 16, 3, padding=1, stride=2), + torch.nn.SiLU(), + torch.nn.Conv2d(16, 16, 3, padding=1), + torch.nn.SiLU(), + torch.nn.Conv2d(16, 16, 3, padding=1, stride=2), + torch.nn.SiLU(), + zero_module(torch.nn.Conv2d(16, 16, 3, padding=1)), + ) + + def forward( + self, + img: torch.Tensor, + img_ids: torch.Tensor, + controlnet_cond: torch.Tensor, + txt: torch.Tensor, + txt_ids: torch.Tensor, + timesteps: torch.Tensor, + y: torch.Tensor, + guidance: torch.Tensor | None = None, + ) -> XLabsControlNetFluxOutput: + if img.ndim != 3 or txt.ndim != 3: + raise ValueError("Input img and txt tensors must have 3 dimensions.") + + # running on sequences img + img = self.img_in(img) + controlnet_cond = self.input_hint_block(controlnet_cond) + controlnet_cond = rearrange(controlnet_cond, "b c (h ph) (w pw) -> b (h w) (c ph pw)", ph=2, pw=2) + controlnet_cond = self.pos_embed_input(controlnet_cond) + img = img + controlnet_cond + vec = self.time_in(timestep_embedding(timesteps, 256)) + if self.params.guidance_embed: + if guidance is None: + raise ValueError("Didn't get guidance strength for guidance distilled model.") + vec = vec + self.guidance_in(timestep_embedding(guidance, 256)) + vec = vec + self.vector_in(y) + txt = self.txt_in(txt) + + ids = torch.cat((txt_ids, img_ids), dim=1) + pe = self.pe_embedder(ids) + + block_res_samples: list[torch.Tensor] = [] + + for block in self.double_blocks: + img, txt = block(img=img, txt=txt, vec=vec, pe=pe) + block_res_samples.append(img) + + controlnet_block_res_samples: list[torch.Tensor] = [] + for block_res_sample, controlnet_block in zip(block_res_samples, self.controlnet_blocks, strict=True): + block_res_sample = controlnet_block(block_res_sample) + controlnet_block_res_samples.append(block_res_sample) + + return XLabsControlNetFluxOutput(controlnet_double_block_residuals=controlnet_block_res_samples) diff --git a/invokeai/backend/flux/controlnet/zero_module.py b/invokeai/backend/flux/controlnet/zero_module.py new file mode 100644 index 0000000000000000000000000000000000000000..53a21861a93ef705bd25f01682aab591811e1ced --- /dev/null +++ b/invokeai/backend/flux/controlnet/zero_module.py @@ -0,0 +1,12 @@ +from typing import TypeVar + +import torch + +T = TypeVar("T", bound=torch.nn.Module) + + +def zero_module(module: T) -> T: + """Initialize the parameters of a module to zero.""" + for p in module.parameters(): + torch.nn.init.zeros_(p) + return module diff --git a/invokeai/backend/flux/custom_block_processor.py b/invokeai/backend/flux/custom_block_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..e0c7779e935a05f630a1df19d665ab5dda0c8d04 --- /dev/null +++ b/invokeai/backend/flux/custom_block_processor.py @@ -0,0 +1,83 @@ +import einops +import torch + +from invokeai.backend.flux.extensions.xlabs_ip_adapter_extension import XLabsIPAdapterExtension +from invokeai.backend.flux.math import attention +from invokeai.backend.flux.modules.layers import DoubleStreamBlock + + +class CustomDoubleStreamBlockProcessor: + """A class containing a custom implementation of DoubleStreamBlock.forward() with additional features + (IP-Adapter, etc.). + """ + + @staticmethod + def _double_stream_block_forward( + block: DoubleStreamBlock, img: torch.Tensor, txt: torch.Tensor, vec: torch.Tensor, pe: torch.Tensor + ) -> tuple[torch.Tensor, torch.Tensor, torch.Tensor]: + """This function is a direct copy of DoubleStreamBlock.forward(), but it returns some of the intermediate + values. + """ + img_mod1, img_mod2 = block.img_mod(vec) + txt_mod1, txt_mod2 = block.txt_mod(vec) + + # prepare image for attention + img_modulated = block.img_norm1(img) + img_modulated = (1 + img_mod1.scale) * img_modulated + img_mod1.shift + img_qkv = block.img_attn.qkv(img_modulated) + img_q, img_k, img_v = einops.rearrange(img_qkv, "B L (K H D) -> K B H L D", K=3, H=block.num_heads) + img_q, img_k = block.img_attn.norm(img_q, img_k, img_v) + + # prepare txt for attention + txt_modulated = block.txt_norm1(txt) + txt_modulated = (1 + txt_mod1.scale) * txt_modulated + txt_mod1.shift + txt_qkv = block.txt_attn.qkv(txt_modulated) + txt_q, txt_k, txt_v = einops.rearrange(txt_qkv, "B L (K H D) -> K B H L D", K=3, H=block.num_heads) + txt_q, txt_k = block.txt_attn.norm(txt_q, txt_k, txt_v) + + # run actual attention + q = torch.cat((txt_q, img_q), dim=2) + k = torch.cat((txt_k, img_k), dim=2) + v = torch.cat((txt_v, img_v), dim=2) + + attn = attention(q, k, v, pe=pe) + txt_attn, img_attn = attn[:, : txt.shape[1]], attn[:, txt.shape[1] :] + + # calculate the img bloks + img = img + img_mod1.gate * block.img_attn.proj(img_attn) + img = img + img_mod2.gate * block.img_mlp((1 + img_mod2.scale) * block.img_norm2(img) + img_mod2.shift) + + # calculate the txt bloks + txt = txt + txt_mod1.gate * block.txt_attn.proj(txt_attn) + txt = txt + txt_mod2.gate * block.txt_mlp((1 + txt_mod2.scale) * block.txt_norm2(txt) + txt_mod2.shift) + return img, txt, img_q + + @staticmethod + def custom_double_block_forward( + timestep_index: int, + total_num_timesteps: int, + block_index: int, + block: DoubleStreamBlock, + img: torch.Tensor, + txt: torch.Tensor, + vec: torch.Tensor, + pe: torch.Tensor, + ip_adapter_extensions: list[XLabsIPAdapterExtension], + ) -> tuple[torch.Tensor, torch.Tensor]: + """A custom implementation of DoubleStreamBlock.forward() with additional features: + - IP-Adapter support + """ + img, txt, img_q = CustomDoubleStreamBlockProcessor._double_stream_block_forward(block, img, txt, vec, pe) + + # Apply IP-Adapter conditioning. + for ip_adapter_extension in ip_adapter_extensions: + img = ip_adapter_extension.run_ip_adapter( + timestep_index=timestep_index, + total_num_timesteps=total_num_timesteps, + block_index=block_index, + block=block, + img_q=img_q, + img=img, + ) + + return img, txt diff --git a/invokeai/backend/flux/denoise.py b/invokeai/backend/flux/denoise.py new file mode 100644 index 0000000000000000000000000000000000000000..bb0e60409a87d36410827013fed0bb4de4a17b09 --- /dev/null +++ b/invokeai/backend/flux/denoise.py @@ -0,0 +1,136 @@ +import math +from typing import Callable + +import torch +from tqdm import tqdm + +from invokeai.backend.flux.controlnet.controlnet_flux_output import ControlNetFluxOutput, sum_controlnet_flux_outputs +from invokeai.backend.flux.extensions.inpaint_extension import InpaintExtension +from invokeai.backend.flux.extensions.instantx_controlnet_extension import InstantXControlNetExtension +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.model import Flux +from invokeai.backend.stable_diffusion.diffusers_pipeline import PipelineIntermediateState + + +def denoise( + model: Flux, + # model input + img: torch.Tensor, + img_ids: torch.Tensor, + # positive text conditioning + txt: torch.Tensor, + txt_ids: torch.Tensor, + vec: torch.Tensor, + # negative text conditioning + neg_txt: torch.Tensor | None, + neg_txt_ids: torch.Tensor | None, + neg_vec: torch.Tensor | None, + # sampling parameters + timesteps: list[float], + step_callback: Callable[[PipelineIntermediateState], None], + guidance: float, + cfg_scale: list[float], + inpaint_extension: InpaintExtension | None, + controlnet_extensions: list[XLabsControlNetExtension | InstantXControlNetExtension], + pos_ip_adapter_extensions: list[XLabsIPAdapterExtension], + neg_ip_adapter_extensions: list[XLabsIPAdapterExtension], +): + # step 0 is the initial state + total_steps = len(timesteps) - 1 + step_callback( + PipelineIntermediateState( + step=0, + order=1, + total_steps=total_steps, + timestep=int(timesteps[0]), + latents=img, + ), + ) + # guidance_vec is ignored for schnell. + guidance_vec = torch.full((img.shape[0],), guidance, device=img.device, dtype=img.dtype) + for step_index, (t_curr, t_prev) in tqdm(list(enumerate(zip(timesteps[:-1], timesteps[1:], strict=True)))): + t_vec = torch.full((img.shape[0],), t_curr, dtype=img.dtype, device=img.device) + + # Run ControlNet models. + controlnet_residuals: list[ControlNetFluxOutput] = [] + for controlnet_extension in controlnet_extensions: + controlnet_residuals.append( + controlnet_extension.run_controlnet( + timestep_index=step_index, + total_num_timesteps=total_steps, + img=img, + img_ids=img_ids, + txt=txt, + txt_ids=txt_ids, + y=vec, + timesteps=t_vec, + guidance=guidance_vec, + ) + ) + + # Merge the ControlNet residuals from multiple ControlNets. + # TODO(ryand): We may want to calculate the sum just-in-time to keep peak memory low. Keep in mind, that the + # controlnet_residuals datastructure is efficient in that it likely contains multiple references to the same + # tensors. Calculating the sum materializes each tensor into its own instance. + merged_controlnet_residuals = sum_controlnet_flux_outputs(controlnet_residuals) + + pred = model( + img=img, + img_ids=img_ids, + txt=txt, + txt_ids=txt_ids, + y=vec, + timesteps=t_vec, + guidance=guidance_vec, + timestep_index=step_index, + total_num_timesteps=total_steps, + controlnet_double_block_residuals=merged_controlnet_residuals.double_block_residuals, + controlnet_single_block_residuals=merged_controlnet_residuals.single_block_residuals, + ip_adapter_extensions=pos_ip_adapter_extensions, + ) + + step_cfg_scale = cfg_scale[step_index] + + # If step_cfg_scale, is 1.0, then we don't need to run the negative prediction. + if not math.isclose(step_cfg_scale, 1.0): + # TODO(ryand): Add option to run positive and negative predictions in a single batch for better performance + # on systems with sufficient VRAM. + + if neg_txt is None or neg_txt_ids is None or neg_vec is None: + raise ValueError("Negative text conditioning is required when cfg_scale is not 1.0.") + + neg_pred = model( + img=img, + img_ids=img_ids, + txt=neg_txt, + txt_ids=neg_txt_ids, + y=neg_vec, + timesteps=t_vec, + guidance=guidance_vec, + timestep_index=step_index, + total_num_timesteps=total_steps, + controlnet_double_block_residuals=None, + controlnet_single_block_residuals=None, + ip_adapter_extensions=neg_ip_adapter_extensions, + ) + pred = neg_pred + step_cfg_scale * (pred - neg_pred) + + preview_img = img - t_curr * pred + img = img + (t_prev - t_curr) * pred + + if inpaint_extension is not None: + img = inpaint_extension.merge_intermediate_latents_with_init_latents(img, t_prev) + preview_img = inpaint_extension.merge_intermediate_latents_with_init_latents(preview_img, 0.0) + + step_callback( + PipelineIntermediateState( + step=step_index + 1, + order=1, + total_steps=total_steps, + timestep=int(t_curr), + latents=preview_img, + ), + ) + + return img diff --git a/invokeai/backend/flux/extensions/__init__.py b/invokeai/backend/flux/extensions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/flux/extensions/base_controlnet_extension.py b/invokeai/backend/flux/extensions/base_controlnet_extension.py new file mode 100644 index 0000000000000000000000000000000000000000..9736aaea5b9f9e60337be9ea14ade33dc4770eb0 --- /dev/null +++ b/invokeai/backend/flux/extensions/base_controlnet_extension.py @@ -0,0 +1,45 @@ +import math +from abc import ABC, abstractmethod +from typing import List, Union + +import torch + +from invokeai.backend.flux.controlnet.controlnet_flux_output import ControlNetFluxOutput + + +class BaseControlNetExtension(ABC): + def __init__( + self, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + ): + self._weight = weight + self._begin_step_percent = begin_step_percent + self._end_step_percent = end_step_percent + + def _get_weight(self, timestep_index: int, total_num_timesteps: int) -> float: + first_step = math.floor(self._begin_step_percent * total_num_timesteps) + last_step = math.ceil(self._end_step_percent * total_num_timesteps) + + if timestep_index < first_step or timestep_index > last_step: + return 0.0 + + if isinstance(self._weight, list): + return self._weight[timestep_index] + + return self._weight + + @abstractmethod + def run_controlnet( + self, + timestep_index: int, + total_num_timesteps: int, + img: torch.Tensor, + img_ids: torch.Tensor, + txt: torch.Tensor, + txt_ids: torch.Tensor, + y: torch.Tensor, + timesteps: torch.Tensor, + guidance: torch.Tensor | None, + ) -> ControlNetFluxOutput: ... diff --git a/invokeai/backend/flux/extensions/inpaint_extension.py b/invokeai/backend/flux/extensions/inpaint_extension.py new file mode 100644 index 0000000000000000000000000000000000000000..4843176d5dd9e32aa83311e01caf237304c67563 --- /dev/null +++ b/invokeai/backend/flux/extensions/inpaint_extension.py @@ -0,0 +1,55 @@ +import torch + + +class InpaintExtension: + """A class for managing inpainting with FLUX.""" + + def __init__(self, init_latents: torch.Tensor, inpaint_mask: torch.Tensor, noise: torch.Tensor): + """Initialize InpaintExtension. + + Args: + init_latents (torch.Tensor): The initial latents (i.e. un-noised at timestep 0). In 'packed' format. + inpaint_mask (torch.Tensor): A mask specifying which elements to inpaint. Range [0, 1]. Values of 1 will be + re-generated. Values of 0 will remain unchanged. Values between 0 and 1 can be used to blend the + inpainted region with the background. In 'packed' format. + noise (torch.Tensor): The noise tensor used to noise the init_latents. In 'packed' format. + """ + assert init_latents.shape == inpaint_mask.shape == noise.shape + self._init_latents = init_latents + self._inpaint_mask = inpaint_mask + self._noise = noise + + def _apply_mask_gradient_adjustment(self, t_prev: float) -> torch.Tensor: + """Applies inpaint mask gradient adjustment and returns the inpaint mask to be used at the current timestep.""" + # As we progress through the denoising process, we promote gradient regions of the mask to have a full weight of + # 1.0. This helps to produce more coherent seams around the inpainted region. We experimented with a (small) + # number of promotion strategies (e.g. gradual promotion based on timestep), but found that a simple cutoff + # threshold worked well. + # We use a small epsilon to avoid any potential issues with floating point precision. + eps = 1e-4 + mask_gradient_t_cutoff = 0.5 + if t_prev > mask_gradient_t_cutoff: + # Early in the denoising process, use the inpaint mask as-is. + return self._inpaint_mask + else: + # After the cut-off, promote all non-zero mask values to 1.0. + mask = self._inpaint_mask.where(self._inpaint_mask <= (0.0 + eps), 1.0) + + return mask + + def merge_intermediate_latents_with_init_latents( + self, intermediate_latents: torch.Tensor, t_prev: float + ) -> torch.Tensor: + """Merge the intermediate latents with the initial latents for the current timestep using the inpaint mask. I.e. + update the intermediate latents to keep the regions that are not being inpainted on the correct noise + trajectory. + + This function should be called after each denoising step. + """ + mask = self._apply_mask_gradient_adjustment(t_prev) + + # Noise the init latents for the current timestep. + noised_init_latents = self._noise * t_prev + (1.0 - t_prev) * self._init_latents + + # Merge the intermediate latents with the noised_init_latents using the inpaint_mask. + return intermediate_latents * mask + noised_init_latents * (1.0 - mask) diff --git a/invokeai/backend/flux/extensions/instantx_controlnet_extension.py b/invokeai/backend/flux/extensions/instantx_controlnet_extension.py new file mode 100644 index 0000000000000000000000000000000000000000..f03d2d21aa340c7de5b9fd3661a5913dcd968833 --- /dev/null +++ b/invokeai/backend/flux/extensions/instantx_controlnet_extension.py @@ -0,0 +1,194 @@ +import math +from typing import List, Union + +import torch +from PIL.Image import Image + +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.app.invocations.flux_vae_encode import FluxVaeEncodeInvocation +from invokeai.app.util.controlnet_utils import CONTROLNET_RESIZE_VALUES, prepare_control_image +from invokeai.backend.flux.controlnet.controlnet_flux_output import ControlNetFluxOutput +from invokeai.backend.flux.controlnet.instantx_controlnet_flux import ( + InstantXControlNetFlux, + InstantXControlNetFluxOutput, +) +from invokeai.backend.flux.extensions.base_controlnet_extension import BaseControlNetExtension +from invokeai.backend.flux.sampling_utils import pack +from invokeai.backend.model_manager.load.load_base import LoadedModel + + +class InstantXControlNetExtension(BaseControlNetExtension): + def __init__( + self, + model: InstantXControlNetFlux, + controlnet_cond: torch.Tensor, + instantx_control_mode: torch.Tensor | None, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + ): + super().__init__( + weight=weight, + begin_step_percent=begin_step_percent, + end_step_percent=end_step_percent, + ) + self._model = model + # The VAE-encoded and 'packed' control image to pass to the ControlNet model. + self._controlnet_cond = controlnet_cond + # TODO(ryand): Should we define an enum for the instantx_control_mode? Is it likely to change for future models? + # The control mode for InstantX ControlNet union models. + # See the values defined here: https://huggingface.co/InstantX/FLUX.1-dev-Controlnet-Union#control-mode + # Expected shape: (batch_size, 1), Expected dtype: torch.long + # If None, a zero-embedding will be used. + self._instantx_control_mode = instantx_control_mode + + # TODO(ryand): Pass in these params if a new base transformer / InstantX ControlNet pair get released. + self._flux_transformer_num_double_blocks = 19 + self._flux_transformer_num_single_blocks = 38 + + @classmethod + def prepare_controlnet_cond( + cls, + controlnet_image: Image, + vae_info: LoadedModel, + latent_height: int, + latent_width: int, + dtype: torch.dtype, + device: torch.device, + resize_mode: CONTROLNET_RESIZE_VALUES, + ): + image_height = latent_height * LATENT_SCALE_FACTOR + image_width = latent_width * LATENT_SCALE_FACTOR + + resized_controlnet_image = prepare_control_image( + image=controlnet_image, + do_classifier_free_guidance=False, + width=image_width, + height=image_height, + device=device, + dtype=dtype, + control_mode="balanced", + resize_mode=resize_mode, + ) + + # Shift the image from [0, 1] to [-1, 1]. + resized_controlnet_image = resized_controlnet_image * 2 - 1 + + # Run VAE encoder. + controlnet_cond = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=resized_controlnet_image) + controlnet_cond = pack(controlnet_cond) + + return controlnet_cond + + @classmethod + def from_controlnet_image( + cls, + model: InstantXControlNetFlux, + controlnet_image: Image, + instantx_control_mode: torch.Tensor | None, + vae_info: LoadedModel, + latent_height: int, + latent_width: int, + dtype: torch.dtype, + device: torch.device, + resize_mode: CONTROLNET_RESIZE_VALUES, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + ): + image_height = latent_height * LATENT_SCALE_FACTOR + image_width = latent_width * LATENT_SCALE_FACTOR + + resized_controlnet_image = prepare_control_image( + image=controlnet_image, + do_classifier_free_guidance=False, + width=image_width, + height=image_height, + device=device, + dtype=dtype, + control_mode="balanced", + resize_mode=resize_mode, + ) + + # Shift the image from [0, 1] to [-1, 1]. + resized_controlnet_image = resized_controlnet_image * 2 - 1 + + # Run VAE encoder. + controlnet_cond = FluxVaeEncodeInvocation.vae_encode(vae_info=vae_info, image_tensor=resized_controlnet_image) + controlnet_cond = pack(controlnet_cond) + + return cls( + model=model, + controlnet_cond=controlnet_cond, + instantx_control_mode=instantx_control_mode, + weight=weight, + begin_step_percent=begin_step_percent, + end_step_percent=end_step_percent, + ) + + def _instantx_output_to_controlnet_output( + self, instantx_output: InstantXControlNetFluxOutput + ) -> ControlNetFluxOutput: + # The `interval_control` logic here is based on + # https://github.com/huggingface/diffusers/blob/31058cdaef63ca660a1a045281d156239fba8192/src/diffusers/models/transformers/transformer_flux.py#L507-L511 + + # Handle double block residuals. + double_block_residuals: list[torch.Tensor] = [] + double_block_samples = instantx_output.controlnet_block_samples + if double_block_samples: + interval_control = self._flux_transformer_num_double_blocks / len(double_block_samples) + interval_control = int(math.ceil(interval_control)) + for i in range(self._flux_transformer_num_double_blocks): + double_block_residuals.append(double_block_samples[i // interval_control]) + + # Handle single block residuals. + single_block_residuals: list[torch.Tensor] = [] + single_block_samples = instantx_output.controlnet_single_block_samples + if single_block_samples: + interval_control = self._flux_transformer_num_single_blocks / len(single_block_samples) + interval_control = int(math.ceil(interval_control)) + for i in range(self._flux_transformer_num_single_blocks): + single_block_residuals.append(single_block_samples[i // interval_control]) + + return ControlNetFluxOutput( + double_block_residuals=double_block_residuals or None, + single_block_residuals=single_block_residuals or None, + ) + + def run_controlnet( + self, + timestep_index: int, + total_num_timesteps: int, + img: torch.Tensor, + img_ids: torch.Tensor, + txt: torch.Tensor, + txt_ids: torch.Tensor, + y: torch.Tensor, + timesteps: torch.Tensor, + guidance: torch.Tensor | None, + ) -> ControlNetFluxOutput: + weight = self._get_weight(timestep_index=timestep_index, total_num_timesteps=total_num_timesteps) + if weight < 1e-6: + return ControlNetFluxOutput(single_block_residuals=None, double_block_residuals=None) + + # Make sure inputs have correct device and dtype. + self._controlnet_cond = self._controlnet_cond.to(device=img.device, dtype=img.dtype) + self._instantx_control_mode = ( + self._instantx_control_mode.to(device=img.device) if self._instantx_control_mode is not None else None + ) + + instantx_output: InstantXControlNetFluxOutput = self._model( + controlnet_cond=self._controlnet_cond, + controlnet_mode=self._instantx_control_mode, + img=img, + img_ids=img_ids, + txt=txt, + txt_ids=txt_ids, + timesteps=timesteps, + y=y, + guidance=guidance, + ) + + controlnet_output = self._instantx_output_to_controlnet_output(instantx_output) + controlnet_output.apply_weight(weight) + return controlnet_output diff --git a/invokeai/backend/flux/extensions/xlabs_controlnet_extension.py b/invokeai/backend/flux/extensions/xlabs_controlnet_extension.py new file mode 100644 index 0000000000000000000000000000000000000000..1f6409cbbe5ed996d95c165b01ae1f64c286baae --- /dev/null +++ b/invokeai/backend/flux/extensions/xlabs_controlnet_extension.py @@ -0,0 +1,150 @@ +from typing import List, Union + +import torch +from PIL.Image import Image + +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.app.util.controlnet_utils import CONTROLNET_RESIZE_VALUES, prepare_control_image +from invokeai.backend.flux.controlnet.controlnet_flux_output import ControlNetFluxOutput +from invokeai.backend.flux.controlnet.xlabs_controlnet_flux import XLabsControlNetFlux, XLabsControlNetFluxOutput +from invokeai.backend.flux.extensions.base_controlnet_extension import BaseControlNetExtension + + +class XLabsControlNetExtension(BaseControlNetExtension): + def __init__( + self, + model: XLabsControlNetFlux, + controlnet_cond: torch.Tensor, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + ): + super().__init__( + weight=weight, + begin_step_percent=begin_step_percent, + end_step_percent=end_step_percent, + ) + + self._model = model + # _controlnet_cond is the control image passed to the ControlNet model. + # Pixel values are in the range [-1, 1]. Shape: (batch_size, 3, height, width). + self._controlnet_cond = controlnet_cond + + # TODO(ryand): Pass in these params if a new base transformer / XLabs ControlNet pair get released. + self._flux_transformer_num_double_blocks = 19 + self._flux_transformer_num_single_blocks = 38 + + @classmethod + def prepare_controlnet_cond( + cls, + controlnet_image: Image, + latent_height: int, + latent_width: int, + dtype: torch.dtype, + device: torch.device, + resize_mode: CONTROLNET_RESIZE_VALUES, + ): + image_height = latent_height * LATENT_SCALE_FACTOR + image_width = latent_width * LATENT_SCALE_FACTOR + + controlnet_cond = prepare_control_image( + image=controlnet_image, + do_classifier_free_guidance=False, + width=image_width, + height=image_height, + device=device, + dtype=dtype, + control_mode="balanced", + resize_mode=resize_mode, + ) + + # Map pixel values from [0, 1] to [-1, 1]. + controlnet_cond = controlnet_cond * 2 - 1 + + return controlnet_cond + + @classmethod + def from_controlnet_image( + cls, + model: XLabsControlNetFlux, + controlnet_image: Image, + latent_height: int, + latent_width: int, + dtype: torch.dtype, + device: torch.device, + resize_mode: CONTROLNET_RESIZE_VALUES, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + ): + image_height = latent_height * LATENT_SCALE_FACTOR + image_width = latent_width * LATENT_SCALE_FACTOR + + controlnet_cond = prepare_control_image( + image=controlnet_image, + do_classifier_free_guidance=False, + width=image_width, + height=image_height, + device=device, + dtype=dtype, + control_mode="balanced", + resize_mode=resize_mode, + ) + + # Map pixel values from [0, 1] to [-1, 1]. + controlnet_cond = controlnet_cond * 2 - 1 + + return cls( + model=model, + controlnet_cond=controlnet_cond, + weight=weight, + begin_step_percent=begin_step_percent, + end_step_percent=end_step_percent, + ) + + def _xlabs_output_to_controlnet_output(self, xlabs_output: XLabsControlNetFluxOutput) -> ControlNetFluxOutput: + # The modulo index logic used here is based on: + # https://github.com/XLabs-AI/x-flux/blob/47495425dbed499be1e8e5a6e52628b07349cba2/src/flux/model.py#L198-L200 + + # Handle double block residuals. + double_block_residuals: list[torch.Tensor] = [] + xlabs_double_block_residuals = xlabs_output.controlnet_double_block_residuals + if xlabs_double_block_residuals is not None: + for i in range(self._flux_transformer_num_double_blocks): + double_block_residuals.append(xlabs_double_block_residuals[i % len(xlabs_double_block_residuals)]) + + return ControlNetFluxOutput( + double_block_residuals=double_block_residuals, + single_block_residuals=None, + ) + + def run_controlnet( + self, + timestep_index: int, + total_num_timesteps: int, + img: torch.Tensor, + img_ids: torch.Tensor, + txt: torch.Tensor, + txt_ids: torch.Tensor, + y: torch.Tensor, + timesteps: torch.Tensor, + guidance: torch.Tensor | None, + ) -> ControlNetFluxOutput: + weight = self._get_weight(timestep_index=timestep_index, total_num_timesteps=total_num_timesteps) + if weight < 1e-6: + return ControlNetFluxOutput(single_block_residuals=None, double_block_residuals=None) + + xlabs_output: XLabsControlNetFluxOutput = self._model( + img=img, + img_ids=img_ids, + controlnet_cond=self._controlnet_cond, + txt=txt, + txt_ids=txt_ids, + timesteps=timesteps, + y=y, + guidance=guidance, + ) + + controlnet_output = self._xlabs_output_to_controlnet_output(xlabs_output) + controlnet_output.apply_weight(weight) + return controlnet_output diff --git a/invokeai/backend/flux/extensions/xlabs_ip_adapter_extension.py b/invokeai/backend/flux/extensions/xlabs_ip_adapter_extension.py new file mode 100644 index 0000000000000000000000000000000000000000..b7a2bd85a6e6517e87aac438af845ffeaac0c971 --- /dev/null +++ b/invokeai/backend/flux/extensions/xlabs_ip_adapter_extension.py @@ -0,0 +1,89 @@ +import math +from typing import List, Union + +import einops +import torch +from PIL import Image +from transformers import CLIPImageProcessor, CLIPVisionModelWithProjection + +from invokeai.backend.flux.ip_adapter.xlabs_ip_adapter_flux import XlabsIpAdapterFlux +from invokeai.backend.flux.modules.layers import DoubleStreamBlock + + +class XLabsIPAdapterExtension: + def __init__( + self, + model: XlabsIpAdapterFlux, + image_prompt_clip_embed: torch.Tensor, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + ): + self._model = model + self._image_prompt_clip_embed = image_prompt_clip_embed + self._weight = weight + self._begin_step_percent = begin_step_percent + self._end_step_percent = end_step_percent + + self._image_proj: torch.Tensor | None = None + + def _get_weight(self, timestep_index: int, total_num_timesteps: int) -> float: + first_step = math.floor(self._begin_step_percent * total_num_timesteps) + last_step = math.ceil(self._end_step_percent * total_num_timesteps) + + if timestep_index < first_step or timestep_index > last_step: + return 0.0 + + if isinstance(self._weight, list): + return self._weight[timestep_index] + + return self._weight + + @staticmethod + def run_clip_image_encoder( + pil_image: List[Image.Image], image_encoder: CLIPVisionModelWithProjection + ) -> torch.Tensor: + clip_image_processor = CLIPImageProcessor() + clip_image: torch.Tensor = clip_image_processor(images=pil_image, return_tensors="pt").pixel_values + clip_image = clip_image.to(device=image_encoder.device, dtype=image_encoder.dtype) + clip_image_embeds = image_encoder(clip_image).image_embeds + return clip_image_embeds + + def run_image_proj(self, dtype: torch.dtype): + image_prompt_clip_embed = self._image_prompt_clip_embed.to(dtype=dtype) + self._image_proj = self._model.image_proj(image_prompt_clip_embed) + + def run_ip_adapter( + self, + timestep_index: int, + total_num_timesteps: int, + block_index: int, + block: DoubleStreamBlock, + img_q: torch.Tensor, + img: torch.Tensor, + ) -> torch.Tensor: + """The logic in this function is based on: + https://github.com/XLabs-AI/x-flux/blob/47495425dbed499be1e8e5a6e52628b07349cba2/src/flux/modules/layers.py#L245-L301 + """ + weight = self._get_weight(timestep_index=timestep_index, total_num_timesteps=total_num_timesteps) + if weight < 1e-6: + return img + + ip_adapter_block = self._model.ip_adapter_double_blocks.double_blocks[block_index] + + ip_key = ip_adapter_block.ip_adapter_double_stream_k_proj(self._image_proj) + ip_value = ip_adapter_block.ip_adapter_double_stream_v_proj(self._image_proj) + + # Reshape projections for multi-head attention. + ip_key = einops.rearrange(ip_key, "B L (H D) -> B H L D", H=block.num_heads) + ip_value = einops.rearrange(ip_value, "B L (H D) -> B H L D", H=block.num_heads) + + # Compute attention between IP projections and the latent query. + ip_attn = torch.nn.functional.scaled_dot_product_attention( + img_q, ip_key, ip_value, dropout_p=0.0, is_causal=False + ) + ip_attn = einops.rearrange(ip_attn, "B H L D -> B L (H D)", H=block.num_heads) + + img = img + weight * ip_attn + + return img diff --git a/invokeai/backend/flux/ip_adapter/__init__.py b/invokeai/backend/flux/ip_adapter/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/flux/ip_adapter/ip_double_stream_block_processor.py b/invokeai/backend/flux/ip_adapter/ip_double_stream_block_processor.py new file mode 100644 index 0000000000000000000000000000000000000000..9b1bef7f707a4299567f1ebf9024b44084f7cef5 --- /dev/null +++ b/invokeai/backend/flux/ip_adapter/ip_double_stream_block_processor.py @@ -0,0 +1,93 @@ +# This file is based on: +# https://github.com/XLabs-AI/x-flux/blob/47495425dbed499be1e8e5a6e52628b07349cba2/src/flux/modules/layers.py#L221 +import einops +import torch + +from invokeai.backend.flux.math import attention +from invokeai.backend.flux.modules.layers import DoubleStreamBlock + + +class IPDoubleStreamBlockProcessor(torch.nn.Module): + """Attention processor for handling IP-adapter with double stream block.""" + + def __init__(self, context_dim: int, hidden_dim: int): + super().__init__() + + # Ensure context_dim matches the dimension of image_proj + self.context_dim = context_dim + self.hidden_dim = hidden_dim + + # Initialize projections for IP-adapter + self.ip_adapter_double_stream_k_proj = torch.nn.Linear(context_dim, hidden_dim, bias=True) + self.ip_adapter_double_stream_v_proj = torch.nn.Linear(context_dim, hidden_dim, bias=True) + + torch.nn.init.zeros_(self.ip_adapter_double_stream_k_proj.weight) + torch.nn.init.zeros_(self.ip_adapter_double_stream_k_proj.bias) + + torch.nn.init.zeros_(self.ip_adapter_double_stream_v_proj.weight) + torch.nn.init.zeros_(self.ip_adapter_double_stream_v_proj.bias) + + def __call__( + self, + attn: DoubleStreamBlock, + img: torch.Tensor, + txt: torch.Tensor, + vec: torch.Tensor, + pe: torch.Tensor, + image_proj: torch.Tensor, + ip_scale: float = 1.0, + ): + # Prepare image for attention + img_mod1, img_mod2 = attn.img_mod(vec) + txt_mod1, txt_mod2 = attn.txt_mod(vec) + + img_modulated = attn.img_norm1(img) + img_modulated = (1 + img_mod1.scale) * img_modulated + img_mod1.shift + img_qkv = attn.img_attn.qkv(img_modulated) + img_q, img_k, img_v = einops.rearrange( + img_qkv, "B L (K H D) -> K B H L D", K=3, H=attn.num_heads, D=attn.head_dim + ) + img_q, img_k = attn.img_attn.norm(img_q, img_k, img_v) + + txt_modulated = attn.txt_norm1(txt) + txt_modulated = (1 + txt_mod1.scale) * txt_modulated + txt_mod1.shift + txt_qkv = attn.txt_attn.qkv(txt_modulated) + txt_q, txt_k, txt_v = einops.rearrange( + txt_qkv, "B L (K H D) -> K B H L D", K=3, H=attn.num_heads, D=attn.head_dim + ) + txt_q, txt_k = attn.txt_attn.norm(txt_q, txt_k, txt_v) + + q = torch.cat((txt_q, img_q), dim=2) + k = torch.cat((txt_k, img_k), dim=2) + v = torch.cat((txt_v, img_v), dim=2) + + attn1 = attention(q, k, v, pe=pe) + txt_attn, img_attn = attn1[:, : txt.shape[1]], attn1[:, txt.shape[1] :] + + # print(f"txt_attn shape: {txt_attn.size()}") + # print(f"img_attn shape: {img_attn.size()}") + + img = img + img_mod1.gate * attn.img_attn.proj(img_attn) + img = img + img_mod2.gate * attn.img_mlp((1 + img_mod2.scale) * attn.img_norm2(img) + img_mod2.shift) + + txt = txt + txt_mod1.gate * attn.txt_attn.proj(txt_attn) + txt = txt + txt_mod2.gate * attn.txt_mlp((1 + txt_mod2.scale) * attn.txt_norm2(txt) + txt_mod2.shift) + + # IP-adapter processing + ip_query = img_q # latent sample query + ip_key = self.ip_adapter_double_stream_k_proj(image_proj) + ip_value = self.ip_adapter_double_stream_v_proj(image_proj) + + # Reshape projections for multi-head attention + ip_key = einops.rearrange(ip_key, "B L (H D) -> B H L D", H=attn.num_heads, D=attn.head_dim) + ip_value = einops.rearrange(ip_value, "B L (H D) -> B H L D", H=attn.num_heads, D=attn.head_dim) + + # Compute attention between IP projections and the latent query + ip_attention = torch.nn.functional.scaled_dot_product_attention( + ip_query, ip_key, ip_value, dropout_p=0.0, is_causal=False + ) + ip_attention = einops.rearrange(ip_attention, "B H L D -> B L (H D)", H=attn.num_heads, D=attn.head_dim) + + img = img + ip_scale * ip_attention + + return img, txt diff --git a/invokeai/backend/flux/ip_adapter/state_dict_utils.py b/invokeai/backend/flux/ip_adapter/state_dict_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..dff4978480f248c055318244bfcd004c34aed1af --- /dev/null +++ b/invokeai/backend/flux/ip_adapter/state_dict_utils.py @@ -0,0 +1,50 @@ +from typing import Any, Dict + +import torch + +from invokeai.backend.flux.ip_adapter.xlabs_ip_adapter_flux import XlabsIpAdapterParams + + +def is_state_dict_xlabs_ip_adapter(sd: Dict[str, Any]) -> bool: + """Is the state dict for an XLabs FLUX IP-Adapter model? + + This is intended to be a reasonably high-precision detector, but it is not guaranteed to have perfect precision. + """ + # If all of the expected keys are present, then this is very likely an XLabs IP-Adapter model. + expected_keys = { + "double_blocks.0.processor.ip_adapter_double_stream_k_proj.bias", + "double_blocks.0.processor.ip_adapter_double_stream_k_proj.weight", + "double_blocks.0.processor.ip_adapter_double_stream_v_proj.bias", + "double_blocks.0.processor.ip_adapter_double_stream_v_proj.weight", + "ip_adapter_proj_model.norm.bias", + "ip_adapter_proj_model.norm.weight", + "ip_adapter_proj_model.proj.bias", + "ip_adapter_proj_model.proj.weight", + } + + if expected_keys.issubset(sd.keys()): + return True + return False + + +def infer_xlabs_ip_adapter_params_from_state_dict(state_dict: dict[str, torch.Tensor]) -> XlabsIpAdapterParams: + num_double_blocks = 0 + context_dim = 0 + hidden_dim = 0 + + # Count the number of double blocks. + double_block_index = 0 + while f"double_blocks.{double_block_index}.processor.ip_adapter_double_stream_k_proj.weight" in state_dict: + double_block_index += 1 + num_double_blocks = double_block_index + + hidden_dim = state_dict["double_blocks.0.processor.ip_adapter_double_stream_k_proj.weight"].shape[0] + context_dim = state_dict["double_blocks.0.processor.ip_adapter_double_stream_k_proj.weight"].shape[1] + clip_embeddings_dim = state_dict["ip_adapter_proj_model.proj.weight"].shape[1] + + return XlabsIpAdapterParams( + num_double_blocks=num_double_blocks, + context_dim=context_dim, + hidden_dim=hidden_dim, + clip_embeddings_dim=clip_embeddings_dim, + ) diff --git a/invokeai/backend/flux/ip_adapter/xlabs_ip_adapter_flux.py b/invokeai/backend/flux/ip_adapter/xlabs_ip_adapter_flux.py new file mode 100644 index 0000000000000000000000000000000000000000..cfe72eb54b98910cfeca7ccf8283e8c10d356e0d --- /dev/null +++ b/invokeai/backend/flux/ip_adapter/xlabs_ip_adapter_flux.py @@ -0,0 +1,67 @@ +from dataclasses import dataclass + +import torch + +from invokeai.backend.ip_adapter.ip_adapter import ImageProjModel + + +class IPDoubleStreamBlock(torch.nn.Module): + def __init__(self, context_dim: int, hidden_dim: int): + super().__init__() + + self.context_dim = context_dim + self.hidden_dim = hidden_dim + + self.ip_adapter_double_stream_k_proj = torch.nn.Linear(context_dim, hidden_dim, bias=True) + self.ip_adapter_double_stream_v_proj = torch.nn.Linear(context_dim, hidden_dim, bias=True) + + +class IPAdapterDoubleBlocks(torch.nn.Module): + def __init__(self, num_double_blocks: int, context_dim: int, hidden_dim: int): + super().__init__() + self.double_blocks = torch.nn.ModuleList( + [IPDoubleStreamBlock(context_dim, hidden_dim) for _ in range(num_double_blocks)] + ) + + +@dataclass +class XlabsIpAdapterParams: + num_double_blocks: int + context_dim: int + hidden_dim: int + + clip_embeddings_dim: int + + +class XlabsIpAdapterFlux(torch.nn.Module): + def __init__(self, params: XlabsIpAdapterParams): + super().__init__() + self.image_proj = ImageProjModel( + cross_attention_dim=params.context_dim, clip_embeddings_dim=params.clip_embeddings_dim + ) + self.ip_adapter_double_blocks = IPAdapterDoubleBlocks( + num_double_blocks=params.num_double_blocks, context_dim=params.context_dim, hidden_dim=params.hidden_dim + ) + + def load_xlabs_state_dict(self, state_dict: dict[str, torch.Tensor], assign: bool = False): + """We need this custom function to load state dicts rather than using .load_state_dict(...) because the model + structure does not match the state_dict structure. + """ + # Split the state_dict into the image projection model and the double blocks. + image_proj_sd: dict[str, torch.Tensor] = {} + double_blocks_sd: dict[str, torch.Tensor] = {} + for k, v in state_dict.items(): + if k.startswith("ip_adapter_proj_model."): + image_proj_sd[k] = v + elif k.startswith("double_blocks."): + double_blocks_sd[k] = v + else: + raise ValueError(f"Unexpected key: {k}") + + # Initialize the image projection model. + image_proj_sd = {k.replace("ip_adapter_proj_model.", ""): v for k, v in image_proj_sd.items()} + self.image_proj.load_state_dict(image_proj_sd, assign=assign) + + # Initialize the double blocks. + double_blocks_sd = {k.replace("processor.", ""): v for k, v in double_blocks_sd.items()} + self.ip_adapter_double_blocks.load_state_dict(double_blocks_sd, assign=assign) diff --git a/invokeai/backend/flux/math.py b/invokeai/backend/flux/math.py new file mode 100644 index 0000000000000000000000000000000000000000..0fac7a1d16b5d2b9944b3c02e7c4bb0b7863d34e --- /dev/null +++ b/invokeai/backend/flux/math.py @@ -0,0 +1,35 @@ +# Initially pulled from https://github.com/black-forest-labs/flux + +import torch +from einops import rearrange +from torch import Tensor + + +def attention(q: Tensor, k: Tensor, v: Tensor, pe: Tensor) -> Tensor: + q, k = apply_rope(q, k, pe) + + x = torch.nn.functional.scaled_dot_product_attention(q, k, v) + x = rearrange(x, "B H L D -> B L (H D)") + + return x + + +def rope(pos: Tensor, dim: int, theta: int) -> Tensor: + assert dim % 2 == 0 + scale = ( + torch.arange(0, dim, 2, dtype=torch.float32 if pos.device.type == "mps" else torch.float64, device=pos.device) + / dim + ) + omega = 1.0 / (theta**scale) + out = torch.einsum("...n,d->...nd", pos, omega) + out = torch.stack([torch.cos(out), -torch.sin(out), torch.sin(out), torch.cos(out)], dim=-1) + out = rearrange(out, "b n d (i j) -> b n d i j", i=2, j=2) + return out.float() + + +def apply_rope(xq: Tensor, xk: Tensor, freqs_cis: Tensor) -> tuple[Tensor, Tensor]: + xq_ = xq.float().reshape(*xq.shape[:-1], -1, 1, 2) + xk_ = xk.float().reshape(*xk.shape[:-1], -1, 1, 2) + xq_out = freqs_cis[..., 0] * xq_[..., 0] + freqs_cis[..., 1] * xq_[..., 1] + xk_out = freqs_cis[..., 0] * xk_[..., 0] + freqs_cis[..., 1] * xk_[..., 1] + return xq_out.reshape(*xq.shape).type_as(xq), xk_out.reshape(*xk.shape).type_as(xk) diff --git a/invokeai/backend/flux/model.py b/invokeai/backend/flux/model.py new file mode 100644 index 0000000000000000000000000000000000000000..0dadacd8fe1d5f6bb23ca1bb9116bda4d1895bf5 --- /dev/null +++ b/invokeai/backend/flux/model.py @@ -0,0 +1,151 @@ +# Initially pulled from https://github.com/black-forest-labs/flux + +from dataclasses import dataclass + +import torch +from torch import Tensor, nn + +from invokeai.backend.flux.custom_block_processor import CustomDoubleStreamBlockProcessor +from invokeai.backend.flux.extensions.xlabs_ip_adapter_extension import XLabsIPAdapterExtension +from invokeai.backend.flux.modules.layers import ( + DoubleStreamBlock, + EmbedND, + LastLayer, + MLPEmbedder, + SingleStreamBlock, + timestep_embedding, +) + + +@dataclass +class FluxParams: + in_channels: int + vec_in_dim: int + context_in_dim: int + hidden_size: int + mlp_ratio: float + num_heads: int + depth: int + depth_single_blocks: int + axes_dim: list[int] + theta: int + qkv_bias: bool + guidance_embed: bool + + +class Flux(nn.Module): + """ + Transformer model for flow matching on sequences. + """ + + def __init__(self, params: FluxParams): + super().__init__() + + self.params = params + self.in_channels = params.in_channels + self.out_channels = self.in_channels + if params.hidden_size % params.num_heads != 0: + raise ValueError(f"Hidden size {params.hidden_size} must be divisible by num_heads {params.num_heads}") + pe_dim = params.hidden_size // params.num_heads + if sum(params.axes_dim) != pe_dim: + raise ValueError(f"Got {params.axes_dim} but expected positional dim {pe_dim}") + self.hidden_size = params.hidden_size + self.num_heads = params.num_heads + self.pe_embedder = EmbedND(dim=pe_dim, theta=params.theta, axes_dim=params.axes_dim) + self.img_in = nn.Linear(self.in_channels, self.hidden_size, bias=True) + self.time_in = MLPEmbedder(in_dim=256, hidden_dim=self.hidden_size) + self.vector_in = MLPEmbedder(params.vec_in_dim, self.hidden_size) + self.guidance_in = ( + MLPEmbedder(in_dim=256, hidden_dim=self.hidden_size) if params.guidance_embed else nn.Identity() + ) + self.txt_in = nn.Linear(params.context_in_dim, self.hidden_size) + + self.double_blocks = nn.ModuleList( + [ + DoubleStreamBlock( + self.hidden_size, + self.num_heads, + mlp_ratio=params.mlp_ratio, + qkv_bias=params.qkv_bias, + ) + for _ in range(params.depth) + ] + ) + + self.single_blocks = nn.ModuleList( + [ + SingleStreamBlock(self.hidden_size, self.num_heads, mlp_ratio=params.mlp_ratio) + for _ in range(params.depth_single_blocks) + ] + ) + + self.final_layer = LastLayer(self.hidden_size, 1, self.out_channels) + + def forward( + self, + img: Tensor, + img_ids: Tensor, + txt: Tensor, + txt_ids: Tensor, + timesteps: Tensor, + y: Tensor, + guidance: Tensor | None, + timestep_index: int, + total_num_timesteps: int, + controlnet_double_block_residuals: list[Tensor] | None, + controlnet_single_block_residuals: list[Tensor] | None, + ip_adapter_extensions: list[XLabsIPAdapterExtension], + ) -> Tensor: + if img.ndim != 3 or txt.ndim != 3: + raise ValueError("Input img and txt tensors must have 3 dimensions.") + + # running on sequences img + img = self.img_in(img) + vec = self.time_in(timestep_embedding(timesteps, 256)) + if self.params.guidance_embed: + if guidance is None: + raise ValueError("Didn't get guidance strength for guidance distilled model.") + vec = vec + self.guidance_in(timestep_embedding(guidance, 256)) + vec = vec + self.vector_in(y) + txt = self.txt_in(txt) + + ids = torch.cat((txt_ids, img_ids), dim=1) + pe = self.pe_embedder(ids) + + # Validate double_block_residuals shape. + if controlnet_double_block_residuals is not None: + assert len(controlnet_double_block_residuals) == len(self.double_blocks) + for block_index, block in enumerate(self.double_blocks): + assert isinstance(block, DoubleStreamBlock) + + img, txt = CustomDoubleStreamBlockProcessor.custom_double_block_forward( + timestep_index=timestep_index, + total_num_timesteps=total_num_timesteps, + block_index=block_index, + block=block, + img=img, + txt=txt, + vec=vec, + pe=pe, + ip_adapter_extensions=ip_adapter_extensions, + ) + + if controlnet_double_block_residuals is not None: + img += controlnet_double_block_residuals[block_index] + + img = torch.cat((txt, img), 1) + + # Validate single_block_residuals shape. + if controlnet_single_block_residuals is not None: + assert len(controlnet_single_block_residuals) == len(self.single_blocks) + + for block_index, block in enumerate(self.single_blocks): + img = block(img, vec=vec, pe=pe) + + if controlnet_single_block_residuals is not None: + img[:, txt.shape[1] :, ...] += controlnet_single_block_residuals[block_index] + + img = img[:, txt.shape[1] :, ...] + + img = self.final_layer(img, vec) # (N, T, patch_size ** 2 * out_channels) + return img diff --git a/invokeai/backend/flux/modules/autoencoder.py b/invokeai/backend/flux/modules/autoencoder.py new file mode 100644 index 0000000000000000000000000000000000000000..6b072a82f63d1eaadef6ed815dc3e2975233bcee --- /dev/null +++ b/invokeai/backend/flux/modules/autoencoder.py @@ -0,0 +1,324 @@ +# Initially pulled from https://github.com/black-forest-labs/flux + +from dataclasses import dataclass + +import torch +from einops import rearrange +from torch import Tensor, nn + + +@dataclass +class AutoEncoderParams: + resolution: int + in_channels: int + ch: int + out_ch: int + ch_mult: list[int] + num_res_blocks: int + z_channels: int + scale_factor: float + shift_factor: float + + +class AttnBlock(nn.Module): + def __init__(self, in_channels: int): + super().__init__() + self.in_channels = in_channels + + self.norm = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True) + + self.q = nn.Conv2d(in_channels, in_channels, kernel_size=1) + self.k = nn.Conv2d(in_channels, in_channels, kernel_size=1) + self.v = nn.Conv2d(in_channels, in_channels, kernel_size=1) + self.proj_out = nn.Conv2d(in_channels, in_channels, kernel_size=1) + + def attention(self, h_: Tensor) -> Tensor: + h_ = self.norm(h_) + q = self.q(h_) + k = self.k(h_) + v = self.v(h_) + + b, c, h, w = q.shape + q = rearrange(q, "b c h w -> b 1 (h w) c").contiguous() + k = rearrange(k, "b c h w -> b 1 (h w) c").contiguous() + v = rearrange(v, "b c h w -> b 1 (h w) c").contiguous() + h_ = nn.functional.scaled_dot_product_attention(q, k, v) + + return rearrange(h_, "b 1 (h w) c -> b c h w", h=h, w=w, c=c, b=b) + + def forward(self, x: Tensor) -> Tensor: + return x + self.proj_out(self.attention(x)) + + +class ResnetBlock(nn.Module): + def __init__(self, in_channels: int, out_channels: int): + super().__init__() + self.in_channels = in_channels + out_channels = in_channels if out_channels is None else out_channels + self.out_channels = out_channels + + self.norm1 = nn.GroupNorm(num_groups=32, num_channels=in_channels, eps=1e-6, affine=True) + self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=3, stride=1, padding=1) + self.norm2 = nn.GroupNorm(num_groups=32, num_channels=out_channels, eps=1e-6, affine=True) + self.conv2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, stride=1, padding=1) + if self.in_channels != self.out_channels: + self.nin_shortcut = nn.Conv2d(in_channels, out_channels, kernel_size=1, stride=1, padding=0) + + def forward(self, x): + h = x + h = self.norm1(h) + h = torch.nn.functional.silu(h) + h = self.conv1(h) + + h = self.norm2(h) + h = torch.nn.functional.silu(h) + h = self.conv2(h) + + if self.in_channels != self.out_channels: + x = self.nin_shortcut(x) + + return x + h + + +class Downsample(nn.Module): + def __init__(self, in_channels: int): + super().__init__() + # no asymmetric padding in torch conv, must do it ourselves + self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=2, padding=0) + + def forward(self, x: Tensor): + pad = (0, 1, 0, 1) + x = nn.functional.pad(x, pad, mode="constant", value=0) + x = self.conv(x) + return x + + +class Upsample(nn.Module): + def __init__(self, in_channels: int): + super().__init__() + self.conv = nn.Conv2d(in_channels, in_channels, kernel_size=3, stride=1, padding=1) + + def forward(self, x: Tensor): + x = nn.functional.interpolate(x, scale_factor=2.0, mode="nearest") + x = self.conv(x) + return x + + +class Encoder(nn.Module): + def __init__( + self, + resolution: int, + in_channels: int, + ch: int, + ch_mult: list[int], + num_res_blocks: int, + z_channels: int, + ): + super().__init__() + self.ch = ch + self.num_resolutions = len(ch_mult) + self.num_res_blocks = num_res_blocks + self.resolution = resolution + self.in_channels = in_channels + # downsampling + self.conv_in = nn.Conv2d(in_channels, self.ch, kernel_size=3, stride=1, padding=1) + + curr_res = resolution + in_ch_mult = (1,) + tuple(ch_mult) + self.in_ch_mult = in_ch_mult + self.down = nn.ModuleList() + block_in = self.ch + for i_level in range(self.num_resolutions): + block = nn.ModuleList() + attn = nn.ModuleList() + block_in = ch * in_ch_mult[i_level] + block_out = ch * ch_mult[i_level] + for _ in range(self.num_res_blocks): + block.append(ResnetBlock(in_channels=block_in, out_channels=block_out)) + block_in = block_out + down = nn.Module() + down.block = block + down.attn = attn + if i_level != self.num_resolutions - 1: + down.downsample = Downsample(block_in) + curr_res = curr_res // 2 + self.down.append(down) + + # middle + self.mid = nn.Module() + self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in) + self.mid.attn_1 = AttnBlock(block_in) + self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in) + + # end + self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True) + self.conv_out = nn.Conv2d(block_in, 2 * z_channels, kernel_size=3, stride=1, padding=1) + + def forward(self, x: Tensor) -> Tensor: + # downsampling + hs = [self.conv_in(x)] + for i_level in range(self.num_resolutions): + for i_block in range(self.num_res_blocks): + h = self.down[i_level].block[i_block](hs[-1]) + if len(self.down[i_level].attn) > 0: + h = self.down[i_level].attn[i_block](h) + hs.append(h) + if i_level != self.num_resolutions - 1: + hs.append(self.down[i_level].downsample(hs[-1])) + + # middle + h = hs[-1] + h = self.mid.block_1(h) + h = self.mid.attn_1(h) + h = self.mid.block_2(h) + # end + h = self.norm_out(h) + h = torch.nn.functional.silu(h) + h = self.conv_out(h) + return h + + +class Decoder(nn.Module): + def __init__( + self, + ch: int, + out_ch: int, + ch_mult: list[int], + num_res_blocks: int, + in_channels: int, + resolution: int, + z_channels: int, + ): + super().__init__() + self.ch = ch + self.num_resolutions = len(ch_mult) + self.num_res_blocks = num_res_blocks + self.resolution = resolution + self.in_channels = in_channels + self.ffactor = 2 ** (self.num_resolutions - 1) + + # compute in_ch_mult, block_in and curr_res at lowest res + block_in = ch * ch_mult[self.num_resolutions - 1] + curr_res = resolution // 2 ** (self.num_resolutions - 1) + self.z_shape = (1, z_channels, curr_res, curr_res) + + # z to block_in + self.conv_in = nn.Conv2d(z_channels, block_in, kernel_size=3, stride=1, padding=1) + + # middle + self.mid = nn.Module() + self.mid.block_1 = ResnetBlock(in_channels=block_in, out_channels=block_in) + self.mid.attn_1 = AttnBlock(block_in) + self.mid.block_2 = ResnetBlock(in_channels=block_in, out_channels=block_in) + + # upsampling + self.up = nn.ModuleList() + for i_level in reversed(range(self.num_resolutions)): + block = nn.ModuleList() + attn = nn.ModuleList() + block_out = ch * ch_mult[i_level] + for _ in range(self.num_res_blocks + 1): + block.append(ResnetBlock(in_channels=block_in, out_channels=block_out)) + block_in = block_out + up = nn.Module() + up.block = block + up.attn = attn + if i_level != 0: + up.upsample = Upsample(block_in) + curr_res = curr_res * 2 + self.up.insert(0, up) # prepend to get consistent order + + # end + self.norm_out = nn.GroupNorm(num_groups=32, num_channels=block_in, eps=1e-6, affine=True) + self.conv_out = nn.Conv2d(block_in, out_ch, kernel_size=3, stride=1, padding=1) + + def forward(self, z: Tensor) -> Tensor: + # z to block_in + h = self.conv_in(z) + + # middle + h = self.mid.block_1(h) + h = self.mid.attn_1(h) + h = self.mid.block_2(h) + + # upsampling + for i_level in reversed(range(self.num_resolutions)): + for i_block in range(self.num_res_blocks + 1): + h = self.up[i_level].block[i_block](h) + if len(self.up[i_level].attn) > 0: + h = self.up[i_level].attn[i_block](h) + if i_level != 0: + h = self.up[i_level].upsample(h) + + # end + h = self.norm_out(h) + h = torch.nn.functional.silu(h) + h = self.conv_out(h) + return h + + +class DiagonalGaussian(nn.Module): + def __init__(self, chunk_dim: int = 1): + super().__init__() + self.chunk_dim = chunk_dim + + def forward(self, z: Tensor, sample: bool = True, generator: torch.Generator | None = None) -> Tensor: + mean, logvar = torch.chunk(z, 2, dim=self.chunk_dim) + if sample: + std = torch.exp(0.5 * logvar) + # Unfortunately, torch.randn_like(...) does not accept a generator argument at the time of writing, so we + # have to use torch.randn(...) instead. + return mean + std * torch.randn(size=mean.size(), generator=generator, dtype=mean.dtype, device=mean.device) + else: + return mean + + +class AutoEncoder(nn.Module): + def __init__(self, params: AutoEncoderParams): + super().__init__() + self.encoder = Encoder( + resolution=params.resolution, + in_channels=params.in_channels, + ch=params.ch, + ch_mult=params.ch_mult, + num_res_blocks=params.num_res_blocks, + z_channels=params.z_channels, + ) + self.decoder = Decoder( + resolution=params.resolution, + in_channels=params.in_channels, + ch=params.ch, + out_ch=params.out_ch, + ch_mult=params.ch_mult, + num_res_blocks=params.num_res_blocks, + z_channels=params.z_channels, + ) + self.reg = DiagonalGaussian() + + self.scale_factor = params.scale_factor + self.shift_factor = params.shift_factor + + def encode(self, x: Tensor, sample: bool = True, generator: torch.Generator | None = None) -> Tensor: + """Run VAE encoding on input tensor x. + + Args: + x (Tensor): Input image tensor. Shape: (batch_size, in_channels, height, width). + sample (bool, optional): If True, sample from the encoded distribution, else, return the distribution mean. + Defaults to True. + generator (torch.Generator | None, optional): Optional random number generator for reproducibility. + Defaults to None. + + Returns: + Tensor: Encoded latent tensor. Shape: (batch_size, z_channels, latent_height, latent_width). + """ + + z = self.reg(self.encoder(x), sample=sample, generator=generator) + z = self.scale_factor * (z - self.shift_factor) + return z + + def decode(self, z: Tensor) -> Tensor: + z = z / self.scale_factor + self.shift_factor + return self.decoder(z) + + def forward(self, x: Tensor) -> Tensor: + return self.decode(self.encode(x)) diff --git a/invokeai/backend/flux/modules/conditioner.py b/invokeai/backend/flux/modules/conditioner.py new file mode 100644 index 0000000000000000000000000000000000000000..de6d8256c4ffadab48ca3d5e21954f5675040e21 --- /dev/null +++ b/invokeai/backend/flux/modules/conditioner.py @@ -0,0 +1,33 @@ +# Initially pulled from https://github.com/black-forest-labs/flux + +from torch import Tensor, nn +from transformers import PreTrainedModel, PreTrainedTokenizer + + +class HFEncoder(nn.Module): + def __init__(self, encoder: PreTrainedModel, tokenizer: PreTrainedTokenizer, is_clip: bool, max_length: int): + super().__init__() + self.max_length = max_length + self.is_clip = is_clip + self.output_key = "pooler_output" if self.is_clip else "last_hidden_state" + self.tokenizer = tokenizer + self.hf_module = encoder + self.hf_module = self.hf_module.eval().requires_grad_(False) + + def forward(self, text: list[str]) -> Tensor: + batch_encoding = self.tokenizer( + text, + truncation=True, + max_length=self.max_length, + return_length=False, + return_overflowing_tokens=False, + padding="max_length", + return_tensors="pt", + ) + + outputs = self.hf_module( + input_ids=batch_encoding["input_ids"].to(self.hf_module.device), + attention_mask=None, + output_hidden_states=False, + ) + return outputs[self.output_key] diff --git a/invokeai/backend/flux/modules/layers.py b/invokeai/backend/flux/modules/layers.py new file mode 100644 index 0000000000000000000000000000000000000000..23dc2448d3c4749eae4fa2a816b7f8a3230d9212 --- /dev/null +++ b/invokeai/backend/flux/modules/layers.py @@ -0,0 +1,253 @@ +# Initially pulled from https://github.com/black-forest-labs/flux + +import math +from dataclasses import dataclass + +import torch +from einops import rearrange +from torch import Tensor, nn + +from invokeai.backend.flux.math import attention, rope + + +class EmbedND(nn.Module): + def __init__(self, dim: int, theta: int, axes_dim: list[int]): + super().__init__() + self.dim = dim + self.theta = theta + self.axes_dim = axes_dim + + def forward(self, ids: Tensor) -> Tensor: + n_axes = ids.shape[-1] + emb = torch.cat( + [rope(ids[..., i], self.axes_dim[i], self.theta) for i in range(n_axes)], + dim=-3, + ) + + return emb.unsqueeze(1) + + +def timestep_embedding(t: Tensor, dim, max_period=10000, time_factor: float = 1000.0): + """ + Create sinusoidal timestep embeddings. + :param t: a 1-D Tensor of N indices, one per batch element. + These may be fractional. + :param dim: the dimension of the output. + :param max_period: controls the minimum frequency of the embeddings. + :return: an (N, D) Tensor of positional embeddings. + """ + t = time_factor * t + half = dim // 2 + freqs = torch.exp(-math.log(max_period) * torch.arange(start=0, end=half, dtype=torch.float32) / half).to(t.device) + + args = t[:, None].float() * freqs[None] + embedding = torch.cat([torch.cos(args), torch.sin(args)], dim=-1) + if dim % 2: + embedding = torch.cat([embedding, torch.zeros_like(embedding[:, :1])], dim=-1) + if torch.is_floating_point(t): + embedding = embedding.to(t) + return embedding + + +class MLPEmbedder(nn.Module): + def __init__(self, in_dim: int, hidden_dim: int): + super().__init__() + self.in_layer = nn.Linear(in_dim, hidden_dim, bias=True) + self.silu = nn.SiLU() + self.out_layer = nn.Linear(hidden_dim, hidden_dim, bias=True) + + def forward(self, x: Tensor) -> Tensor: + return self.out_layer(self.silu(self.in_layer(x))) + + +class RMSNorm(torch.nn.Module): + def __init__(self, dim: int): + super().__init__() + self.scale = nn.Parameter(torch.ones(dim)) + + def forward(self, x: Tensor): + x_dtype = x.dtype + x = x.float() + rrms = torch.rsqrt(torch.mean(x**2, dim=-1, keepdim=True) + 1e-6) + return (x * rrms).to(dtype=x_dtype) * self.scale + + +class QKNorm(torch.nn.Module): + def __init__(self, dim: int): + super().__init__() + self.query_norm = RMSNorm(dim) + self.key_norm = RMSNorm(dim) + + def forward(self, q: Tensor, k: Tensor, v: Tensor) -> tuple[Tensor, Tensor]: + q = self.query_norm(q) + k = self.key_norm(k) + return q.to(v), k.to(v) + + +class SelfAttention(nn.Module): + def __init__(self, dim: int, num_heads: int = 8, qkv_bias: bool = False): + super().__init__() + self.num_heads = num_heads + head_dim = dim // num_heads + + self.qkv = nn.Linear(dim, dim * 3, bias=qkv_bias) + self.norm = QKNorm(head_dim) + self.proj = nn.Linear(dim, dim) + + def forward(self, x: Tensor, pe: Tensor) -> Tensor: + qkv = self.qkv(x) + q, k, v = rearrange(qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + q, k = self.norm(q, k, v) + x = attention(q, k, v, pe=pe) + x = self.proj(x) + return x + + +@dataclass +class ModulationOut: + shift: Tensor + scale: Tensor + gate: Tensor + + +class Modulation(nn.Module): + def __init__(self, dim: int, double: bool): + super().__init__() + self.is_double = double + self.multiplier = 6 if double else 3 + self.lin = nn.Linear(dim, self.multiplier * dim, bias=True) + + def forward(self, vec: Tensor) -> tuple[ModulationOut, ModulationOut | None]: + out = self.lin(nn.functional.silu(vec))[:, None, :].chunk(self.multiplier, dim=-1) + + return ( + ModulationOut(*out[:3]), + ModulationOut(*out[3:]) if self.is_double else None, + ) + + +class DoubleStreamBlock(nn.Module): + def __init__(self, hidden_size: int, num_heads: int, mlp_ratio: float, qkv_bias: bool = False): + super().__init__() + + mlp_hidden_dim = int(hidden_size * mlp_ratio) + self.num_heads = num_heads + self.hidden_size = hidden_size + self.img_mod = Modulation(hidden_size, double=True) + self.img_norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.img_attn = SelfAttention(dim=hidden_size, num_heads=num_heads, qkv_bias=qkv_bias) + + self.img_norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.img_mlp = nn.Sequential( + nn.Linear(hidden_size, mlp_hidden_dim, bias=True), + nn.GELU(approximate="tanh"), + nn.Linear(mlp_hidden_dim, hidden_size, bias=True), + ) + + self.txt_mod = Modulation(hidden_size, double=True) + self.txt_norm1 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.txt_attn = SelfAttention(dim=hidden_size, num_heads=num_heads, qkv_bias=qkv_bias) + + self.txt_norm2 = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.txt_mlp = nn.Sequential( + nn.Linear(hidden_size, mlp_hidden_dim, bias=True), + nn.GELU(approximate="tanh"), + nn.Linear(mlp_hidden_dim, hidden_size, bias=True), + ) + + def forward(self, img: Tensor, txt: Tensor, vec: Tensor, pe: Tensor) -> tuple[Tensor, Tensor]: + img_mod1, img_mod2 = self.img_mod(vec) + txt_mod1, txt_mod2 = self.txt_mod(vec) + + # prepare image for attention + img_modulated = self.img_norm1(img) + img_modulated = (1 + img_mod1.scale) * img_modulated + img_mod1.shift + img_qkv = self.img_attn.qkv(img_modulated) + img_q, img_k, img_v = rearrange(img_qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + img_q, img_k = self.img_attn.norm(img_q, img_k, img_v) + + # prepare txt for attention + txt_modulated = self.txt_norm1(txt) + txt_modulated = (1 + txt_mod1.scale) * txt_modulated + txt_mod1.shift + txt_qkv = self.txt_attn.qkv(txt_modulated) + txt_q, txt_k, txt_v = rearrange(txt_qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + txt_q, txt_k = self.txt_attn.norm(txt_q, txt_k, txt_v) + + # run actual attention + q = torch.cat((txt_q, img_q), dim=2) + k = torch.cat((txt_k, img_k), dim=2) + v = torch.cat((txt_v, img_v), dim=2) + + attn = attention(q, k, v, pe=pe) + txt_attn, img_attn = attn[:, : txt.shape[1]], attn[:, txt.shape[1] :] + + # calculate the img bloks + img = img + img_mod1.gate * self.img_attn.proj(img_attn) + img = img + img_mod2.gate * self.img_mlp((1 + img_mod2.scale) * self.img_norm2(img) + img_mod2.shift) + + # calculate the txt bloks + txt = txt + txt_mod1.gate * self.txt_attn.proj(txt_attn) + txt = txt + txt_mod2.gate * self.txt_mlp((1 + txt_mod2.scale) * self.txt_norm2(txt) + txt_mod2.shift) + return img, txt + + +class SingleStreamBlock(nn.Module): + """ + A DiT block with parallel linear layers as described in + https://arxiv.org/abs/2302.05442 and adapted modulation interface. + """ + + def __init__( + self, + hidden_size: int, + num_heads: int, + mlp_ratio: float = 4.0, + qk_scale: float | None = None, + ): + super().__init__() + self.hidden_dim = hidden_size + self.num_heads = num_heads + head_dim = hidden_size // num_heads + self.scale = qk_scale or head_dim**-0.5 + + self.mlp_hidden_dim = int(hidden_size * mlp_ratio) + # qkv and mlp_in + self.linear1 = nn.Linear(hidden_size, hidden_size * 3 + self.mlp_hidden_dim) + # proj and mlp_out + self.linear2 = nn.Linear(hidden_size + self.mlp_hidden_dim, hidden_size) + + self.norm = QKNorm(head_dim) + + self.hidden_size = hidden_size + self.pre_norm = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + + self.mlp_act = nn.GELU(approximate="tanh") + self.modulation = Modulation(hidden_size, double=False) + + def forward(self, x: Tensor, vec: Tensor, pe: Tensor) -> Tensor: + mod, _ = self.modulation(vec) + x_mod = (1 + mod.scale) * self.pre_norm(x) + mod.shift + qkv, mlp = torch.split(self.linear1(x_mod), [3 * self.hidden_size, self.mlp_hidden_dim], dim=-1) + + q, k, v = rearrange(qkv, "B L (K H D) -> K B H L D", K=3, H=self.num_heads) + q, k = self.norm(q, k, v) + + # compute attention + attn = attention(q, k, v, pe=pe) + # compute activation in mlp stream, cat again and run second linear layer + output = self.linear2(torch.cat((attn, self.mlp_act(mlp)), 2)) + return x + mod.gate * output + + +class LastLayer(nn.Module): + def __init__(self, hidden_size: int, patch_size: int, out_channels: int): + super().__init__() + self.norm_final = nn.LayerNorm(hidden_size, elementwise_affine=False, eps=1e-6) + self.linear = nn.Linear(hidden_size, patch_size * patch_size * out_channels, bias=True) + self.adaLN_modulation = nn.Sequential(nn.SiLU(), nn.Linear(hidden_size, 2 * hidden_size, bias=True)) + + def forward(self, x: Tensor, vec: Tensor) -> Tensor: + shift, scale = self.adaLN_modulation(vec).chunk(2, dim=1) + x = (1 + scale[:, None, :]) * self.norm_final(x) + shift[:, None, :] + x = self.linear(x) + return x diff --git a/invokeai/backend/flux/sampling_utils.py b/invokeai/backend/flux/sampling_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a4b36df9fdea8458908cd3ea491905ceb68ea2a5 --- /dev/null +++ b/invokeai/backend/flux/sampling_utils.py @@ -0,0 +1,184 @@ +# Initially pulled from https://github.com/black-forest-labs/flux + +import math +from typing import Callable + +import torch +from einops import rearrange, repeat + + +def get_noise( + num_samples: int, + height: int, + width: int, + device: torch.device, + dtype: torch.dtype, + seed: int, +): + # 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, + 16, + # allow for packing + 2 * math.ceil(height / 16), + 2 * math.ceil(width / 16), + device=rand_device, + dtype=rand_dtype, + generator=torch.Generator(device=rand_device).manual_seed(seed), + ).to(device=device, dtype=dtype) + + +def time_shift(mu: float, sigma: float, t: torch.Tensor) -> torch.Tensor: + return math.exp(mu) / (math.exp(mu) + (1 / t - 1) ** sigma) + + +def get_lin_function(x1: float = 256, y1: float = 0.5, x2: float = 4096, y2: float = 1.15) -> Callable[[float], float]: + m = (y2 - y1) / (x2 - x1) + b = y1 - m * x1 + return lambda x: m * x + b + + +def get_schedule( + num_steps: int, + image_seq_len: int, + base_shift: float = 0.5, + max_shift: float = 1.15, + shift: bool = True, +) -> list[float]: + # extra step for zero + timesteps = torch.linspace(1, 0, num_steps + 1) + + # shifting the schedule to favor high timesteps for higher signal images + if shift: + # estimate mu based on linear estimation between two points + mu = get_lin_function(y1=base_shift, y2=max_shift)(image_seq_len) + timesteps = time_shift(mu, 1.0, timesteps) + + return timesteps.tolist() + + +def _find_last_index_ge_val(timesteps: list[float], val: float, eps: float = 1e-6) -> int: + """Find the last index in timesteps that is >= val. + + We use epsilon-close equality to avoid potential floating point errors. + """ + idx = len(list(filter(lambda t: t >= (val - eps), timesteps))) - 1 + assert idx >= 0 + return idx + + +def clip_timestep_schedule(timesteps: list[float], denoising_start: float, denoising_end: float) -> list[float]: + """Clip the timestep schedule to the denoising range. + + Args: + timesteps (list[float]): The original timestep schedule: [1.0, ..., 0.0]. + denoising_start (float): A value in [0, 1] specifying the start of the denoising process. E.g. a value of 0.2 + would mean that the denoising process start at the last timestep in the schedule >= 0.8. + denoising_end (float): A value in [0, 1] specifying the end of the denoising process. E.g. a value of 0.8 would + mean that the denoising process end at the last timestep in the schedule >= 0.2. + + Returns: + list[float]: The clipped timestep schedule. + """ + assert 0.0 <= denoising_start <= 1.0 + assert 0.0 <= denoising_end <= 1.0 + assert denoising_start <= denoising_end + + t_start_val = 1.0 - denoising_start + t_end_val = 1.0 - denoising_end + + t_start_idx = _find_last_index_ge_val(timesteps, t_start_val) + t_end_idx = _find_last_index_ge_val(timesteps, t_end_val) + + clipped_timesteps = timesteps[t_start_idx : t_end_idx + 1] + + return clipped_timesteps + + +def clip_timestep_schedule_fractional( + timesteps: list[float], denoising_start: float, denoising_end: float +) -> list[float]: + """Clip the timestep schedule to the denoising range. Insert new timesteps to exactly match the desired denoising + range. (A fractional version of clip_timestep_schedule().) + + Args: + timesteps (list[float]): The original timestep schedule: [1.0, ..., 0.0]. + denoising_start (float): A value in [0, 1] specifying the start of the denoising process. E.g. a value of 0.2 + would mean that the denoising process start at t=0.8. + denoising_end (float): A value in [0, 1] specifying the end of the denoising process. E.g. a value of 0.8 would + mean that the denoising process ends at t=0.2. + + Returns: + list[float]: The clipped timestep schedule. + """ + assert 0.0 <= denoising_start <= 1.0 + assert 0.0 <= denoising_end <= 1.0 + assert denoising_start <= denoising_end + + t_start_val = 1.0 - denoising_start + t_end_val = 1.0 - denoising_end + + t_start_idx = _find_last_index_ge_val(timesteps, t_start_val) + t_end_idx = _find_last_index_ge_val(timesteps, t_end_val) + + clipped_timesteps = timesteps[t_start_idx : t_end_idx + 1] + + # We know that clipped_timesteps[0] >= t_start_val. Replace clipped_timesteps[0] with t_start_val. + clipped_timesteps[0] = t_start_val + + # We know that clipped_timesteps[-1] >= t_end_val. If clipped_timesteps[-1] > t_end_val, add another step to + # t_end_val. + eps = 1e-6 + if clipped_timesteps[-1] > t_end_val + eps: + clipped_timesteps.append(t_end_val) + + return clipped_timesteps + + +def unpack(x: torch.Tensor, height: int, width: int) -> torch.Tensor: + """Unpack flat array of patch embeddings to latent image.""" + return rearrange( + x, + "b (h w) (c ph pw) -> b c (h ph) (w pw)", + h=math.ceil(height / 16), + w=math.ceil(width / 16), + ph=2, + pw=2, + ) + + +def pack(x: torch.Tensor) -> torch.Tensor: + """Pack latent image to flattented array of patch embeddings.""" + # Pixel unshuffle with a scale of 2, and flatten the height/width dimensions to get an array of patches. + return rearrange(x, "b c (h ph) (w pw) -> b (h w) (c ph pw)", ph=2, pw=2) + + +def generate_img_ids(h: int, w: int, batch_size: int, device: torch.device, dtype: torch.dtype) -> torch.Tensor: + """Generate tensor of image position ids. + + Args: + h (int): Height of image in latent space. + w (int): Width of image in latent space. + batch_size (int): Batch size. + device (torch.device): Device. + dtype (torch.dtype): dtype. + + Returns: + torch.Tensor: Image position ids. + """ + + if device.type == "mps": + orig_dtype = dtype + dtype = torch.float16 + + img_ids = torch.zeros(h // 2, w // 2, 3, device=device, dtype=dtype) + img_ids[..., 1] = img_ids[..., 1] + torch.arange(h // 2, device=device, dtype=dtype)[:, None] + img_ids[..., 2] = img_ids[..., 2] + torch.arange(w // 2, device=device, dtype=dtype)[None, :] + img_ids = repeat(img_ids, "h w c -> b (h w) c", b=batch_size) + + if device.type == "mps": + img_ids.to(orig_dtype) + + return img_ids diff --git a/invokeai/backend/flux/util.py b/invokeai/backend/flux/util.py new file mode 100644 index 0000000000000000000000000000000000000000..c81424f8ce41d2dea480e8e0aa838ac176dd6051 --- /dev/null +++ b/invokeai/backend/flux/util.py @@ -0,0 +1,71 @@ +# Initially pulled from https://github.com/black-forest-labs/flux + +from dataclasses import dataclass +from typing import Dict, Literal + +from invokeai.backend.flux.model import FluxParams +from invokeai.backend.flux.modules.autoencoder import AutoEncoderParams + + +@dataclass +class ModelSpec: + params: FluxParams + ae_params: AutoEncoderParams + ckpt_path: str | None + ae_path: str | None + repo_id: str | None + repo_flow: str | None + repo_ae: str | None + + +max_seq_lengths: Dict[str, Literal[256, 512]] = { + "flux-dev": 512, + "flux-schnell": 256, +} + + +ae_params = { + "flux": AutoEncoderParams( + resolution=256, + in_channels=3, + ch=128, + out_ch=3, + ch_mult=[1, 2, 4, 4], + num_res_blocks=2, + z_channels=16, + scale_factor=0.3611, + shift_factor=0.1159, + ) +} + + +params = { + "flux-dev": FluxParams( + in_channels=64, + vec_in_dim=768, + context_in_dim=4096, + hidden_size=3072, + mlp_ratio=4.0, + num_heads=24, + depth=19, + depth_single_blocks=38, + axes_dim=[16, 56, 56], + theta=10_000, + qkv_bias=True, + guidance_embed=True, + ), + "flux-schnell": FluxParams( + in_channels=64, + vec_in_dim=768, + context_in_dim=4096, + hidden_size=3072, + mlp_ratio=4.0, + num_heads=24, + depth=19, + depth_single_blocks=38, + axes_dim=[16, 56, 56], + theta=10_000, + qkv_bias=True, + guidance_embed=False, + ), +} diff --git a/invokeai/backend/image_util/__init__.py b/invokeai/backend/image_util/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..bc5eed7ddd7924095109ef288092e4d3b1f465d2 --- /dev/null +++ b/invokeai/backend/image_util/__init__.py @@ -0,0 +1,12 @@ +""" +Initialization file for invokeai.backend.image_util methods. +""" + +from invokeai.backend.image_util.infill_methods.patchmatch import PatchMatch # noqa: F401 +from invokeai.backend.image_util.pngwriter import ( # noqa: F401 + PngWriter, + PromptFormatter, + retrieve_metadata, + write_metadata, +) +from invokeai.backend.image_util.util import InitImageResizer, make_grid # noqa: F401 diff --git a/invokeai/backend/image_util/basicsr/LICENSE b/invokeai/backend/image_util/basicsr/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..1c9b5b800ea5a6a95b6dd2b22964a843a0144fd5 --- /dev/null +++ b/invokeai/backend/image_util/basicsr/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "[]" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2018-2022 BasicSR Authors + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. \ No newline at end of file diff --git a/invokeai/backend/image_util/basicsr/__init__.py b/invokeai/backend/image_util/basicsr/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..1d14b8e81e1b7695d3d822e9f6d4e5bbd0194024 --- /dev/null +++ b/invokeai/backend/image_util/basicsr/__init__.py @@ -0,0 +1,18 @@ +""" +Adapted from https://github.com/XPixelGroup/BasicSR +License: Apache-2.0 + +As of Feb 2024, `basicsr` appears to be unmaintained. It imports a function from `torchvision` that is removed in +`torchvision` 0.17. Here is the deprecation warning: + + UserWarning: The torchvision.transforms.functional_tensor module is deprecated in 0.15 and will be **removed in + 0.17**. Please don't rely on it. You probably just need to use APIs in torchvision.transforms.functional or in + torchvision.transforms.v2.functional. + +As a result, a dependency on `basicsr` means we cannot keep our `torchvision` dependency up to date. + +Because we only rely on a single class `RRDBNet` from `basicsr`, we've copied the relevant code here and removed the +dependency on `basicsr`. + +The code is almost unchanged, only a few type annotations have been added. The license is also copied. +""" diff --git a/invokeai/backend/image_util/basicsr/arch_util.py b/invokeai/backend/image_util/basicsr/arch_util.py new file mode 100644 index 0000000000000000000000000000000000000000..45b3029ff87a3f489dc128abdbc985ad30779fa0 --- /dev/null +++ b/invokeai/backend/image_util/basicsr/arch_util.py @@ -0,0 +1,75 @@ +from typing import Type + +import torch +from torch import nn as nn +from torch.nn import init as init +from torch.nn.modules.batchnorm import _BatchNorm + + +@torch.no_grad() +def default_init_weights( + module_list: list[nn.Module] | nn.Module, scale: float = 1, bias_fill: float = 0, **kwargs +) -> None: + """Initialize network weights. + + Args: + module_list (list[nn.Module] | nn.Module): Modules to be initialized. + scale (float): Scale initialized weights, especially for residual + blocks. Default: 1. + bias_fill (float): The value to fill bias. Default: 0 + kwargs (dict): Other arguments for initialization function. + """ + if not isinstance(module_list, list): + module_list = [module_list] + for module in module_list: + for m in module.modules(): + if isinstance(m, nn.Conv2d): + init.kaiming_normal_(m.weight, **kwargs) + m.weight.data *= scale + if m.bias is not None: + m.bias.data.fill_(bias_fill) + elif isinstance(m, nn.Linear): + init.kaiming_normal_(m.weight, **kwargs) + m.weight.data *= scale + if m.bias is not None: + m.bias.data.fill_(bias_fill) + elif isinstance(m, _BatchNorm): + init.constant_(m.weight, 1) + if m.bias is not None: + m.bias.data.fill_(bias_fill) + + +def make_layer(basic_block: Type[nn.Module], num_basic_block: int, **kwarg) -> nn.Sequential: + """Make layers by stacking the same blocks. + + Args: + basic_block (Type[nn.Module]): nn.Module class for basic block. + num_basic_block (int): number of blocks. + + Returns: + nn.Sequential: Stacked blocks in nn.Sequential. + """ + layers = [] + for _ in range(num_basic_block): + layers.append(basic_block(**kwarg)) + return nn.Sequential(*layers) + + +# TODO: may write a cpp file +def pixel_unshuffle(x: torch.Tensor, scale: int) -> torch.Tensor: + """Pixel unshuffle. + + Args: + x (Tensor): Input feature with shape (b, c, hh, hw). + scale (int): Downsample ratio. + + Returns: + Tensor: the pixel unshuffled feature. + """ + b, c, hh, hw = x.size() + out_channel = c * (scale**2) + assert hh % scale == 0 and hw % scale == 0 + h = hh // scale + w = hw // scale + x_view = x.view(b, c, h, scale, w, scale) + return x_view.permute(0, 1, 3, 5, 2, 4).reshape(b, out_channel, h, w) diff --git a/invokeai/backend/image_util/basicsr/rrdbnet_arch.py b/invokeai/backend/image_util/basicsr/rrdbnet_arch.py new file mode 100644 index 0000000000000000000000000000000000000000..a99a69712363ed5046845ef74a9ea629aa7291a4 --- /dev/null +++ b/invokeai/backend/image_util/basicsr/rrdbnet_arch.py @@ -0,0 +1,125 @@ +import torch +from torch import nn as nn +from torch.nn import functional as F + +from invokeai.backend.image_util.basicsr.arch_util import default_init_weights, make_layer, pixel_unshuffle + + +class ResidualDenseBlock(nn.Module): + """Residual Dense Block. + + Used in RRDB block in ESRGAN. + + Args: + num_feat (int): Channel number of intermediate features. + num_grow_ch (int): Channels for each growth. + """ + + def __init__(self, num_feat: int = 64, num_grow_ch: int = 32) -> None: + super(ResidualDenseBlock, self).__init__() + self.conv1 = nn.Conv2d(num_feat, num_grow_ch, 3, 1, 1) + self.conv2 = nn.Conv2d(num_feat + num_grow_ch, num_grow_ch, 3, 1, 1) + self.conv3 = nn.Conv2d(num_feat + 2 * num_grow_ch, num_grow_ch, 3, 1, 1) + self.conv4 = nn.Conv2d(num_feat + 3 * num_grow_ch, num_grow_ch, 3, 1, 1) + self.conv5 = nn.Conv2d(num_feat + 4 * num_grow_ch, num_feat, 3, 1, 1) + + self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True) + + # initialization + default_init_weights([self.conv1, self.conv2, self.conv3, self.conv4, self.conv5], 0.1) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x1 = self.lrelu(self.conv1(x)) + x2 = self.lrelu(self.conv2(torch.cat((x, x1), 1))) + x3 = self.lrelu(self.conv3(torch.cat((x, x1, x2), 1))) + x4 = self.lrelu(self.conv4(torch.cat((x, x1, x2, x3), 1))) + x5 = self.conv5(torch.cat((x, x1, x2, x3, x4), 1)) + # Empirically, we use 0.2 to scale the residual for better performance + return x5 * 0.2 + x + + +class RRDB(nn.Module): + """Residual in Residual Dense Block. + + Used in RRDB-Net in ESRGAN. + + Args: + num_feat (int): Channel number of intermediate features. + num_grow_ch (int): Channels for each growth. + """ + + def __init__(self, num_feat: int, num_grow_ch: int = 32) -> None: + super(RRDB, self).__init__() + self.rdb1 = ResidualDenseBlock(num_feat, num_grow_ch) + self.rdb2 = ResidualDenseBlock(num_feat, num_grow_ch) + self.rdb3 = ResidualDenseBlock(num_feat, num_grow_ch) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + out = self.rdb1(x) + out = self.rdb2(out) + out = self.rdb3(out) + # Empirically, we use 0.2 to scale the residual for better performance + return out * 0.2 + x + + +class RRDBNet(nn.Module): + """Networks consisting of Residual in Residual Dense Block, which is used + in ESRGAN. + + ESRGAN: Enhanced Super-Resolution Generative Adversarial Networks. + + We extend ESRGAN for scale x2 and scale x1. + Note: This is one option for scale 1, scale 2 in RRDBNet. + We first employ the pixel-unshuffle (an inverse operation of pixelshuffle to reduce the spatial size + and enlarge the channel size before feeding inputs into the main ESRGAN architecture. + + Args: + num_in_ch (int): Channel number of inputs. + num_out_ch (int): Channel number of outputs. + num_feat (int): Channel number of intermediate features. + Default: 64 + num_block (int): Block number in the trunk network. Defaults: 23 + num_grow_ch (int): Channels for each growth. Default: 32. + """ + + def __init__( + self, + num_in_ch: int, + num_out_ch: int, + scale: int = 4, + num_feat: int = 64, + num_block: int = 23, + num_grow_ch: int = 32, + ) -> None: + super(RRDBNet, self).__init__() + self.scale = scale + if scale == 2: + num_in_ch = num_in_ch * 4 + elif scale == 1: + num_in_ch = num_in_ch * 16 + self.conv_first = nn.Conv2d(num_in_ch, num_feat, 3, 1, 1) + self.body = make_layer(RRDB, num_block, num_feat=num_feat, num_grow_ch=num_grow_ch) + self.conv_body = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + # upsample + self.conv_up1 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_up2 = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_hr = nn.Conv2d(num_feat, num_feat, 3, 1, 1) + self.conv_last = nn.Conv2d(num_feat, num_out_ch, 3, 1, 1) + + self.lrelu = nn.LeakyReLU(negative_slope=0.2, inplace=True) + + def forward(self, x: torch.Tensor) -> torch.Tensor: + if self.scale == 2: + feat = pixel_unshuffle(x, scale=2) + elif self.scale == 1: + feat = pixel_unshuffle(x, scale=4) + else: + feat = x + feat = self.conv_first(feat) + body_feat = self.conv_body(self.body(feat)) + feat = feat + body_feat + # upsample + feat = self.lrelu(self.conv_up1(F.interpolate(feat, scale_factor=2, mode="nearest"))) + feat = self.lrelu(self.conv_up2(F.interpolate(feat, scale_factor=2, mode="nearest"))) + out = self.conv_last(self.lrelu(self.conv_hr(feat))) + return out diff --git a/invokeai/backend/image_util/canny.py b/invokeai/backend/image_util/canny.py new file mode 100644 index 0000000000000000000000000000000000000000..c1628dc182898de0460eded99c4893c9009e6622 --- /dev/null +++ b/invokeai/backend/image_util/canny.py @@ -0,0 +1,41 @@ +import cv2 +from PIL import Image + +from invokeai.backend.image_util.util import ( + cv2_to_pil, + normalize_image_channel_count, + pil_to_cv2, + resize_image_to_resolution, +) + + +def get_canny_edges( + image: Image.Image, low_threshold: int, high_threshold: int, detect_resolution: int, image_resolution: int +) -> Image.Image: + """Returns the edges of an image using the Canny edge detection algorithm. + + Adapted from https://github.com/huggingface/controlnet_aux (Apache-2.0 license). + + Args: + image: The input image. + low_threshold: The lower threshold for the hysteresis procedure. + high_threshold: The upper threshold for the hysteresis procedure. + input_resolution: The resolution of the input image. The image will be resized to this resolution before edge detection. + output_resolution: The resolution of the output image. The edges will be resized to this resolution before returning. + + Returns: + The Canny edges of the input image. + """ + + if image.mode != "RGB": + image = image.convert("RGB") + + np_image = pil_to_cv2(image) + np_image = normalize_image_channel_count(np_image) + np_image = resize_image_to_resolution(np_image, detect_resolution) + + edge_map = cv2.Canny(np_image, low_threshold, high_threshold) + edge_map = normalize_image_channel_count(edge_map) + edge_map = resize_image_to_resolution(edge_map, image_resolution) + + return cv2_to_pil(edge_map) diff --git a/invokeai/backend/image_util/content_shuffle.py b/invokeai/backend/image_util/content_shuffle.py new file mode 100644 index 0000000000000000000000000000000000000000..76e3dcf7182ac4615e156212f0ce7242ec7c6372 --- /dev/null +++ b/invokeai/backend/image_util/content_shuffle.py @@ -0,0 +1,40 @@ +# Adapted from https://github.com/huggingface/controlnet_aux + +import cv2 +import numpy as np +from PIL import Image + +from invokeai.backend.image_util.util import np_to_pil, pil_to_np + + +def make_noise_disk(H, W, C, F): + noise = np.random.uniform(low=0, high=1, size=((H // F) + 2, (W // F) + 2, C)) + noise = cv2.resize(noise, (W + 2 * F, H + 2 * F), interpolation=cv2.INTER_CUBIC) + noise = noise[F : F + H, F : F + W] + noise -= np.min(noise) + noise /= np.max(noise) + if C == 1: + noise = noise[:, :, None] + return noise + + +def content_shuffle(input_image: Image.Image, scale_factor: int | None = None) -> Image.Image: + """Shuffles the content of an image using a disk noise pattern, similar to a 'liquify' effect.""" + + np_img = pil_to_np(input_image) + + height, width, _channels = np_img.shape + + if scale_factor is None: + scale_factor = 256 + + x = make_noise_disk(height, width, 1, scale_factor) * float(width - 1) + y = make_noise_disk(height, width, 1, scale_factor) * float(height - 1) + + flow = np.concatenate([x, y], axis=2).astype(np.float32) + + shuffled_img = cv2.remap(np_img, flow, None, cv2.INTER_LINEAR) + + output_img = np_to_pil(shuffled_img) + + return output_img diff --git a/invokeai/backend/image_util/depth_anything/depth_anything_pipeline.py b/invokeai/backend/image_util/depth_anything/depth_anything_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..732aa9ceced6c37e8e816b97811f5a47c7451b7e --- /dev/null +++ b/invokeai/backend/image_util/depth_anything/depth_anything_pipeline.py @@ -0,0 +1,41 @@ +import pathlib +from typing import Optional + +import torch +from PIL import Image +from transformers import pipeline +from transformers.pipelines import DepthEstimationPipeline + +from invokeai.backend.raw_model import RawModel + + +class DepthAnythingPipeline(RawModel): + """Custom wrapper for the Depth Estimation pipeline from transformers adding compatibility + for Invoke's Model Management System""" + + def __init__(self, pipeline: DepthEstimationPipeline) -> None: + self._pipeline = pipeline + + def generate_depth(self, image: Image.Image) -> Image.Image: + depth_map = self._pipeline(image)["depth"] + assert isinstance(depth_map, Image.Image) + return depth_map + + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None): + if device is not None and device.type not in {"cpu", "cuda"}: + device = None + self._pipeline.model.to(device=device, dtype=dtype) + self._pipeline.device = self._pipeline.model.device + + def calc_size(self) -> int: + from invokeai.backend.model_manager.load.model_util import calc_module_size + + return calc_module_size(self._pipeline.model) + + @classmethod + def load_model(cls, model_path: pathlib.Path): + """Load the model from the given path and return a DepthAnythingPipeline instance.""" + + depth_anything_pipeline = pipeline(model=str(model_path), task="depth-estimation", local_files_only=True) + assert isinstance(depth_anything_pipeline, DepthEstimationPipeline) + return cls(depth_anything_pipeline) diff --git a/invokeai/backend/image_util/dw_openpose/__init__.py b/invokeai/backend/image_util/dw_openpose/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d1e4c7412b59ae936b37e84536f1ac2ab57c4fb0 --- /dev/null +++ b/invokeai/backend/image_util/dw_openpose/__init__.py @@ -0,0 +1,256 @@ +from pathlib import Path +from typing import Dict + +import huggingface_hub +import numpy as np +import onnxruntime as ort +import torch +from controlnet_aux.util import resize_image +from PIL import Image + +from invokeai.backend.image_util.dw_openpose.onnxdet import inference_detector +from invokeai.backend.image_util.dw_openpose.onnxpose import inference_pose +from invokeai.backend.image_util.dw_openpose.utils import NDArrayInt, draw_bodypose, draw_facepose, draw_handpose +from invokeai.backend.image_util.dw_openpose.wholebody import Wholebody +from invokeai.backend.image_util.util import np_to_pil +from invokeai.backend.util.devices import TorchDevice + +DWPOSE_MODELS = { + "yolox_l.onnx": "https://huggingface.co/yzd-v/DWPose/resolve/main/yolox_l.onnx?download=true", + "dw-ll_ucoco_384.onnx": "https://huggingface.co/yzd-v/DWPose/resolve/main/dw-ll_ucoco_384.onnx?download=true", +} + + +def draw_pose( + pose: Dict[str, NDArrayInt | Dict[str, NDArrayInt]], + H: int, + W: int, + draw_face: bool = True, + draw_body: bool = True, + draw_hands: bool = True, + resolution: int = 512, +) -> Image.Image: + bodies = pose["bodies"] + faces = pose["faces"] + hands = pose["hands"] + + assert isinstance(bodies, dict) + candidate = bodies["candidate"] + + assert isinstance(bodies, dict) + subset = bodies["subset"] + + canvas = np.zeros(shape=(H, W, 3), dtype=np.uint8) + + if draw_body: + canvas = draw_bodypose(canvas, candidate, subset) + + if draw_hands: + assert isinstance(hands, np.ndarray) + canvas = draw_handpose(canvas, hands) + + if draw_face: + assert isinstance(hands, np.ndarray) + canvas = draw_facepose(canvas, faces) # type: ignore + + dwpose_image: Image.Image = resize_image( + canvas, + resolution, + ) + dwpose_image = Image.fromarray(dwpose_image) + + return dwpose_image + + +class DWOpenposeDetector: + """ + Code from the original implementation of the DW Openpose Detector. + Credits: https://github.com/IDEA-Research/DWPose + """ + + def __init__(self, onnx_det: Path, onnx_pose: Path) -> None: + self.pose_estimation = Wholebody(onnx_det=onnx_det, onnx_pose=onnx_pose) + + def __call__( + self, + image: Image.Image, + draw_face: bool = False, + draw_body: bool = True, + draw_hands: bool = False, + resolution: int = 512, + ) -> Image.Image: + np_image = np.array(image) + H, W, C = np_image.shape + + with torch.no_grad(): + candidate, subset = self.pose_estimation(np_image) + nums, keys, locs = candidate.shape + candidate[..., 0] /= float(W) + candidate[..., 1] /= float(H) + body = candidate[:, :18].copy() + body = body.reshape(nums * 18, locs) + score = subset[:, :18] + for i in range(len(score)): + for j in range(len(score[i])): + if score[i][j] > 0.3: + score[i][j] = int(18 * i + j) + else: + score[i][j] = -1 + + un_visible = subset < 0.3 + candidate[un_visible] = -1 + + # foot = candidate[:, 18:24] + + faces = candidate[:, 24:92] + + hands = candidate[:, 92:113] + hands = np.vstack([hands, candidate[:, 113:]]) + + bodies = {"candidate": body, "subset": score} + pose = {"bodies": bodies, "hands": hands, "faces": faces} + + return draw_pose( + pose, H, W, draw_face=draw_face, draw_hands=draw_hands, draw_body=draw_body, resolution=resolution + ) + + +class DWOpenposeDetector2: + """ + Code from the original implementation of the DW Openpose Detector. + Credits: https://github.com/IDEA-Research/DWPose + + This implementation is similar to DWOpenposeDetector, with some alterations to allow the onnx models to be loaded + and managed by the model manager. + """ + + hf_repo_id = "yzd-v/DWPose" + hf_filename_onnx_det = "yolox_l.onnx" + hf_filename_onnx_pose = "dw-ll_ucoco_384.onnx" + + @classmethod + def get_model_url_det(cls) -> str: + """Returns the URL for the detection model.""" + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename_onnx_det) + + @classmethod + def get_model_url_pose(cls) -> str: + """Returns the URL for the pose model.""" + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename_onnx_pose) + + @staticmethod + def create_onnx_inference_session(model_path: Path) -> ort.InferenceSession: + """Creates an ONNX Inference Session for the given model path, using the appropriate execution provider based on + the device type.""" + + device = TorchDevice.choose_torch_device() + providers = ["CUDAExecutionProvider"] if device.type == "cuda" else ["CPUExecutionProvider"] + return ort.InferenceSession(path_or_bytes=model_path, providers=providers) + + def __init__(self, session_det: ort.InferenceSession, session_pose: ort.InferenceSession): + self.session_det = session_det + self.session_pose = session_pose + + def pose_estimation(self, np_image: np.ndarray): + """Does the pose estimation on the given image and returns the keypoints and scores.""" + + det_result = inference_detector(self.session_det, np_image) + keypoints, scores = inference_pose(self.session_pose, det_result, np_image) + + keypoints_info = np.concatenate((keypoints, scores[..., None]), axis=-1) + # compute neck joint + neck = np.mean(keypoints_info[:, [5, 6]], axis=1) + # neck score when visualizing pred + neck[:, 2:4] = np.logical_and(keypoints_info[:, 5, 2:4] > 0.3, keypoints_info[:, 6, 2:4] > 0.3).astype(int) + new_keypoints_info = np.insert(keypoints_info, 17, neck, axis=1) + mmpose_idx = [17, 6, 8, 10, 7, 9, 12, 14, 16, 13, 15, 2, 1, 4, 3] + openpose_idx = [1, 2, 3, 4, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17] + new_keypoints_info[:, openpose_idx] = new_keypoints_info[:, mmpose_idx] + keypoints_info = new_keypoints_info + + keypoints, scores = keypoints_info[..., :2], keypoints_info[..., 2] + + return keypoints, scores + + def run( + self, + image: Image.Image, + draw_face: bool = False, + draw_body: bool = True, + draw_hands: bool = False, + ) -> Image.Image: + """Detects the pose in the given image and returns an solid black image with pose drawn on top, suitable for + use with a ControlNet.""" + + np_image = np.array(image) + H, W, C = np_image.shape + + with torch.no_grad(): + candidate, subset = self.pose_estimation(np_image) + nums, keys, locs = candidate.shape + candidate[..., 0] /= float(W) + candidate[..., 1] /= float(H) + body = candidate[:, :18].copy() + body = body.reshape(nums * 18, locs) + score = subset[:, :18] + for i in range(len(score)): + for j in range(len(score[i])): + if score[i][j] > 0.3: + score[i][j] = int(18 * i + j) + else: + score[i][j] = -1 + + un_visible = subset < 0.3 + candidate[un_visible] = -1 + + # foot = candidate[:, 18:24] + + faces = candidate[:, 24:92] + + hands = candidate[:, 92:113] + hands = np.vstack([hands, candidate[:, 113:]]) + + bodies = {"candidate": body, "subset": score} + pose = {"bodies": bodies, "hands": hands, "faces": faces} + + return DWOpenposeDetector2.draw_pose( + pose, H, W, draw_face=draw_face, draw_hands=draw_hands, draw_body=draw_body + ) + + @staticmethod + def draw_pose( + pose: Dict[str, NDArrayInt | Dict[str, NDArrayInt]], + H: int, + W: int, + draw_face: bool = True, + draw_body: bool = True, + draw_hands: bool = True, + ) -> Image.Image: + """Draws the pose on a black image and returns it as a PIL Image.""" + + bodies = pose["bodies"] + faces = pose["faces"] + hands = pose["hands"] + + assert isinstance(bodies, dict) + candidate = bodies["candidate"] + + assert isinstance(bodies, dict) + subset = bodies["subset"] + + canvas = np.zeros(shape=(H, W, 3), dtype=np.uint8) + + if draw_body: + canvas = draw_bodypose(canvas, candidate, subset) + + if draw_hands: + assert isinstance(hands, np.ndarray) + canvas = draw_handpose(canvas, hands) + + if draw_face: + assert isinstance(hands, np.ndarray) + canvas = draw_facepose(canvas, faces) # type: ignore + + dwpose_image = np_to_pil(canvas) + + return dwpose_image diff --git a/invokeai/backend/image_util/dw_openpose/onnxdet.py b/invokeai/backend/image_util/dw_openpose/onnxdet.py new file mode 100644 index 0000000000000000000000000000000000000000..3706bb8fa3d05968d669db954c335711a857a417 --- /dev/null +++ b/invokeai/backend/image_util/dw_openpose/onnxdet.py @@ -0,0 +1,128 @@ +# Code from the original DWPose Implementation: https://github.com/IDEA-Research/DWPose + +import cv2 +import numpy as np + + +def nms(boxes, scores, nms_thr): + """Single class NMS implemented in Numpy.""" + x1 = boxes[:, 0] + y1 = boxes[:, 1] + x2 = boxes[:, 2] + y2 = boxes[:, 3] + + areas = (x2 - x1 + 1) * (y2 - y1 + 1) + order = scores.argsort()[::-1] + + keep = [] + while order.size > 0: + i = order[0] + keep.append(i) + xx1 = np.maximum(x1[i], x1[order[1:]]) + yy1 = np.maximum(y1[i], y1[order[1:]]) + xx2 = np.minimum(x2[i], x2[order[1:]]) + yy2 = np.minimum(y2[i], y2[order[1:]]) + + w = np.maximum(0.0, xx2 - xx1 + 1) + h = np.maximum(0.0, yy2 - yy1 + 1) + inter = w * h + ovr = inter / (areas[i] + areas[order[1:]] - inter) + + inds = np.where(ovr <= nms_thr)[0] + order = order[inds + 1] + + return keep + + +def multiclass_nms(boxes, scores, nms_thr, score_thr): + """Multiclass NMS implemented in Numpy. Class-aware version.""" + final_dets = [] + num_classes = scores.shape[1] + for cls_ind in range(num_classes): + cls_scores = scores[:, cls_ind] + valid_score_mask = cls_scores > score_thr + if valid_score_mask.sum() == 0: + continue + else: + valid_scores = cls_scores[valid_score_mask] + valid_boxes = boxes[valid_score_mask] + keep = nms(valid_boxes, valid_scores, nms_thr) + if len(keep) > 0: + cls_inds = np.ones((len(keep), 1)) * cls_ind + dets = np.concatenate([valid_boxes[keep], valid_scores[keep, None], cls_inds], 1) + final_dets.append(dets) + if len(final_dets) == 0: + return None + return np.concatenate(final_dets, 0) + + +def demo_postprocess(outputs, img_size, p6=False): + grids = [] + expanded_strides = [] + strides = [8, 16, 32] if not p6 else [8, 16, 32, 64] + + hsizes = [img_size[0] // stride for stride in strides] + wsizes = [img_size[1] // stride for stride in strides] + + for hsize, wsize, stride in zip(hsizes, wsizes, strides, strict=False): + xv, yv = np.meshgrid(np.arange(wsize), np.arange(hsize)) + grid = np.stack((xv, yv), 2).reshape(1, -1, 2) + grids.append(grid) + shape = grid.shape[:2] + expanded_strides.append(np.full((*shape, 1), stride)) + + grids = np.concatenate(grids, 1) + expanded_strides = np.concatenate(expanded_strides, 1) + outputs[..., :2] = (outputs[..., :2] + grids) * expanded_strides + outputs[..., 2:4] = np.exp(outputs[..., 2:4]) * expanded_strides + + return outputs + + +def preprocess(img, input_size, swap=(2, 0, 1)): + if len(img.shape) == 3: + padded_img = np.ones((input_size[0], input_size[1], 3), dtype=np.uint8) * 114 + else: + padded_img = np.ones(input_size, dtype=np.uint8) * 114 + + r = min(input_size[0] / img.shape[0], input_size[1] / img.shape[1]) + resized_img = cv2.resize( + img, + (int(img.shape[1] * r), int(img.shape[0] * r)), + interpolation=cv2.INTER_LINEAR, + ).astype(np.uint8) + padded_img[: int(img.shape[0] * r), : int(img.shape[1] * r)] = resized_img + + padded_img = padded_img.transpose(swap) + padded_img = np.ascontiguousarray(padded_img, dtype=np.float32) + return padded_img, r + + +def inference_detector(session, oriImg): + input_shape = (640, 640) + img, ratio = preprocess(oriImg, input_shape) + + ort_inputs = {session.get_inputs()[0].name: img[None, :, :, :]} + output = session.run(None, ort_inputs) + predictions = demo_postprocess(output[0], input_shape)[0] + + boxes = predictions[:, :4] + scores = predictions[:, 4:5] * predictions[:, 5:] + + boxes_xyxy = np.ones_like(boxes) + boxes_xyxy[:, 0] = boxes[:, 0] - boxes[:, 2] / 2.0 + boxes_xyxy[:, 1] = boxes[:, 1] - boxes[:, 3] / 2.0 + boxes_xyxy[:, 2] = boxes[:, 0] + boxes[:, 2] / 2.0 + boxes_xyxy[:, 3] = boxes[:, 1] + boxes[:, 3] / 2.0 + boxes_xyxy /= ratio + dets = multiclass_nms(boxes_xyxy, scores, nms_thr=0.45, score_thr=0.1) + if dets is not None: + final_boxes, final_scores, final_cls_inds = dets[:, :4], dets[:, 4], dets[:, 5] + isscore = final_scores > 0.3 + iscat = final_cls_inds == 0 + isbbox = [i and j for (i, j) in zip(isscore, iscat, strict=False)] + final_boxes = final_boxes[isbbox] + else: + final_boxes = np.array([]) + + return final_boxes diff --git a/invokeai/backend/image_util/dw_openpose/onnxpose.py b/invokeai/backend/image_util/dw_openpose/onnxpose.py new file mode 100644 index 0000000000000000000000000000000000000000..d949f95801b2928628dae045ddd12a7ab6bec866 --- /dev/null +++ b/invokeai/backend/image_util/dw_openpose/onnxpose.py @@ -0,0 +1,361 @@ +# Code from the original DWPose Implementation: https://github.com/IDEA-Research/DWPose + +from typing import List, Tuple + +import cv2 +import numpy as np +import onnxruntime as ort + + +def preprocess( + img: np.ndarray, out_bbox, input_size: Tuple[int, int] = (192, 256) +) -> Tuple[np.ndarray, np.ndarray, np.ndarray]: + """Do preprocessing for RTMPose model inference. + + Args: + img (np.ndarray): Input image in shape. + input_size (tuple): Input image size in shape (w, h). + + Returns: + tuple: + - resized_img (np.ndarray): Preprocessed image. + - center (np.ndarray): Center of image. + - scale (np.ndarray): Scale of image. + """ + # get shape of image + img_shape = img.shape[:2] + out_img, out_center, out_scale = [], [], [] + if len(out_bbox) == 0: + out_bbox = [[0, 0, img_shape[1], img_shape[0]]] + for i in range(len(out_bbox)): + x0 = out_bbox[i][0] + y0 = out_bbox[i][1] + x1 = out_bbox[i][2] + y1 = out_bbox[i][3] + bbox = np.array([x0, y0, x1, y1]) + + # get center and scale + center, scale = bbox_xyxy2cs(bbox, padding=1.25) + + # do affine transformation + resized_img, scale = top_down_affine(input_size, scale, center, img) + + # normalize image + mean = np.array([123.675, 116.28, 103.53]) + std = np.array([58.395, 57.12, 57.375]) + resized_img = (resized_img - mean) / std + + out_img.append(resized_img) + out_center.append(center) + out_scale.append(scale) + + return out_img, out_center, out_scale + + +def inference(sess: ort.InferenceSession, img: np.ndarray) -> np.ndarray: + """Inference RTMPose model. + + Args: + sess (ort.InferenceSession): ONNXRuntime session. + img (np.ndarray): Input image in shape. + + Returns: + outputs (np.ndarray): Output of RTMPose model. + """ + all_out = [] + # build input + for i in range(len(img)): + input = [img[i].transpose(2, 0, 1)] + + # build output + sess_input = {sess.get_inputs()[0].name: input} + sess_output = [] + for out in sess.get_outputs(): + sess_output.append(out.name) + + # run model + outputs = sess.run(sess_output, sess_input) + all_out.append(outputs) + + return all_out + + +def postprocess( + outputs: List[np.ndarray], + model_input_size: Tuple[int, int], + center: Tuple[int, int], + scale: Tuple[int, int], + simcc_split_ratio: float = 2.0, +) -> Tuple[np.ndarray, np.ndarray]: + """Postprocess for RTMPose model output. + + Args: + outputs (np.ndarray): Output of RTMPose model. + model_input_size (tuple): RTMPose model Input image size. + center (tuple): Center of bbox in shape (x, y). + scale (tuple): Scale of bbox in shape (w, h). + simcc_split_ratio (float): Split ratio of simcc. + + Returns: + tuple: + - keypoints (np.ndarray): Rescaled keypoints. + - scores (np.ndarray): Model predict scores. + """ + all_key = [] + all_score = [] + for i in range(len(outputs)): + # use simcc to decode + simcc_x, simcc_y = outputs[i] + keypoints, scores = decode(simcc_x, simcc_y, simcc_split_ratio) + + # rescale keypoints + keypoints = keypoints / model_input_size * scale[i] + center[i] - scale[i] / 2 + all_key.append(keypoints[0]) + all_score.append(scores[0]) + + return np.array(all_key), np.array(all_score) + + +def bbox_xyxy2cs(bbox: np.ndarray, padding: float = 1.0) -> Tuple[np.ndarray, np.ndarray]: + """Transform the bbox format from (x,y,w,h) into (center, scale) + + Args: + bbox (ndarray): Bounding box(es) in shape (4,) or (n, 4), formatted + as (left, top, right, bottom) + padding (float): BBox padding factor that will be multilied to scale. + Default: 1.0 + + Returns: + tuple: A tuple containing center and scale. + - np.ndarray[float32]: Center (x, y) of the bbox in shape (2,) or + (n, 2) + - np.ndarray[float32]: Scale (w, h) of the bbox in shape (2,) or + (n, 2) + """ + # convert single bbox from (4, ) to (1, 4) + dim = bbox.ndim + if dim == 1: + bbox = bbox[None, :] + + # get bbox center and scale + x1, y1, x2, y2 = np.hsplit(bbox, [1, 2, 3]) + center = np.hstack([x1 + x2, y1 + y2]) * 0.5 + scale = np.hstack([x2 - x1, y2 - y1]) * padding + + if dim == 1: + center = center[0] + scale = scale[0] + + return center, scale + + +def _fix_aspect_ratio(bbox_scale: np.ndarray, aspect_ratio: float) -> np.ndarray: + """Extend the scale to match the given aspect ratio. + + Args: + scale (np.ndarray): The image scale (w, h) in shape (2, ) + aspect_ratio (float): The ratio of ``w/h`` + + Returns: + np.ndarray: The reshaped image scale in (2, ) + """ + w, h = np.hsplit(bbox_scale, [1]) + bbox_scale = np.where(w > h * aspect_ratio, np.hstack([w, w / aspect_ratio]), np.hstack([h * aspect_ratio, h])) + return bbox_scale + + +def _rotate_point(pt: np.ndarray, angle_rad: float) -> np.ndarray: + """Rotate a point by an angle. + + Args: + pt (np.ndarray): 2D point coordinates (x, y) in shape (2, ) + angle_rad (float): rotation angle in radian + + Returns: + np.ndarray: Rotated point in shape (2, ) + """ + sn, cs = np.sin(angle_rad), np.cos(angle_rad) + rot_mat = np.array([[cs, -sn], [sn, cs]]) + return rot_mat @ pt + + +def _get_3rd_point(a: np.ndarray, b: np.ndarray) -> np.ndarray: + """To calculate the affine matrix, three pairs of points are required. This + function is used to get the 3rd point, given 2D points a & b. + + The 3rd point is defined by rotating vector `a - b` by 90 degrees + anticlockwise, using b as the rotation center. + + Args: + a (np.ndarray): The 1st point (x,y) in shape (2, ) + b (np.ndarray): The 2nd point (x,y) in shape (2, ) + + Returns: + np.ndarray: The 3rd point. + """ + direction = a - b + c = b + np.r_[-direction[1], direction[0]] + return c + + +def get_warp_matrix( + center: np.ndarray, + scale: np.ndarray, + rot: float, + output_size: Tuple[int, int], + shift: Tuple[float, float] = (0.0, 0.0), + inv: bool = False, +) -> np.ndarray: + """Calculate the affine transformation matrix that can warp the bbox area + in the input image to the output size. + + Args: + center (np.ndarray[2, ]): Center of the bounding box (x, y). + scale (np.ndarray[2, ]): Scale of the bounding box + wrt [width, height]. + rot (float): Rotation angle (degree). + output_size (np.ndarray[2, ] | list(2,)): Size of the + destination heatmaps. + shift (0-100%): Shift translation ratio wrt the width/height. + Default (0., 0.). + inv (bool): Option to inverse the affine transform direction. + (inv=False: src->dst or inv=True: dst->src) + + Returns: + np.ndarray: A 2x3 transformation matrix + """ + shift = np.array(shift) + src_w = scale[0] + dst_w = output_size[0] + dst_h = output_size[1] + + # compute transformation matrix + rot_rad = np.deg2rad(rot) + src_dir = _rotate_point(np.array([0.0, src_w * -0.5]), rot_rad) + dst_dir = np.array([0.0, dst_w * -0.5]) + + # get four corners of the src rectangle in the original image + src = np.zeros((3, 2), dtype=np.float32) + src[0, :] = center + scale * shift + src[1, :] = center + src_dir + scale * shift + src[2, :] = _get_3rd_point(src[0, :], src[1, :]) + + # get four corners of the dst rectangle in the input image + dst = np.zeros((3, 2), dtype=np.float32) + dst[0, :] = [dst_w * 0.5, dst_h * 0.5] + dst[1, :] = np.array([dst_w * 0.5, dst_h * 0.5]) + dst_dir + dst[2, :] = _get_3rd_point(dst[0, :], dst[1, :]) + + if inv: + warp_mat = cv2.getAffineTransform(np.float32(dst), np.float32(src)) + else: + warp_mat = cv2.getAffineTransform(np.float32(src), np.float32(dst)) + + return warp_mat + + +def top_down_affine( + input_size: dict, bbox_scale: dict, bbox_center: dict, img: np.ndarray +) -> Tuple[np.ndarray, np.ndarray]: + """Get the bbox image as the model input by affine transform. + + Args: + input_size (dict): The input size of the model. + bbox_scale (dict): The bbox scale of the img. + bbox_center (dict): The bbox center of the img. + img (np.ndarray): The original image. + + Returns: + tuple: A tuple containing center and scale. + - np.ndarray[float32]: img after affine transform. + - np.ndarray[float32]: bbox scale after affine transform. + """ + w, h = input_size + warp_size = (int(w), int(h)) + + # reshape bbox to fixed aspect ratio + bbox_scale = _fix_aspect_ratio(bbox_scale, aspect_ratio=w / h) + + # get the affine matrix + center = bbox_center + scale = bbox_scale + rot = 0 + warp_mat = get_warp_matrix(center, scale, rot, output_size=(w, h)) + + # do affine transform + img = cv2.warpAffine(img, warp_mat, warp_size, flags=cv2.INTER_LINEAR) + + return img, bbox_scale + + +def get_simcc_maximum(simcc_x: np.ndarray, simcc_y: np.ndarray) -> Tuple[np.ndarray, np.ndarray]: + """Get maximum response location and value from simcc representations. + + Note: + instance number: N + num_keypoints: K + heatmap height: H + heatmap width: W + + Args: + simcc_x (np.ndarray): x-axis SimCC in shape (K, Wx) or (N, K, Wx) + simcc_y (np.ndarray): y-axis SimCC in shape (K, Wy) or (N, K, Wy) + + Returns: + tuple: + - locs (np.ndarray): locations of maximum heatmap responses in shape + (K, 2) or (N, K, 2) + - vals (np.ndarray): values of maximum heatmap responses in shape + (K,) or (N, K) + """ + N, K, Wx = simcc_x.shape + simcc_x = simcc_x.reshape(N * K, -1) + simcc_y = simcc_y.reshape(N * K, -1) + + # get maximum value locations + x_locs = np.argmax(simcc_x, axis=1) + y_locs = np.argmax(simcc_y, axis=1) + locs = np.stack((x_locs, y_locs), axis=-1).astype(np.float32) + max_val_x = np.amax(simcc_x, axis=1) + max_val_y = np.amax(simcc_y, axis=1) + + # get maximum value across x and y axis + mask = max_val_x > max_val_y + max_val_x[mask] = max_val_y[mask] + vals = max_val_x + locs[vals <= 0.0] = -1 + + # reshape + locs = locs.reshape(N, K, 2) + vals = vals.reshape(N, K) + + return locs, vals + + +def decode(simcc_x: np.ndarray, simcc_y: np.ndarray, simcc_split_ratio) -> Tuple[np.ndarray, np.ndarray]: + """Modulate simcc distribution with Gaussian. + + Args: + simcc_x (np.ndarray[K, Wx]): model predicted simcc in x. + simcc_y (np.ndarray[K, Wy]): model predicted simcc in y. + simcc_split_ratio (int): The split ratio of simcc. + + Returns: + tuple: A tuple containing center and scale. + - np.ndarray[float32]: keypoints in shape (K, 2) or (n, K, 2) + - np.ndarray[float32]: scores in shape (K,) or (n, K) + """ + keypoints, scores = get_simcc_maximum(simcc_x, simcc_y) + keypoints /= simcc_split_ratio + + return keypoints, scores + + +def inference_pose(session, out_bbox, oriImg): + h, w = session.get_inputs()[0].shape[2:] + model_input_size = (w, h) + resized_img, center, scale = preprocess(oriImg, out_bbox, model_input_size) + outputs = inference(session, resized_img) + keypoints, scores = postprocess(outputs, model_input_size, center, scale) + + return keypoints, scores diff --git a/invokeai/backend/image_util/dw_openpose/utils.py b/invokeai/backend/image_util/dw_openpose/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..dc142dfa71cffb9182b38bb72cde58c284ba10e9 --- /dev/null +++ b/invokeai/backend/image_util/dw_openpose/utils.py @@ -0,0 +1,157 @@ +# Code from the original DWPose Implementation: https://github.com/IDEA-Research/DWPose + +import math + +import cv2 +import matplotlib +import numpy as np +import numpy.typing as npt + +eps = 0.01 +NDArrayInt = npt.NDArray[np.uint8] + + +def draw_bodypose(canvas: NDArrayInt, candidate: NDArrayInt, subset: NDArrayInt) -> NDArrayInt: + H, W, C = canvas.shape + candidate = np.array(candidate) + subset = np.array(subset) + + stickwidth = 4 + + limbSeq = [ + [2, 3], + [2, 6], + [3, 4], + [4, 5], + [6, 7], + [7, 8], + [2, 9], + [9, 10], + [10, 11], + [2, 12], + [12, 13], + [13, 14], + [2, 1], + [1, 15], + [15, 17], + [1, 16], + [16, 18], + [3, 17], + [6, 18], + ] + + colors = [ + [255, 0, 0], + [255, 85, 0], + [255, 170, 0], + [255, 255, 0], + [170, 255, 0], + [85, 255, 0], + [0, 255, 0], + [0, 255, 85], + [0, 255, 170], + [0, 255, 255], + [0, 170, 255], + [0, 85, 255], + [0, 0, 255], + [85, 0, 255], + [170, 0, 255], + [255, 0, 255], + [255, 0, 170], + [255, 0, 85], + ] + + for i in range(17): + for n in range(len(subset)): + index = subset[n][np.array(limbSeq[i]) - 1] + if -1 in index: + continue + Y = candidate[index.astype(int), 0] * float(W) + X = candidate[index.astype(int), 1] * float(H) + mX = np.mean(X) + mY = np.mean(Y) + length = ((X[0] - X[1]) ** 2 + (Y[0] - Y[1]) ** 2) ** 0.5 + angle = math.degrees(math.atan2(X[0] - X[1], Y[0] - Y[1])) + polygon = cv2.ellipse2Poly((int(mY), int(mX)), (int(length / 2), stickwidth), int(angle), 0, 360, 1) + cv2.fillConvexPoly(canvas, polygon, colors[i]) + + canvas = (canvas * 0.6).astype(np.uint8) + + for i in range(18): + for n in range(len(subset)): + index = int(subset[n][i]) + if index == -1: + continue + x, y = candidate[index][0:2] + x = int(x * W) + y = int(y * H) + cv2.circle(canvas, (int(x), int(y)), 4, colors[i], thickness=-1) + + return canvas + + +def draw_handpose(canvas: NDArrayInt, all_hand_peaks: NDArrayInt) -> NDArrayInt: + H, W, C = canvas.shape + + edges = [ + [0, 1], + [1, 2], + [2, 3], + [3, 4], + [0, 5], + [5, 6], + [6, 7], + [7, 8], + [0, 9], + [9, 10], + [10, 11], + [11, 12], + [0, 13], + [13, 14], + [14, 15], + [15, 16], + [0, 17], + [17, 18], + [18, 19], + [19, 20], + ] + + for peaks in all_hand_peaks: + peaks = np.array(peaks) + + for ie, e in enumerate(edges): + x1, y1 = peaks[e[0]] + x2, y2 = peaks[e[1]] + x1 = int(x1 * W) + y1 = int(y1 * H) + x2 = int(x2 * W) + y2 = int(y2 * H) + if x1 > eps and y1 > eps and x2 > eps and y2 > eps: + cv2.line( + canvas, + (x1, y1), + (x2, y2), + matplotlib.colors.hsv_to_rgb([ie / float(len(edges)), 1.0, 1.0]) * 255, + thickness=2, + ) + + for _, keyponit in enumerate(peaks): + x, y = keyponit + x = int(x * W) + y = int(y * H) + if x > eps and y > eps: + cv2.circle(canvas, (x, y), 4, (0, 0, 255), thickness=-1) + return canvas + + +def draw_facepose(canvas: NDArrayInt, all_lmks: NDArrayInt) -> NDArrayInt: + H, W, C = canvas.shape + for lmks in all_lmks: + lmks = np.array(lmks) + for lmk in lmks: + x, y = lmk + x = int(x * W) + y = int(y * H) + if x > eps and y > eps: + cv2.circle(canvas, (x, y), 3, (255, 255, 255), thickness=-1) + return canvas diff --git a/invokeai/backend/image_util/dw_openpose/wholebody.py b/invokeai/backend/image_util/dw_openpose/wholebody.py new file mode 100644 index 0000000000000000000000000000000000000000..ce028df1fee8bf1ccebb3f24d0512e90b8f3295a --- /dev/null +++ b/invokeai/backend/image_util/dw_openpose/wholebody.py @@ -0,0 +1,44 @@ +# Code from the original DWPose Implementation: https://github.com/IDEA-Research/DWPose +# Modified pathing to suit Invoke + + +from pathlib import Path + +import numpy as np +import onnxruntime as ort + +from invokeai.app.services.config.config_default import get_config +from invokeai.backend.image_util.dw_openpose.onnxdet import inference_detector +from invokeai.backend.image_util.dw_openpose.onnxpose import inference_pose +from invokeai.backend.util.devices import TorchDevice + +config = get_config() + + +class Wholebody: + def __init__(self, onnx_det: Path, onnx_pose: Path): + device = TorchDevice.choose_torch_device() + + providers = ["CUDAExecutionProvider"] if device.type == "cuda" else ["CPUExecutionProvider"] + + self.session_det = ort.InferenceSession(path_or_bytes=onnx_det, providers=providers) + self.session_pose = ort.InferenceSession(path_or_bytes=onnx_pose, providers=providers) + + def __call__(self, oriImg): + det_result = inference_detector(self.session_det, oriImg) + keypoints, scores = inference_pose(self.session_pose, det_result, oriImg) + + keypoints_info = np.concatenate((keypoints, scores[..., None]), axis=-1) + # compute neck joint + neck = np.mean(keypoints_info[:, [5, 6]], axis=1) + # neck score when visualizing pred + neck[:, 2:4] = np.logical_and(keypoints_info[:, 5, 2:4] > 0.3, keypoints_info[:, 6, 2:4] > 0.3).astype(int) + new_keypoints_info = np.insert(keypoints_info, 17, neck, axis=1) + mmpose_idx = [17, 6, 8, 10, 7, 9, 12, 14, 16, 13, 15, 2, 1, 4, 3] + openpose_idx = [1, 2, 3, 4, 6, 7, 8, 9, 10, 12, 13, 14, 15, 16, 17] + new_keypoints_info[:, openpose_idx] = new_keypoints_info[:, mmpose_idx] + keypoints_info = new_keypoints_info + + keypoints, scores = keypoints_info[..., :2], keypoints_info[..., 2] + + return keypoints, scores diff --git a/invokeai/backend/image_util/grounding_dino/__init__.py b/invokeai/backend/image_util/grounding_dino/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/image_util/grounding_dino/detection_result.py b/invokeai/backend/image_util/grounding_dino/detection_result.py new file mode 100644 index 0000000000000000000000000000000000000000..2d0c78e681200973593dcadb707bf02d72b05b49 --- /dev/null +++ b/invokeai/backend/image_util/grounding_dino/detection_result.py @@ -0,0 +1,22 @@ +from pydantic import BaseModel, ConfigDict + + +class BoundingBox(BaseModel): + """Bounding box helper class.""" + + xmin: int + ymin: int + xmax: int + ymax: int + + +class DetectionResult(BaseModel): + """Detection result from Grounding DINO.""" + + score: float + label: str + box: BoundingBox + model_config = ConfigDict( + # Allow arbitrary types for mask, since it will be a numpy array. + arbitrary_types_allowed=True + ) diff --git a/invokeai/backend/image_util/grounding_dino/grounding_dino_pipeline.py b/invokeai/backend/image_util/grounding_dino/grounding_dino_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..772e8c0dd85502685e314427de0eed22b03d40be --- /dev/null +++ b/invokeai/backend/image_util/grounding_dino/grounding_dino_pipeline.py @@ -0,0 +1,37 @@ +from typing import Optional + +import torch +from PIL import Image +from transformers.pipelines import ZeroShotObjectDetectionPipeline + +from invokeai.backend.image_util.grounding_dino.detection_result import DetectionResult +from invokeai.backend.raw_model import RawModel + + +class GroundingDinoPipeline(RawModel): + """A wrapper class for a ZeroShotObjectDetectionPipeline that makes it compatible with the model manager's memory + management system. + """ + + def __init__(self, pipeline: ZeroShotObjectDetectionPipeline): + self._pipeline = pipeline + + def detect(self, image: Image.Image, candidate_labels: list[str], threshold: float = 0.1) -> list[DetectionResult]: + results = self._pipeline(image=image, candidate_labels=candidate_labels, threshold=threshold) + assert results is not None + results = [DetectionResult.model_validate(result) for result in results] + return results + + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None): + # HACK(ryand): The GroundingDinoPipeline does not work on MPS devices. We only allow it to be moved to CPU or + # CUDA. + if device is not None and device.type not in {"cpu", "cuda"}: + device = None + self._pipeline.model.to(device=device, dtype=dtype) + self._pipeline.device = self._pipeline.model.device + + def calc_size(self) -> int: + # HACK(ryand): Fix the circular import issue. + from invokeai.backend.model_manager.load.model_util import calc_module_size + + return calc_module_size(self._pipeline.model) diff --git a/invokeai/backend/image_util/hed.py b/invokeai/backend/image_util/hed.py new file mode 100644 index 0000000000000000000000000000000000000000..ec12c26b2e3ec91a297994168d4afdbb296da7db --- /dev/null +++ b/invokeai/backend/image_util/hed.py @@ -0,0 +1,216 @@ +# Adapted from https://github.com/huggingface/controlnet_aux + +import pathlib + +import cv2 +import huggingface_hub +import numpy as np +import torch +from einops import rearrange +from huggingface_hub import hf_hub_download +from PIL import Image + +from invokeai.backend.image_util.util import ( + nms, + normalize_image_channel_count, + np_to_pil, + pil_to_np, + resize_image_to_resolution, + safe_step, +) + + +class DoubleConvBlock(torch.nn.Module): + def __init__(self, input_channel, output_channel, layer_number): + super().__init__() + self.convs = torch.nn.Sequential() + self.convs.append( + torch.nn.Conv2d( + in_channels=input_channel, out_channels=output_channel, kernel_size=(3, 3), stride=(1, 1), padding=1 + ) + ) + for _i in range(1, layer_number): + self.convs.append( + torch.nn.Conv2d( + in_channels=output_channel, + out_channels=output_channel, + kernel_size=(3, 3), + stride=(1, 1), + padding=1, + ) + ) + self.projection = torch.nn.Conv2d( + in_channels=output_channel, out_channels=1, kernel_size=(1, 1), stride=(1, 1), padding=0 + ) + + def __call__(self, x, down_sampling=False): + h = x + if down_sampling: + h = torch.nn.functional.max_pool2d(h, kernel_size=(2, 2), stride=(2, 2)) + for conv in self.convs: + h = conv(h) + h = torch.nn.functional.relu(h) + return h, self.projection(h) + + +class ControlNetHED_Apache2(torch.nn.Module): + def __init__(self): + super().__init__() + self.norm = torch.nn.Parameter(torch.zeros(size=(1, 3, 1, 1))) + self.block1 = DoubleConvBlock(input_channel=3, output_channel=64, layer_number=2) + self.block2 = DoubleConvBlock(input_channel=64, output_channel=128, layer_number=2) + self.block3 = DoubleConvBlock(input_channel=128, output_channel=256, layer_number=3) + self.block4 = DoubleConvBlock(input_channel=256, output_channel=512, layer_number=3) + self.block5 = DoubleConvBlock(input_channel=512, output_channel=512, layer_number=3) + + def __call__(self, x): + h = x - self.norm + h, projection1 = self.block1(h) + h, projection2 = self.block2(h, down_sampling=True) + h, projection3 = self.block3(h, down_sampling=True) + h, projection4 = self.block4(h, down_sampling=True) + h, projection5 = self.block5(h, down_sampling=True) + return projection1, projection2, projection3, projection4, projection5 + + +class HEDProcessor: + """Holistically-Nested Edge Detection. + + On instantiation, loads the HED model from the HuggingFace Hub. + """ + + def __init__(self): + model_path = hf_hub_download("lllyasviel/Annotators", "ControlNetHED.pth") + self.network = ControlNetHED_Apache2() + self.network.load_state_dict(torch.load(model_path, map_location="cpu")) + self.network.float().eval() + + def to(self, device: torch.device): + self.network.to(device) + return self + + def run( + self, + input_image: Image.Image, + detect_resolution: int = 512, + image_resolution: int = 512, + safe: bool = False, + scribble: bool = False, + ) -> Image.Image: + """Processes an image and returns the detected edges. + + Args: + input_image: The input image. + detect_resolution: The resolution to fit the image to before edge detection. + image_resolution: The resolution to fit the edges to before returning. + safe: Whether to apply safe step to the detected edges. + scribble: Whether to apply non-maximum suppression and Gaussian blur to the detected edges. + + Returns: + The detected edges. + """ + device = next(iter(self.network.parameters())).device + np_image = pil_to_np(input_image) + np_image = normalize_image_channel_count(np_image) + np_image = resize_image_to_resolution(np_image, detect_resolution) + + assert np_image.ndim == 3 + height, width, _channels = np_image.shape + with torch.no_grad(): + image_hed = torch.from_numpy(np_image.copy()).float().to(device) + image_hed = rearrange(image_hed, "h w c -> 1 c h w") + edges = self.network(image_hed) + edges = [e.detach().cpu().numpy().astype(np.float32)[0, 0] for e in edges] + edges = [cv2.resize(e, (width, height), interpolation=cv2.INTER_LINEAR) for e in edges] + edges = np.stack(edges, axis=2) + edge = 1 / (1 + np.exp(-np.mean(edges, axis=2).astype(np.float64))) + if safe: + edge = safe_step(edge) + edge = (edge * 255.0).clip(0, 255).astype(np.uint8) + + detected_map = edge + detected_map = normalize_image_channel_count(detected_map) + + img = resize_image_to_resolution(np_image, image_resolution) + height, width, _channels = img.shape + + detected_map = cv2.resize(detected_map, (width, height), interpolation=cv2.INTER_LINEAR) + + if scribble: + detected_map = nms(detected_map, 127, 3.0) + detected_map = cv2.GaussianBlur(detected_map, (0, 0), 3.0) + detected_map[detected_map > 4] = 255 + detected_map[detected_map < 255] = 0 + + return np_to_pil(detected_map) + + +class HEDEdgeDetector: + """Simple wrapper around the HED model for detecting edges in an image.""" + + hf_repo_id = "lllyasviel/Annotators" + hf_filename = "ControlNetHED.pth" + + def __init__(self, model: ControlNetHED_Apache2): + self.model = model + + @classmethod + def get_model_url(cls) -> str: + """Get the URL to download the model from the Hugging Face Hub.""" + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename) + + @classmethod + def load_model(cls, model_path: pathlib.Path) -> ControlNetHED_Apache2: + """Load the model from a file.""" + model = ControlNetHED_Apache2() + model.load_state_dict(torch.load(model_path, map_location="cpu")) + model.float().eval() + return model + + def to(self, device: torch.device): + self.model.to(device) + return self + + def run(self, image: Image.Image, safe: bool = False, scribble: bool = False) -> Image.Image: + """Processes an image and returns the detected edges. + + Args: + image: The input image. + safe: Whether to apply safe step to the detected edges. + scribble: Whether to apply non-maximum suppression and Gaussian blur to the detected edges. + + Returns: + The detected edges. + """ + + device = next(iter(self.model.parameters())).device + + np_image = pil_to_np(image) + + height, width, _channels = np_image.shape + + with torch.no_grad(): + image_hed = torch.from_numpy(np_image.copy()).float().to(device) + image_hed = rearrange(image_hed, "h w c -> 1 c h w") + edges = self.model(image_hed) + edges = [e.detach().cpu().numpy().astype(np.float32)[0, 0] for e in edges] + edges = [cv2.resize(e, (width, height), interpolation=cv2.INTER_LINEAR) for e in edges] + edges = np.stack(edges, axis=2) + edge = 1 / (1 + np.exp(-np.mean(edges, axis=2).astype(np.float64))) + if safe: + edge = safe_step(edge) + edge = (edge * 255.0).clip(0, 255).astype(np.uint8) + + detected_map = edge + + detected_map = cv2.resize(detected_map, (width, height), interpolation=cv2.INTER_LINEAR) + + if scribble: + detected_map = nms(detected_map, 127, 3.0) + detected_map = cv2.GaussianBlur(detected_map, (0, 0), 3.0) + detected_map[detected_map > 4] = 255 + detected_map[detected_map < 255] = 0 + + output = np_to_pil(detected_map) + + return output diff --git a/invokeai/backend/image_util/infill_methods/cv2_inpaint.py b/invokeai/backend/image_util/infill_methods/cv2_inpaint.py new file mode 100644 index 0000000000000000000000000000000000000000..edc16e3bfbbcdabee7c5301197945a89ee0c3a3f --- /dev/null +++ b/invokeai/backend/image_util/infill_methods/cv2_inpaint.py @@ -0,0 +1,20 @@ +import cv2 +import numpy as np +from PIL import Image + + +def cv2_inpaint(image: Image.Image) -> Image.Image: + # Prepare Image + image_array = np.array(image.convert("RGB")) + image_cv = cv2.cvtColor(image_array, cv2.COLOR_RGB2BGR) + + # Prepare Mask From Alpha Channel + mask = image.split()[3].convert("RGB") + mask_array = np.array(mask) + mask_cv = cv2.cvtColor(mask_array, cv2.COLOR_BGR2GRAY) + mask_inv = cv2.bitwise_not(mask_cv) + + # Inpaint Image + inpainted_result = cv2.inpaint(image_cv, mask_inv, 3, cv2.INPAINT_TELEA) + inpainted_image = Image.fromarray(cv2.cvtColor(inpainted_result, cv2.COLOR_BGR2RGB)) + return inpainted_image diff --git a/invokeai/backend/image_util/infill_methods/lama.py b/invokeai/backend/image_util/infill_methods/lama.py new file mode 100644 index 0000000000000000000000000000000000000000..cd5838d1f2bc8b7d6c2e6cc58e54408bddbe6615 --- /dev/null +++ b/invokeai/backend/image_util/infill_methods/lama.py @@ -0,0 +1,53 @@ +from pathlib import Path +from typing import Any + +import numpy as np +import torch +from PIL import Image + +import invokeai.backend.util.logging as logger +from invokeai.backend.model_manager.config import AnyModel + + +def norm_img(np_img): + if len(np_img.shape) == 2: + np_img = np_img[:, :, np.newaxis] + np_img = np.transpose(np_img, (2, 0, 1)) + np_img = np_img.astype("float32") / 255 + return np_img + + +class LaMA: + def __init__(self, model: AnyModel): + self._model = model + + def __call__(self, input_image: Image.Image, *args: Any, **kwds: Any) -> Any: + image = np.asarray(input_image.convert("RGB")) + image = norm_img(image) + + mask = input_image.split()[-1] + mask = np.asarray(mask) + mask = np.invert(mask) + mask = norm_img(mask) + mask = (mask > 0) * 1 + + device = next(self._model.buffers()).device + image = torch.from_numpy(image).unsqueeze(0).to(device) + mask = torch.from_numpy(mask).unsqueeze(0).to(device) + + with torch.inference_mode(): + infilled_image = self._model(image, mask) + + infilled_image = infilled_image[0].permute(1, 2, 0).detach().cpu().numpy() + infilled_image = np.clip(infilled_image * 255, 0, 255).astype("uint8") + infilled_image = Image.fromarray(infilled_image) + + return infilled_image + + @staticmethod + def load_jit_model(url_or_path: str | Path, device: torch.device | str = "cpu") -> torch.nn.Module: + model_path = url_or_path + logger.info(f"Loading model from: {model_path}") + model: torch.nn.Module = torch.jit.load(model_path, map_location="cpu").to(device) # type: ignore + model.eval() + return model diff --git a/invokeai/backend/image_util/infill_methods/mosaic.py b/invokeai/backend/image_util/infill_methods/mosaic.py new file mode 100644 index 0000000000000000000000000000000000000000..2715a100d2889e481812341caa4ae49b6a15c7ef --- /dev/null +++ b/invokeai/backend/image_util/infill_methods/mosaic.py @@ -0,0 +1,60 @@ +from typing import Tuple + +import numpy as np +from PIL import Image + + +def infill_mosaic( + image: Image.Image, + tile_shape: Tuple[int, int] = (64, 64), + min_color: Tuple[int, int, int, int] = (0, 0, 0, 0), + max_color: Tuple[int, int, int, int] = (255, 255, 255, 0), +) -> Image.Image: + """ + image:PIL - A PIL Image + tile_shape: Tuple[int,int] - Tile width & Tile Height + min_color: Tuple[int,int,int] - RGB values for the lowest color to clip to (0-255) + max_color: Tuple[int,int,int] - RGB values for the highest color to clip to (0-255) + """ + + np_image = np.array(image) # Convert image to np array + alpha = np_image[:, :, 3] # Get the mask from the alpha channel of the image + non_transparent_pixels = np_image[alpha != 0, :3] # List of non-transparent pixels + + # Create color tiles to paste in the empty areas of the image + tile_width, tile_height = tile_shape + + # Clip the range of colors in the image to a particular spectrum only + r_min, g_min, b_min, _ = min_color + r_max, g_max, b_max, _ = max_color + non_transparent_pixels[:, 0] = np.clip(non_transparent_pixels[:, 0], r_min, r_max) + non_transparent_pixels[:, 1] = np.clip(non_transparent_pixels[:, 1], g_min, g_max) + non_transparent_pixels[:, 2] = np.clip(non_transparent_pixels[:, 2], b_min, b_max) + + tiles = [] + for _ in range(256): + color = non_transparent_pixels[np.random.randint(len(non_transparent_pixels))] + tile = np.zeros((tile_height, tile_width, 3), dtype=np.uint8) + tile[:, :] = color + tiles.append(tile) + + # Fill the transparent area with tiles + filled_image = np.zeros((image.height, image.width, 3), dtype=np.uint8) + + for x in range(image.width): + for y in range(image.height): + tile = tiles[np.random.randint(len(tiles))] + try: + filled_image[ + y - (y % tile_height) : y - (y % tile_height) + tile_height, + x - (x % tile_width) : x - (x % tile_width) + tile_width, + ] = tile + except ValueError: + # Need to handle edge cases - literally + pass + + filled_image = Image.fromarray(filled_image) # Convert the filled tiles image to PIL + image = Image.composite( + image, filled_image, image.split()[-1] + ) # Composite the original image on top of the filled tiles + return image diff --git a/invokeai/backend/image_util/infill_methods/patchmatch.py b/invokeai/backend/image_util/infill_methods/patchmatch.py new file mode 100644 index 0000000000000000000000000000000000000000..7e9cdf8fa4176a54bc1654bfdfad9a44a3b0e461 --- /dev/null +++ b/invokeai/backend/image_util/infill_methods/patchmatch.py @@ -0,0 +1,67 @@ +""" +This module defines a singleton object, "patchmatch" that +wraps the actual patchmatch object. It respects the global +"try_patchmatch" attribute, so that patchmatch loading can +be suppressed or deferred +""" + +import numpy as np +from PIL import Image + +import invokeai.backend.util.logging as logger +from invokeai.app.services.config.config_default import get_config + + +class PatchMatch: + """ + Thin class wrapper around the patchmatch function. + """ + + patch_match = None + tried_load: bool = False + + def __init__(self): + super().__init__() + + @classmethod + def _load_patch_match(cls): + if cls.tried_load: + return + if get_config().patchmatch: + from patchmatch import patch_match as pm + + if pm.patchmatch_available: + logger.info("Patchmatch initialized") + cls.patch_match = pm + else: + logger.info("Patchmatch not loaded (nonfatal)") + else: + logger.info("Patchmatch loading disabled") + cls.tried_load = True + + @classmethod + def patchmatch_available(cls) -> bool: + cls._load_patch_match() + if not cls.patch_match: + return False + return cls.patch_match.patchmatch_available + + @classmethod + def inpaint(cls, image: Image.Image) -> Image.Image: + if cls.patch_match is None or not cls.patchmatch_available(): + return image + + np_image = np.array(image) + mask = 255 - np_image[:, :, 3] + infilled = cls.patch_match.inpaint(np_image[:, :, :3], mask, patch_size=3) + return Image.fromarray(infilled, mode="RGB") + + +def infill_patchmatch(image: Image.Image) -> Image.Image: + IS_PATCHMATCH_AVAILABLE = PatchMatch.patchmatch_available() + + if not IS_PATCHMATCH_AVAILABLE: + logger.warning("PatchMatch is not available on this system") + return image + + return PatchMatch.inpaint(image) diff --git a/invokeai/backend/image_util/infill_methods/test_images/source1.webp b/invokeai/backend/image_util/infill_methods/test_images/source1.webp new file mode 100644 index 0000000000000000000000000000000000000000..7057eefa85f27b7f043838f5b14ca3048a14a633 Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source1.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source10.webp b/invokeai/backend/image_util/infill_methods/test_images/source10.webp new file mode 100644 index 0000000000000000000000000000000000000000..f185d52a573e58d2fb5fdb67e3dc44531b4c9eac Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source10.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source2.webp b/invokeai/backend/image_util/infill_methods/test_images/source2.webp new file mode 100644 index 0000000000000000000000000000000000000000..b25060024a7164a431c21570902daec0d8b70c54 Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source2.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source3.webp b/invokeai/backend/image_util/infill_methods/test_images/source3.webp new file mode 100644 index 0000000000000000000000000000000000000000..64227084c74354745302a5eab10116fe7f80999a Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source3.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source4.webp b/invokeai/backend/image_util/infill_methods/test_images/source4.webp new file mode 100644 index 0000000000000000000000000000000000000000..66a4260063ad1bedfed25a5ad94ba25cbceed1c2 Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source4.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source5.webp b/invokeai/backend/image_util/infill_methods/test_images/source5.webp new file mode 100644 index 0000000000000000000000000000000000000000..49b87b268f1ada602b21829a8cdba3b27b89d0f1 Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source5.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source6.webp b/invokeai/backend/image_util/infill_methods/test_images/source6.webp new file mode 100644 index 0000000000000000000000000000000000000000..e16e132004938e11cfb920fc1bc859bfc33864e8 Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source6.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source7.webp b/invokeai/backend/image_util/infill_methods/test_images/source7.webp new file mode 100644 index 0000000000000000000000000000000000000000..723a5fddbd7fe66c22c1c3c51c146e17bf80d0f4 Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source7.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source8.webp b/invokeai/backend/image_util/infill_methods/test_images/source8.webp new file mode 100644 index 0000000000000000000000000000000000000000..32a0fea10971a2f19c23e65e988fe941352b7d8f Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source8.webp differ diff --git a/invokeai/backend/image_util/infill_methods/test_images/source9.webp b/invokeai/backend/image_util/infill_methods/test_images/source9.webp new file mode 100644 index 0000000000000000000000000000000000000000..062f25ed8d559621a20beac4b9e4dd2d64b5bb79 Binary files /dev/null and b/invokeai/backend/image_util/infill_methods/test_images/source9.webp differ diff --git a/invokeai/backend/image_util/infill_methods/tile.ipynb b/invokeai/backend/image_util/infill_methods/tile.ipynb new file mode 100644 index 0000000000000000000000000000000000000000..eac7a436577c0c39565dd3152c65ea8d75c1977f --- /dev/null +++ b/invokeai/backend/image_util/infill_methods/tile.ipynb @@ -0,0 +1,95 @@ +{ + "cells": [ + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "\"\"\"Smoke test for the tile infill\"\"\"\n", + "\n", + "from pathlib import Path\n", + "from typing import Optional\n", + "from PIL import Image\n", + "from invokeai.backend.image_util.infill_methods.tile import infill_tile\n", + "\n", + "images: list[tuple[str, Image.Image]] = []\n", + "\n", + "for i in sorted(Path(\"./test_images/\").glob(\"*.webp\")):\n", + " images.append((i.name, Image.open(i)))\n", + " images.append((i.name, Image.open(i).transpose(Image.FLIP_LEFT_RIGHT)))\n", + " images.append((i.name, Image.open(i).transpose(Image.FLIP_TOP_BOTTOM)))\n", + " images.append((i.name, Image.open(i).resize((512, 512))))\n", + " images.append((i.name, Image.open(i).resize((1234, 461))))\n", + "\n", + "outputs: list[tuple[str, Image.Image, Image.Image, Optional[Image.Image]]] = []\n", + "\n", + "for name, image in images:\n", + " try:\n", + " output = infill_tile(image, seed=0, tile_size=32)\n", + " outputs.append((name, image, output.infilled, output.tile_image))\n", + " except ValueError as e:\n", + " print(f\"Skipping image {name}: {e}\")" + ] + }, + { + "cell_type": "code", + "execution_count": null, + "metadata": {}, + "outputs": [], + "source": [ + "# Display the images in jupyter notebook\n", + "import matplotlib.pyplot as plt\n", + "from PIL import ImageOps\n", + "\n", + "fig, axes = plt.subplots(len(outputs), 3, figsize=(10, 3 * len(outputs)))\n", + "plt.subplots_adjust(hspace=0)\n", + "\n", + "for i, (name, original, infilled, tile_image) in enumerate(outputs):\n", + " # Add a border to each image, helps to see the edges\n", + " size = original.size\n", + " original = ImageOps.expand(original, border=5, fill=\"red\")\n", + " filled = ImageOps.expand(infilled, border=5, fill=\"red\")\n", + " if tile_image:\n", + " tile_image = ImageOps.expand(tile_image, border=5, fill=\"red\")\n", + "\n", + " axes[i, 0].imshow(original)\n", + " axes[i, 0].axis(\"off\")\n", + " axes[i, 0].set_title(f\"Original ({name} - {size})\")\n", + "\n", + " if tile_image:\n", + " axes[i, 1].imshow(tile_image)\n", + " axes[i, 1].axis(\"off\")\n", + " axes[i, 1].set_title(\"Tile Image\")\n", + " else:\n", + " axes[i, 1].axis(\"off\")\n", + " axes[i, 1].set_title(\"NO TILES GENERATED (NO TRANSPARENCY)\")\n", + "\n", + " axes[i, 2].imshow(filled)\n", + " axes[i, 2].axis(\"off\")\n", + " axes[i, 2].set_title(\"Filled\")" + ] + } + ], + "metadata": { + "kernelspec": { + "display_name": ".invokeai", + "language": "python", + "name": "python3" + }, + "language_info": { + "codemirror_mode": { + "name": "ipython", + "version": 3 + }, + "file_extension": ".py", + "mimetype": "text/x-python", + "name": "python", + "nbconvert_exporter": "python", + "pygments_lexer": "ipython3", + "version": "3.10.12" + } + }, + "nbformat": 4, + "nbformat_minor": 2 +} diff --git a/invokeai/backend/image_util/infill_methods/tile.py b/invokeai/backend/image_util/infill_methods/tile.py new file mode 100644 index 0000000000000000000000000000000000000000..03cb6c1a8c6c2149a10d5e081b7023f7f18d9143 --- /dev/null +++ b/invokeai/backend/image_util/infill_methods/tile.py @@ -0,0 +1,122 @@ +from dataclasses import dataclass +from typing import Optional + +import numpy as np +from PIL import Image + + +def create_tile_pool(img_array: np.ndarray, tile_size: tuple[int, int]) -> list[np.ndarray]: + """ + Create a pool of tiles from non-transparent areas of the image by systematically walking through the image. + + Args: + img_array: numpy array of the image. + tile_size: tuple (tile_width, tile_height) specifying the size of each tile. + + Returns: + A list of numpy arrays, each representing a tile. + """ + tiles: list[np.ndarray] = [] + rows, cols = img_array.shape[:2] + tile_width, tile_height = tile_size + + for y in range(0, rows - tile_height + 1, tile_height): + for x in range(0, cols - tile_width + 1, tile_width): + tile = img_array[y : y + tile_height, x : x + tile_width] + # Check if the image has an alpha channel and the tile is completely opaque + if img_array.shape[2] == 4 and np.all(tile[:, :, 3] == 255): + tiles.append(tile) + elif img_array.shape[2] == 3: # If no alpha channel, append the tile + tiles.append(tile) + + if not tiles: + raise ValueError( + "Not enough opaque pixels to generate any tiles. Use a smaller tile size or a different image." + ) + + return tiles + + +def create_filled_image( + img_array: np.ndarray, tile_pool: list[np.ndarray], tile_size: tuple[int, int], seed: int +) -> np.ndarray: + """ + Create an image of the same dimensions as the original, filled entirely with tiles from the pool. + + Args: + img_array: numpy array of the original image. + tile_pool: A list of numpy arrays, each representing a tile. + tile_size: tuple (tile_width, tile_height) specifying the size of each tile. + + Returns: + A numpy array representing the filled image. + """ + + rows, cols, _ = img_array.shape + tile_width, tile_height = tile_size + + # Prep an empty RGB image + filled_img_array = np.zeros((rows, cols, 3), dtype=img_array.dtype) + + # Make the random tile selection reproducible + rng = np.random.default_rng(seed) + + for y in range(0, rows, tile_height): + for x in range(0, cols, tile_width): + # Pick a random tile from the pool + tile = tile_pool[rng.integers(len(tile_pool))] + + # Calculate the space available (may be less than tile size near the edges) + space_y = min(tile_height, rows - y) + space_x = min(tile_width, cols - x) + + # Crop the tile if necessary to fit into the available space + cropped_tile = tile[:space_y, :space_x, :3] + + # Fill the available space with the (possibly cropped) tile + filled_img_array[y : y + space_y, x : x + space_x, :3] = cropped_tile + + return filled_img_array + + +@dataclass +class InfillTileOutput: + infilled: Image.Image + tile_image: Optional[Image.Image] = None + + +def infill_tile(image_to_infill: Image.Image, seed: int, tile_size: int) -> InfillTileOutput: + """Infills an image with random tiles from the image itself. + + If the image is not an RGBA image, it is returned untouched. + + Args: + image: The image to infill. + tile_size: The size of the tiles to use for infilling. + + Raises: + ValueError: If there are not enough opaque pixels to generate any tiles. + """ + + if image_to_infill.mode != "RGBA": + return InfillTileOutput(infilled=image_to_infill) + + # Internally, we want a tuple of (tile_width, tile_height). In the future, the tile size can be any rectangle. + _tile_size = (tile_size, tile_size) + np_image = np.array(image_to_infill, dtype=np.uint8) + + # Create the pool of tiles that we will use to infill + tile_pool = create_tile_pool(np_image, _tile_size) + + # Create an image from the tiles, same size as the original + tile_np_image = create_filled_image(np_image, tile_pool, _tile_size, seed) + + # Paste the OG image over the tile image, effectively infilling the area + tile_image = Image.fromarray(tile_np_image, "RGB") + infilled = tile_image.copy() + infilled.paste(image_to_infill, (0, 0), image_to_infill.split()[-1]) + + # I think we want this to be "RGBA"? + infilled.convert("RGBA") + + return InfillTileOutput(infilled=infilled, tile_image=tile_image) diff --git a/invokeai/backend/image_util/invisible_watermark.py b/invokeai/backend/image_util/invisible_watermark.py new file mode 100644 index 0000000000000000000000000000000000000000..84342e442fc04cfde7c4368bd7301b8795876a4c --- /dev/null +++ b/invokeai/backend/image_util/invisible_watermark.py @@ -0,0 +1,30 @@ +""" +This module defines a singleton object, "invisible_watermark" that +wraps the invisible watermark model. It respects the global "invisible_watermark" +configuration variable, that allows the watermarking to be supressed. +""" + +import cv2 +import numpy as np +from imwatermark import WatermarkEncoder +from PIL import Image + +import invokeai.backend.util.logging as logger +from invokeai.app.services.config.config_default import get_config + +config = get_config() + + +class InvisibleWatermark: + """ + Wrapper around InvisibleWatermark module. + """ + + @classmethod + def add_watermark(cls, image: Image.Image, watermark_text: str) -> Image.Image: + logger.debug(f'Applying invisible watermark "{watermark_text}"') + bgr = cv2.cvtColor(np.array(image.convert("RGB")), cv2.COLOR_RGB2BGR) + encoder = WatermarkEncoder() + encoder.set_watermark("bytes", watermark_text.encode("utf-8")) + bgr_encoded = encoder.encode(bgr, "dwtDct") + return Image.fromarray(cv2.cvtColor(bgr_encoded, cv2.COLOR_BGR2RGB)).convert("RGBA") diff --git a/invokeai/backend/image_util/lineart.py b/invokeai/backend/image_util/lineart.py new file mode 100644 index 0000000000000000000000000000000000000000..8fcca24b0e017dda2000d9a16cfc795ef92911bf --- /dev/null +++ b/invokeai/backend/image_util/lineart.py @@ -0,0 +1,227 @@ +"""Adapted from https://github.com/huggingface/controlnet_aux (Apache-2.0 license).""" + +import pathlib + +import cv2 +import huggingface_hub +import numpy as np +import torch +import torch.nn as nn +from einops import rearrange +from huggingface_hub import hf_hub_download +from PIL import Image + +from invokeai.backend.image_util.util import ( + normalize_image_channel_count, + np_to_pil, + pil_to_np, + resize_image_to_resolution, +) + + +class ResidualBlock(nn.Module): + def __init__(self, in_features): + super(ResidualBlock, self).__init__() + + conv_block = [ + nn.ReflectionPad2d(1), + nn.Conv2d(in_features, in_features, 3), + nn.InstanceNorm2d(in_features), + nn.ReLU(inplace=True), + nn.ReflectionPad2d(1), + nn.Conv2d(in_features, in_features, 3), + nn.InstanceNorm2d(in_features), + ] + + self.conv_block = nn.Sequential(*conv_block) + + def forward(self, x): + return x + self.conv_block(x) + + +class Generator(nn.Module): + def __init__(self, input_nc, output_nc, n_residual_blocks=9, sigmoid=True): + super(Generator, self).__init__() + + # Initial convolution block + model0 = [nn.ReflectionPad2d(3), nn.Conv2d(input_nc, 64, 7), nn.InstanceNorm2d(64), nn.ReLU(inplace=True)] + self.model0 = nn.Sequential(*model0) + + # Downsampling + model1 = [] + in_features = 64 + out_features = in_features * 2 + for _ in range(2): + model1 += [ + nn.Conv2d(in_features, out_features, 3, stride=2, padding=1), + nn.InstanceNorm2d(out_features), + nn.ReLU(inplace=True), + ] + in_features = out_features + out_features = in_features * 2 + self.model1 = nn.Sequential(*model1) + + model2 = [] + # Residual blocks + for _ in range(n_residual_blocks): + model2 += [ResidualBlock(in_features)] + self.model2 = nn.Sequential(*model2) + + # Upsampling + model3 = [] + out_features = in_features // 2 + for _ in range(2): + model3 += [ + nn.ConvTranspose2d(in_features, out_features, 3, stride=2, padding=1, output_padding=1), + nn.InstanceNorm2d(out_features), + nn.ReLU(inplace=True), + ] + in_features = out_features + out_features = in_features // 2 + self.model3 = nn.Sequential(*model3) + + # Output layer + model4 = [nn.ReflectionPad2d(3), nn.Conv2d(64, output_nc, 7)] + if sigmoid: + model4 += [nn.Sigmoid()] + + self.model4 = nn.Sequential(*model4) + + def forward(self, x, cond=None): + out = self.model0(x) + out = self.model1(out) + out = self.model2(out) + out = self.model3(out) + out = self.model4(out) + + return out + + +class LineartProcessor: + """Processor for lineart detection.""" + + def __init__(self): + model_path = hf_hub_download("lllyasviel/Annotators", "sk_model.pth") + self.model = Generator(3, 1, 3) + self.model.load_state_dict(torch.load(model_path, map_location=torch.device("cpu"))) + self.model.eval() + + coarse_model_path = hf_hub_download("lllyasviel/Annotators", "sk_model2.pth") + self.model_coarse = Generator(3, 1, 3) + self.model_coarse.load_state_dict(torch.load(coarse_model_path, map_location=torch.device("cpu"))) + self.model_coarse.eval() + + def to(self, device: torch.device): + self.model.to(device) + self.model_coarse.to(device) + return self + + def run( + self, input_image: Image.Image, coarse: bool = False, detect_resolution: int = 512, image_resolution: int = 512 + ) -> Image.Image: + """Processes an image to detect lineart. + + Args: + input_image: The input image. + coarse: Whether to use the coarse model. + detect_resolution: The resolution to fit the image to before edge detection. + image_resolution: The resolution of the output image. + + Returns: + The detected lineart. + """ + device = next(iter(self.model.parameters())).device + + np_image = pil_to_np(input_image) + np_image = normalize_image_channel_count(np_image) + np_image = resize_image_to_resolution(np_image, detect_resolution) + + model = self.model_coarse if coarse else self.model + assert np_image.ndim == 3 + image = np_image + with torch.no_grad(): + image = torch.from_numpy(image).float().to(device) + image = image / 255.0 + image = rearrange(image, "h w c -> 1 c h w") + line = model(image)[0][0] + + line = line.cpu().numpy() + line = (line * 255.0).clip(0, 255).astype(np.uint8) + + detected_map = line + + detected_map = normalize_image_channel_count(detected_map) + + img = resize_image_to_resolution(np_image, image_resolution) + H, W, C = img.shape + + detected_map = cv2.resize(detected_map, (W, H), interpolation=cv2.INTER_LINEAR) + detected_map = 255 - detected_map + + return np_to_pil(detected_map) + + +class LineartEdgeDetector: + """Simple wrapper around the fine and coarse lineart models for detecting edges in an image.""" + + hf_repo_id = "lllyasviel/Annotators" + hf_filename_fine = "sk_model.pth" + hf_filename_coarse = "sk_model2.pth" + + @classmethod + def get_model_url(cls, coarse: bool = False) -> str: + """Get the URL to download the model from the Hugging Face Hub.""" + if coarse: + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename_coarse) + else: + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename_fine) + + @classmethod + def load_model(cls, model_path: pathlib.Path) -> Generator: + """Load the model from a file.""" + model = Generator(3, 1, 3) + model.load_state_dict(torch.load(model_path, map_location="cpu")) + model.float().eval() + return model + + def __init__(self, model: Generator) -> None: + self.model = model + + def to(self, device: torch.device): + self.model.to(device) + return self + + def run(self, image: Image.Image) -> Image.Image: + """Detects edges in the input image with the selected lineart model. + + Args: + input: The input image. + coarse: Whether to use the coarse model. + + Returns: + The detected edges. + """ + device = next(iter(self.model.parameters())).device + + np_image = pil_to_np(image) + + with torch.no_grad(): + np_image = torch.from_numpy(np_image).float().to(device) + np_image = np_image / 255.0 + np_image = rearrange(np_image, "h w c -> 1 c h w") + line = self.model(np_image)[0][0] + + line = line.cpu().numpy() + line = (line * 255.0).clip(0, 255).astype(np.uint8) + + detected_map = 255 - line + + # The lineart model often outputs a lot of almost-black noise. SD1.5 ControlNets seem to be OK with this, but + # SDXL ControlNets are not - they need a cleaner map. 12 was experimentally determined to be a good threshold, + # eliminating all the noise while keeping the actual edges. Other approaches to thresholding may be better, + # for example stretching the contrast or removing noise. + detected_map[detected_map < 12] = 0 + + output = np_to_pil(detected_map) + + return output diff --git a/invokeai/backend/image_util/lineart_anime.py b/invokeai/backend/image_util/lineart_anime.py new file mode 100644 index 0000000000000000000000000000000000000000..09dcb6655e3985770d08848ca9e928efe7512e84 --- /dev/null +++ b/invokeai/backend/image_util/lineart_anime.py @@ -0,0 +1,273 @@ +"""Adapted from https://github.com/huggingface/controlnet_aux (Apache-2.0 license).""" + +import functools +import pathlib +from typing import Optional + +import cv2 +import huggingface_hub +import numpy as np +import torch +import torch.nn as nn +from einops import rearrange +from huggingface_hub import hf_hub_download +from PIL import Image + +from invokeai.backend.image_util.util import ( + normalize_image_channel_count, + np_to_pil, + pil_to_np, + resize_image_to_resolution, +) + + +class UnetGenerator(nn.Module): + """Create a Unet-based generator""" + + def __init__( + self, + input_nc: int, + output_nc: int, + num_downs: int, + ngf: int = 64, + norm_layer=nn.BatchNorm2d, + use_dropout: bool = False, + ): + """Construct a Unet generator + Parameters: + input_nc (int) -- the number of channels in input images + output_nc (int) -- the number of channels in output images + num_downs (int) -- the number of downsamplings in UNet. For example, # if |num_downs| == 7, + image of size 128x128 will become of size 1x1 # at the bottleneck + ngf (int) -- the number of filters in the last conv layer + norm_layer -- normalization layer + We construct the U-Net from the innermost layer to the outermost layer. + It is a recursive process. + """ + super(UnetGenerator, self).__init__() + # construct unet structure + unet_block = UnetSkipConnectionBlock( + ngf * 8, ngf * 8, input_nc=None, submodule=None, norm_layer=norm_layer, innermost=True + ) # add the innermost layer + for _ in range(num_downs - 5): # add intermediate layers with ngf * 8 filters + unet_block = UnetSkipConnectionBlock( + ngf * 8, ngf * 8, input_nc=None, submodule=unet_block, norm_layer=norm_layer, use_dropout=use_dropout + ) + # gradually reduce the number of filters from ngf * 8 to ngf + unet_block = UnetSkipConnectionBlock( + ngf * 4, ngf * 8, input_nc=None, submodule=unet_block, norm_layer=norm_layer + ) + unet_block = UnetSkipConnectionBlock( + ngf * 2, ngf * 4, input_nc=None, submodule=unet_block, norm_layer=norm_layer + ) + unet_block = UnetSkipConnectionBlock(ngf, ngf * 2, input_nc=None, submodule=unet_block, norm_layer=norm_layer) + self.model = UnetSkipConnectionBlock( + output_nc, ngf, input_nc=input_nc, submodule=unet_block, outermost=True, norm_layer=norm_layer + ) # add the outermost layer + + def forward(self, input): + """Standard forward""" + return self.model(input) + + +class UnetSkipConnectionBlock(nn.Module): + """Defines the Unet submodule with skip connection. + X -------------------identity---------------------- + |-- downsampling -- |submodule| -- upsampling --| + """ + + def __init__( + self, + outer_nc: int, + inner_nc: int, + input_nc: Optional[int] = None, + submodule=None, + outermost: bool = False, + innermost: bool = False, + norm_layer=nn.BatchNorm2d, + use_dropout: bool = False, + ): + """Construct a Unet submodule with skip connections. + Parameters: + outer_nc (int) -- the number of filters in the outer conv layer + inner_nc (int) -- the number of filters in the inner conv layer + input_nc (int) -- the number of channels in input images/features + submodule (UnetSkipConnectionBlock) -- previously defined submodules + outermost (bool) -- if this module is the outermost module + innermost (bool) -- if this module is the innermost module + norm_layer -- normalization layer + use_dropout (bool) -- if use dropout layers. + """ + super(UnetSkipConnectionBlock, self).__init__() + self.outermost = outermost + if isinstance(norm_layer, functools.partial): + use_bias = norm_layer.func == nn.InstanceNorm2d + else: + use_bias = norm_layer == nn.InstanceNorm2d + if input_nc is None: + input_nc = outer_nc + downconv = nn.Conv2d(input_nc, inner_nc, kernel_size=4, stride=2, padding=1, bias=use_bias) + downrelu = nn.LeakyReLU(0.2, True) + downnorm = norm_layer(inner_nc) + uprelu = nn.ReLU(True) + upnorm = norm_layer(outer_nc) + + if outermost: + upconv = nn.ConvTranspose2d(inner_nc * 2, outer_nc, kernel_size=4, stride=2, padding=1) + down = [downconv] + up = [uprelu, upconv, nn.Tanh()] + model = down + [submodule] + up + elif innermost: + upconv = nn.ConvTranspose2d(inner_nc, outer_nc, kernel_size=4, stride=2, padding=1, bias=use_bias) + down = [downrelu, downconv] + up = [uprelu, upconv, upnorm] + model = down + up + else: + upconv = nn.ConvTranspose2d(inner_nc * 2, outer_nc, kernel_size=4, stride=2, padding=1, bias=use_bias) + down = [downrelu, downconv, downnorm] + up = [uprelu, upconv, upnorm] + + if use_dropout: + model = down + [submodule] + up + [nn.Dropout(0.5)] + else: + model = down + [submodule] + up + + self.model = nn.Sequential(*model) + + def forward(self, x): + if self.outermost: + return self.model(x) + else: # add skip connections + return torch.cat([x, self.model(x)], 1) + + +class LineartAnimeProcessor: + """Processes an image to detect lineart.""" + + def __init__(self): + model_path = hf_hub_download("lllyasviel/Annotators", "netG.pth") + norm_layer = functools.partial(nn.InstanceNorm2d, affine=False, track_running_stats=False) + self.model = UnetGenerator(3, 1, 8, 64, norm_layer=norm_layer, use_dropout=False) + ckpt = torch.load(model_path) + for key in list(ckpt.keys()): + if "module." in key: + ckpt[key.replace("module.", "")] = ckpt[key] + del ckpt[key] + self.model.load_state_dict(ckpt) + self.model.eval() + + def to(self, device: torch.device): + self.model.to(device) + return self + + def run(self, input_image: Image.Image, detect_resolution: int = 512, image_resolution: int = 512) -> Image.Image: + """Processes an image to detect lineart. + + Args: + input_image: The input image. + detect_resolution: The resolution to use for detection. + image_resolution: The resolution to use for the output image. + + Returns: + The detected lineart. + """ + device = next(iter(self.model.parameters())).device + np_image = pil_to_np(input_image) + + np_image = normalize_image_channel_count(np_image) + np_image = resize_image_to_resolution(np_image, detect_resolution) + + H, W, C = np_image.shape + Hn = 256 * int(np.ceil(float(H) / 256.0)) + Wn = 256 * int(np.ceil(float(W) / 256.0)) + img = cv2.resize(np_image, (Wn, Hn), interpolation=cv2.INTER_CUBIC) + with torch.no_grad(): + image_feed = torch.from_numpy(img).float().to(device) + image_feed = image_feed / 127.5 - 1.0 + image_feed = rearrange(image_feed, "h w c -> 1 c h w") + + line = self.model(image_feed)[0, 0] * 127.5 + 127.5 + line = line.cpu().numpy() + + line = cv2.resize(line, (W, H), interpolation=cv2.INTER_CUBIC) + line = line.clip(0, 255).astype(np.uint8) + + detected_map = line + + detected_map = normalize_image_channel_count(detected_map) + + img = resize_image_to_resolution(np_image, image_resolution) + H, W, C = img.shape + + detected_map = cv2.resize(detected_map, (W, H), interpolation=cv2.INTER_LINEAR) + detected_map = 255 - detected_map + + return np_to_pil(detected_map) + + +class LineartAnimeEdgeDetector: + """Simple wrapper around the Lineart Anime model for detecting edges in an image.""" + + hf_repo_id = "lllyasviel/Annotators" + hf_filename = "netG.pth" + + @classmethod + def get_model_url(cls) -> str: + """Get the URL to download the model from the Hugging Face Hub.""" + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename) + + @classmethod + def load_model(cls, model_path: pathlib.Path) -> UnetGenerator: + """Load the model from a file.""" + norm_layer = functools.partial(nn.InstanceNorm2d, affine=False, track_running_stats=False) + model = UnetGenerator(3, 1, 8, 64, norm_layer=norm_layer, use_dropout=False) + ckpt = torch.load(model_path) + for key in list(ckpt.keys()): + if "module." in key: + ckpt[key.replace("module.", "")] = ckpt[key] + del ckpt[key] + model.load_state_dict(ckpt) + model.eval() + return model + + def __init__(self, model: UnetGenerator) -> None: + self.model = model + + def to(self, device: torch.device): + self.model.to(device) + return self + + def run(self, image: Image.Image) -> Image.Image: + """Processes an image and returns the detected edges.""" + device = next(iter(self.model.parameters())).device + + np_image = pil_to_np(image) + + height, width, _channels = np_image.shape + new_height = 256 * int(np.ceil(float(height) / 256.0)) + new_width = 256 * int(np.ceil(float(width) / 256.0)) + + resized_img = cv2.resize(np_image, (new_width, new_height), interpolation=cv2.INTER_CUBIC) + + with torch.no_grad(): + image_feed = torch.from_numpy(resized_img).float().to(device) + image_feed = image_feed / 127.5 - 1.0 + image_feed = rearrange(image_feed, "h w c -> 1 c h w") + + line = self.model(image_feed)[0, 0] * 127.5 + 127.5 + line = line.cpu().numpy() + + line = cv2.resize(line, (width, height), interpolation=cv2.INTER_CUBIC) + line = line.clip(0, 255).astype(np.uint8) + + detected_map = 255 - line + + # The lineart model often outputs a lot of almost-black noise. SD1.5 ControlNets seem to be OK with this, but + # SDXL ControlNets are not - they need a cleaner map. 12 was experimentally determined to be a good threshold, + # eliminating all the noise while keeping the actual edges. Other approaches to thresholding may be better, + # for example stretching the contrast or removing noise. + detected_map[detected_map < 12] = 0 + + output = np_to_pil(detected_map) + + return output diff --git a/invokeai/backend/image_util/mediapipe_face/__init__.py b/invokeai/backend/image_util/mediapipe_face/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..da41425b4331be13974a071a9c3e55bd47d2c9f6 --- /dev/null +++ b/invokeai/backend/image_util/mediapipe_face/__init__.py @@ -0,0 +1,15 @@ +# Adapted from https://github.com/huggingface/controlnet_aux + +from PIL import Image + +from invokeai.backend.image_util.mediapipe_face.mediapipe_face_common import generate_annotation +from invokeai.backend.image_util.util import np_to_pil, pil_to_np + + +def detect_faces(image: Image.Image, max_faces: int = 1, min_confidence: float = 0.5) -> Image.Image: + """Detects faces in an image using MediaPipe.""" + + np_img = pil_to_np(image) + detected_map = generate_annotation(np_img, max_faces, min_confidence) + detected_map_pil = np_to_pil(detected_map) + return detected_map_pil diff --git a/invokeai/backend/image_util/mediapipe_face/mediapipe_face_common.py b/invokeai/backend/image_util/mediapipe_face/mediapipe_face_common.py new file mode 100644 index 0000000000000000000000000000000000000000..4cf7a66cdc7228bfa2a01f3f743255a13fc55d0a --- /dev/null +++ b/invokeai/backend/image_util/mediapipe_face/mediapipe_face_common.py @@ -0,0 +1,149 @@ +from typing import Mapping + +import mediapipe as mp +import numpy + +mp_drawing = mp.solutions.drawing_utils +mp_drawing_styles = mp.solutions.drawing_styles +mp_face_detection = mp.solutions.face_detection # Only for counting faces. +mp_face_mesh = mp.solutions.face_mesh +mp_face_connections = mp.solutions.face_mesh_connections.FACEMESH_TESSELATION +mp_hand_connections = mp.solutions.hands_connections.HAND_CONNECTIONS +mp_body_connections = mp.solutions.pose_connections.POSE_CONNECTIONS + +DrawingSpec = mp.solutions.drawing_styles.DrawingSpec +PoseLandmark = mp.solutions.drawing_styles.PoseLandmark + +min_face_size_pixels: int = 64 +f_thick = 2 +f_rad = 1 +right_iris_draw = DrawingSpec(color=(10, 200, 250), thickness=f_thick, circle_radius=f_rad) +right_eye_draw = DrawingSpec(color=(10, 200, 180), thickness=f_thick, circle_radius=f_rad) +right_eyebrow_draw = DrawingSpec(color=(10, 220, 180), thickness=f_thick, circle_radius=f_rad) +left_iris_draw = DrawingSpec(color=(250, 200, 10), thickness=f_thick, circle_radius=f_rad) +left_eye_draw = DrawingSpec(color=(180, 200, 10), thickness=f_thick, circle_radius=f_rad) +left_eyebrow_draw = DrawingSpec(color=(180, 220, 10), thickness=f_thick, circle_radius=f_rad) +mouth_draw = DrawingSpec(color=(10, 180, 10), thickness=f_thick, circle_radius=f_rad) +head_draw = DrawingSpec(color=(10, 200, 10), thickness=f_thick, circle_radius=f_rad) + +# mp_face_mesh.FACEMESH_CONTOURS has all the items we care about. +face_connection_spec = {} +for edge in mp_face_mesh.FACEMESH_FACE_OVAL: + face_connection_spec[edge] = head_draw +for edge in mp_face_mesh.FACEMESH_LEFT_EYE: + face_connection_spec[edge] = left_eye_draw +for edge in mp_face_mesh.FACEMESH_LEFT_EYEBROW: + face_connection_spec[edge] = left_eyebrow_draw +# for edge in mp_face_mesh.FACEMESH_LEFT_IRIS: +# face_connection_spec[edge] = left_iris_draw +for edge in mp_face_mesh.FACEMESH_RIGHT_EYE: + face_connection_spec[edge] = right_eye_draw +for edge in mp_face_mesh.FACEMESH_RIGHT_EYEBROW: + face_connection_spec[edge] = right_eyebrow_draw +# for edge in mp_face_mesh.FACEMESH_RIGHT_IRIS: +# face_connection_spec[edge] = right_iris_draw +for edge in mp_face_mesh.FACEMESH_LIPS: + face_connection_spec[edge] = mouth_draw +iris_landmark_spec = {468: right_iris_draw, 473: left_iris_draw} + + +def draw_pupils(image, landmark_list, drawing_spec, halfwidth: int = 2): + """We have a custom function to draw the pupils because the mp.draw_landmarks method requires a parameter for all + landmarks. Until our PR is merged into mediapipe, we need this separate method.""" + if len(image.shape) != 3: + raise ValueError("Input image must be H,W,C.") + image_rows, image_cols, image_channels = image.shape + if image_channels != 3: # BGR channels + raise ValueError("Input image must contain three channel bgr data.") + for idx, landmark in enumerate(landmark_list.landmark): + if (landmark.HasField("visibility") and landmark.visibility < 0.9) or ( + landmark.HasField("presence") and landmark.presence < 0.5 + ): + continue + if landmark.x >= 1.0 or landmark.x < 0 or landmark.y >= 1.0 or landmark.y < 0: + continue + image_x = int(image_cols * landmark.x) + image_y = int(image_rows * landmark.y) + draw_color = None + if isinstance(drawing_spec, Mapping): + if drawing_spec.get(idx) is None: + continue + else: + draw_color = drawing_spec[idx].color + elif isinstance(drawing_spec, DrawingSpec): + draw_color = drawing_spec.color + image[image_y - halfwidth : image_y + halfwidth, image_x - halfwidth : image_x + halfwidth, :] = draw_color + + +def reverse_channels(image): + """Given a numpy array in RGB form, convert to BGR. Will also convert from BGR to RGB.""" + # im[:,:,::-1] is a neat hack to convert BGR to RGB by reversing the indexing order. + # im[:,:,::[2,1,0]] would also work but makes a copy of the data. + return image[:, :, ::-1] + + +def generate_annotation(img_rgb, max_faces: int, min_confidence: float): + """ + Find up to 'max_faces' inside the provided input image. + If min_face_size_pixels is provided and nonzero it will be used to filter faces that occupy less than this many + pixels in the image. + """ + with mp_face_mesh.FaceMesh( + static_image_mode=True, + max_num_faces=max_faces, + refine_landmarks=True, + min_detection_confidence=min_confidence, + ) as facemesh: + img_height, img_width, img_channels = img_rgb.shape + assert img_channels == 3 + + results = facemesh.process(img_rgb).multi_face_landmarks + + if results is None: + print("No faces detected in controlnet image for Mediapipe face annotator.") + return numpy.zeros_like(img_rgb) + + # Filter faces that are too small + filtered_landmarks = [] + for lm in results: + landmarks = lm.landmark + face_rect = [ + landmarks[0].x, + landmarks[0].y, + landmarks[0].x, + landmarks[0].y, + ] # Left, up, right, down. + for i in range(len(landmarks)): + face_rect[0] = min(face_rect[0], landmarks[i].x) + face_rect[1] = min(face_rect[1], landmarks[i].y) + face_rect[2] = max(face_rect[2], landmarks[i].x) + face_rect[3] = max(face_rect[3], landmarks[i].y) + if min_face_size_pixels > 0: + face_width = abs(face_rect[2] - face_rect[0]) + face_height = abs(face_rect[3] - face_rect[1]) + face_width_pixels = face_width * img_width + face_height_pixels = face_height * img_height + face_size = min(face_width_pixels, face_height_pixels) + if face_size >= min_face_size_pixels: + filtered_landmarks.append(lm) + else: + filtered_landmarks.append(lm) + + # Annotations are drawn in BGR for some reason, but we don't need to flip a zero-filled image at the start. + empty = numpy.zeros_like(img_rgb) + + # Draw detected faces: + for face_landmarks in filtered_landmarks: + mp_drawing.draw_landmarks( + empty, + face_landmarks, + connections=face_connection_spec.keys(), + landmark_drawing_spec=None, + connection_drawing_spec=face_connection_spec, + ) + draw_pupils(empty, face_landmarks, iris_landmark_spec, 2) + + # Flip BGR back to RGB. + empty = reverse_channels(empty).copy() + + return empty diff --git a/invokeai/backend/image_util/mlsd/__init__.py b/invokeai/backend/image_util/mlsd/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..0423865be737e3b4a9999a90e43be3e0e39b0385 --- /dev/null +++ b/invokeai/backend/image_util/mlsd/__init__.py @@ -0,0 +1,66 @@ +# Adapted from https://github.com/huggingface/controlnet_aux + +import pathlib + +import cv2 +import huggingface_hub +import numpy as np +import torch +from PIL import Image + +from invokeai.backend.image_util.mlsd.models.mbv2_mlsd_large import MobileV2_MLSD_Large +from invokeai.backend.image_util.mlsd.utils import pred_lines +from invokeai.backend.image_util.util import np_to_pil, pil_to_np, resize_to_multiple + + +class MLSDDetector: + """Simple wrapper around a MLSD model for detecting edges as line segments in an image.""" + + hf_repo_id = "lllyasviel/ControlNet" + hf_filename = "annotator/ckpts/mlsd_large_512_fp32.pth" + + @classmethod + def get_model_url(cls) -> str: + """Get the URL to download the model from the Hugging Face Hub.""" + + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename) + + @classmethod + def load_model(cls, model_path: pathlib.Path) -> MobileV2_MLSD_Large: + """Load the model from a file.""" + + model = MobileV2_MLSD_Large() + model.load_state_dict(torch.load(model_path), strict=True) + model.eval() + return model + + def __init__(self, model: MobileV2_MLSD_Large) -> None: + self.model = model + + def to(self, device: torch.device): + self.model.to(device) + return self + + def run(self, image: Image.Image, score_threshold: float = 0.1, distance_threshold: float = 20.0) -> Image.Image: + """Processes an image and returns the detected edges.""" + + np_img = pil_to_np(image) + + height, width, _channels = np_img.shape + + # This model requires the input image to have a resolution that is a multiple of 64 + np_img = resize_to_multiple(np_img, 64) + img_output = np.zeros_like(np_img) + + with torch.no_grad(): + lines = pred_lines(np_img, self.model, [np_img.shape[0], np_img.shape[1]], score_threshold, distance_threshold) + for line in lines: + x_start, y_start, x_end, y_end = [int(val) for val in line] + cv2.line(img_output, (x_start, y_start), (x_end, y_end), [255, 255, 255], 1) + + detected_map = img_output[:, :, 0] + + # Back to the original size + output_image = cv2.resize(detected_map, (width, height), interpolation=cv2.INTER_LINEAR) + + return np_to_pil(output_image) diff --git a/invokeai/backend/image_util/mlsd/models/__init__.py b/invokeai/backend/image_util/mlsd/models/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/image_util/mlsd/models/mbv2_mlsd_large.py b/invokeai/backend/image_util/mlsd/models/mbv2_mlsd_large.py new file mode 100644 index 0000000000000000000000000000000000000000..7d21284ef6300a7feb7a6d5511117363bcaa3f05 --- /dev/null +++ b/invokeai/backend/image_util/mlsd/models/mbv2_mlsd_large.py @@ -0,0 +1,290 @@ +import torch +import torch.nn as nn +import torch.utils.model_zoo as model_zoo +from torch.nn import functional as F + + +class BlockTypeA(nn.Module): + def __init__(self, in_c1, in_c2, out_c1, out_c2, upscale = True): + super(BlockTypeA, self).__init__() + self.conv1 = nn.Sequential( + nn.Conv2d(in_c2, out_c2, kernel_size=1), + nn.BatchNorm2d(out_c2), + nn.ReLU(inplace=True) + ) + self.conv2 = nn.Sequential( + nn.Conv2d(in_c1, out_c1, kernel_size=1), + nn.BatchNorm2d(out_c1), + nn.ReLU(inplace=True) + ) + self.upscale = upscale + + def forward(self, a, b): + b = self.conv1(b) + a = self.conv2(a) + if self.upscale: + b = F.interpolate(b, scale_factor=2.0, mode='bilinear', align_corners=True) + return torch.cat((a, b), dim=1) + + +class BlockTypeB(nn.Module): + def __init__(self, in_c, out_c): + super(BlockTypeB, self).__init__() + self.conv1 = nn.Sequential( + nn.Conv2d(in_c, in_c, kernel_size=3, padding=1), + nn.BatchNorm2d(in_c), + nn.ReLU() + ) + self.conv2 = nn.Sequential( + nn.Conv2d(in_c, out_c, kernel_size=3, padding=1), + nn.BatchNorm2d(out_c), + nn.ReLU() + ) + + def forward(self, x): + x = self.conv1(x) + x + x = self.conv2(x) + return x + +class BlockTypeC(nn.Module): + def __init__(self, in_c, out_c): + super(BlockTypeC, self).__init__() + self.conv1 = nn.Sequential( + nn.Conv2d(in_c, in_c, kernel_size=3, padding=5, dilation=5), + nn.BatchNorm2d(in_c), + nn.ReLU() + ) + self.conv2 = nn.Sequential( + nn.Conv2d(in_c, in_c, kernel_size=3, padding=1), + nn.BatchNorm2d(in_c), + nn.ReLU() + ) + self.conv3 = nn.Conv2d(in_c, out_c, kernel_size=1) + + def forward(self, x): + x = self.conv1(x) + x = self.conv2(x) + x = self.conv3(x) + return x + +def _make_divisible(v, divisor, min_value=None): + """ + This function is taken from the original tf repo. + It ensures that all layers have a channel number that is divisible by 8 + It can be seen here: + https://github.com/tensorflow/models/blob/master/research/slim/nets/mobilenet/mobilenet.py + :param v: + :param divisor: + :param min_value: + :return: + """ + if min_value is None: + min_value = divisor + new_v = max(min_value, int(v + divisor / 2) // divisor * divisor) + # Make sure that round down does not go down by more than 10%. + if new_v < 0.9 * v: + new_v += divisor + return new_v + + +class ConvBNReLU(nn.Sequential): + def __init__(self, in_planes, out_planes, kernel_size=3, stride=1, groups=1): + self.channel_pad = out_planes - in_planes + self.stride = stride + #padding = (kernel_size - 1) // 2 + + # TFLite uses slightly different padding than PyTorch + if stride == 2: + padding = 0 + else: + padding = (kernel_size - 1) // 2 + + super(ConvBNReLU, self).__init__( + nn.Conv2d(in_planes, out_planes, kernel_size, stride, padding, groups=groups, bias=False), + nn.BatchNorm2d(out_planes), + nn.ReLU6(inplace=True) + ) + self.max_pool = nn.MaxPool2d(kernel_size=stride, stride=stride) + + + def forward(self, x): + # TFLite uses different padding + if self.stride == 2: + x = F.pad(x, (0, 1, 0, 1), "constant", 0) + #print(x.shape) + + for module in self: + if not isinstance(module, nn.MaxPool2d): + x = module(x) + return x + + +class InvertedResidual(nn.Module): + def __init__(self, inp, oup, stride, expand_ratio): + super(InvertedResidual, self).__init__() + self.stride = stride + assert stride in [1, 2] + + hidden_dim = int(round(inp * expand_ratio)) + self.use_res_connect = self.stride == 1 and inp == oup + + layers = [] + if expand_ratio != 1: + # pw + layers.append(ConvBNReLU(inp, hidden_dim, kernel_size=1)) + layers.extend([ + # dw + ConvBNReLU(hidden_dim, hidden_dim, stride=stride, groups=hidden_dim), + # pw-linear + nn.Conv2d(hidden_dim, oup, 1, 1, 0, bias=False), + nn.BatchNorm2d(oup), + ]) + self.conv = nn.Sequential(*layers) + + def forward(self, x): + if self.use_res_connect: + return x + self.conv(x) + else: + return self.conv(x) + + +class MobileNetV2(nn.Module): + def __init__(self, pretrained=True): + """ + MobileNet V2 main class + Args: + num_classes (int): Number of classes + width_mult (float): Width multiplier - adjusts number of channels in each layer by this amount + inverted_residual_setting: Network structure + round_nearest (int): Round the number of channels in each layer to be a multiple of this number + Set to 1 to turn off rounding + block: Module specifying inverted residual building block for mobilenet + """ + super(MobileNetV2, self).__init__() + + block = InvertedResidual + input_channel = 32 + last_channel = 1280 + width_mult = 1.0 + round_nearest = 8 + + inverted_residual_setting = [ + # t, c, n, s + [1, 16, 1, 1], + [6, 24, 2, 2], + [6, 32, 3, 2], + [6, 64, 4, 2], + [6, 96, 3, 1], + #[6, 160, 3, 2], + #[6, 320, 1, 1], + ] + + # only check the first element, assuming user knows t,c,n,s are required + if len(inverted_residual_setting) == 0 or len(inverted_residual_setting[0]) != 4: + raise ValueError("inverted_residual_setting should be non-empty " + "or a 4-element list, got {}".format(inverted_residual_setting)) + + # building first layer + input_channel = _make_divisible(input_channel * width_mult, round_nearest) + self.last_channel = _make_divisible(last_channel * max(1.0, width_mult), round_nearest) + features = [ConvBNReLU(4, input_channel, stride=2)] + # building inverted residual blocks + for t, c, n, s in inverted_residual_setting: + output_channel = _make_divisible(c * width_mult, round_nearest) + for i in range(n): + stride = s if i == 0 else 1 + features.append(block(input_channel, output_channel, stride, expand_ratio=t)) + input_channel = output_channel + + self.features = nn.Sequential(*features) + self.fpn_selected = [1, 3, 6, 10, 13] + # weight initialization + for m in self.modules(): + if isinstance(m, nn.Conv2d): + nn.init.kaiming_normal_(m.weight, mode='fan_out') + if m.bias is not None: + nn.init.zeros_(m.bias) + elif isinstance(m, nn.BatchNorm2d): + nn.init.ones_(m.weight) + nn.init.zeros_(m.bias) + elif isinstance(m, nn.Linear): + nn.init.normal_(m.weight, 0, 0.01) + nn.init.zeros_(m.bias) + if pretrained: + self._load_pretrained_model() + + def _forward_impl(self, x): + # This exists since TorchScript doesn't support inheritance, so the superclass method + # (this one) needs to have a name other than `forward` that can be accessed in a subclass + fpn_features = [] + for i, f in enumerate(self.features): + if i > self.fpn_selected[-1]: + break + x = f(x) + if i in self.fpn_selected: + fpn_features.append(x) + + c1, c2, c3, c4, c5 = fpn_features + return c1, c2, c3, c4, c5 + + + def forward(self, x): + return self._forward_impl(x) + + def _load_pretrained_model(self): + pretrain_dict = model_zoo.load_url('https://download.pytorch.org/models/mobilenet_v2-b0353104.pth') + model_dict = {} + state_dict = self.state_dict() + for k, v in pretrain_dict.items(): + if k in state_dict: + model_dict[k] = v + state_dict.update(model_dict) + self.load_state_dict(state_dict) + + +class MobileV2_MLSD_Large(nn.Module): + def __init__(self): + super(MobileV2_MLSD_Large, self).__init__() + + self.backbone = MobileNetV2(pretrained=False) + ## A, B + self.block15 = BlockTypeA(in_c1= 64, in_c2= 96, + out_c1= 64, out_c2=64, + upscale=False) + self.block16 = BlockTypeB(128, 64) + + ## A, B + self.block17 = BlockTypeA(in_c1 = 32, in_c2 = 64, + out_c1= 64, out_c2= 64) + self.block18 = BlockTypeB(128, 64) + + ## A, B + self.block19 = BlockTypeA(in_c1=24, in_c2=64, + out_c1=64, out_c2=64) + self.block20 = BlockTypeB(128, 64) + + ## A, B, C + self.block21 = BlockTypeA(in_c1=16, in_c2=64, + out_c1=64, out_c2=64) + self.block22 = BlockTypeB(128, 64) + + self.block23 = BlockTypeC(64, 16) + + def forward(self, x): + c1, c2, c3, c4, c5 = self.backbone(x) + + x = self.block15(c4, c5) + x = self.block16(x) + + x = self.block17(c3, x) + x = self.block18(x) + + x = self.block19(c2, x) + x = self.block20(x) + + x = self.block21(c1, x) + x = self.block22(x) + x = self.block23(x) + x = x[:, 7:, :, :] + + return x diff --git a/invokeai/backend/image_util/mlsd/models/mbv2_mlsd_tiny.py b/invokeai/backend/image_util/mlsd/models/mbv2_mlsd_tiny.py new file mode 100644 index 0000000000000000000000000000000000000000..5c1f94af6488c4ec60d9214fecb1190546cc31f3 --- /dev/null +++ b/invokeai/backend/image_util/mlsd/models/mbv2_mlsd_tiny.py @@ -0,0 +1,273 @@ +import torch +import torch.nn as nn +import torch.utils.model_zoo as model_zoo +from torch.nn import functional as F + + +class BlockTypeA(nn.Module): + def __init__(self, in_c1, in_c2, out_c1, out_c2, upscale = True): + super(BlockTypeA, self).__init__() + self.conv1 = nn.Sequential( + nn.Conv2d(in_c2, out_c2, kernel_size=1), + nn.BatchNorm2d(out_c2), + nn.ReLU(inplace=True) + ) + self.conv2 = nn.Sequential( + nn.Conv2d(in_c1, out_c1, kernel_size=1), + nn.BatchNorm2d(out_c1), + nn.ReLU(inplace=True) + ) + self.upscale = upscale + + def forward(self, a, b): + b = self.conv1(b) + a = self.conv2(a) + b = F.interpolate(b, scale_factor=2.0, mode='bilinear', align_corners=True) + return torch.cat((a, b), dim=1) + + +class BlockTypeB(nn.Module): + def __init__(self, in_c, out_c): + super(BlockTypeB, self).__init__() + self.conv1 = nn.Sequential( + nn.Conv2d(in_c, in_c, kernel_size=3, padding=1), + nn.BatchNorm2d(in_c), + nn.ReLU() + ) + self.conv2 = nn.Sequential( + nn.Conv2d(in_c, out_c, kernel_size=3, padding=1), + nn.BatchNorm2d(out_c), + nn.ReLU() + ) + + def forward(self, x): + x = self.conv1(x) + x + x = self.conv2(x) + return x + +class BlockTypeC(nn.Module): + def __init__(self, in_c, out_c): + super(BlockTypeC, self).__init__() + self.conv1 = nn.Sequential( + nn.Conv2d(in_c, in_c, kernel_size=3, padding=5, dilation=5), + nn.BatchNorm2d(in_c), + nn.ReLU() + ) + self.conv2 = nn.Sequential( + nn.Conv2d(in_c, in_c, kernel_size=3, padding=1), + nn.BatchNorm2d(in_c), + nn.ReLU() + ) + self.conv3 = nn.Conv2d(in_c, out_c, kernel_size=1) + + def forward(self, x): + x = self.conv1(x) + x = self.conv2(x) + x = self.conv3(x) + return x + +def _make_divisible(v, divisor, min_value=None): + """ + This function is taken from the original tf repo. + It ensures that all layers have a channel number that is divisible by 8 + It can be seen here: + https://github.com/tensorflow/models/blob/master/research/slim/nets/mobilenet/mobilenet.py + :param v: + :param divisor: + :param min_value: + :return: + """ + if min_value is None: + min_value = divisor + new_v = max(min_value, int(v + divisor / 2) // divisor * divisor) + # Make sure that round down does not go down by more than 10%. + if new_v < 0.9 * v: + new_v += divisor + return new_v + + +class ConvBNReLU(nn.Sequential): + def __init__(self, in_planes, out_planes, kernel_size=3, stride=1, groups=1): + self.channel_pad = out_planes - in_planes + self.stride = stride + #padding = (kernel_size - 1) // 2 + + # TFLite uses slightly different padding than PyTorch + if stride == 2: + padding = 0 + else: + padding = (kernel_size - 1) // 2 + + super(ConvBNReLU, self).__init__( + nn.Conv2d(in_planes, out_planes, kernel_size, stride, padding, groups=groups, bias=False), + nn.BatchNorm2d(out_planes), + nn.ReLU6(inplace=True) + ) + self.max_pool = nn.MaxPool2d(kernel_size=stride, stride=stride) + + + def forward(self, x): + # TFLite uses different padding + if self.stride == 2: + x = F.pad(x, (0, 1, 0, 1), "constant", 0) + #print(x.shape) + + for module in self: + if not isinstance(module, nn.MaxPool2d): + x = module(x) + return x + + +class InvertedResidual(nn.Module): + def __init__(self, inp, oup, stride, expand_ratio): + super(InvertedResidual, self).__init__() + self.stride = stride + assert stride in [1, 2] + + hidden_dim = int(round(inp * expand_ratio)) + self.use_res_connect = self.stride == 1 and inp == oup + + layers = [] + if expand_ratio != 1: + # pw + layers.append(ConvBNReLU(inp, hidden_dim, kernel_size=1)) + layers.extend([ + # dw + ConvBNReLU(hidden_dim, hidden_dim, stride=stride, groups=hidden_dim), + # pw-linear + nn.Conv2d(hidden_dim, oup, 1, 1, 0, bias=False), + nn.BatchNorm2d(oup), + ]) + self.conv = nn.Sequential(*layers) + + def forward(self, x): + if self.use_res_connect: + return x + self.conv(x) + else: + return self.conv(x) + + +class MobileNetV2(nn.Module): + def __init__(self, pretrained=True): + """ + MobileNet V2 main class + Args: + num_classes (int): Number of classes + width_mult (float): Width multiplier - adjusts number of channels in each layer by this amount + inverted_residual_setting: Network structure + round_nearest (int): Round the number of channels in each layer to be a multiple of this number + Set to 1 to turn off rounding + block: Module specifying inverted residual building block for mobilenet + """ + super(MobileNetV2, self).__init__() + + block = InvertedResidual + input_channel = 32 + last_channel = 1280 + width_mult = 1.0 + round_nearest = 8 + + inverted_residual_setting = [ + # t, c, n, s + [1, 16, 1, 1], + [6, 24, 2, 2], + [6, 32, 3, 2], + [6, 64, 4, 2], + #[6, 96, 3, 1], + #[6, 160, 3, 2], + #[6, 320, 1, 1], + ] + + # only check the first element, assuming user knows t,c,n,s are required + if len(inverted_residual_setting) == 0 or len(inverted_residual_setting[0]) != 4: + raise ValueError("inverted_residual_setting should be non-empty " + "or a 4-element list, got {}".format(inverted_residual_setting)) + + # building first layer + input_channel = _make_divisible(input_channel * width_mult, round_nearest) + self.last_channel = _make_divisible(last_channel * max(1.0, width_mult), round_nearest) + features = [ConvBNReLU(4, input_channel, stride=2)] + # building inverted residual blocks + for t, c, n, s in inverted_residual_setting: + output_channel = _make_divisible(c * width_mult, round_nearest) + for i in range(n): + stride = s if i == 0 else 1 + features.append(block(input_channel, output_channel, stride, expand_ratio=t)) + input_channel = output_channel + self.features = nn.Sequential(*features) + + self.fpn_selected = [3, 6, 10] + # weight initialization + for m in self.modules(): + if isinstance(m, nn.Conv2d): + nn.init.kaiming_normal_(m.weight, mode='fan_out') + if m.bias is not None: + nn.init.zeros_(m.bias) + elif isinstance(m, nn.BatchNorm2d): + nn.init.ones_(m.weight) + nn.init.zeros_(m.bias) + elif isinstance(m, nn.Linear): + nn.init.normal_(m.weight, 0, 0.01) + nn.init.zeros_(m.bias) + + #if pretrained: + # self._load_pretrained_model() + + def _forward_impl(self, x): + # This exists since TorchScript doesn't support inheritance, so the superclass method + # (this one) needs to have a name other than `forward` that can be accessed in a subclass + fpn_features = [] + for i, f in enumerate(self.features): + if i > self.fpn_selected[-1]: + break + x = f(x) + if i in self.fpn_selected: + fpn_features.append(x) + + c2, c3, c4 = fpn_features + return c2, c3, c4 + + + def forward(self, x): + return self._forward_impl(x) + + def _load_pretrained_model(self): + pretrain_dict = model_zoo.load_url('https://download.pytorch.org/models/mobilenet_v2-b0353104.pth') + model_dict = {} + state_dict = self.state_dict() + for k, v in pretrain_dict.items(): + if k in state_dict: + model_dict[k] = v + state_dict.update(model_dict) + self.load_state_dict(state_dict) + + +class MobileV2_MLSD_Tiny(nn.Module): + def __init__(self): + super(MobileV2_MLSD_Tiny, self).__init__() + + self.backbone = MobileNetV2(pretrained=True) + + self.block12 = BlockTypeA(in_c1= 32, in_c2= 64, + out_c1= 64, out_c2=64) + self.block13 = BlockTypeB(128, 64) + + self.block14 = BlockTypeA(in_c1 = 24, in_c2 = 64, + out_c1= 32, out_c2= 32) + self.block15 = BlockTypeB(64, 64) + + self.block16 = BlockTypeC(64, 16) + + def forward(self, x): + c2, c3, c4 = self.backbone(x) + + x = self.block12(c3, c4) + x = self.block13(x) + x = self.block14(c2, x) + x = self.block15(x) + x = self.block16(x) + x = x[:, 7:, :, :] + #print(x.shape) + x = F.interpolate(x, scale_factor=2.0, mode='bilinear', align_corners=True) + + return x diff --git a/invokeai/backend/image_util/mlsd/utils.py b/invokeai/backend/image_util/mlsd/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..dbe9a98d09e7f53b167eb1206451cf521d5d5355 --- /dev/null +++ b/invokeai/backend/image_util/mlsd/utils.py @@ -0,0 +1,587 @@ +''' +modified by lihaoweicv +pytorch version +''' + +''' +M-LSD +Copyright 2021-present NAVER Corp. +Apache License v2.0 +''' + +import cv2 +import numpy as np +import torch +from torch.nn import functional as F + + +def deccode_output_score_and_ptss(tpMap, topk_n = 200, ksize = 5): + ''' + tpMap: + center: tpMap[1, 0, :, :] + displacement: tpMap[1, 1:5, :, :] + ''' + b, c, h, w = tpMap.shape + assert b==1, 'only support bsize==1' + displacement = tpMap[:, 1:5, :, :][0] + center = tpMap[:, 0, :, :] + heat = torch.sigmoid(center) + hmax = F.max_pool2d( heat, (ksize, ksize), stride=1, padding=(ksize-1)//2) + keep = (hmax == heat).float() + heat = heat * keep + heat = heat.reshape(-1, ) + + scores, indices = torch.topk(heat, topk_n, dim=-1, largest=True) + yy = torch.floor_divide(indices, w).unsqueeze(-1) + xx = torch.fmod(indices, w).unsqueeze(-1) + ptss = torch.cat((yy, xx),dim=-1) + + ptss = ptss.detach().cpu().numpy() + scores = scores.detach().cpu().numpy() + displacement = displacement.detach().cpu().numpy() + displacement = displacement.transpose((1,2,0)) + return ptss, scores, displacement + + +def pred_lines(image, model, + input_shape=[512, 512], + score_thr=0.10, + dist_thr=20.0): + h, w, _ = image.shape + + device = next(iter(model.parameters())).device + h_ratio, w_ratio = [h / input_shape[0], w / input_shape[1]] + + resized_image = np.concatenate([cv2.resize(image, (input_shape[1], input_shape[0]), interpolation=cv2.INTER_AREA), + np.ones([input_shape[0], input_shape[1], 1])], axis=-1) + + resized_image = resized_image.transpose((2,0,1)) + batch_image = np.expand_dims(resized_image, axis=0).astype('float32') + batch_image = (batch_image / 127.5) - 1.0 + + batch_image = torch.from_numpy(batch_image).float() + batch_image = batch_image.to(device) + outputs = model(batch_image) + pts, pts_score, vmap = deccode_output_score_and_ptss(outputs, 200, 3) + start = vmap[:, :, :2] + end = vmap[:, :, 2:] + dist_map = np.sqrt(np.sum((start - end) ** 2, axis=-1)) + + segments_list = [] + for center, score in zip(pts, pts_score, strict=False): + y, x = center + distance = dist_map[y, x] + if score > score_thr and distance > dist_thr: + disp_x_start, disp_y_start, disp_x_end, disp_y_end = vmap[y, x, :] + x_start = x + disp_x_start + y_start = y + disp_y_start + x_end = x + disp_x_end + y_end = y + disp_y_end + segments_list.append([x_start, y_start, x_end, y_end]) + + if segments_list: + lines = 2 * np.array(segments_list) # 256 > 512 + lines[:, 0] = lines[:, 0] * w_ratio + lines[:, 1] = lines[:, 1] * h_ratio + lines[:, 2] = lines[:, 2] * w_ratio + lines[:, 3] = lines[:, 3] * h_ratio + else: + # No segments detected - return empty array + lines = np.array([]) + + return lines + + +def pred_squares(image, + model, + input_shape=[512, 512], + params={'score': 0.06, + 'outside_ratio': 0.28, + 'inside_ratio': 0.45, + 'w_overlap': 0.0, + 'w_degree': 1.95, + 'w_length': 0.0, + 'w_area': 1.86, + 'w_center': 0.14}): + ''' + shape = [height, width] + ''' + h, w, _ = image.shape + original_shape = [h, w] + device = next(iter(model.parameters())).device + + resized_image = np.concatenate([cv2.resize(image, (input_shape[0], input_shape[1]), interpolation=cv2.INTER_AREA), + np.ones([input_shape[0], input_shape[1], 1])], axis=-1) + resized_image = resized_image.transpose((2, 0, 1)) + batch_image = np.expand_dims(resized_image, axis=0).astype('float32') + batch_image = (batch_image / 127.5) - 1.0 + + batch_image = torch.from_numpy(batch_image).float().to(device) + outputs = model(batch_image) + + pts, pts_score, vmap = deccode_output_score_and_ptss(outputs, 200, 3) + start = vmap[:, :, :2] # (x, y) + end = vmap[:, :, 2:] # (x, y) + dist_map = np.sqrt(np.sum((start - end) ** 2, axis=-1)) + + junc_list = [] + segments_list = [] + for junc, score in zip(pts, pts_score, strict=False): + y, x = junc + distance = dist_map[y, x] + if score > params['score'] and distance > 20.0: + junc_list.append([x, y]) + disp_x_start, disp_y_start, disp_x_end, disp_y_end = vmap[y, x, :] + d_arrow = 1.0 + x_start = x + d_arrow * disp_x_start + y_start = y + d_arrow * disp_y_start + x_end = x + d_arrow * disp_x_end + y_end = y + d_arrow * disp_y_end + segments_list.append([x_start, y_start, x_end, y_end]) + + segments = np.array(segments_list) + + ####### post processing for squares + # 1. get unique lines + point = np.array([[0, 0]]) + point = point[0] + start = segments[:, :2] + end = segments[:, 2:] + diff = start - end + a = diff[:, 1] + b = -diff[:, 0] + c = a * start[:, 0] + b * start[:, 1] + + d = np.abs(a * point[0] + b * point[1] - c) / np.sqrt(a ** 2 + b ** 2 + 1e-10) + theta = np.arctan2(diff[:, 0], diff[:, 1]) * 180 / np.pi + theta[theta < 0.0] += 180 + hough = np.concatenate([d[:, None], theta[:, None]], axis=-1) + + d_quant = 1 + theta_quant = 2 + hough[:, 0] //= d_quant + hough[:, 1] //= theta_quant + _, indices, counts = np.unique(hough, axis=0, return_index=True, return_counts=True) + + acc_map = np.zeros([512 // d_quant + 1, 360 // theta_quant + 1], dtype='float32') + idx_map = np.zeros([512 // d_quant + 1, 360 // theta_quant + 1], dtype='int32') - 1 + yx_indices = hough[indices, :].astype('int32') + acc_map[yx_indices[:, 0], yx_indices[:, 1]] = counts + idx_map[yx_indices[:, 0], yx_indices[:, 1]] = indices + + acc_map_np = acc_map + # acc_map = acc_map[None, :, :, None] + # + # ### fast suppression using tensorflow op + # acc_map = tf.constant(acc_map, dtype=tf.float32) + # max_acc_map = tf.keras.layers.MaxPool2D(pool_size=(5, 5), strides=1, padding='same')(acc_map) + # acc_map = acc_map * tf.cast(tf.math.equal(acc_map, max_acc_map), tf.float32) + # flatten_acc_map = tf.reshape(acc_map, [1, -1]) + # topk_values, topk_indices = tf.math.top_k(flatten_acc_map, k=len(pts)) + # _, h, w, _ = acc_map.shape + # y = tf.expand_dims(topk_indices // w, axis=-1) + # x = tf.expand_dims(topk_indices % w, axis=-1) + # yx = tf.concat([y, x], axis=-1) + + ### fast suppression using pytorch op + acc_map = torch.from_numpy(acc_map_np).unsqueeze(0).unsqueeze(0) + _,_, h, w = acc_map.shape + max_acc_map = F.max_pool2d(acc_map,kernel_size=5, stride=1, padding=2) + acc_map = acc_map * ( (acc_map == max_acc_map).float() ) + flatten_acc_map = acc_map.reshape([-1, ]) + + scores, indices = torch.topk(flatten_acc_map, len(pts), dim=-1, largest=True) + yy = torch.div(indices, w, rounding_mode='floor').unsqueeze(-1) + xx = torch.fmod(indices, w).unsqueeze(-1) + yx = torch.cat((yy, xx), dim=-1) + + yx = yx.detach().cpu().numpy() + + topk_values = scores.detach().cpu().numpy() + indices = idx_map[yx[:, 0], yx[:, 1]] + basis = 5 // 2 + + merged_segments = [] + for yx_pt, max_indice, value in zip(yx, indices, topk_values, strict=False): + y, x = yx_pt + if max_indice == -1 or value == 0: + continue + segment_list = [] + for y_offset in range(-basis, basis + 1): + for x_offset in range(-basis, basis + 1): + indice = idx_map[y + y_offset, x + x_offset] + cnt = int(acc_map_np[y + y_offset, x + x_offset]) + if indice != -1: + segment_list.append(segments[indice]) + if cnt > 1: + check_cnt = 1 + current_hough = hough[indice] + for new_indice, new_hough in enumerate(hough): + if (current_hough == new_hough).all() and indice != new_indice: + segment_list.append(segments[new_indice]) + check_cnt += 1 + if check_cnt == cnt: + break + group_segments = np.array(segment_list).reshape([-1, 2]) + sorted_group_segments = np.sort(group_segments, axis=0) + x_min, y_min = sorted_group_segments[0, :] + x_max, y_max = sorted_group_segments[-1, :] + + deg = theta[max_indice] + if deg >= 90: + merged_segments.append([x_min, y_max, x_max, y_min]) + else: + merged_segments.append([x_min, y_min, x_max, y_max]) + + # 2. get intersections + new_segments = np.array(merged_segments) # (x1, y1, x2, y2) + start = new_segments[:, :2] # (x1, y1) + end = new_segments[:, 2:] # (x2, y2) + new_centers = (start + end) / 2.0 + diff = start - end + dist_segments = np.sqrt(np.sum(diff ** 2, axis=-1)) + + # ax + by = c + a = diff[:, 1] + b = -diff[:, 0] + c = a * start[:, 0] + b * start[:, 1] + pre_det = a[:, None] * b[None, :] + det = pre_det - np.transpose(pre_det) + + pre_inter_y = a[:, None] * c[None, :] + inter_y = (pre_inter_y - np.transpose(pre_inter_y)) / (det + 1e-10) + pre_inter_x = c[:, None] * b[None, :] + inter_x = (pre_inter_x - np.transpose(pre_inter_x)) / (det + 1e-10) + inter_pts = np.concatenate([inter_x[:, :, None], inter_y[:, :, None]], axis=-1).astype('int32') + + # 3. get corner information + # 3.1 get distance + ''' + dist_segments: + | dist(0), dist(1), dist(2), ...| + dist_inter_to_segment1: + | dist(inter,0), dist(inter,0), dist(inter,0), ... | + | dist(inter,1), dist(inter,1), dist(inter,1), ... | + ... + dist_inter_to_semgnet2: + | dist(inter,0), dist(inter,1), dist(inter,2), ... | + | dist(inter,0), dist(inter,1), dist(inter,2), ... | + ... + ''' + + dist_inter_to_segment1_start = np.sqrt( + np.sum(((inter_pts - start[:, None, :]) ** 2), axis=-1, keepdims=True)) # [n_batch, n_batch, 1] + dist_inter_to_segment1_end = np.sqrt( + np.sum(((inter_pts - end[:, None, :]) ** 2), axis=-1, keepdims=True)) # [n_batch, n_batch, 1] + dist_inter_to_segment2_start = np.sqrt( + np.sum(((inter_pts - start[None, :, :]) ** 2), axis=-1, keepdims=True)) # [n_batch, n_batch, 1] + dist_inter_to_segment2_end = np.sqrt( + np.sum(((inter_pts - end[None, :, :]) ** 2), axis=-1, keepdims=True)) # [n_batch, n_batch, 1] + + # sort ascending + dist_inter_to_segment1 = np.sort( + np.concatenate([dist_inter_to_segment1_start, dist_inter_to_segment1_end], axis=-1), + axis=-1) # [n_batch, n_batch, 2] + dist_inter_to_segment2 = np.sort( + np.concatenate([dist_inter_to_segment2_start, dist_inter_to_segment2_end], axis=-1), + axis=-1) # [n_batch, n_batch, 2] + + # 3.2 get degree + inter_to_start = new_centers[:, None, :] - inter_pts + deg_inter_to_start = np.arctan2(inter_to_start[:, :, 1], inter_to_start[:, :, 0]) * 180 / np.pi + deg_inter_to_start[deg_inter_to_start < 0.0] += 360 + inter_to_end = new_centers[None, :, :] - inter_pts + deg_inter_to_end = np.arctan2(inter_to_end[:, :, 1], inter_to_end[:, :, 0]) * 180 / np.pi + deg_inter_to_end[deg_inter_to_end < 0.0] += 360 + + ''' + B -- G + | | + C -- R + B : blue / G: green / C: cyan / R: red + + 0 -- 1 + | | + 3 -- 2 + ''' + # rename variables + deg1_map, deg2_map = deg_inter_to_start, deg_inter_to_end + # sort deg ascending + deg_sort = np.sort(np.concatenate([deg1_map[:, :, None], deg2_map[:, :, None]], axis=-1), axis=-1) + + deg_diff_map = np.abs(deg1_map - deg2_map) + # we only consider the smallest degree of intersect + deg_diff_map[deg_diff_map > 180] = 360 - deg_diff_map[deg_diff_map > 180] + + # define available degree range + deg_range = [60, 120] + + corner_dict = {corner_info: [] for corner_info in range(4)} + inter_points = [] + for i in range(inter_pts.shape[0]): + for j in range(i + 1, inter_pts.shape[1]): + # i, j > line index, always i < j + x, y = inter_pts[i, j, :] + deg1, deg2 = deg_sort[i, j, :] + deg_diff = deg_diff_map[i, j] + + check_degree = deg_diff > deg_range[0] and deg_diff < deg_range[1] + + outside_ratio = params['outside_ratio'] # over ratio >>> drop it! + inside_ratio = params['inside_ratio'] # over ratio >>> drop it! + check_distance = ((dist_inter_to_segment1[i, j, 1] >= dist_segments[i] and \ + dist_inter_to_segment1[i, j, 0] <= dist_segments[i] * outside_ratio) or \ + (dist_inter_to_segment1[i, j, 1] <= dist_segments[i] and \ + dist_inter_to_segment1[i, j, 0] <= dist_segments[i] * inside_ratio)) and \ + ((dist_inter_to_segment2[i, j, 1] >= dist_segments[j] and \ + dist_inter_to_segment2[i, j, 0] <= dist_segments[j] * outside_ratio) or \ + (dist_inter_to_segment2[i, j, 1] <= dist_segments[j] and \ + dist_inter_to_segment2[i, j, 0] <= dist_segments[j] * inside_ratio)) + + if check_degree and check_distance: + corner_info = None + + if (deg1 >= 0 and deg1 <= 45 and deg2 >= 45 and deg2 <= 120) or \ + (deg2 >= 315 and deg1 >= 45 and deg1 <= 120): + corner_info, color_info = 0, 'blue' + elif (deg1 >= 45 and deg1 <= 125 and deg2 >= 125 and deg2 <= 225): + corner_info, color_info = 1, 'green' + elif (deg1 >= 125 and deg1 <= 225 and deg2 >= 225 and deg2 <= 315): + corner_info, color_info = 2, 'black' + elif (deg1 >= 0 and deg1 <= 45 and deg2 >= 225 and deg2 <= 315) or \ + (deg2 >= 315 and deg1 >= 225 and deg1 <= 315): + corner_info, color_info = 3, 'cyan' + else: + corner_info, color_info = 4, 'red' # we don't use it + continue + + corner_dict[corner_info].append([x, y, i, j]) + inter_points.append([x, y]) + + square_list = [] + connect_list = [] + segments_list = [] + for corner0 in corner_dict[0]: + for corner1 in corner_dict[1]: + connect01 = False + for corner0_line in corner0[2:]: + if corner0_line in corner1[2:]: + connect01 = True + break + if connect01: + for corner2 in corner_dict[2]: + connect12 = False + for corner1_line in corner1[2:]: + if corner1_line in corner2[2:]: + connect12 = True + break + if connect12: + for corner3 in corner_dict[3]: + connect23 = False + for corner2_line in corner2[2:]: + if corner2_line in corner3[2:]: + connect23 = True + break + if connect23: + for corner3_line in corner3[2:]: + if corner3_line in corner0[2:]: + # SQUARE!!! + ''' + 0 -- 1 + | | + 3 -- 2 + square_list: + order: 0 > 1 > 2 > 3 + | x0, y0, x1, y1, x2, y2, x3, y3 | + | x0, y0, x1, y1, x2, y2, x3, y3 | + ... + connect_list: + order: 01 > 12 > 23 > 30 + | line_idx01, line_idx12, line_idx23, line_idx30 | + | line_idx01, line_idx12, line_idx23, line_idx30 | + ... + segments_list: + order: 0 > 1 > 2 > 3 + | line_idx0_i, line_idx0_j, line_idx1_i, line_idx1_j, line_idx2_i, line_idx2_j, line_idx3_i, line_idx3_j | + | line_idx0_i, line_idx0_j, line_idx1_i, line_idx1_j, line_idx2_i, line_idx2_j, line_idx3_i, line_idx3_j | + ... + ''' + square_list.append(corner0[:2] + corner1[:2] + corner2[:2] + corner3[:2]) + connect_list.append([corner0_line, corner1_line, corner2_line, corner3_line]) + segments_list.append(corner0[2:] + corner1[2:] + corner2[2:] + corner3[2:]) + + def check_outside_inside(segments_info, connect_idx): + # return 'outside or inside', min distance, cover_param, peri_param + if connect_idx == segments_info[0]: + check_dist_mat = dist_inter_to_segment1 + else: + check_dist_mat = dist_inter_to_segment2 + + i, j = segments_info + min_dist, max_dist = check_dist_mat[i, j, :] + connect_dist = dist_segments[connect_idx] + if max_dist > connect_dist: + return 'outside', min_dist, 0, 1 + else: + return 'inside', min_dist, -1, -1 + + top_square = None + + try: + map_size = input_shape[0] / 2 + squares = np.array(square_list).reshape([-1, 4, 2]) + score_array = [] + connect_array = np.array(connect_list) + segments_array = np.array(segments_list).reshape([-1, 4, 2]) + + # get degree of corners: + squares_rollup = np.roll(squares, 1, axis=1) + squares_rolldown = np.roll(squares, -1, axis=1) + vec1 = squares_rollup - squares + normalized_vec1 = vec1 / (np.linalg.norm(vec1, axis=-1, keepdims=True) + 1e-10) + vec2 = squares_rolldown - squares + normalized_vec2 = vec2 / (np.linalg.norm(vec2, axis=-1, keepdims=True) + 1e-10) + inner_products = np.sum(normalized_vec1 * normalized_vec2, axis=-1) # [n_squares, 4] + squares_degree = np.arccos(inner_products) * 180 / np.pi # [n_squares, 4] + + # get square score + overlap_scores = [] + degree_scores = [] + length_scores = [] + + for connects, segments, square, degree in zip(connect_array, segments_array, squares, squares_degree, strict=False): + ''' + 0 -- 1 + | | + 3 -- 2 + + # segments: [4, 2] + # connects: [4] + ''' + + ###################################### OVERLAP SCORES + cover = 0 + perimeter = 0 + # check 0 > 1 > 2 > 3 + square_length = [] + + for start_idx in range(4): + end_idx = (start_idx + 1) % 4 + + connect_idx = connects[start_idx] # segment idx of segment01 + start_segments = segments[start_idx] + end_segments = segments[end_idx] + + start_point = square[start_idx] + end_point = square[end_idx] + + # check whether outside or inside + start_position, start_min, start_cover_param, start_peri_param = check_outside_inside(start_segments, + connect_idx) + end_position, end_min, end_cover_param, end_peri_param = check_outside_inside(end_segments, connect_idx) + + cover += dist_segments[connect_idx] + start_cover_param * start_min + end_cover_param * end_min + perimeter += dist_segments[connect_idx] + start_peri_param * start_min + end_peri_param * end_min + + square_length.append( + dist_segments[connect_idx] + start_peri_param * start_min + end_peri_param * end_min) + + overlap_scores.append(cover / perimeter) + ###################################### + ###################################### DEGREE SCORES + ''' + deg0 vs deg2 + deg1 vs deg3 + ''' + deg0, deg1, deg2, deg3 = degree + deg_ratio1 = deg0 / deg2 + if deg_ratio1 > 1.0: + deg_ratio1 = 1 / deg_ratio1 + deg_ratio2 = deg1 / deg3 + if deg_ratio2 > 1.0: + deg_ratio2 = 1 / deg_ratio2 + degree_scores.append((deg_ratio1 + deg_ratio2) / 2) + ###################################### + ###################################### LENGTH SCORES + ''' + len0 vs len2 + len1 vs len3 + ''' + len0, len1, len2, len3 = square_length + len_ratio1 = len0 / len2 if len2 > len0 else len2 / len0 + len_ratio2 = len1 / len3 if len3 > len1 else len3 / len1 + length_scores.append((len_ratio1 + len_ratio2) / 2) + + ###################################### + + overlap_scores = np.array(overlap_scores) + overlap_scores /= np.max(overlap_scores) + + degree_scores = np.array(degree_scores) + # degree_scores /= np.max(degree_scores) + + length_scores = np.array(length_scores) + + ###################################### AREA SCORES + area_scores = np.reshape(squares, [-1, 4, 2]) + area_x = area_scores[:, :, 0] + area_y = area_scores[:, :, 1] + correction = area_x[:, -1] * area_y[:, 0] - area_y[:, -1] * area_x[:, 0] + area_scores = np.sum(area_x[:, :-1] * area_y[:, 1:], axis=-1) - np.sum(area_y[:, :-1] * area_x[:, 1:], axis=-1) + area_scores = 0.5 * np.abs(area_scores + correction) + area_scores /= (map_size * map_size) # np.max(area_scores) + ###################################### + + ###################################### CENTER SCORES + centers = np.array([[256 // 2, 256 // 2]], dtype='float32') # [1, 2] + # squares: [n, 4, 2] + square_centers = np.mean(squares, axis=1) # [n, 2] + center2center = np.sqrt(np.sum((centers - square_centers) ** 2)) + center_scores = center2center / (map_size / np.sqrt(2.0)) + + ''' + score_w = [overlap, degree, area, center, length] + ''' + score_w = [0.0, 1.0, 10.0, 0.5, 1.0] + score_array = params['w_overlap'] * overlap_scores \ + + params['w_degree'] * degree_scores \ + + params['w_area'] * area_scores \ + - params['w_center'] * center_scores \ + + params['w_length'] * length_scores + + best_square = [] + + sorted_idx = np.argsort(score_array)[::-1] + score_array = score_array[sorted_idx] + squares = squares[sorted_idx] + + except Exception: + pass + + '''return list + merged_lines, squares, scores + ''' + + try: + new_segments[:, 0] = new_segments[:, 0] * 2 / input_shape[1] * original_shape[1] + new_segments[:, 1] = new_segments[:, 1] * 2 / input_shape[0] * original_shape[0] + new_segments[:, 2] = new_segments[:, 2] * 2 / input_shape[1] * original_shape[1] + new_segments[:, 3] = new_segments[:, 3] * 2 / input_shape[0] * original_shape[0] + except: + new_segments = [] + + try: + squares[:, :, 0] = squares[:, :, 0] * 2 / input_shape[1] * original_shape[1] + squares[:, :, 1] = squares[:, :, 1] * 2 / input_shape[0] * original_shape[0] + except: + squares = [] + score_array = [] + + try: + inter_points = np.array(inter_points) + inter_points[:, 0] = inter_points[:, 0] * 2 / input_shape[1] * original_shape[1] + inter_points[:, 1] = inter_points[:, 1] * 2 / input_shape[0] * original_shape[0] + except: + inter_points = [] + + return new_segments, squares, score_array, inter_points diff --git a/invokeai/backend/image_util/normal_bae/LICENSE b/invokeai/backend/image_util/normal_bae/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..16a9d56a3d4c15e4f34ac5426459c58487b01520 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/LICENSE @@ -0,0 +1,21 @@ +MIT License + +Copyright (c) 2022 Caroline Chan + +Permission is hereby granted, free of charge, to any person obtaining a copy +of this software and associated documentation files (the "Software"), to deal +in the Software without restriction, including without limitation the rights +to use, copy, modify, merge, publish, distribute, sublicense, and/or sell +copies of the Software, and to permit persons to whom the Software is +furnished to do so, subject to the following conditions: + +The above copyright notice and this permission notice shall be included in all +copies or substantial portions of the Software. + +THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR +IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, +FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. 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 SOFTWARE OR THE USE OR OTHER DEALINGS IN THE +SOFTWARE. \ No newline at end of file diff --git a/invokeai/backend/image_util/normal_bae/__init__.py b/invokeai/backend/image_util/normal_bae/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d0b1339113e635604fa6a35b529c124ff10b8bd9 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/__init__.py @@ -0,0 +1,93 @@ +# Adapted from https://github.com/huggingface/controlnet_aux + +import pathlib +import types + +import cv2 +import huggingface_hub +import numpy as np +import torch +import torchvision.transforms as transforms +from einops import rearrange +from PIL import Image + +from invokeai.backend.image_util.normal_bae.nets.NNET import NNET +from invokeai.backend.image_util.util import np_to_pil, pil_to_np, resize_to_multiple + + +class NormalMapDetector: + """Simple wrapper around the Normal BAE model for normal map generation.""" + + hf_repo_id = "lllyasviel/Annotators" + hf_filename = "scannet.pt" + + @classmethod + def get_model_url(cls) -> str: + """Get the URL to download the model from the Hugging Face Hub.""" + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename) + + @classmethod + def load_model(cls, model_path: pathlib.Path) -> NNET: + """Load the model from a file.""" + + args = types.SimpleNamespace() + args.mode = "client" + args.architecture = "BN" + args.pretrained = "scannet" + args.sampling_ratio = 0.4 + args.importance_ratio = 0.7 + + model = NNET(args) + + ckpt = torch.load(model_path, map_location="cpu")["model"] + load_dict = {} + for k, v in ckpt.items(): + if k.startswith("module."): + k_ = k.replace("module.", "") + load_dict[k_] = v + else: + load_dict[k] = v + + model.load_state_dict(load_dict) + model.eval() + + return model + + def __init__(self, model: NNET) -> None: + self.model = model + self.norm = transforms.Normalize(mean=[0.485, 0.456, 0.406], std=[0.229, 0.224, 0.225]) + + def to(self, device: torch.device): + self.model.to(device) + return self + + def run(self, image: Image.Image): + """Processes an image and returns the detected normal map.""" + + device = next(iter(self.model.parameters())).device + np_image = pil_to_np(image) + + height, width, _channels = np_image.shape + + # The model requires the image to be a multiple of 8 + np_image = resize_to_multiple(np_image, 8) + + image_normal = np_image + + with torch.no_grad(): + image_normal = torch.from_numpy(image_normal).float().to(device) + image_normal = image_normal / 255.0 + image_normal = rearrange(image_normal, "h w c -> 1 c h w") + image_normal = self.norm(image_normal) + + normal = self.model(image_normal) + normal = normal[0][-1][:, :3] + normal = ((normal + 1) * 0.5).clip(0, 1) + + normal = rearrange(normal[0], "c h w -> h w c").cpu().numpy() + normal_image = (normal * 255.0).clip(0, 255).astype(np.uint8) + + # Back to the original size + output_image = cv2.resize(normal_image, (width, height), interpolation=cv2.INTER_LINEAR) + + return np_to_pil(output_image) diff --git a/invokeai/backend/image_util/normal_bae/nets/NNET.py b/invokeai/backend/image_util/normal_bae/nets/NNET.py new file mode 100644 index 0000000000000000000000000000000000000000..3ddbc50c3ac18aa4b7f16779fe3c0133981ecc7a --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/NNET.py @@ -0,0 +1,22 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .submodules.encoder import Encoder +from .submodules.decoder import Decoder + + +class NNET(nn.Module): + def __init__(self, args): + super(NNET, self).__init__() + self.encoder = Encoder() + self.decoder = Decoder(args) + + def get_1x_lr_params(self): # lr/10 learning rate + return self.encoder.parameters() + + def get_10x_lr_params(self): # lr learning rate + return self.decoder.parameters() + + def forward(self, img, **kwargs): + return self.decoder(self.encoder(img), **kwargs) \ No newline at end of file diff --git a/invokeai/backend/image_util/normal_bae/nets/__init__.py b/invokeai/backend/image_util/normal_bae/nets/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/image_util/normal_bae/nets/baseline.py b/invokeai/backend/image_util/normal_bae/nets/baseline.py new file mode 100644 index 0000000000000000000000000000000000000000..602d0fbdac1acc9ede9bc1f2e10a5df78831ce9d --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/baseline.py @@ -0,0 +1,85 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .submodules.submodules import UpSampleBN, norm_normalize + + +# This is the baseline encoder-decoder we used in the ablation study +class NNET(nn.Module): + def __init__(self, args=None): + super(NNET, self).__init__() + self.encoder = Encoder() + self.decoder = Decoder(num_classes=4) + + def forward(self, x, **kwargs): + out = self.decoder(self.encoder(x), **kwargs) + + # Bilinearly upsample the output to match the input resolution + up_out = F.interpolate(out, size=[x.size(2), x.size(3)], mode='bilinear', align_corners=False) + + # L2-normalize the first three channels / ensure positive value for concentration parameters (kappa) + up_out = norm_normalize(up_out) + return up_out + + def get_1x_lr_params(self): # lr/10 learning rate + return self.encoder.parameters() + + def get_10x_lr_params(self): # lr learning rate + modules = [self.decoder] + for m in modules: + yield from m.parameters() + + +# Encoder +class Encoder(nn.Module): + def __init__(self): + super(Encoder, self).__init__() + + basemodel_name = 'tf_efficientnet_b5_ap' + basemodel = torch.hub.load('rwightman/gen-efficientnet-pytorch', basemodel_name, pretrained=True) + + # Remove last layer + basemodel.global_pool = nn.Identity() + basemodel.classifier = nn.Identity() + + self.original_model = basemodel + + def forward(self, x): + features = [x] + for k, v in self.original_model._modules.items(): + if (k == 'blocks'): + for ki, vi in v._modules.items(): + features.append(vi(features[-1])) + else: + features.append(v(features[-1])) + return features + + +# Decoder (no pixel-wise MLP, no uncertainty-guided sampling) +class Decoder(nn.Module): + def __init__(self, num_classes=4): + super(Decoder, self).__init__() + self.conv2 = nn.Conv2d(2048, 2048, kernel_size=1, stride=1, padding=0) + self.up1 = UpSampleBN(skip_input=2048 + 176, output_features=1024) + self.up2 = UpSampleBN(skip_input=1024 + 64, output_features=512) + self.up3 = UpSampleBN(skip_input=512 + 40, output_features=256) + self.up4 = UpSampleBN(skip_input=256 + 24, output_features=128) + self.conv3 = nn.Conv2d(128, num_classes, kernel_size=3, stride=1, padding=1) + + def forward(self, features): + x_block0, x_block1, x_block2, x_block3, x_block4 = features[4], features[5], features[6], features[8], features[11] + x_d0 = self.conv2(x_block4) + x_d1 = self.up1(x_d0, x_block3) + x_d2 = self.up2(x_d1, x_block2) + x_d3 = self.up3(x_d2, x_block1) + x_d4 = self.up4(x_d3, x_block0) + out = self.conv3(x_d4) + return out + + +if __name__ == '__main__': + model = Baseline() + x = torch.rand(2, 3, 480, 640) + out = model(x) + print(out.shape) diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/__init__.py b/invokeai/backend/image_util/normal_bae/nets/submodules/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/decoder.py b/invokeai/backend/image_util/normal_bae/nets/submodules/decoder.py new file mode 100644 index 0000000000000000000000000000000000000000..993203d1792311f1c492091eaea3c1ac9088187f --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/decoder.py @@ -0,0 +1,202 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F +from .submodules import UpSampleBN, UpSampleGN, norm_normalize, sample_points + + +class Decoder(nn.Module): + def __init__(self, args): + super(Decoder, self).__init__() + + # hyper-parameter for sampling + self.sampling_ratio = args.sampling_ratio + self.importance_ratio = args.importance_ratio + + # feature-map + self.conv2 = nn.Conv2d(2048, 2048, kernel_size=1, stride=1, padding=0) + if args.architecture == 'BN': + self.up1 = UpSampleBN(skip_input=2048 + 176, output_features=1024) + self.up2 = UpSampleBN(skip_input=1024 + 64, output_features=512) + self.up3 = UpSampleBN(skip_input=512 + 40, output_features=256) + self.up4 = UpSampleBN(skip_input=256 + 24, output_features=128) + + elif args.architecture == 'GN': + self.up1 = UpSampleGN(skip_input=2048 + 176, output_features=1024) + self.up2 = UpSampleGN(skip_input=1024 + 64, output_features=512) + self.up3 = UpSampleGN(skip_input=512 + 40, output_features=256) + self.up4 = UpSampleGN(skip_input=256 + 24, output_features=128) + + else: + raise Exception('invalid architecture') + + # produces 1/8 res output + self.out_conv_res8 = nn.Conv2d(512, 4, kernel_size=3, stride=1, padding=1) + + # produces 1/4 res output + self.out_conv_res4 = nn.Sequential( + nn.Conv1d(512 + 4, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 4, kernel_size=1), + ) + + # produces 1/2 res output + self.out_conv_res2 = nn.Sequential( + nn.Conv1d(256 + 4, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 4, kernel_size=1), + ) + + # produces 1/1 res output + self.out_conv_res1 = nn.Sequential( + nn.Conv1d(128 + 4, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 128, kernel_size=1), nn.ReLU(), + nn.Conv1d(128, 4, kernel_size=1), + ) + + def forward(self, features, gt_norm_mask=None, mode='test'): + x_block0, x_block1, x_block2, x_block3, x_block4 = features[4], features[5], features[6], features[8], features[11] + + # generate feature-map + + x_d0 = self.conv2(x_block4) # x_d0 : [2, 2048, 15, 20] 1/32 res + x_d1 = self.up1(x_d0, x_block3) # x_d1 : [2, 1024, 30, 40] 1/16 res + x_d2 = self.up2(x_d1, x_block2) # x_d2 : [2, 512, 60, 80] 1/8 res + x_d3 = self.up3(x_d2, x_block1) # x_d3: [2, 256, 120, 160] 1/4 res + x_d4 = self.up4(x_d3, x_block0) # x_d4: [2, 128, 240, 320] 1/2 res + + # 1/8 res output + out_res8 = self.out_conv_res8(x_d2) # out_res8: [2, 4, 60, 80] 1/8 res output + out_res8 = norm_normalize(out_res8) # out_res8: [2, 4, 60, 80] 1/8 res output + + ################################################################################################################ + # out_res4 + ################################################################################################################ + + if mode == 'train': + # upsampling ... out_res8: [2, 4, 60, 80] -> out_res8_res4: [2, 4, 120, 160] + out_res8_res4 = F.interpolate(out_res8, scale_factor=2, mode='bilinear', align_corners=True) + B, _, H, W = out_res8_res4.shape + + # samples: [B, 1, N, 2] + point_coords_res4, rows_int, cols_int = sample_points(out_res8_res4.detach(), gt_norm_mask, + sampling_ratio=self.sampling_ratio, + beta=self.importance_ratio) + + # output (needed for evaluation / visualization) + out_res4 = out_res8_res4 + + # grid_sample feature-map + feat_res4 = F.grid_sample(x_d2, point_coords_res4, mode='bilinear', align_corners=True) # (B, 512, 1, N) + init_pred = F.grid_sample(out_res8, point_coords_res4, mode='bilinear', align_corners=True) # (B, 4, 1, N) + feat_res4 = torch.cat([feat_res4, init_pred], dim=1) # (B, 512+4, 1, N) + + # prediction (needed to compute loss) + samples_pred_res4 = self.out_conv_res4(feat_res4[:, :, 0, :]) # (B, 4, N) + samples_pred_res4 = norm_normalize(samples_pred_res4) # (B, 4, N) - normalized + + for i in range(B): + out_res4[i, :, rows_int[i, :], cols_int[i, :]] = samples_pred_res4[i, :, :] + + else: + # grid_sample feature-map + feat_map = F.interpolate(x_d2, scale_factor=2, mode='bilinear', align_corners=True) + init_pred = F.interpolate(out_res8, scale_factor=2, mode='bilinear', align_corners=True) + feat_map = torch.cat([feat_map, init_pred], dim=1) # (B, 512+4, H, W) + B, _, H, W = feat_map.shape + + # try all pixels + out_res4 = self.out_conv_res4(feat_map.view(B, 512 + 4, -1)) # (B, 4, N) + out_res4 = norm_normalize(out_res4) # (B, 4, N) - normalized + out_res4 = out_res4.view(B, 4, H, W) + samples_pred_res4 = point_coords_res4 = None + + ################################################################################################################ + # out_res2 + ################################################################################################################ + + if mode == 'train': + + # upsampling ... out_res4: [2, 4, 120, 160] -> out_res4_res2: [2, 4, 240, 320] + out_res4_res2 = F.interpolate(out_res4, scale_factor=2, mode='bilinear', align_corners=True) + B, _, H, W = out_res4_res2.shape + + # samples: [B, 1, N, 2] + point_coords_res2, rows_int, cols_int = sample_points(out_res4_res2.detach(), gt_norm_mask, + sampling_ratio=self.sampling_ratio, + beta=self.importance_ratio) + + # output (needed for evaluation / visualization) + out_res2 = out_res4_res2 + + # grid_sample feature-map + feat_res2 = F.grid_sample(x_d3, point_coords_res2, mode='bilinear', align_corners=True) # (B, 256, 1, N) + init_pred = F.grid_sample(out_res4, point_coords_res2, mode='bilinear', align_corners=True) # (B, 4, 1, N) + feat_res2 = torch.cat([feat_res2, init_pred], dim=1) # (B, 256+4, 1, N) + + # prediction (needed to compute loss) + samples_pred_res2 = self.out_conv_res2(feat_res2[:, :, 0, :]) # (B, 4, N) + samples_pred_res2 = norm_normalize(samples_pred_res2) # (B, 4, N) - normalized + + for i in range(B): + out_res2[i, :, rows_int[i, :], cols_int[i, :]] = samples_pred_res2[i, :, :] + + else: + # grid_sample feature-map + feat_map = F.interpolate(x_d3, scale_factor=2, mode='bilinear', align_corners=True) + init_pred = F.interpolate(out_res4, scale_factor=2, mode='bilinear', align_corners=True) + feat_map = torch.cat([feat_map, init_pred], dim=1) # (B, 512+4, H, W) + B, _, H, W = feat_map.shape + + out_res2 = self.out_conv_res2(feat_map.view(B, 256 + 4, -1)) # (B, 4, N) + out_res2 = norm_normalize(out_res2) # (B, 4, N) - normalized + out_res2 = out_res2.view(B, 4, H, W) + samples_pred_res2 = point_coords_res2 = None + + ################################################################################################################ + # out_res1 + ################################################################################################################ + + if mode == 'train': + # upsampling ... out_res4: [2, 4, 120, 160] -> out_res4_res2: [2, 4, 240, 320] + out_res2_res1 = F.interpolate(out_res2, scale_factor=2, mode='bilinear', align_corners=True) + B, _, H, W = out_res2_res1.shape + + # samples: [B, 1, N, 2] + point_coords_res1, rows_int, cols_int = sample_points(out_res2_res1.detach(), gt_norm_mask, + sampling_ratio=self.sampling_ratio, + beta=self.importance_ratio) + + # output (needed for evaluation / visualization) + out_res1 = out_res2_res1 + + # grid_sample feature-map + feat_res1 = F.grid_sample(x_d4, point_coords_res1, mode='bilinear', align_corners=True) # (B, 128, 1, N) + init_pred = F.grid_sample(out_res2, point_coords_res1, mode='bilinear', align_corners=True) # (B, 4, 1, N) + feat_res1 = torch.cat([feat_res1, init_pred], dim=1) # (B, 128+4, 1, N) + + # prediction (needed to compute loss) + samples_pred_res1 = self.out_conv_res1(feat_res1[:, :, 0, :]) # (B, 4, N) + samples_pred_res1 = norm_normalize(samples_pred_res1) # (B, 4, N) - normalized + + for i in range(B): + out_res1[i, :, rows_int[i, :], cols_int[i, :]] = samples_pred_res1[i, :, :] + + else: + # grid_sample feature-map + feat_map = F.interpolate(x_d4, scale_factor=2, mode='bilinear', align_corners=True) + init_pred = F.interpolate(out_res2, scale_factor=2, mode='bilinear', align_corners=True) + feat_map = torch.cat([feat_map, init_pred], dim=1) # (B, 512+4, H, W) + B, _, H, W = feat_map.shape + + out_res1 = self.out_conv_res1(feat_map.view(B, 128 + 4, -1)) # (B, 4, N) + out_res1 = norm_normalize(out_res1) # (B, 4, N) - normalized + out_res1 = out_res1.view(B, 4, H, W) + samples_pred_res1 = point_coords_res1 = None + + return [out_res8, out_res4, out_res2, out_res1], \ + [out_res8, samples_pred_res4, samples_pred_res2, samples_pred_res1], \ + [None, point_coords_res4, point_coords_res2, point_coords_res1] + diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/.gitignore b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..f04e5fff91094d9b9c662bba977d762bf71516ac --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/.gitignore @@ -0,0 +1,109 @@ +# Byte-compiled / optimized / DLL files +__pycache__/ +*.py[cod] +*$py.class + +# C extensions +*.so + +# Distribution / packaging +.Python +build/ +develop-eggs/ +dist/ +downloads/ +eggs/ +.eggs/ +lib/ +lib64/ +parts/ +sdist/ +var/ +wheels/ +*.egg-info/ +.installed.cfg +*.egg +MANIFEST + +# PyInstaller +# Usually these files are written by a python script from a template +# before PyInstaller builds the exe, so as to inject date/other infos into it. +*.manifest +*.spec + +# Installer logs +pip-log.txt +pip-delete-this-directory.txt + +# Unit test / coverage reports +htmlcov/ +.tox/ +.coverage +.coverage.* +.cache +nosetests.xml +coverage.xml +*.cover +.hypothesis/ +.pytest_cache/ + +# Translations +*.mo +*.pot + +# Django stuff: +*.log +local_settings.py +db.sqlite3 + +# Flask stuff: +instance/ +.webassets-cache + +# Scrapy stuff: +.scrapy + +# Sphinx documentation +docs/_build/ + +# PyBuilder +target/ + +# Jupyter Notebook +.ipynb_checkpoints + +# pyenv +.python-version + +# celery beat schedule file +celerybeat-schedule + +# SageMath parsed files +*.sage.py + +# Environments +.env +.venv +env/ +venv/ +ENV/ +env.bak/ +venv.bak/ + +# Spyder project settings +.spyderproject +.spyproject + +# Rope project settings +.ropeproject + +# mkdocs documentation +/site + +# pytorch stuff +*.pth +*.onnx +*.pb + +trained_models/ +.fuse_hidden* diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/BENCHMARK.md b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/BENCHMARK.md new file mode 100644 index 0000000000000000000000000000000000000000..6ead7171ce5a5bbd2702f6b5c825dc9808ba5658 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/BENCHMARK.md @@ -0,0 +1,555 @@ +# Model Performance Benchmarks + +All benchmarks run as per: + +``` +python onnx_export.py --model mobilenetv3_100 ./mobilenetv3_100.onnx +python onnx_optimize.py ./mobilenetv3_100.onnx --output mobilenetv3_100-opt.onnx +python onnx_to_caffe.py ./mobilenetv3_100.onnx --c2-prefix mobilenetv3 +python onnx_to_caffe.py ./mobilenetv3_100-opt.onnx --c2-prefix mobilenetv3-opt +python caffe2_benchmark.py --c2-init ./mobilenetv3.init.pb --c2-predict ./mobilenetv3.predict.pb +python caffe2_benchmark.py --c2-init ./mobilenetv3-opt.init.pb --c2-predict ./mobilenetv3-opt.predict.pb +``` + +## EfficientNet-B0 + +### Unoptimized +``` +Main run finished. Milliseconds per iter: 49.2862. Iters per second: 20.2897 +Time per operator type: + 29.7378 ms. 60.5145%. Conv + 12.1785 ms. 24.7824%. Sigmoid + 3.62811 ms. 7.38297%. SpatialBN + 2.98444 ms. 6.07314%. Mul + 0.326902 ms. 0.665225%. AveragePool + 0.197317 ms. 0.401528%. FC + 0.0852877 ms. 0.173555%. Add + 0.0032607 ms. 0.00663532%. Squeeze + 49.1416 ms in Total +FLOP per operator type: + 0.76907 GFLOP. 95.2696%. Conv + 0.0269508 GFLOP. 3.33857%. SpatialBN + 0.00846444 GFLOP. 1.04855%. Mul + 0.002561 GFLOP. 0.317248%. FC + 0.000210112 GFLOP. 0.0260279%. Add + 0.807256 GFLOP in Total +Feature Memory Read per operator type: + 58.5253 MB. 43.0891%. Mul + 43.2015 MB. 31.807%. Conv + 27.2869 MB. 20.0899%. SpatialBN + 5.12912 MB. 3.77631%. FC + 1.6809 MB. 1.23756%. Add + 135.824 MB in Total +Feature Memory Written per operator type: + 33.8578 MB. 38.1965%. Mul + 26.9881 MB. 30.4465%. Conv + 26.9508 MB. 30.4044%. SpatialBN + 0.840448 MB. 0.948147%. Add + 0.004 MB. 0.00451258%. FC + 88.6412 MB in Total +Parameter Memory per operator type: + 15.8248 MB. 74.9391%. Conv + 5.124 MB. 24.265%. FC + 0.168064 MB. 0.795877%. SpatialBN + 0 MB. 0%. Add + 0 MB. 0%. Mul + 21.1168 MB in Total +``` +### Optimized +``` +Main run finished. Milliseconds per iter: 46.0838. Iters per second: 21.6996 +Time per operator type: + 29.776 ms. 65.002%. Conv + 12.2803 ms. 26.8084%. Sigmoid + 3.15073 ms. 6.87815%. Mul + 0.328651 ms. 0.717456%. AveragePool + 0.186237 ms. 0.406563%. FC + 0.0832429 ms. 0.181722%. Add + 0.0026184 ms. 0.00571606%. Squeeze + 45.8078 ms in Total +FLOP per operator type: + 0.76907 GFLOP. 98.5601%. Conv + 0.00846444 GFLOP. 1.08476%. Mul + 0.002561 GFLOP. 0.328205%. FC + 0.000210112 GFLOP. 0.0269269%. Add + 0.780305 GFLOP in Total +Feature Memory Read per operator type: + 58.5253 MB. 53.8803%. Mul + 43.2855 MB. 39.8501%. Conv + 5.12912 MB. 4.72204%. FC + 1.6809 MB. 1.54749%. Add + 108.621 MB in Total +Feature Memory Written per operator type: + 33.8578 MB. 54.8834%. Mul + 26.9881 MB. 43.7477%. Conv + 0.840448 MB. 1.36237%. Add + 0.004 MB. 0.00648399%. FC + 61.6904 MB in Total +Parameter Memory per operator type: + 15.8248 MB. 75.5403%. Conv + 5.124 MB. 24.4597%. FC + 0 MB. 0%. Add + 0 MB. 0%. Mul + 20.9488 MB in Total +``` + +## EfficientNet-B1 +### Optimized +``` +Main run finished. Milliseconds per iter: 71.8102. Iters per second: 13.9256 +Time per operator type: + 45.7915 ms. 66.3206%. Conv + 17.8718 ms. 25.8841%. Sigmoid + 4.44132 ms. 6.43244%. Mul + 0.51001 ms. 0.738658%. AveragePool + 0.233283 ms. 0.337868%. Add + 0.194986 ms. 0.282402%. FC + 0.00268255 ms. 0.00388519%. Squeeze + 69.0456 ms in Total +FLOP per operator type: + 1.37105 GFLOP. 98.7673%. Conv + 0.0138759 GFLOP. 0.99959%. Mul + 0.002561 GFLOP. 0.184489%. FC + 0.000674432 GFLOP. 0.0485847%. Add + 1.38816 GFLOP in Total +Feature Memory Read per operator type: + 94.624 MB. 54.0789%. Mul + 69.8255 MB. 39.9062%. Conv + 5.39546 MB. 3.08357%. Add + 5.12912 MB. 2.93136%. FC + 174.974 MB in Total +Feature Memory Written per operator type: + 55.5035 MB. 54.555%. Mul + 43.5333 MB. 42.7894%. Conv + 2.69773 MB. 2.65163%. Add + 0.004 MB. 0.00393165%. FC + 101.739 MB in Total +Parameter Memory per operator type: + 25.7479 MB. 83.4024%. Conv + 5.124 MB. 16.5976%. FC + 0 MB. 0%. Add + 0 MB. 0%. Mul + 30.8719 MB in Total +``` + +## EfficientNet-B2 +### Optimized +``` +Main run finished. Milliseconds per iter: 92.28. Iters per second: 10.8366 +Time per operator type: + 61.4627 ms. 67.5845%. Conv + 22.7458 ms. 25.0113%. Sigmoid + 5.59931 ms. 6.15701%. Mul + 0.642567 ms. 0.706568%. AveragePool + 0.272795 ms. 0.299965%. Add + 0.216178 ms. 0.237709%. FC + 0.00268895 ms. 0.00295677%. Squeeze + 90.942 ms in Total +FLOP per operator type: + 1.98431 GFLOP. 98.9343%. Conv + 0.0177039 GFLOP. 0.882686%. Mul + 0.002817 GFLOP. 0.140451%. FC + 0.000853984 GFLOP. 0.0425782%. Add + 2.00568 GFLOP in Total +Feature Memory Read per operator type: + 120.609 MB. 54.9637%. Mul + 86.3512 MB. 39.3519%. Conv + 6.83187 MB. 3.11341%. Add + 5.64163 MB. 2.571%. FC + 219.433 MB in Total +Feature Memory Written per operator type: + 70.8155 MB. 54.6573%. Mul + 55.3273 MB. 42.7031%. Conv + 3.41594 MB. 2.63651%. Add + 0.004 MB. 0.00308731%. FC + 129.563 MB in Total +Parameter Memory per operator type: + 30.4721 MB. 84.3913%. Conv + 5.636 MB. 15.6087%. FC + 0 MB. 0%. Add + 0 MB. 0%. Mul + 36.1081 MB in Total +``` + +## MixNet-M +### Optimized +``` +Main run finished. Milliseconds per iter: 63.1122. Iters per second: 15.8448 +Time per operator type: + 48.1139 ms. 75.2052%. Conv + 7.1341 ms. 11.1511%. Sigmoid + 2.63706 ms. 4.12189%. SpatialBN + 1.73186 ms. 2.70701%. Mul + 1.38707 ms. 2.16809%. Split + 1.29322 ms. 2.02139%. Concat + 1.00093 ms. 1.56452%. Relu + 0.235309 ms. 0.367803%. Add + 0.221579 ms. 0.346343%. FC + 0.219315 ms. 0.342803%. AveragePool + 0.00250145 ms. 0.00390993%. Squeeze + 63.9768 ms in Total +FLOP per operator type: + 0.675273 GFLOP. 95.5827%. Conv + 0.0221072 GFLOP. 3.12921%. SpatialBN + 0.00538445 GFLOP. 0.762152%. Mul + 0.003073 GFLOP. 0.434973%. FC + 0.000642488 GFLOP. 0.0909421%. Add + 0 GFLOP. 0%. Concat + 0 GFLOP. 0%. Relu + 0.70648 GFLOP in Total +Feature Memory Read per operator type: + 46.8424 MB. 30.502%. Conv + 36.8626 MB. 24.0036%. Mul + 22.3152 MB. 14.5309%. SpatialBN + 22.1074 MB. 14.3955%. Concat + 14.1496 MB. 9.21372%. Relu + 6.15414 MB. 4.00735%. FC + 5.1399 MB. 3.34692%. Add + 153.571 MB in Total +Feature Memory Written per operator type: + 32.7672 MB. 28.4331%. Conv + 22.1072 MB. 19.1831%. Concat + 22.1072 MB. 19.1831%. SpatialBN + 21.5378 MB. 18.689%. Mul + 14.1496 MB. 12.2781%. Relu + 2.56995 MB. 2.23003%. Add + 0.004 MB. 0.00347092%. FC + 115.243 MB in Total +Parameter Memory per operator type: + 13.7059 MB. 68.674%. Conv + 6.148 MB. 30.8049%. FC + 0.104 MB. 0.521097%. SpatialBN + 0 MB. 0%. Add + 0 MB. 0%. Concat + 0 MB. 0%. Mul + 0 MB. 0%. Relu + 19.9579 MB in Total +``` + +## TF MobileNet-V3 Large 1.0 + +### Optimized +``` +Main run finished. Milliseconds per iter: 22.0495. Iters per second: 45.3525 +Time per operator type: + 17.437 ms. 80.0087%. Conv + 1.27662 ms. 5.8577%. Add + 1.12759 ms. 5.17387%. Div + 0.701155 ms. 3.21721%. Mul + 0.562654 ms. 2.58171%. Relu + 0.431144 ms. 1.97828%. Clip + 0.156902 ms. 0.719936%. FC + 0.0996858 ms. 0.457402%. AveragePool + 0.00112455 ms. 0.00515993%. Flatten + 21.7939 ms in Total +FLOP per operator type: + 0.43062 GFLOP. 98.1484%. Conv + 0.002561 GFLOP. 0.583713%. FC + 0.00210867 GFLOP. 0.480616%. Mul + 0.00193868 GFLOP. 0.441871%. Add + 0.00151532 GFLOP. 0.345377%. Div + 0 GFLOP. 0%. Relu + 0.438743 GFLOP in Total +Feature Memory Read per operator type: + 34.7967 MB. 43.9391%. Conv + 14.496 MB. 18.3046%. Mul + 9.44828 MB. 11.9307%. Add + 9.26157 MB. 11.6949%. Relu + 6.0614 MB. 7.65395%. Div + 5.12912 MB. 6.47673%. FC + 79.193 MB in Total +Feature Memory Written per operator type: + 17.6247 MB. 35.8656%. Conv + 9.26157 MB. 18.847%. Relu + 8.43469 MB. 17.1643%. Mul + 7.75472 MB. 15.7806%. Add + 6.06128 MB. 12.3345%. Div + 0.004 MB. 0.00813985%. FC + 49.1409 MB in Total +Parameter Memory per operator type: + 16.6851 MB. 76.5052%. Conv + 5.124 MB. 23.4948%. FC + 0 MB. 0%. Add + 0 MB. 0%. Div + 0 MB. 0%. Mul + 0 MB. 0%. Relu + 21.8091 MB in Total +``` + +## MobileNet-V3 (RW) + +### Unoptimized +``` +Main run finished. Milliseconds per iter: 24.8316. Iters per second: 40.2712 +Time per operator type: + 15.9266 ms. 69.2624%. Conv + 2.36551 ms. 10.2873%. SpatialBN + 1.39102 ms. 6.04936%. Add + 1.30327 ms. 5.66773%. Div + 0.737014 ms. 3.20517%. Mul + 0.639697 ms. 2.78195%. Relu + 0.375681 ms. 1.63378%. Clip + 0.153126 ms. 0.665921%. FC + 0.0993787 ms. 0.432184%. AveragePool + 0.0032632 ms. 0.0141912%. Squeeze + 22.9946 ms in Total +FLOP per operator type: + 0.430616 GFLOP. 94.4041%. Conv + 0.0175992 GFLOP. 3.85829%. SpatialBN + 0.002561 GFLOP. 0.561449%. FC + 0.00210961 GFLOP. 0.46249%. Mul + 0.00173891 GFLOP. 0.381223%. Add + 0.00151626 GFLOP. 0.33241%. Div + 0 GFLOP. 0%. Relu + 0.456141 GFLOP in Total +Feature Memory Read per operator type: + 34.7354 MB. 36.4363%. Conv + 17.7944 MB. 18.6658%. SpatialBN + 14.5035 MB. 15.2137%. Mul + 9.25778 MB. 9.71113%. Relu + 7.84641 MB. 8.23064%. Add + 6.06516 MB. 6.36216%. Div + 5.12912 MB. 5.38029%. FC + 95.3317 MB in Total +Feature Memory Written per operator type: + 17.6246 MB. 26.7264%. Conv + 17.5992 MB. 26.6878%. SpatialBN + 9.25778 MB. 14.0387%. Relu + 8.43843 MB. 12.7962%. Mul + 6.95565 MB. 10.5477%. Add + 6.06502 MB. 9.19713%. Div + 0.004 MB. 0.00606568%. FC + 65.9447 MB in Total +Parameter Memory per operator type: + 16.6778 MB. 76.1564%. Conv + 5.124 MB. 23.3979%. FC + 0.0976 MB. 0.445674%. SpatialBN + 0 MB. 0%. Add + 0 MB. 0%. Div + 0 MB. 0%. Mul + 0 MB. 0%. Relu + 21.8994 MB in Total + +``` +### Optimized + +``` +Main run finished. Milliseconds per iter: 22.0981. Iters per second: 45.2527 +Time per operator type: + 17.146 ms. 78.8965%. Conv + 1.38453 ms. 6.37084%. Add + 1.30991 ms. 6.02749%. Div + 0.685417 ms. 3.15391%. Mul + 0.532589 ms. 2.45068%. Relu + 0.418263 ms. 1.92461%. Clip + 0.15128 ms. 0.696106%. FC + 0.102065 ms. 0.469648%. AveragePool + 0.0022143 ms. 0.010189%. Squeeze + 21.7323 ms in Total +FLOP per operator type: + 0.430616 GFLOP. 98.1927%. Conv + 0.002561 GFLOP. 0.583981%. FC + 0.00210961 GFLOP. 0.481051%. Mul + 0.00173891 GFLOP. 0.396522%. Add + 0.00151626 GFLOP. 0.34575%. Div + 0 GFLOP. 0%. Relu + 0.438542 GFLOP in Total +Feature Memory Read per operator type: + 34.7842 MB. 44.833%. Conv + 14.5035 MB. 18.6934%. Mul + 9.25778 MB. 11.9323%. Relu + 7.84641 MB. 10.1132%. Add + 6.06516 MB. 7.81733%. Div + 5.12912 MB. 6.61087%. FC + 77.5861 MB in Total +Feature Memory Written per operator type: + 17.6246 MB. 36.4556%. Conv + 9.25778 MB. 19.1492%. Relu + 8.43843 MB. 17.4544%. Mul + 6.95565 MB. 14.3874%. Add + 6.06502 MB. 12.5452%. Div + 0.004 MB. 0.00827378%. FC + 48.3455 MB in Total +Parameter Memory per operator type: + 16.6778 MB. 76.4973%. Conv + 5.124 MB. 23.5027%. FC + 0 MB. 0%. Add + 0 MB. 0%. Div + 0 MB. 0%. Mul + 0 MB. 0%. Relu + 21.8018 MB in Total + +``` + +## MnasNet-A1 + +### Unoptimized +``` +Main run finished. Milliseconds per iter: 30.0892. Iters per second: 33.2345 +Time per operator type: + 24.4656 ms. 79.0905%. Conv + 4.14958 ms. 13.4144%. SpatialBN + 1.60598 ms. 5.19169%. Relu + 0.295219 ms. 0.95436%. Mul + 0.187609 ms. 0.606486%. FC + 0.120556 ms. 0.389724%. AveragePool + 0.09036 ms. 0.292109%. Add + 0.015727 ms. 0.050841%. Sigmoid + 0.00306205 ms. 0.00989875%. Squeeze + 30.9337 ms in Total +FLOP per operator type: + 0.620598 GFLOP. 95.6434%. Conv + 0.0248873 GFLOP. 3.8355%. SpatialBN + 0.002561 GFLOP. 0.394688%. FC + 0.000597408 GFLOP. 0.0920695%. Mul + 0.000222656 GFLOP. 0.0343146%. Add + 0 GFLOP. 0%. Relu + 0.648867 GFLOP in Total +Feature Memory Read per operator type: + 35.5457 MB. 38.4109%. Conv + 25.1552 MB. 27.1829%. SpatialBN + 22.5235 MB. 24.339%. Relu + 5.12912 MB. 5.54256%. FC + 2.40586 MB. 2.59978%. Mul + 1.78125 MB. 1.92483%. Add + 92.5406 MB in Total +Feature Memory Written per operator type: + 24.9042 MB. 32.9424%. Conv + 24.8873 MB. 32.92%. SpatialBN + 22.5235 MB. 29.7932%. Relu + 2.38963 MB. 3.16092%. Mul + 0.890624 MB. 1.17809%. Add + 0.004 MB. 0.00529106%. FC + 75.5993 MB in Total +Parameter Memory per operator type: + 10.2732 MB. 66.1459%. Conv + 5.124 MB. 32.9917%. FC + 0.133952 MB. 0.86247%. SpatialBN + 0 MB. 0%. Add + 0 MB. 0%. Mul + 0 MB. 0%. Relu + 15.5312 MB in Total +``` + +### Optimized +``` +Main run finished. Milliseconds per iter: 24.2367. Iters per second: 41.2597 +Time per operator type: + 22.0547 ms. 91.1375%. Conv + 1.49096 ms. 6.16116%. Relu + 0.253417 ms. 1.0472%. Mul + 0.18506 ms. 0.76473%. FC + 0.112942 ms. 0.466717%. AveragePool + 0.086769 ms. 0.358559%. Add + 0.0127889 ms. 0.0528479%. Sigmoid + 0.0027346 ms. 0.0113003%. Squeeze + 24.1994 ms in Total +FLOP per operator type: + 0.620598 GFLOP. 99.4581%. Conv + 0.002561 GFLOP. 0.41043%. FC + 0.000597408 GFLOP. 0.0957417%. Mul + 0.000222656 GFLOP. 0.0356832%. Add + 0 GFLOP. 0%. Relu + 0.623979 GFLOP in Total +Feature Memory Read per operator type: + 35.6127 MB. 52.7968%. Conv + 22.5235 MB. 33.3917%. Relu + 5.12912 MB. 7.60406%. FC + 2.40586 MB. 3.56675%. Mul + 1.78125 MB. 2.64075%. Add + 67.4524 MB in Total +Feature Memory Written per operator type: + 24.9042 MB. 49.1092%. Conv + 22.5235 MB. 44.4145%. Relu + 2.38963 MB. 4.71216%. Mul + 0.890624 MB. 1.75624%. Add + 0.004 MB. 0.00788768%. FC + 50.712 MB in Total +Parameter Memory per operator type: + 10.2732 MB. 66.7213%. Conv + 5.124 MB. 33.2787%. FC + 0 MB. 0%. Add + 0 MB. 0%. Mul + 0 MB. 0%. Relu + 15.3972 MB in Total +``` +## MnasNet-B1 + +### Unoptimized +``` +Main run finished. Milliseconds per iter: 28.3109. Iters per second: 35.322 +Time per operator type: + 29.1121 ms. 83.3081%. Conv + 4.14959 ms. 11.8746%. SpatialBN + 1.35823 ms. 3.88675%. Relu + 0.186188 ms. 0.532802%. FC + 0.116244 ms. 0.332647%. Add + 0.018641 ms. 0.0533437%. AveragePool + 0.0040904 ms. 0.0117052%. Squeeze + 34.9451 ms in Total +FLOP per operator type: + 0.626272 GFLOP. 96.2088%. Conv + 0.0218266 GFLOP. 3.35303%. SpatialBN + 0.002561 GFLOP. 0.393424%. FC + 0.000291648 GFLOP. 0.0448034%. Add + 0 GFLOP. 0%. Relu + 0.650951 GFLOP in Total +Feature Memory Read per operator type: + 34.4354 MB. 41.3788%. Conv + 22.1299 MB. 26.5921%. SpatialBN + 19.1923 MB. 23.0622%. Relu + 5.12912 MB. 6.16333%. FC + 2.33318 MB. 2.80364%. Add + 83.2199 MB in Total +Feature Memory Written per operator type: + 21.8266 MB. 34.0955%. Conv + 21.8266 MB. 34.0955%. SpatialBN + 19.1923 MB. 29.9805%. Relu + 1.16659 MB. 1.82234%. Add + 0.004 MB. 0.00624844%. FC + 64.016 MB in Total +Parameter Memory per operator type: + 12.2576 MB. 69.9104%. Conv + 5.124 MB. 29.2245%. FC + 0.15168 MB. 0.865099%. SpatialBN + 0 MB. 0%. Add + 0 MB. 0%. Relu + 17.5332 MB in Total +``` + +### Optimized +``` +Main run finished. Milliseconds per iter: 26.6364. Iters per second: 37.5426 +Time per operator type: + 24.9888 ms. 94.0962%. Conv + 1.26147 ms. 4.75011%. Relu + 0.176234 ms. 0.663619%. FC + 0.113309 ms. 0.426672%. Add + 0.0138708 ms. 0.0522311%. AveragePool + 0.00295685 ms. 0.0111341%. Squeeze + 26.5566 ms in Total +FLOP per operator type: + 0.626272 GFLOP. 99.5466%. Conv + 0.002561 GFLOP. 0.407074%. FC + 0.000291648 GFLOP. 0.0463578%. Add + 0 GFLOP. 0%. Relu + 0.629124 GFLOP in Total +Feature Memory Read per operator type: + 34.5112 MB. 56.4224%. Conv + 19.1923 MB. 31.3775%. Relu + 5.12912 MB. 8.3856%. FC + 2.33318 MB. 3.81452%. Add + 61.1658 MB in Total +Feature Memory Written per operator type: + 21.8266 MB. 51.7346%. Conv + 19.1923 MB. 45.4908%. Relu + 1.16659 MB. 2.76513%. Add + 0.004 MB. 0.00948104%. FC + 42.1895 MB in Total +Parameter Memory per operator type: + 12.2576 MB. 70.5205%. Conv + 5.124 MB. 29.4795%. FC + 0 MB. 0%. Add + 0 MB. 0%. Relu + 17.3816 MB in Total +``` diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/LICENSE b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..80e7d15508202f3262a50db27f5198460d7f509f --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/LICENSE @@ -0,0 +1,201 @@ + Apache License + Version 2.0, January 2004 + http://www.apache.org/licenses/ + + TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION + + 1. Definitions. + + "License" shall mean the terms and conditions for use, reproduction, + and distribution as defined by Sections 1 through 9 of this document. + + "Licensor" shall mean the copyright owner or entity authorized by + the copyright owner that is granting the License. + + "Legal Entity" shall mean the union of the acting entity and all + other entities that control, are controlled by, or are under common + control with that entity. For the purposes of this definition, + "control" means (i) the power, direct or indirect, to cause the + direction or management of such entity, whether by contract or + otherwise, or (ii) ownership of fifty percent (50%) or more of the + outstanding shares, or (iii) beneficial ownership of such entity. + + "You" (or "Your") shall mean an individual or Legal Entity + exercising permissions granted by this License. + + "Source" form shall mean the preferred form for making modifications, + including but not limited to software source code, documentation + source, and configuration files. + + "Object" form shall mean any form resulting from mechanical + transformation or translation of a Source form, including but + not limited to compiled object code, generated documentation, + and conversions to other media types. + + "Work" shall mean the work of authorship, whether in Source or + Object form, made available under the License, as indicated by a + copyright notice that is included in or attached to the work + (an example is provided in the Appendix below). + + "Derivative Works" shall mean any work, whether in Source or Object + form, that is based on (or derived from) the Work and for which the + editorial revisions, annotations, elaborations, or other modifications + represent, as a whole, an original work of authorship. For the purposes + of this License, Derivative Works shall not include works that remain + separable from, or merely link (or bind by name) to the interfaces of, + the Work and Derivative Works thereof. + + "Contribution" shall mean any work of authorship, including + the original version of the Work and any modifications or additions + to that Work or Derivative Works thereof, that is intentionally + submitted to Licensor for inclusion in the Work by the copyright owner + or by an individual or Legal Entity authorized to submit on behalf of + the copyright owner. For the purposes of this definition, "submitted" + means any form of electronic, verbal, or written communication sent + to the Licensor or its representatives, including but not limited to + communication on electronic mailing lists, source code control systems, + and issue tracking systems that are managed by, or on behalf of, the + Licensor for the purpose of discussing and improving the Work, but + excluding communication that is conspicuously marked or otherwise + designated in writing by the copyright owner as "Not a Contribution." + + "Contributor" shall mean Licensor and any individual or Legal Entity + on behalf of whom a Contribution has been received by Licensor and + subsequently incorporated within the Work. + + 2. Grant of Copyright License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + copyright license to reproduce, prepare Derivative Works of, + publicly display, publicly perform, sublicense, and distribute the + Work and such Derivative Works in Source or Object form. + + 3. Grant of Patent License. Subject to the terms and conditions of + this License, each Contributor hereby grants to You a perpetual, + worldwide, non-exclusive, no-charge, royalty-free, irrevocable + (except as stated in this section) patent license to make, have made, + use, offer to sell, sell, import, and otherwise transfer the Work, + where such license applies only to those patent claims licensable + by such Contributor that are necessarily infringed by their + Contribution(s) alone or by combination of their Contribution(s) + with the Work to which such Contribution(s) was submitted. If You + institute patent litigation against any entity (including a + cross-claim or counterclaim in a lawsuit) alleging that the Work + or a Contribution incorporated within the Work constitutes direct + or contributory patent infringement, then any patent licenses + granted to You under this License for that Work shall terminate + as of the date such litigation is filed. + + 4. Redistribution. You may reproduce and distribute copies of the + Work or Derivative Works thereof in any medium, with or without + modifications, and in Source or Object form, provided that You + meet the following conditions: + + (a) You must give any other recipients of the Work or + Derivative Works a copy of this License; and + + (b) You must cause any modified files to carry prominent notices + stating that You changed the files; and + + (c) You must retain, in the Source form of any Derivative Works + that You distribute, all copyright, patent, trademark, and + attribution notices from the Source form of the Work, + excluding those notices that do not pertain to any part of + the Derivative Works; and + + (d) If the Work includes a "NOTICE" text file as part of its + distribution, then any Derivative Works that You distribute must + include a readable copy of the attribution notices contained + within such NOTICE file, excluding those notices that do not + pertain to any part of the Derivative Works, in at least one + of the following places: within a NOTICE text file distributed + as part of the Derivative Works; within the Source form or + documentation, if provided along with the Derivative Works; or, + within a display generated by the Derivative Works, if and + wherever such third-party notices normally appear. The contents + of the NOTICE file are for informational purposes only and + do not modify the License. You may add Your own attribution + notices within Derivative Works that You distribute, alongside + or as an addendum to the NOTICE text from the Work, provided + that such additional attribution notices cannot be construed + as modifying the License. + + You may add Your own copyright statement to Your modifications and + may provide additional or different license terms and conditions + for use, reproduction, or distribution of Your modifications, or + for any such Derivative Works as a whole, provided Your use, + reproduction, and distribution of the Work otherwise complies with + the conditions stated in this License. + + 5. Submission of Contributions. Unless You explicitly state otherwise, + any Contribution intentionally submitted for inclusion in the Work + by You to the Licensor shall be under the terms and conditions of + this License, without any additional terms or conditions. + Notwithstanding the above, nothing herein shall supersede or modify + the terms of any separate license agreement you may have executed + with Licensor regarding such Contributions. + + 6. Trademarks. This License does not grant permission to use the trade + names, trademarks, service marks, or product names of the Licensor, + except as required for reasonable and customary use in describing the + origin of the Work and reproducing the content of the NOTICE file. + + 7. Disclaimer of Warranty. Unless required by applicable law or + agreed to in writing, Licensor provides the Work (and each + Contributor provides its Contributions) on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or + implied, including, without limitation, any warranties or conditions + of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A + PARTICULAR PURPOSE. You are solely responsible for determining the + appropriateness of using or redistributing the Work and assume any + risks associated with Your exercise of permissions under this License. + + 8. Limitation of Liability. In no event and under no legal theory, + whether in tort (including negligence), contract, or otherwise, + unless required by applicable law (such as deliberate and grossly + negligent acts) or agreed to in writing, shall any Contributor be + liable to You for damages, including any direct, indirect, special, + incidental, or consequential damages of any character arising as a + result of this License or out of the use or inability to use the + Work (including but not limited to damages for loss of goodwill, + work stoppage, computer failure or malfunction, or any and all + other commercial damages or losses), even if such Contributor + has been advised of the possibility of such damages. + + 9. Accepting Warranty or Additional Liability. While redistributing + the Work or Derivative Works thereof, You may choose to offer, + and charge a fee for, acceptance of support, warranty, indemnity, + or other liability obligations and/or rights consistent with this + License. However, in accepting such obligations, You may act only + on Your own behalf and on Your sole responsibility, not on behalf + of any other Contributor, and only if You agree to indemnify, + defend, and hold each Contributor harmless for any liability + incurred by, or claims asserted against, such Contributor by reason + of your accepting any such warranty or additional liability. + + END OF TERMS AND CONDITIONS + + APPENDIX: How to apply the Apache License to your work. + + To apply the Apache License to your work, attach the following + boilerplate notice, with the fields enclosed by brackets "{}" + replaced with your own identifying information. (Don't include + the brackets!) The text should be enclosed in the appropriate + comment syntax for the file format. We also recommend that a + file or class name and description of purpose be included on the + same "printed page" as the copyright notice for easier + identification within third-party archives. + + Copyright 2020 Ross Wightman + + Licensed under the Apache License, Version 2.0 (the "License"); + you may not use this file except in compliance with the License. + You may obtain a copy of the License at + + http://www.apache.org/licenses/LICENSE-2.0 + + Unless required by applicable law or agreed to in writing, software + distributed under the License is distributed on an "AS IS" BASIS, + WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or implied. + See the License for the specific language governing permissions and + limitations under the License. diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/README.md b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/README.md new file mode 100644 index 0000000000000000000000000000000000000000..463368280d6a5015060eb73d20fe6512f8e04c50 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/README.md @@ -0,0 +1,323 @@ +# (Generic) EfficientNets for PyTorch + +A 'generic' implementation of EfficientNet, MixNet, MobileNetV3, etc. that covers most of the compute/parameter efficient architectures derived from the MobileNet V1/V2 block sequence, including those found via automated neural architecture search. + +All models are implemented by GenEfficientNet or MobileNetV3 classes, with string based architecture definitions to configure the block layouts (idea from [here](https://github.com/tensorflow/tpu/blob/master/models/official/mnasnet/mnasnet_models.py)) + +## What's New + +### Aug 19, 2020 +* Add updated PyTorch trained EfficientNet-B3 weights trained by myself with `timm` (82.1 top-1) +* Add PyTorch trained EfficientNet-Lite0 contributed by [@hal-314](https://github.com/hal-314) (75.5 top-1) +* Update ONNX and Caffe2 export / utility scripts to work with latest PyTorch / ONNX +* ONNX runtime based validation script added +* activations (mostly) brought in sync with `timm` equivalents + + +### April 5, 2020 +* Add some newly trained MobileNet-V2 models trained with latest h-params, rand augment. They compare quite favourably to EfficientNet-Lite + * 3.5M param MobileNet-V2 100 @ 73% + * 4.5M param MobileNet-V2 110d @ 75% + * 6.1M param MobileNet-V2 140 @ 76.5% + * 5.8M param MobileNet-V2 120d @ 77.3% + +### March 23, 2020 + * Add EfficientNet-Lite models w/ weights ported from [Tensorflow TPU](https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet/lite) + * Add PyTorch trained MobileNet-V3 Large weights with 75.77% top-1 + * IMPORTANT CHANGE (if training from scratch) - weight init changed to better match Tensorflow impl, set `fix_group_fanout=False` in `initialize_weight_goog` for old behavior + +### Feb 12, 2020 + * Add EfficientNet-L2 and B0-B7 NoisyStudent weights ported from [Tensorflow TPU](https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet) + * Port new EfficientNet-B8 (RandAugment) weights from TF TPU, these are different than the B8 AdvProp, different input normalization. + * Add RandAugment PyTorch trained EfficientNet-ES (EdgeTPU-Small) weights with 78.1 top-1. Trained by [Andrew Lavin](https://github.com/andravin) + +### Jan 22, 2020 + * Update weights for EfficientNet B0, B2, B3 and MixNet-XL with latest RandAugment trained weights. Trained with (https://github.com/rwightman/pytorch-image-models) + * Fix torchscript compatibility for PyTorch 1.4, add torchscript support for MixedConv2d using ModuleDict + * Test models, torchscript, onnx export with PyTorch 1.4 -- no issues + +### Nov 22, 2019 + * New top-1 high! Ported official TF EfficientNet AdvProp (https://arxiv.org/abs/1911.09665) weights and B8 model spec. Created a new set of `ap` models since they use a different + preprocessing (Inception mean/std) from the original EfficientNet base/AA/RA weights. + +### Nov 15, 2019 + * Ported official TF MobileNet-V3 float32 large/small/minimalistic weights + * Modifications to MobileNet-V3 model and components to support some additional config needed for differences between TF MobileNet-V3 and mine + +### Oct 30, 2019 + * Many of the models will now work with torch.jit.script, MixNet being the biggest exception + * Improved interface for enabling torchscript or ONNX export compatible modes (via config) + * Add JIT optimized mem-efficient Swish/Mish autograd.fn in addition to memory-efficient autgrad.fn + * Activation factory to select best version of activation by name or override one globally + * Add pretrained checkpoint load helper that handles input conv and classifier changes + +### Oct 27, 2019 + * Add CondConv EfficientNet variants ported from https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet/condconv + * Add RandAug weights for TF EfficientNet B5 and B7 from https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet + * Bring over MixNet-XL model and depth scaling algo from my pytorch-image-models code base + * Switch activations and global pooling to modules + * Add memory-efficient Swish/Mish impl + * Add as_sequential() method to all models and allow as an argument in entrypoint fns + * Move MobileNetV3 into own file since it has a different head + * Remove ChamNet, MobileNet V2/V1 since they will likely never be used here + +## Models + +Implemented models include: + * EfficientNet NoisyStudent (B0-B7, L2) (https://arxiv.org/abs/1911.04252) + * EfficientNet AdvProp (B0-B8) (https://arxiv.org/abs/1911.09665) + * EfficientNet (B0-B8) (https://arxiv.org/abs/1905.11946) + * EfficientNet-EdgeTPU (S, M, L) (https://ai.googleblog.com/2019/08/efficientnet-edgetpu-creating.html) + * EfficientNet-CondConv (https://arxiv.org/abs/1904.04971) + * EfficientNet-Lite (https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet/lite) + * MixNet (https://arxiv.org/abs/1907.09595) + * MNASNet B1, A1 (Squeeze-Excite), and Small (https://arxiv.org/abs/1807.11626) + * MobileNet-V3 (https://arxiv.org/abs/1905.02244) + * FBNet-C (https://arxiv.org/abs/1812.03443) + * Single-Path NAS (https://arxiv.org/abs/1904.02877) + +I originally implemented and trained some these models with code [here](https://github.com/rwightman/pytorch-image-models), this repository contains just the GenEfficientNet models, validation, and associated ONNX/Caffe2 export code. + +## Pretrained + +I've managed to train several of the models to accuracies close to or above the originating papers and official impl. My training code is here: https://github.com/rwightman/pytorch-image-models + + +|Model | Prec@1 (Err) | Prec@5 (Err) | Param#(M) | MAdds(M) | Image Scaling | Resolution | Crop | +|---|---|---|---|---|---|---|---| +| efficientnet_b3 | 82.240 (17.760) | 96.116 (3.884) | 12.23 | TBD | bicubic | 320 | 1.0 | +| efficientnet_b3 | 82.076 (17.924) | 96.020 (3.980) | 12.23 | TBD | bicubic | 300 | 0.904 | +| mixnet_xl | 81.074 (18.926) | 95.282 (4.718) | 11.90 | TBD | bicubic | 256 | 1.0 | +| efficientnet_b2 | 80.612 (19.388) | 95.318 (4.682) | 9.1 | TBD | bicubic | 288 | 1.0 | +| mixnet_xl | 80.476 (19.524) | 94.936 (5.064) | 11.90 | TBD | bicubic | 224 | 0.875 | +| efficientnet_b2 | 80.288 (19.712) | 95.166 (4.834) | 9.1 | 1003 | bicubic | 260 | 0.890 | +| mixnet_l | 78.976 (21.024 | 94.184 (5.816) | 7.33 | TBD | bicubic | 224 | 0.875 | +| efficientnet_b1 | 78.692 (21.308) | 94.086 (5.914) | 7.8 | 694 | bicubic | 240 | 0.882 | +| efficientnet_es | 78.066 (21.934) | 93.926 (6.074) | 5.44 | TBD | bicubic | 224 | 0.875 | +| efficientnet_b0 | 77.698 (22.302) | 93.532 (6.468) | 5.3 | 390 | bicubic | 224 | 0.875 | +| mobilenetv2_120d | 77.294 (22.706 | 93.502 (6.498) | 5.8 | TBD | bicubic | 224 | 0.875 | +| mixnet_m | 77.256 (22.744) | 93.418 (6.582) | 5.01 | 353 | bicubic | 224 | 0.875 | +| mobilenetv2_140 | 76.524 (23.476) | 92.990 (7.010) | 6.1 | TBD | bicubic | 224 | 0.875 | +| mixnet_s | 75.988 (24.012) | 92.794 (7.206) | 4.13 | TBD | bicubic | 224 | 0.875 | +| mobilenetv3_large_100 | 75.766 (24.234) | 92.542 (7.458) | 5.5 | TBD | bicubic | 224 | 0.875 | +| mobilenetv3_rw | 75.634 (24.366) | 92.708 (7.292) | 5.5 | 219 | bicubic | 224 | 0.875 | +| efficientnet_lite0 | 75.472 (24.528) | 92.520 (7.480) | 4.65 | TBD | bicubic | 224 | 0.875 | +| mnasnet_a1 | 75.448 (24.552) | 92.604 (7.396) | 3.9 | 312 | bicubic | 224 | 0.875 | +| fbnetc_100 | 75.124 (24.876) | 92.386 (7.614) | 5.6 | 385 | bilinear | 224 | 0.875 | +| mobilenetv2_110d | 75.052 (24.948) | 92.180 (7.820) | 4.5 | TBD | bicubic | 224 | 0.875 | +| mnasnet_b1 | 74.658 (25.342) | 92.114 (7.886) | 4.4 | 315 | bicubic | 224 | 0.875 | +| spnasnet_100 | 74.084 (25.916) | 91.818 (8.182) | 4.4 | TBD | bilinear | 224 | 0.875 | +| mobilenetv2_100 | 72.978 (27.022) | 91.016 (8.984) | 3.5 | TBD | bicubic | 224 | 0.875 | + + +More pretrained models to come... + + +## Ported Weights + +The weights ported from Tensorflow checkpoints for the EfficientNet models do pretty much match accuracy in Tensorflow once a SAME convolution padding equivalent is added, and the same crop factors, image scaling, etc (see table) are used via cmd line args. + +**IMPORTANT:** +* Tensorflow ported weights for EfficientNet AdvProp (AP), EfficientNet EdgeTPU, EfficientNet-CondConv, EfficientNet-Lite, and MobileNet-V3 models use Inception style (0.5, 0.5, 0.5) for mean and std. +* Enabling the Tensorflow preprocessing pipeline with `--tf-preprocessing` at validation time will improve scores by 0.1-0.5%, very close to original TF impl. + +To run validation for tf_efficientnet_b5: +`python validate.py /path/to/imagenet/validation/ --model tf_efficientnet_b5 -b 64 --img-size 456 --crop-pct 0.934 --interpolation bicubic` + +To run validation w/ TF preprocessing for tf_efficientnet_b5: +`python validate.py /path/to/imagenet/validation/ --model tf_efficientnet_b5 -b 64 --img-size 456 --tf-preprocessing` + +To run validation for a model with Inception preprocessing, ie EfficientNet-B8 AdvProp: +`python validate.py /path/to/imagenet/validation/ --model tf_efficientnet_b8_ap -b 48 --num-gpu 2 --img-size 672 --crop-pct 0.954 --mean 0.5 --std 0.5` + +|Model | Prec@1 (Err) | Prec@5 (Err) | Param # | Image Scaling | Image Size | Crop | +|---|---|---|---|---|---|---| +| tf_efficientnet_l2_ns *tfp | 88.352 (11.648) | 98.652 (1.348) | 480 | bicubic | 800 | N/A | +| tf_efficientnet_l2_ns | TBD | TBD | 480 | bicubic | 800 | 0.961 | +| tf_efficientnet_l2_ns_475 | 88.234 (11.766) | 98.546 (1.454) | 480 | bicubic | 475 | 0.936 | +| tf_efficientnet_l2_ns_475 *tfp | 88.172 (11.828) | 98.566 (1.434) | 480 | bicubic | 475 | N/A | +| tf_efficientnet_b7_ns *tfp | 86.844 (13.156) | 98.084 (1.916) | 66.35 | bicubic | 600 | N/A | +| tf_efficientnet_b7_ns | 86.840 (13.160) | 98.094 (1.906) | 66.35 | bicubic | 600 | N/A | +| tf_efficientnet_b6_ns | 86.452 (13.548) | 97.882 (2.118) | 43.04 | bicubic | 528 | N/A | +| tf_efficientnet_b6_ns *tfp | 86.444 (13.556) | 97.880 (2.120) | 43.04 | bicubic | 528 | N/A | +| tf_efficientnet_b5_ns *tfp | 86.064 (13.936) | 97.746 (2.254) | 30.39 | bicubic | 456 | N/A | +| tf_efficientnet_b5_ns | 86.088 (13.912) | 97.752 (2.248) | 30.39 | bicubic | 456 | N/A | +| tf_efficientnet_b8_ap *tfp | 85.436 (14.564) | 97.272 (2.728) | 87.4 | bicubic | 672 | N/A | +| tf_efficientnet_b8 *tfp | 85.384 (14.616) | 97.394 (2.606) | 87.4 | bicubic | 672 | N/A | +| tf_efficientnet_b8 | 85.370 (14.630) | 97.390 (2.610) | 87.4 | bicubic | 672 | 0.954 | +| tf_efficientnet_b8_ap | 85.368 (14.632) | 97.294 (2.706) | 87.4 | bicubic | 672 | 0.954 | +| tf_efficientnet_b4_ns *tfp | 85.298 (14.702) | 97.504 (2.496) | 19.34 | bicubic | 380 | N/A | +| tf_efficientnet_b4_ns | 85.162 (14.838) | 97.470 (2.530) | 19.34 | bicubic | 380 | 0.922 | +| tf_efficientnet_b7_ap *tfp | 85.154 (14.846) | 97.244 (2.756) | 66.35 | bicubic | 600 | N/A | +| tf_efficientnet_b7_ap | 85.118 (14.882) | 97.252 (2.748) | 66.35 | bicubic | 600 | 0.949 | +| tf_efficientnet_b7 *tfp | 84.940 (15.060) | 97.214 (2.786) | 66.35 | bicubic | 600 | N/A | +| tf_efficientnet_b7 | 84.932 (15.068) | 97.208 (2.792) | 66.35 | bicubic | 600 | 0.949 | +| tf_efficientnet_b6_ap | 84.786 (15.214) | 97.138 (2.862) | 43.04 | bicubic | 528 | 0.942 | +| tf_efficientnet_b6_ap *tfp | 84.760 (15.240) | 97.124 (2.876) | 43.04 | bicubic | 528 | N/A | +| tf_efficientnet_b5_ap *tfp | 84.276 (15.724) | 96.932 (3.068) | 30.39 | bicubic | 456 | N/A | +| tf_efficientnet_b5_ap | 84.254 (15.746) | 96.976 (3.024) | 30.39 | bicubic | 456 | 0.934 | +| tf_efficientnet_b6 *tfp | 84.140 (15.860) | 96.852 (3.148) | 43.04 | bicubic | 528 | N/A | +| tf_efficientnet_b6 | 84.110 (15.890) | 96.886 (3.114) | 43.04 | bicubic | 528 | 0.942 | +| tf_efficientnet_b3_ns *tfp | 84.054 (15.946) | 96.918 (3.082) | 12.23 | bicubic | 300 | N/A | +| tf_efficientnet_b3_ns | 84.048 (15.952) | 96.910 (3.090) | 12.23 | bicubic | 300 | .904 | +| tf_efficientnet_b5 *tfp | 83.822 (16.178) | 96.756 (3.244) | 30.39 | bicubic | 456 | N/A | +| tf_efficientnet_b5 | 83.812 (16.188) | 96.748 (3.252) | 30.39 | bicubic | 456 | 0.934 | +| tf_efficientnet_b4_ap *tfp | 83.278 (16.722) | 96.376 (3.624) | 19.34 | bicubic | 380 | N/A | +| tf_efficientnet_b4_ap | 83.248 (16.752) | 96.388 (3.612) | 19.34 | bicubic | 380 | 0.922 | +| tf_efficientnet_b4 | 83.022 (16.978) | 96.300 (3.700) | 19.34 | bicubic | 380 | 0.922 | +| tf_efficientnet_b4 *tfp | 82.948 (17.052) | 96.308 (3.692) | 19.34 | bicubic | 380 | N/A | +| tf_efficientnet_b2_ns *tfp | 82.436 (17.564) | 96.268 (3.732) | 9.11 | bicubic | 260 | N/A | +| tf_efficientnet_b2_ns | 82.380 (17.620) | 96.248 (3.752) | 9.11 | bicubic | 260 | 0.89 | +| tf_efficientnet_b3_ap *tfp | 81.882 (18.118) | 95.662 (4.338) | 12.23 | bicubic | 300 | N/A | +| tf_efficientnet_b3_ap | 81.828 (18.172) | 95.624 (4.376) | 12.23 | bicubic | 300 | 0.904 | +| tf_efficientnet_b3 | 81.636 (18.364) | 95.718 (4.282) | 12.23 | bicubic | 300 | 0.904 | +| tf_efficientnet_b3 *tfp | 81.576 (18.424) | 95.662 (4.338) | 12.23 | bicubic | 300 | N/A | +| tf_efficientnet_lite4 | 81.528 (18.472) | 95.668 (4.332) | 13.00 | bilinear | 380 | 0.92 | +| tf_efficientnet_b1_ns *tfp | 81.514 (18.486) | 95.776 (4.224) | 7.79 | bicubic | 240 | N/A | +| tf_efficientnet_lite4 *tfp | 81.502 (18.498) | 95.676 (4.324) | 13.00 | bilinear | 380 | N/A | +| tf_efficientnet_b1_ns | 81.388 (18.612) | 95.738 (4.262) | 7.79 | bicubic | 240 | 0.88 | +| tf_efficientnet_el | 80.534 (19.466) | 95.190 (4.810) | 10.59 | bicubic | 300 | 0.904 | +| tf_efficientnet_el *tfp | 80.476 (19.524) | 95.200 (4.800) | 10.59 | bicubic | 300 | N/A | +| tf_efficientnet_b2_ap *tfp | 80.420 (19.580) | 95.040 (4.960) | 9.11 | bicubic | 260 | N/A | +| tf_efficientnet_b2_ap | 80.306 (19.694) | 95.028 (4.972) | 9.11 | bicubic | 260 | 0.890 | +| tf_efficientnet_b2 *tfp | 80.188 (19.812) | 94.974 (5.026) | 9.11 | bicubic | 260 | N/A | +| tf_efficientnet_b2 | 80.086 (19.914) | 94.908 (5.092) | 9.11 | bicubic | 260 | 0.890 | +| tf_efficientnet_lite3 | 79.812 (20.188) | 94.914 (5.086) | 8.20 | bilinear | 300 | 0.904 | +| tf_efficientnet_lite3 *tfp | 79.734 (20.266) | 94.838 (5.162) | 8.20 | bilinear | 300 | N/A | +| tf_efficientnet_b1_ap *tfp | 79.532 (20.468) | 94.378 (5.622) | 7.79 | bicubic | 240 | N/A | +| tf_efficientnet_cc_b1_8e *tfp | 79.464 (20.536)| 94.492 (5.508) | 39.7 | bicubic | 240 | 0.88 | +| tf_efficientnet_cc_b1_8e | 79.298 (20.702) | 94.364 (5.636) | 39.7 | bicubic | 240 | 0.88 | +| tf_efficientnet_b1_ap | 79.278 (20.722) | 94.308 (5.692) | 7.79 | bicubic | 240 | 0.88 | +| tf_efficientnet_b1 *tfp | 79.172 (20.828) | 94.450 (5.550) | 7.79 | bicubic | 240 | N/A | +| tf_efficientnet_em *tfp | 78.958 (21.042) | 94.458 (5.542) | 6.90 | bicubic | 240 | N/A | +| tf_efficientnet_b0_ns *tfp | 78.806 (21.194) | 94.496 (5.504) | 5.29 | bicubic | 224 | N/A | +| tf_mixnet_l *tfp | 78.846 (21.154) | 94.212 (5.788) | 7.33 | bilinear | 224 | N/A | +| tf_efficientnet_b1 | 78.826 (21.174) | 94.198 (5.802) | 7.79 | bicubic | 240 | 0.88 | +| tf_mixnet_l | 78.770 (21.230) | 94.004 (5.996) | 7.33 | bicubic | 224 | 0.875 | +| tf_efficientnet_em | 78.742 (21.258) | 94.332 (5.668) | 6.90 | bicubic | 240 | 0.875 | +| tf_efficientnet_b0_ns | 78.658 (21.342) | 94.376 (5.624) | 5.29 | bicubic | 224 | 0.875 | +| tf_efficientnet_cc_b0_8e *tfp | 78.314 (21.686) | 93.790 (6.210) | 24.0 | bicubic | 224 | 0.875 | +| tf_efficientnet_cc_b0_8e | 77.908 (22.092) | 93.656 (6.344) | 24.0 | bicubic | 224 | 0.875 | +| tf_efficientnet_cc_b0_4e *tfp | 77.746 (22.254) | 93.552 (6.448) | 13.3 | bicubic | 224 | 0.875 | +| tf_efficientnet_cc_b0_4e | 77.304 (22.696) | 93.332 (6.668) | 13.3 | bicubic | 224 | 0.875 | +| tf_efficientnet_es *tfp | 77.616 (22.384) | 93.750 (6.250) | 5.44 | bicubic | 224 | N/A | +| tf_efficientnet_lite2 *tfp | 77.544 (22.456) | 93.800 (6.200) | 6.09 | bilinear | 260 | N/A | +| tf_efficientnet_lite2 | 77.460 (22.540) | 93.746 (6.254) | 6.09 | bicubic | 260 | 0.89 | +| tf_efficientnet_b0_ap *tfp | 77.514 (22.486) | 93.576 (6.424) | 5.29 | bicubic | 224 | N/A | +| tf_efficientnet_es | 77.264 (22.736) | 93.600 (6.400) | 5.44 | bicubic | 224 | N/A | +| tf_efficientnet_b0 *tfp | 77.258 (22.742) | 93.478 (6.522) | 5.29 | bicubic | 224 | N/A | +| tf_efficientnet_b0_ap | 77.084 (22.916) | 93.254 (6.746) | 5.29 | bicubic | 224 | 0.875 | +| tf_mixnet_m *tfp | 77.072 (22.928) | 93.368 (6.632) | 5.01 | bilinear | 224 | N/A | +| tf_mixnet_m | 76.950 (23.050) | 93.156 (6.844) | 5.01 | bicubic | 224 | 0.875 | +| tf_efficientnet_b0 | 76.848 (23.152) | 93.228 (6.772) | 5.29 | bicubic | 224 | 0.875 | +| tf_efficientnet_lite1 *tfp | 76.764 (23.236) | 93.326 (6.674) | 5.42 | bilinear | 240 | N/A | +| tf_efficientnet_lite1 | 76.638 (23.362) | 93.232 (6.768) | 5.42 | bicubic | 240 | 0.882 | +| tf_mixnet_s *tfp | 75.800 (24.200) | 92.788 (7.212) | 4.13 | bilinear | 224 | N/A | +| tf_mobilenetv3_large_100 *tfp | 75.768 (24.232) | 92.710 (7.290) | 5.48 | bilinear | 224 | N/A | +| tf_mixnet_s | 75.648 (24.352) | 92.636 (7.364) | 4.13 | bicubic | 224 | 0.875 | +| tf_mobilenetv3_large_100 | 75.516 (24.484) | 92.600 (7.400) | 5.48 | bilinear | 224 | 0.875 | +| tf_efficientnet_lite0 *tfp | 75.074 (24.926) | 92.314 (7.686) | 4.65 | bilinear | 224 | N/A | +| tf_efficientnet_lite0 | 74.842 (25.158) | 92.170 (7.830) | 4.65 | bicubic | 224 | 0.875 | +| tf_mobilenetv3_large_075 *tfp | 73.730 (26.270) | 91.616 (8.384) | 3.99 | bilinear | 224 |N/A | +| tf_mobilenetv3_large_075 | 73.442 (26.558) | 91.352 (8.648) | 3.99 | bilinear | 224 | 0.875 | +| tf_mobilenetv3_large_minimal_100 *tfp | 72.678 (27.322) | 90.860 (9.140) | 3.92 | bilinear | 224 | N/A | +| tf_mobilenetv3_large_minimal_100 | 72.244 (27.756) | 90.636 (9.364) | 3.92 | bilinear | 224 | 0.875 | +| tf_mobilenetv3_small_100 *tfp | 67.918 (32.082) | 87.958 (12.042 | 2.54 | bilinear | 224 | N/A | +| tf_mobilenetv3_small_100 | 67.918 (32.082) | 87.662 (12.338) | 2.54 | bilinear | 224 | 0.875 | +| tf_mobilenetv3_small_075 *tfp | 66.142 (33.858) | 86.498 (13.502) | 2.04 | bilinear | 224 | N/A | +| tf_mobilenetv3_small_075 | 65.718 (34.282) | 86.136 (13.864) | 2.04 | bilinear | 224 | 0.875 | +| tf_mobilenetv3_small_minimal_100 *tfp | 63.378 (36.622) | 84.802 (15.198) | 2.04 | bilinear | 224 | N/A | +| tf_mobilenetv3_small_minimal_100 | 62.898 (37.102) | 84.230 (15.770) | 2.04 | bilinear | 224 | 0.875 | + + +*tfp models validated with `tf-preprocessing` pipeline + +Google tf and tflite weights ported from official Tensorflow repositories +* https://github.com/tensorflow/tpu/tree/master/models/official/mnasnet +* https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet +* https://github.com/tensorflow/models/tree/master/research/slim/nets/mobilenet + +## Usage + +### Environment + +All development and testing has been done in Conda Python 3 environments on Linux x86-64 systems, specifically Python 3.6.x, 3.7.x, 3.8.x. + +Users have reported that a Python 3 Anaconda install in Windows works. I have not verified this myself. + +PyTorch versions 1.4, 1.5, 1.6 have been tested with this code. + +I've tried to keep the dependencies minimal, the setup is as per the PyTorch default install instructions for Conda: +``` +conda create -n torch-env +conda activate torch-env +conda install -c pytorch pytorch torchvision cudatoolkit=10.2 +``` + +### PyTorch Hub + +Models can be accessed via the PyTorch Hub API + +``` +>>> torch.hub.list('rwightman/gen-efficientnet-pytorch') +['efficientnet_b0', ...] +>>> model = torch.hub.load('rwightman/gen-efficientnet-pytorch', 'efficientnet_b0', pretrained=True) +>>> model.eval() +>>> output = model(torch.randn(1,3,224,224)) +``` + +### Pip +This package can be installed via pip. + +Install (after conda env/install): +``` +pip install geffnet +``` + +Eval use: +``` +>>> import geffnet +>>> m = geffnet.create_model('mobilenetv3_large_100', pretrained=True) +>>> m.eval() +``` + +Train use: +``` +>>> import geffnet +>>> # models can also be created by using the entrypoint directly +>>> m = geffnet.efficientnet_b2(pretrained=True, drop_rate=0.25, drop_connect_rate=0.2) +>>> m.train() +``` + +Create in a nn.Sequential container, for fast.ai, etc: +``` +>>> import geffnet +>>> m = geffnet.mixnet_l(pretrained=True, drop_rate=0.25, drop_connect_rate=0.2, as_sequential=True) +``` + +### Exporting + +Scripts are included to +* export models to ONNX (`onnx_export.py`) +* optimized ONNX graph (`onnx_optimize.py` or `onnx_validate.py` w/ `--onnx-output-opt` arg) +* validate with ONNX runtime (`onnx_validate.py`) +* convert ONNX model to Caffe2 (`onnx_to_caffe.py`) +* validate in Caffe2 (`caffe2_validate.py`) +* benchmark in Caffe2 w/ FLOPs, parameters output (`caffe2_benchmark.py`) + +As an example, to export the MobileNet-V3 pretrained model and then run an Imagenet validation: +``` +python onnx_export.py --model mobilenetv3_large_100 ./mobilenetv3_100.onnx +python onnx_validate.py /imagenet/validation/ --onnx-input ./mobilenetv3_100.onnx +``` + +These scripts were tested to be working as of PyTorch 1.6 and ONNX 1.7 w/ ONNX runtime 1.4. Caffe2 compatible +export now requires additional args mentioned in the export script (not needed in earlier versions). + +#### Export Notes +1. The TF ported weights with the 'SAME' conv padding activated cannot be exported to ONNX unless `_EXPORTABLE` flag in `config.py` is set to True. Use `config.set_exportable(True)` as in the `onnx_export.py` script. +2. TF ported models with 'SAME' padding will have the padding fixed at export time to the resolution used for export. Even though dynamic padding is supported in opset >= 11, I can't get it working. +3. ONNX optimize facility doesn't work reliably in PyTorch 1.6 / ONNX 1.7. Fortunately, the onnxruntime based inference is working very well now and includes on the fly optimization. +3. ONNX / Caffe2 export/import frequently breaks with different PyTorch and ONNX version releases. Please check their respective issue trackers before filing issues here. + + diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/__init__.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/caffe2_benchmark.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/caffe2_benchmark.py new file mode 100644 index 0000000000000000000000000000000000000000..93f28a1e63d9f7287ca02997c7991fe66dd0aeb9 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/caffe2_benchmark.py @@ -0,0 +1,65 @@ +""" Caffe2 validation script + +This script runs Caffe2 benchmark on exported ONNX model. +It is a useful tool for reporting model FLOPS. + +Copyright 2020 Ross Wightman +""" +import argparse +from caffe2.python import core, workspace, model_helper +from caffe2.proto import caffe2_pb2 + + +parser = argparse.ArgumentParser(description='Caffe2 Model Benchmark') +parser.add_argument('--c2-prefix', default='', type=str, metavar='NAME', + help='caffe2 model pb name prefix') +parser.add_argument('--c2-init', default='', type=str, metavar='PATH', + help='caffe2 model init .pb') +parser.add_argument('--c2-predict', default='', type=str, metavar='PATH', + help='caffe2 model predict .pb') +parser.add_argument('-b', '--batch-size', default=1, type=int, + metavar='N', help='mini-batch size (default: 1)') +parser.add_argument('--img-size', default=224, type=int, + metavar='N', help='Input image dimension, uses model default if empty') + + +def main(): + args = parser.parse_args() + args.gpu_id = 0 + if args.c2_prefix: + args.c2_init = args.c2_prefix + '.init.pb' + args.c2_predict = args.c2_prefix + '.predict.pb' + + model = model_helper.ModelHelper(name="le_net", init_params=False) + + # Bring in the init net from init_net.pb + init_net_proto = caffe2_pb2.NetDef() + with open(args.c2_init, "rb") as f: + init_net_proto.ParseFromString(f.read()) + model.param_init_net = core.Net(init_net_proto) + + # bring in the predict net from predict_net.pb + predict_net_proto = caffe2_pb2.NetDef() + with open(args.c2_predict, "rb") as f: + predict_net_proto.ParseFromString(f.read()) + model.net = core.Net(predict_net_proto) + + # CUDA performance not impressive + #device_opts = core.DeviceOption(caffe2_pb2.PROTO_CUDA, args.gpu_id) + #model.net.RunAllOnGPU(gpu_id=args.gpu_id, use_cudnn=True) + #model.param_init_net.RunAllOnGPU(gpu_id=args.gpu_id, use_cudnn=True) + + input_blob = model.net.external_inputs[0] + model.param_init_net.GaussianFill( + [], + input_blob.GetUnscopedName(), + shape=(args.batch_size, 3, args.img_size, args.img_size), + mean=0.0, + std=1.0) + workspace.RunNetOnce(model.param_init_net) + workspace.CreateNet(model.net, overwrite=True) + workspace.BenchmarkNet(model.net.Proto().name, 5, 20, True) + + +if __name__ == '__main__': + main() diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/caffe2_validate.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/caffe2_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..7cfaab38c095663fe32e4addbdf06b57bcb53614 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/caffe2_validate.py @@ -0,0 +1,138 @@ +""" Caffe2 validation script + +This script is created to verify exported ONNX models running in Caffe2 +It utilizes the same PyTorch dataloader/processing pipeline for a +fair comparison against the originals. + +Copyright 2020 Ross Wightman +""" +import argparse +import numpy as np +from caffe2.python import core, workspace, model_helper +from caffe2.proto import caffe2_pb2 +from data import create_loader, resolve_data_config, Dataset +from utils import AverageMeter +import time + +parser = argparse.ArgumentParser(description='Caffe2 ImageNet Validation') +parser.add_argument('data', metavar='DIR', + help='path to dataset') +parser.add_argument('--c2-prefix', default='', type=str, metavar='NAME', + help='caffe2 model pb name prefix') +parser.add_argument('--c2-init', default='', type=str, metavar='PATH', + help='caffe2 model init .pb') +parser.add_argument('--c2-predict', default='', type=str, metavar='PATH', + help='caffe2 model predict .pb') +parser.add_argument('-j', '--workers', default=2, type=int, metavar='N', + help='number of data loading workers (default: 2)') +parser.add_argument('-b', '--batch-size', default=256, type=int, + metavar='N', help='mini-batch size (default: 256)') +parser.add_argument('--img-size', default=None, type=int, + metavar='N', help='Input image dimension, uses model default if empty') +parser.add_argument('--mean', type=float, nargs='+', default=None, metavar='MEAN', + help='Override mean pixel value of dataset') +parser.add_argument('--std', type=float, nargs='+', default=None, metavar='STD', + help='Override std deviation of of dataset') +parser.add_argument('--crop-pct', type=float, default=None, metavar='PCT', + help='Override default crop pct of 0.875') +parser.add_argument('--interpolation', default='', type=str, metavar='NAME', + help='Image resize interpolation type (overrides model)') +parser.add_argument('--tf-preprocessing', dest='tf_preprocessing', action='store_true', + help='use tensorflow mnasnet preporcessing') +parser.add_argument('--print-freq', '-p', default=10, type=int, + metavar='N', help='print frequency (default: 10)') + + +def main(): + args = parser.parse_args() + args.gpu_id = 0 + if args.c2_prefix: + args.c2_init = args.c2_prefix + '.init.pb' + args.c2_predict = args.c2_prefix + '.predict.pb' + + model = model_helper.ModelHelper(name="validation_net", init_params=False) + + # Bring in the init net from init_net.pb + init_net_proto = caffe2_pb2.NetDef() + with open(args.c2_init, "rb") as f: + init_net_proto.ParseFromString(f.read()) + model.param_init_net = core.Net(init_net_proto) + + # bring in the predict net from predict_net.pb + predict_net_proto = caffe2_pb2.NetDef() + with open(args.c2_predict, "rb") as f: + predict_net_proto.ParseFromString(f.read()) + model.net = core.Net(predict_net_proto) + + data_config = resolve_data_config(None, args) + loader = create_loader( + Dataset(args.data, load_bytes=args.tf_preprocessing), + input_size=data_config['input_size'], + batch_size=args.batch_size, + use_prefetcher=False, + interpolation=data_config['interpolation'], + mean=data_config['mean'], + std=data_config['std'], + num_workers=args.workers, + crop_pct=data_config['crop_pct'], + tensorflow_preprocessing=args.tf_preprocessing) + + # this is so obvious, wonderful interface + input_blob = model.net.external_inputs[0] + output_blob = model.net.external_outputs[0] + + if True: + device_opts = None + else: + # CUDA is crashing, no idea why, awesome error message, give it a try for kicks + device_opts = core.DeviceOption(caffe2_pb2.PROTO_CUDA, args.gpu_id) + model.net.RunAllOnGPU(gpu_id=args.gpu_id, use_cudnn=True) + model.param_init_net.RunAllOnGPU(gpu_id=args.gpu_id, use_cudnn=True) + + model.param_init_net.GaussianFill( + [], input_blob.GetUnscopedName(), + shape=(1,) + data_config['input_size'], mean=0.0, std=1.0) + workspace.RunNetOnce(model.param_init_net) + workspace.CreateNet(model.net, overwrite=True) + + batch_time = AverageMeter() + top1 = AverageMeter() + top5 = AverageMeter() + end = time.time() + for i, (input, target) in enumerate(loader): + # run the net and return prediction + caffe2_in = input.data.numpy() + workspace.FeedBlob(input_blob, caffe2_in, device_opts) + workspace.RunNet(model.net, num_iter=1) + output = workspace.FetchBlob(output_blob) + + # measure accuracy and record loss + prec1, prec5 = accuracy_np(output.data, target.numpy()) + top1.update(prec1.item(), input.size(0)) + top5.update(prec5.item(), input.size(0)) + + # measure elapsed time + batch_time.update(time.time() - end) + end = time.time() + + if i % args.print_freq == 0: + print('Test: [{0}/{1}]\t' + 'Time {batch_time.val:.3f} ({batch_time.avg:.3f}, {rate_avg:.3f}/s, {ms_avg:.3f} ms/sample) \t' + 'Prec@1 {top1.val:.3f} ({top1.avg:.3f})\t' + 'Prec@5 {top5.val:.3f} ({top5.avg:.3f})'.format( + i, len(loader), batch_time=batch_time, rate_avg=input.size(0) / batch_time.avg, + ms_avg=100 * batch_time.avg / input.size(0), top1=top1, top5=top5)) + + print(' * Prec@1 {top1.avg:.3f} ({top1a:.3f}) Prec@5 {top5.avg:.3f} ({top5a:.3f})'.format( + top1=top1, top1a=100-top1.avg, top5=top5, top5a=100.-top5.avg)) + + +def accuracy_np(output, target): + max_indices = np.argsort(output, axis=1)[:, ::-1] + top5 = 100 * np.equal(max_indices[:, :5], target[:, np.newaxis]).sum(axis=1).mean() + top1 = 100 * np.equal(max_indices[:, 0], target).mean() + return top1, top5 + + +if __name__ == '__main__': + main() diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/__init__.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2e441a5838d1e972823b9668ac8d459445f6f6ce --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/__init__.py @@ -0,0 +1,5 @@ +from .gen_efficientnet import * +from .mobilenetv3 import * +from .model_factory import create_model +from .config import is_exportable, is_scriptable, set_exportable, set_scriptable +from .activations import * \ No newline at end of file diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/__init__.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..813421a743ffc33b8eb53ebf62dd4a03d831b654 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/__init__.py @@ -0,0 +1,137 @@ +from geffnet import config +from geffnet.activations.activations_me import * +from geffnet.activations.activations_jit import * +from geffnet.activations.activations import * +import torch + +_has_silu = 'silu' in dir(torch.nn.functional) + +_ACT_FN_DEFAULT = dict( + silu=F.silu if _has_silu else swish, + swish=F.silu if _has_silu else swish, + mish=mish, + relu=F.relu, + relu6=F.relu6, + sigmoid=sigmoid, + tanh=tanh, + hard_sigmoid=hard_sigmoid, + hard_swish=hard_swish, +) + +_ACT_FN_JIT = dict( + silu=F.silu if _has_silu else swish_jit, + swish=F.silu if _has_silu else swish_jit, + mish=mish_jit, +) + +_ACT_FN_ME = dict( + silu=F.silu if _has_silu else swish_me, + swish=F.silu if _has_silu else swish_me, + mish=mish_me, + hard_swish=hard_swish_me, + hard_sigmoid_jit=hard_sigmoid_me, +) + +_ACT_LAYER_DEFAULT = dict( + silu=nn.SiLU if _has_silu else Swish, + swish=nn.SiLU if _has_silu else Swish, + mish=Mish, + relu=nn.ReLU, + relu6=nn.ReLU6, + sigmoid=Sigmoid, + tanh=Tanh, + hard_sigmoid=HardSigmoid, + hard_swish=HardSwish, +) + +_ACT_LAYER_JIT = dict( + silu=nn.SiLU if _has_silu else SwishJit, + swish=nn.SiLU if _has_silu else SwishJit, + mish=MishJit, +) + +_ACT_LAYER_ME = dict( + silu=nn.SiLU if _has_silu else SwishMe, + swish=nn.SiLU if _has_silu else SwishMe, + mish=MishMe, + hard_swish=HardSwishMe, + hard_sigmoid=HardSigmoidMe +) + +_OVERRIDE_FN = dict() +_OVERRIDE_LAYER = dict() + + +def add_override_act_fn(name, fn): + global _OVERRIDE_FN + _OVERRIDE_FN[name] = fn + + +def update_override_act_fn(overrides): + assert isinstance(overrides, dict) + global _OVERRIDE_FN + _OVERRIDE_FN.update(overrides) + + +def clear_override_act_fn(): + global _OVERRIDE_FN + _OVERRIDE_FN = dict() + + +def add_override_act_layer(name, fn): + _OVERRIDE_LAYER[name] = fn + + +def update_override_act_layer(overrides): + assert isinstance(overrides, dict) + global _OVERRIDE_LAYER + _OVERRIDE_LAYER.update(overrides) + + +def clear_override_act_layer(): + global _OVERRIDE_LAYER + _OVERRIDE_LAYER = dict() + + +def get_act_fn(name='relu'): + """ Activation Function Factory + Fetching activation fns by name with this function allows export or torch script friendly + functions to be returned dynamically based on current config. + """ + if name in _OVERRIDE_FN: + return _OVERRIDE_FN[name] + use_me = not (config.is_exportable() or config.is_scriptable() or config.is_no_jit()) + if use_me and name in _ACT_FN_ME: + # If not exporting or scripting the model, first look for a memory optimized version + # activation with custom autograd, then fallback to jit scripted, then a Python or Torch builtin + return _ACT_FN_ME[name] + if config.is_exportable() and name in ('silu', 'swish'): + # FIXME PyTorch SiLU doesn't ONNX export, this is a temp hack + return swish + use_jit = not (config.is_exportable() or config.is_no_jit()) + # NOTE: export tracing should work with jit scripted components, but I keep running into issues + if use_jit and name in _ACT_FN_JIT: # jit scripted models should be okay for export/scripting + return _ACT_FN_JIT[name] + return _ACT_FN_DEFAULT[name] + + +def get_act_layer(name='relu'): + """ Activation Layer Factory + Fetching activation layers by name with this function allows export or torch script friendly + functions to be returned dynamically based on current config. + """ + if name in _OVERRIDE_LAYER: + return _OVERRIDE_LAYER[name] + use_me = not (config.is_exportable() or config.is_scriptable() or config.is_no_jit()) + if use_me and name in _ACT_LAYER_ME: + return _ACT_LAYER_ME[name] + if config.is_exportable() and name in ('silu', 'swish'): + # FIXME PyTorch SiLU doesn't ONNX export, this is a temp hack + return Swish + use_jit = not (config.is_exportable() or config.is_no_jit()) + # NOTE: export tracing should work with jit scripted components, but I keep running into issues + if use_jit and name in _ACT_FN_JIT: # jit scripted models should be okay for export/scripting + return _ACT_LAYER_JIT[name] + return _ACT_LAYER_DEFAULT[name] + + diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations.py new file mode 100644 index 0000000000000000000000000000000000000000..bdea692d1397673b2513d898c33edbcb37d94240 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations.py @@ -0,0 +1,102 @@ +""" Activations + +A collection of activations fn and modules with a common interface so that they can +easily be swapped. All have an `inplace` arg even if not used. + +Copyright 2020 Ross Wightman +""" +from torch import nn as nn +from torch.nn import functional as F + + +def swish(x, inplace: bool = False): + """Swish - Described originally as SiLU (https://arxiv.org/abs/1702.03118v3) + and also as Swish (https://arxiv.org/abs/1710.05941). + + TODO Rename to SiLU with addition to PyTorch + """ + return x.mul_(x.sigmoid()) if inplace else x.mul(x.sigmoid()) + + +class Swish(nn.Module): + def __init__(self, inplace: bool = False): + super(Swish, self).__init__() + self.inplace = inplace + + def forward(self, x): + return swish(x, self.inplace) + + +def mish(x, inplace: bool = False): + """Mish: A Self Regularized Non-Monotonic Neural Activation Function - https://arxiv.org/abs/1908.08681 + """ + return x.mul(F.softplus(x).tanh()) + + +class Mish(nn.Module): + def __init__(self, inplace: bool = False): + super(Mish, self).__init__() + self.inplace = inplace + + def forward(self, x): + return mish(x, self.inplace) + + +def sigmoid(x, inplace: bool = False): + return x.sigmoid_() if inplace else x.sigmoid() + + +# PyTorch has this, but not with a consistent inplace argmument interface +class Sigmoid(nn.Module): + def __init__(self, inplace: bool = False): + super(Sigmoid, self).__init__() + self.inplace = inplace + + def forward(self, x): + return x.sigmoid_() if self.inplace else x.sigmoid() + + +def tanh(x, inplace: bool = False): + return x.tanh_() if inplace else x.tanh() + + +# PyTorch has this, but not with a consistent inplace argmument interface +class Tanh(nn.Module): + def __init__(self, inplace: bool = False): + super(Tanh, self).__init__() + self.inplace = inplace + + def forward(self, x): + return x.tanh_() if self.inplace else x.tanh() + + +def hard_swish(x, inplace: bool = False): + inner = F.relu6(x + 3.).div_(6.) + return x.mul_(inner) if inplace else x.mul(inner) + + +class HardSwish(nn.Module): + def __init__(self, inplace: bool = False): + super(HardSwish, self).__init__() + self.inplace = inplace + + def forward(self, x): + return hard_swish(x, self.inplace) + + +def hard_sigmoid(x, inplace: bool = False): + if inplace: + return x.add_(3.).clamp_(0., 6.).div_(6.) + else: + return F.relu6(x + 3.) / 6. + + +class HardSigmoid(nn.Module): + def __init__(self, inplace: bool = False): + super(HardSigmoid, self).__init__() + self.inplace = inplace + + def forward(self, x): + return hard_sigmoid(x, self.inplace) + + diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations_jit.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations_jit.py new file mode 100644 index 0000000000000000000000000000000000000000..7176b05e779787528a47f20d55d64d4a0f219360 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations_jit.py @@ -0,0 +1,79 @@ +""" Activations (jit) + +A collection of jit-scripted activations fn and modules with a common interface so that they can +easily be swapped. All have an `inplace` arg even if not used. + +All jit scripted activations are lacking in-place variations on purpose, scripted kernel fusion does not +currently work across in-place op boundaries, thus performance is equal to or less than the non-scripted +versions if they contain in-place ops. + +Copyright 2020 Ross Wightman +""" + +import torch +from torch import nn as nn +from torch.nn import functional as F + +__all__ = ['swish_jit', 'SwishJit', 'mish_jit', 'MishJit', + 'hard_sigmoid_jit', 'HardSigmoidJit', 'hard_swish_jit', 'HardSwishJit'] + + +@torch.jit.script +def swish_jit(x, inplace: bool = False): + """Swish - Described originally as SiLU (https://arxiv.org/abs/1702.03118v3) + and also as Swish (https://arxiv.org/abs/1710.05941). + + TODO Rename to SiLU with addition to PyTorch + """ + return x.mul(x.sigmoid()) + + +@torch.jit.script +def mish_jit(x, _inplace: bool = False): + """Mish: A Self Regularized Non-Monotonic Neural Activation Function - https://arxiv.org/abs/1908.08681 + """ + return x.mul(F.softplus(x).tanh()) + + +class SwishJit(nn.Module): + def __init__(self, inplace: bool = False): + super(SwishJit, self).__init__() + + def forward(self, x): + return swish_jit(x) + + +class MishJit(nn.Module): + def __init__(self, inplace: bool = False): + super(MishJit, self).__init__() + + def forward(self, x): + return mish_jit(x) + + +@torch.jit.script +def hard_sigmoid_jit(x, inplace: bool = False): + # return F.relu6(x + 3.) / 6. + return (x + 3).clamp(min=0, max=6).div(6.) # clamp seems ever so slightly faster? + + +class HardSigmoidJit(nn.Module): + def __init__(self, inplace: bool = False): + super(HardSigmoidJit, self).__init__() + + def forward(self, x): + return hard_sigmoid_jit(x) + + +@torch.jit.script +def hard_swish_jit(x, inplace: bool = False): + # return x * (F.relu6(x + 3.) / 6) + return x * (x + 3).clamp(min=0, max=6).div(6.) # clamp seems ever so slightly faster? + + +class HardSwishJit(nn.Module): + def __init__(self, inplace: bool = False): + super(HardSwishJit, self).__init__() + + def forward(self, x): + return hard_swish_jit(x) diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations_me.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations_me.py new file mode 100644 index 0000000000000000000000000000000000000000..e91df5a50fdbe40bc386e2541a4fda743ad95e9a --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/activations/activations_me.py @@ -0,0 +1,174 @@ +""" Activations (memory-efficient w/ custom autograd) + +A collection of activations fn and modules with a common interface so that they can +easily be swapped. All have an `inplace` arg even if not used. + +These activations are not compatible with jit scripting or ONNX export of the model, please use either +the JIT or basic versions of the activations. + +Copyright 2020 Ross Wightman +""" + +import torch +from torch import nn as nn +from torch.nn import functional as F + + +__all__ = ['swish_me', 'SwishMe', 'mish_me', 'MishMe', + 'hard_sigmoid_me', 'HardSigmoidMe', 'hard_swish_me', 'HardSwishMe'] + + +@torch.jit.script +def swish_jit_fwd(x): + return x.mul(torch.sigmoid(x)) + + +@torch.jit.script +def swish_jit_bwd(x, grad_output): + x_sigmoid = torch.sigmoid(x) + return grad_output * (x_sigmoid * (1 + x * (1 - x_sigmoid))) + + +class SwishJitAutoFn(torch.autograd.Function): + """ torch.jit.script optimised Swish w/ memory-efficient checkpoint + Inspired by conversation btw Jeremy Howard & Adam Pazske + https://twitter.com/jeremyphoward/status/1188251041835315200 + + Swish - Described originally as SiLU (https://arxiv.org/abs/1702.03118v3) + and also as Swish (https://arxiv.org/abs/1710.05941). + + TODO Rename to SiLU with addition to PyTorch + """ + + @staticmethod + def forward(ctx, x): + ctx.save_for_backward(x) + return swish_jit_fwd(x) + + @staticmethod + def backward(ctx, grad_output): + x = ctx.saved_tensors[0] + return swish_jit_bwd(x, grad_output) + + +def swish_me(x, inplace=False): + return SwishJitAutoFn.apply(x) + + +class SwishMe(nn.Module): + def __init__(self, inplace: bool = False): + super(SwishMe, self).__init__() + + def forward(self, x): + return SwishJitAutoFn.apply(x) + + +@torch.jit.script +def mish_jit_fwd(x): + return x.mul(torch.tanh(F.softplus(x))) + + +@torch.jit.script +def mish_jit_bwd(x, grad_output): + x_sigmoid = torch.sigmoid(x) + x_tanh_sp = F.softplus(x).tanh() + return grad_output.mul(x_tanh_sp + x * x_sigmoid * (1 - x_tanh_sp * x_tanh_sp)) + + +class MishJitAutoFn(torch.autograd.Function): + """ Mish: A Self Regularized Non-Monotonic Neural Activation Function - https://arxiv.org/abs/1908.08681 + A memory efficient, jit scripted variant of Mish + """ + @staticmethod + def forward(ctx, x): + ctx.save_for_backward(x) + return mish_jit_fwd(x) + + @staticmethod + def backward(ctx, grad_output): + x = ctx.saved_tensors[0] + return mish_jit_bwd(x, grad_output) + + +def mish_me(x, inplace=False): + return MishJitAutoFn.apply(x) + + +class MishMe(nn.Module): + def __init__(self, inplace: bool = False): + super(MishMe, self).__init__() + + def forward(self, x): + return MishJitAutoFn.apply(x) + + +@torch.jit.script +def hard_sigmoid_jit_fwd(x, inplace: bool = False): + return (x + 3).clamp(min=0, max=6).div(6.) + + +@torch.jit.script +def hard_sigmoid_jit_bwd(x, grad_output): + m = torch.ones_like(x) * ((x >= -3.) & (x <= 3.)) / 6. + return grad_output * m + + +class HardSigmoidJitAutoFn(torch.autograd.Function): + @staticmethod + def forward(ctx, x): + ctx.save_for_backward(x) + return hard_sigmoid_jit_fwd(x) + + @staticmethod + def backward(ctx, grad_output): + x = ctx.saved_tensors[0] + return hard_sigmoid_jit_bwd(x, grad_output) + + +def hard_sigmoid_me(x, inplace: bool = False): + return HardSigmoidJitAutoFn.apply(x) + + +class HardSigmoidMe(nn.Module): + def __init__(self, inplace: bool = False): + super(HardSigmoidMe, self).__init__() + + def forward(self, x): + return HardSigmoidJitAutoFn.apply(x) + + +@torch.jit.script +def hard_swish_jit_fwd(x): + return x * (x + 3).clamp(min=0, max=6).div(6.) + + +@torch.jit.script +def hard_swish_jit_bwd(x, grad_output): + m = torch.ones_like(x) * (x >= 3.) + m = torch.where((x >= -3.) & (x <= 3.), x / 3. + .5, m) + return grad_output * m + + +class HardSwishJitAutoFn(torch.autograd.Function): + """A memory efficient, jit-scripted HardSwish activation""" + @staticmethod + def forward(ctx, x): + ctx.save_for_backward(x) + return hard_swish_jit_fwd(x) + + @staticmethod + def backward(ctx, grad_output): + x = ctx.saved_tensors[0] + return hard_swish_jit_bwd(x, grad_output) + + +def hard_swish_me(x, inplace=False): + return HardSwishJitAutoFn.apply(x) + + +class HardSwishMe(nn.Module): + def __init__(self, inplace: bool = False): + super(HardSwishMe, self).__init__() + + def forward(self, x): + return HardSwishJitAutoFn.apply(x) diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/config.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/config.py new file mode 100644 index 0000000000000000000000000000000000000000..27d5307fd9ee0246f1e35f41520f17385d23f1dd --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/config.py @@ -0,0 +1,123 @@ +""" Global layer config state +""" +from typing import Any, Optional + +__all__ = [ + 'is_exportable', 'is_scriptable', 'is_no_jit', 'layer_config_kwargs', + 'set_exportable', 'set_scriptable', 'set_no_jit', 'set_layer_config' +] + +# Set to True if prefer to have layers with no jit optimization (includes activations) +_NO_JIT = False + +# Set to True if prefer to have activation layers with no jit optimization +# NOTE not currently used as no difference between no_jit and no_activation jit as only layers obeying +# the jit flags so far are activations. This will change as more layers are updated and/or added. +_NO_ACTIVATION_JIT = False + +# Set to True if exporting a model with Same padding via ONNX +_EXPORTABLE = False + +# Set to True if wanting to use torch.jit.script on a model +_SCRIPTABLE = False + + +def is_no_jit(): + return _NO_JIT + + +class set_no_jit: + def __init__(self, mode: bool) -> None: + global _NO_JIT + self.prev = _NO_JIT + _NO_JIT = mode + + def __enter__(self) -> None: + pass + + def __exit__(self, *args: Any) -> bool: + global _NO_JIT + _NO_JIT = self.prev + return False + + +def is_exportable(): + return _EXPORTABLE + + +class set_exportable: + def __init__(self, mode: bool) -> None: + global _EXPORTABLE + self.prev = _EXPORTABLE + _EXPORTABLE = mode + + def __enter__(self) -> None: + pass + + def __exit__(self, *args: Any) -> bool: + global _EXPORTABLE + _EXPORTABLE = self.prev + return False + + +def is_scriptable(): + return _SCRIPTABLE + + +class set_scriptable: + def __init__(self, mode: bool) -> None: + global _SCRIPTABLE + self.prev = _SCRIPTABLE + _SCRIPTABLE = mode + + def __enter__(self) -> None: + pass + + def __exit__(self, *args: Any) -> bool: + global _SCRIPTABLE + _SCRIPTABLE = self.prev + return False + + +class set_layer_config: + """ Layer config context manager that allows setting all layer config flags at once. + If a flag arg is None, it will not change the current value. + """ + def __init__( + self, + scriptable: Optional[bool] = None, + exportable: Optional[bool] = None, + no_jit: Optional[bool] = None, + no_activation_jit: Optional[bool] = None): + global _SCRIPTABLE + global _EXPORTABLE + global _NO_JIT + global _NO_ACTIVATION_JIT + self.prev = _SCRIPTABLE, _EXPORTABLE, _NO_JIT, _NO_ACTIVATION_JIT + if scriptable is not None: + _SCRIPTABLE = scriptable + if exportable is not None: + _EXPORTABLE = exportable + if no_jit is not None: + _NO_JIT = no_jit + if no_activation_jit is not None: + _NO_ACTIVATION_JIT = no_activation_jit + + def __enter__(self) -> None: + pass + + def __exit__(self, *args: Any) -> bool: + global _SCRIPTABLE + global _EXPORTABLE + global _NO_JIT + global _NO_ACTIVATION_JIT + _SCRIPTABLE, _EXPORTABLE, _NO_JIT, _NO_ACTIVATION_JIT = self.prev + return False + + +def layer_config_kwargs(kwargs): + """ Consume config kwargs and return contextmgr obj """ + return set_layer_config( + scriptable=kwargs.pop('scriptable', None), + exportable=kwargs.pop('exportable', None), + no_jit=kwargs.pop('no_jit', None)) diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/conv2d_layers.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/conv2d_layers.py new file mode 100644 index 0000000000000000000000000000000000000000..d8467460c4b36e54c83ce2dcd3ebe91d3432cad2 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/conv2d_layers.py @@ -0,0 +1,304 @@ +""" Conv2D w/ SAME padding, CondConv, MixedConv + +A collection of conv layers and padding helpers needed by EfficientNet, MixNet, and +MobileNetV3 models that maintain weight compatibility with original Tensorflow models. + +Copyright 2020 Ross Wightman +""" +import collections.abc +import math +from functools import partial +from itertools import repeat +from typing import Tuple, Optional + +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F + +from .config import * + + +# From PyTorch internals +def _ntuple(n): + def parse(x): + if isinstance(x, collections.abc.Iterable): + return x + return tuple(repeat(x, n)) + return parse + + +_single = _ntuple(1) +_pair = _ntuple(2) +_triple = _ntuple(3) +_quadruple = _ntuple(4) + + +def _is_static_pad(kernel_size, stride=1, dilation=1, **_): + return stride == 1 and (dilation * (kernel_size - 1)) % 2 == 0 + + +def _get_padding(kernel_size, stride=1, dilation=1, **_): + padding = ((stride - 1) + dilation * (kernel_size - 1)) // 2 + return padding + + +def _calc_same_pad(i: int, k: int, s: int, d: int): + return max((-(i // -s) - 1) * s + (k - 1) * d + 1 - i, 0) + + +def _same_pad_arg(input_size, kernel_size, stride, dilation): + ih, iw = input_size + kh, kw = kernel_size + pad_h = _calc_same_pad(ih, kh, stride[0], dilation[0]) + pad_w = _calc_same_pad(iw, kw, stride[1], dilation[1]) + return [pad_w // 2, pad_w - pad_w // 2, pad_h // 2, pad_h - pad_h // 2] + + +def _split_channels(num_chan, num_groups): + split = [num_chan // num_groups for _ in range(num_groups)] + split[0] += num_chan - sum(split) + return split + + +def conv2d_same( + x, weight: torch.Tensor, bias: Optional[torch.Tensor] = None, stride: Tuple[int, int] = (1, 1), + padding: Tuple[int, int] = (0, 0), dilation: Tuple[int, int] = (1, 1), groups: int = 1): + ih, iw = x.size()[-2:] + kh, kw = weight.size()[-2:] + pad_h = _calc_same_pad(ih, kh, stride[0], dilation[0]) + pad_w = _calc_same_pad(iw, kw, stride[1], dilation[1]) + x = F.pad(x, [pad_w // 2, pad_w - pad_w // 2, pad_h // 2, pad_h - pad_h // 2]) + return F.conv2d(x, weight, bias, stride, (0, 0), dilation, groups) + + +class Conv2dSame(nn.Conv2d): + """ Tensorflow like 'SAME' convolution wrapper for 2D convolutions + """ + + # pylint: disable=unused-argument + def __init__(self, in_channels, out_channels, kernel_size, stride=1, + padding=0, dilation=1, groups=1, bias=True): + super(Conv2dSame, self).__init__( + in_channels, out_channels, kernel_size, stride, 0, dilation, groups, bias) + + def forward(self, x): + return conv2d_same(x, self.weight, self.bias, self.stride, self.padding, self.dilation, self.groups) + + +class Conv2dSameExport(nn.Conv2d): + """ ONNX export friendly Tensorflow like 'SAME' convolution wrapper for 2D convolutions + + NOTE: This does not currently work with torch.jit.script + """ + + # pylint: disable=unused-argument + def __init__(self, in_channels, out_channels, kernel_size, stride=1, + padding=0, dilation=1, groups=1, bias=True): + super(Conv2dSameExport, self).__init__( + in_channels, out_channels, kernel_size, stride, 0, dilation, groups, bias) + self.pad = None + self.pad_input_size = (0, 0) + + def forward(self, x): + input_size = x.size()[-2:] + if self.pad is None: + pad_arg = _same_pad_arg(input_size, self.weight.size()[-2:], self.stride, self.dilation) + self.pad = nn.ZeroPad2d(pad_arg) + self.pad_input_size = input_size + + if self.pad is not None: + x = self.pad(x) + return F.conv2d( + x, self.weight, self.bias, self.stride, self.padding, self.dilation, self.groups) + + +def get_padding_value(padding, kernel_size, **kwargs): + dynamic = False + if isinstance(padding, str): + # for any string padding, the padding will be calculated for you, one of three ways + padding = padding.lower() + if padding == 'same': + # TF compatible 'SAME' padding, has a performance and GPU memory allocation impact + if _is_static_pad(kernel_size, **kwargs): + # static case, no extra overhead + padding = _get_padding(kernel_size, **kwargs) + else: + # dynamic padding + padding = 0 + dynamic = True + elif padding == 'valid': + # 'VALID' padding, same as padding=0 + padding = 0 + else: + # Default to PyTorch style 'same'-ish symmetric padding + padding = _get_padding(kernel_size, **kwargs) + return padding, dynamic + + +def create_conv2d_pad(in_chs, out_chs, kernel_size, **kwargs): + padding = kwargs.pop('padding', '') + kwargs.setdefault('bias', False) + padding, is_dynamic = get_padding_value(padding, kernel_size, **kwargs) + if is_dynamic: + if is_exportable(): + assert not is_scriptable() + return Conv2dSameExport(in_chs, out_chs, kernel_size, **kwargs) + else: + return Conv2dSame(in_chs, out_chs, kernel_size, **kwargs) + else: + return nn.Conv2d(in_chs, out_chs, kernel_size, padding=padding, **kwargs) + + +class MixedConv2d(nn.ModuleDict): + """ Mixed Grouped Convolution + Based on MDConv and GroupedConv in MixNet impl: + https://github.com/tensorflow/tpu/blob/master/models/official/mnasnet/mixnet/custom_layers.py + """ + + def __init__(self, in_channels, out_channels, kernel_size=3, + stride=1, padding='', dilation=1, depthwise=False, **kwargs): + super(MixedConv2d, self).__init__() + + kernel_size = kernel_size if isinstance(kernel_size, list) else [kernel_size] + num_groups = len(kernel_size) + in_splits = _split_channels(in_channels, num_groups) + out_splits = _split_channels(out_channels, num_groups) + self.in_channels = sum(in_splits) + self.out_channels = sum(out_splits) + for idx, (k, in_ch, out_ch) in enumerate(zip(kernel_size, in_splits, out_splits)): + conv_groups = out_ch if depthwise else 1 + self.add_module( + str(idx), + create_conv2d_pad( + in_ch, out_ch, k, stride=stride, + padding=padding, dilation=dilation, groups=conv_groups, **kwargs) + ) + self.splits = in_splits + + def forward(self, x): + x_split = torch.split(x, self.splits, 1) + x_out = [conv(x_split[i]) for i, conv in enumerate(self.values())] + x = torch.cat(x_out, 1) + return x + + +def get_condconv_initializer(initializer, num_experts, expert_shape): + def condconv_initializer(weight): + """CondConv initializer function.""" + num_params = np.prod(expert_shape) + if (len(weight.shape) != 2 or weight.shape[0] != num_experts or + weight.shape[1] != num_params): + raise (ValueError( + 'CondConv variables must have shape [num_experts, num_params]')) + for i in range(num_experts): + initializer(weight[i].view(expert_shape)) + return condconv_initializer + + +class CondConv2d(nn.Module): + """ Conditional Convolution + Inspired by: https://github.com/tensorflow/tpu/blob/master/models/official/efficientnet/condconv/condconv_layers.py + + Grouped convolution hackery for parallel execution of the per-sample kernel filters inspired by this discussion: + https://github.com/pytorch/pytorch/issues/17983 + """ + __constants__ = ['bias', 'in_channels', 'out_channels', 'dynamic_padding'] + + def __init__(self, in_channels, out_channels, kernel_size=3, + stride=1, padding='', dilation=1, groups=1, bias=False, num_experts=4): + super(CondConv2d, self).__init__() + + self.in_channels = in_channels + self.out_channels = out_channels + self.kernel_size = _pair(kernel_size) + self.stride = _pair(stride) + padding_val, is_padding_dynamic = get_padding_value( + padding, kernel_size, stride=stride, dilation=dilation) + self.dynamic_padding = is_padding_dynamic # if in forward to work with torchscript + self.padding = _pair(padding_val) + self.dilation = _pair(dilation) + self.groups = groups + self.num_experts = num_experts + + self.weight_shape = (self.out_channels, self.in_channels // self.groups) + self.kernel_size + weight_num_param = 1 + for wd in self.weight_shape: + weight_num_param *= wd + self.weight = torch.nn.Parameter(torch.Tensor(self.num_experts, weight_num_param)) + + if bias: + self.bias_shape = (self.out_channels,) + self.bias = torch.nn.Parameter(torch.Tensor(self.num_experts, self.out_channels)) + else: + self.register_parameter('bias', None) + + self.reset_parameters() + + def reset_parameters(self): + init_weight = get_condconv_initializer( + partial(nn.init.kaiming_uniform_, a=math.sqrt(5)), self.num_experts, self.weight_shape) + init_weight(self.weight) + if self.bias is not None: + fan_in = np.prod(self.weight_shape[1:]) + bound = 1 / math.sqrt(fan_in) + init_bias = get_condconv_initializer( + partial(nn.init.uniform_, a=-bound, b=bound), self.num_experts, self.bias_shape) + init_bias(self.bias) + + def forward(self, x, routing_weights): + B, C, H, W = x.shape + weight = torch.matmul(routing_weights, self.weight) + new_weight_shape = (B * self.out_channels, self.in_channels // self.groups) + self.kernel_size + weight = weight.view(new_weight_shape) + bias = None + if self.bias is not None: + bias = torch.matmul(routing_weights, self.bias) + bias = bias.view(B * self.out_channels) + # move batch elements with channels so each batch element can be efficiently convolved with separate kernel + x = x.view(1, B * C, H, W) + if self.dynamic_padding: + out = conv2d_same( + x, weight, bias, stride=self.stride, padding=self.padding, + dilation=self.dilation, groups=self.groups * B) + else: + out = F.conv2d( + x, weight, bias, stride=self.stride, padding=self.padding, + dilation=self.dilation, groups=self.groups * B) + out = out.permute([1, 0, 2, 3]).view(B, self.out_channels, out.shape[-2], out.shape[-1]) + + # Literal port (from TF definition) + # x = torch.split(x, 1, 0) + # weight = torch.split(weight, 1, 0) + # if self.bias is not None: + # bias = torch.matmul(routing_weights, self.bias) + # bias = torch.split(bias, 1, 0) + # else: + # bias = [None] * B + # out = [] + # for xi, wi, bi in zip(x, weight, bias): + # wi = wi.view(*self.weight_shape) + # if bi is not None: + # bi = bi.view(*self.bias_shape) + # out.append(self.conv_fn( + # xi, wi, bi, stride=self.stride, padding=self.padding, + # dilation=self.dilation, groups=self.groups)) + # out = torch.cat(out, 0) + return out + + +def select_conv2d(in_chs, out_chs, kernel_size, **kwargs): + assert 'groups' not in kwargs # only use 'depthwise' bool arg + if isinstance(kernel_size, list): + assert 'num_experts' not in kwargs # MixNet + CondConv combo not supported currently + # We're going to use only lists for defining the MixedConv2d kernel groups, + # ints, tuples, other iterables will continue to pass to normal conv and specify h, w. + m = MixedConv2d(in_chs, out_chs, kernel_size, **kwargs) + else: + depthwise = kwargs.pop('depthwise', False) + groups = out_chs if depthwise else 1 + if 'num_experts' in kwargs and kwargs['num_experts'] > 0: + m = CondConv2d(in_chs, out_chs, kernel_size, groups=groups, **kwargs) + else: + m = create_conv2d_pad(in_chs, out_chs, kernel_size, groups=groups, **kwargs) + return m diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/efficientnet_builder.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/efficientnet_builder.py new file mode 100644 index 0000000000000000000000000000000000000000..95dd63d400e70d70664c5a433a2772363f865e61 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/efficientnet_builder.py @@ -0,0 +1,683 @@ +""" EfficientNet / MobileNetV3 Blocks and Builder + +Copyright 2020 Ross Wightman +""" +import re +from copy import deepcopy + +from .conv2d_layers import * +from geffnet.activations import * + +__all__ = ['get_bn_args_tf', 'resolve_bn_args', 'resolve_se_args', 'resolve_act_layer', 'make_divisible', + 'round_channels', 'drop_connect', 'SqueezeExcite', 'ConvBnAct', 'DepthwiseSeparableConv', + 'InvertedResidual', 'CondConvResidual', 'EdgeResidual', 'EfficientNetBuilder', 'decode_arch_def', + 'initialize_weight_default', 'initialize_weight_goog', 'BN_MOMENTUM_TF_DEFAULT', 'BN_EPS_TF_DEFAULT' +] + +# Defaults used for Google/Tensorflow training of mobile networks /w RMSprop as per +# papers and TF reference implementations. PT momentum equiv for TF decay is (1 - TF decay) +# NOTE: momentum varies btw .99 and .9997 depending on source +# .99 in official TF TPU impl +# .9997 (/w .999 in search space) for paper +# +# PyTorch defaults are momentum = .1, eps = 1e-5 +# +BN_MOMENTUM_TF_DEFAULT = 1 - 0.99 +BN_EPS_TF_DEFAULT = 1e-3 +_BN_ARGS_TF = dict(momentum=BN_MOMENTUM_TF_DEFAULT, eps=BN_EPS_TF_DEFAULT) + + +def get_bn_args_tf(): + return _BN_ARGS_TF.copy() + + +def resolve_bn_args(kwargs): + bn_args = get_bn_args_tf() if kwargs.pop('bn_tf', False) else {} + bn_momentum = kwargs.pop('bn_momentum', None) + if bn_momentum is not None: + bn_args['momentum'] = bn_momentum + bn_eps = kwargs.pop('bn_eps', None) + if bn_eps is not None: + bn_args['eps'] = bn_eps + return bn_args + + +_SE_ARGS_DEFAULT = dict( + gate_fn=sigmoid, + act_layer=None, # None == use containing block's activation layer + reduce_mid=False, + divisor=1) + + +def resolve_se_args(kwargs, in_chs, act_layer=None): + se_kwargs = kwargs.copy() if kwargs is not None else {} + # fill in args that aren't specified with the defaults + for k, v in _SE_ARGS_DEFAULT.items(): + se_kwargs.setdefault(k, v) + # some models, like MobilNetV3, calculate SE reduction chs from the containing block's mid_ch instead of in_ch + if not se_kwargs.pop('reduce_mid'): + se_kwargs['reduced_base_chs'] = in_chs + # act_layer override, if it remains None, the containing block's act_layer will be used + if se_kwargs['act_layer'] is None: + assert act_layer is not None + se_kwargs['act_layer'] = act_layer + return se_kwargs + + +def resolve_act_layer(kwargs, default='relu'): + act_layer = kwargs.pop('act_layer', default) + if isinstance(act_layer, str): + act_layer = get_act_layer(act_layer) + return act_layer + + +def make_divisible(v: int, divisor: int = 8, min_value: int = None): + min_value = min_value or divisor + new_v = max(min_value, int(v + divisor / 2) // divisor * divisor) + if new_v < 0.9 * v: # ensure round down does not go down by more than 10%. + new_v += divisor + return new_v + + +def round_channels(channels, multiplier=1.0, divisor=8, channel_min=None): + """Round number of filters based on depth multiplier.""" + if not multiplier: + return channels + channels *= multiplier + return make_divisible(channels, divisor, channel_min) + + +def drop_connect(inputs, training: bool = False, drop_connect_rate: float = 0.): + """Apply drop connect.""" + if not training: + return inputs + + keep_prob = 1 - drop_connect_rate + random_tensor = keep_prob + torch.rand( + (inputs.size()[0], 1, 1, 1), dtype=inputs.dtype, device=inputs.device) + random_tensor.floor_() # binarize + output = inputs.div(keep_prob) * random_tensor + return output + + +class SqueezeExcite(nn.Module): + + def __init__(self, in_chs, se_ratio=0.25, reduced_base_chs=None, act_layer=nn.ReLU, gate_fn=sigmoid, divisor=1): + super(SqueezeExcite, self).__init__() + reduced_chs = make_divisible((reduced_base_chs or in_chs) * se_ratio, divisor) + self.conv_reduce = nn.Conv2d(in_chs, reduced_chs, 1, bias=True) + self.act1 = act_layer(inplace=True) + self.conv_expand = nn.Conv2d(reduced_chs, in_chs, 1, bias=True) + self.gate_fn = gate_fn + + def forward(self, x): + x_se = x.mean((2, 3), keepdim=True) + x_se = self.conv_reduce(x_se) + x_se = self.act1(x_se) + x_se = self.conv_expand(x_se) + x = x * self.gate_fn(x_se) + return x + + +class ConvBnAct(nn.Module): + def __init__(self, in_chs, out_chs, kernel_size, + stride=1, pad_type='', act_layer=nn.ReLU, norm_layer=nn.BatchNorm2d, norm_kwargs=None): + super(ConvBnAct, self).__init__() + assert stride in [1, 2] + norm_kwargs = norm_kwargs or {} + self.conv = select_conv2d(in_chs, out_chs, kernel_size, stride=stride, padding=pad_type) + self.bn1 = norm_layer(out_chs, **norm_kwargs) + self.act1 = act_layer(inplace=True) + + def forward(self, x): + x = self.conv(x) + x = self.bn1(x) + x = self.act1(x) + return x + + +class DepthwiseSeparableConv(nn.Module): + """ DepthwiseSeparable block + Used for DS convs in MobileNet-V1 and in the place of IR blocks with an expansion + factor of 1.0. This is an alternative to having a IR with optional first pw conv. + """ + def __init__(self, in_chs, out_chs, dw_kernel_size=3, + stride=1, pad_type='', act_layer=nn.ReLU, noskip=False, + pw_kernel_size=1, pw_act=False, se_ratio=0., se_kwargs=None, + norm_layer=nn.BatchNorm2d, norm_kwargs=None, drop_connect_rate=0.): + super(DepthwiseSeparableConv, self).__init__() + assert stride in [1, 2] + norm_kwargs = norm_kwargs or {} + self.has_residual = (stride == 1 and in_chs == out_chs) and not noskip + self.drop_connect_rate = drop_connect_rate + + self.conv_dw = select_conv2d( + in_chs, in_chs, dw_kernel_size, stride=stride, padding=pad_type, depthwise=True) + self.bn1 = norm_layer(in_chs, **norm_kwargs) + self.act1 = act_layer(inplace=True) + + # Squeeze-and-excitation + if se_ratio is not None and se_ratio > 0.: + se_kwargs = resolve_se_args(se_kwargs, in_chs, act_layer) + self.se = SqueezeExcite(in_chs, se_ratio=se_ratio, **se_kwargs) + else: + self.se = nn.Identity() + + self.conv_pw = select_conv2d(in_chs, out_chs, pw_kernel_size, padding=pad_type) + self.bn2 = norm_layer(out_chs, **norm_kwargs) + self.act2 = act_layer(inplace=True) if pw_act else nn.Identity() + + def forward(self, x): + residual = x + + x = self.conv_dw(x) + x = self.bn1(x) + x = self.act1(x) + + x = self.se(x) + + x = self.conv_pw(x) + x = self.bn2(x) + x = self.act2(x) + + if self.has_residual: + if self.drop_connect_rate > 0.: + x = drop_connect(x, self.training, self.drop_connect_rate) + x += residual + return x + + +class InvertedResidual(nn.Module): + """ Inverted residual block w/ optional SE""" + + def __init__(self, in_chs, out_chs, dw_kernel_size=3, + stride=1, pad_type='', act_layer=nn.ReLU, noskip=False, + exp_ratio=1.0, exp_kernel_size=1, pw_kernel_size=1, + se_ratio=0., se_kwargs=None, norm_layer=nn.BatchNorm2d, norm_kwargs=None, + conv_kwargs=None, drop_connect_rate=0.): + super(InvertedResidual, self).__init__() + norm_kwargs = norm_kwargs or {} + conv_kwargs = conv_kwargs or {} + mid_chs: int = make_divisible(in_chs * exp_ratio) + self.has_residual = (in_chs == out_chs and stride == 1) and not noskip + self.drop_connect_rate = drop_connect_rate + + # Point-wise expansion + self.conv_pw = select_conv2d(in_chs, mid_chs, exp_kernel_size, padding=pad_type, **conv_kwargs) + self.bn1 = norm_layer(mid_chs, **norm_kwargs) + self.act1 = act_layer(inplace=True) + + # Depth-wise convolution + self.conv_dw = select_conv2d( + mid_chs, mid_chs, dw_kernel_size, stride=stride, padding=pad_type, depthwise=True, **conv_kwargs) + self.bn2 = norm_layer(mid_chs, **norm_kwargs) + self.act2 = act_layer(inplace=True) + + # Squeeze-and-excitation + if se_ratio is not None and se_ratio > 0.: + se_kwargs = resolve_se_args(se_kwargs, in_chs, act_layer) + self.se = SqueezeExcite(mid_chs, se_ratio=se_ratio, **se_kwargs) + else: + self.se = nn.Identity() # for jit.script compat + + # Point-wise linear projection + self.conv_pwl = select_conv2d(mid_chs, out_chs, pw_kernel_size, padding=pad_type, **conv_kwargs) + self.bn3 = norm_layer(out_chs, **norm_kwargs) + + def forward(self, x): + residual = x + + # Point-wise expansion + x = self.conv_pw(x) + x = self.bn1(x) + x = self.act1(x) + + # Depth-wise convolution + x = self.conv_dw(x) + x = self.bn2(x) + x = self.act2(x) + + # Squeeze-and-excitation + x = self.se(x) + + # Point-wise linear projection + x = self.conv_pwl(x) + x = self.bn3(x) + + if self.has_residual: + if self.drop_connect_rate > 0.: + x = drop_connect(x, self.training, self.drop_connect_rate) + x += residual + return x + + +class CondConvResidual(InvertedResidual): + """ Inverted residual block w/ CondConv routing""" + + def __init__(self, in_chs, out_chs, dw_kernel_size=3, + stride=1, pad_type='', act_layer=nn.ReLU, noskip=False, + exp_ratio=1.0, exp_kernel_size=1, pw_kernel_size=1, + se_ratio=0., se_kwargs=None, norm_layer=nn.BatchNorm2d, norm_kwargs=None, + num_experts=0, drop_connect_rate=0.): + + self.num_experts = num_experts + conv_kwargs = dict(num_experts=self.num_experts) + + super(CondConvResidual, self).__init__( + in_chs, out_chs, dw_kernel_size=dw_kernel_size, stride=stride, pad_type=pad_type, + act_layer=act_layer, noskip=noskip, exp_ratio=exp_ratio, exp_kernel_size=exp_kernel_size, + pw_kernel_size=pw_kernel_size, se_ratio=se_ratio, se_kwargs=se_kwargs, + norm_layer=norm_layer, norm_kwargs=norm_kwargs, conv_kwargs=conv_kwargs, + drop_connect_rate=drop_connect_rate) + + self.routing_fn = nn.Linear(in_chs, self.num_experts) + + def forward(self, x): + residual = x + + # CondConv routing + pooled_inputs = F.adaptive_avg_pool2d(x, 1).flatten(1) + routing_weights = torch.sigmoid(self.routing_fn(pooled_inputs)) + + # Point-wise expansion + x = self.conv_pw(x, routing_weights) + x = self.bn1(x) + x = self.act1(x) + + # Depth-wise convolution + x = self.conv_dw(x, routing_weights) + x = self.bn2(x) + x = self.act2(x) + + # Squeeze-and-excitation + x = self.se(x) + + # Point-wise linear projection + x = self.conv_pwl(x, routing_weights) + x = self.bn3(x) + + if self.has_residual: + if self.drop_connect_rate > 0.: + x = drop_connect(x, self.training, self.drop_connect_rate) + x += residual + return x + + +class EdgeResidual(nn.Module): + """ EdgeTPU Residual block with expansion convolution followed by pointwise-linear w/ stride""" + + def __init__(self, in_chs, out_chs, exp_kernel_size=3, exp_ratio=1.0, fake_in_chs=0, + stride=1, pad_type='', act_layer=nn.ReLU, noskip=False, pw_kernel_size=1, + se_ratio=0., se_kwargs=None, norm_layer=nn.BatchNorm2d, norm_kwargs=None, drop_connect_rate=0.): + super(EdgeResidual, self).__init__() + norm_kwargs = norm_kwargs or {} + mid_chs = make_divisible(fake_in_chs * exp_ratio) if fake_in_chs > 0 else make_divisible(in_chs * exp_ratio) + self.has_residual = (in_chs == out_chs and stride == 1) and not noskip + self.drop_connect_rate = drop_connect_rate + + # Expansion convolution + self.conv_exp = select_conv2d(in_chs, mid_chs, exp_kernel_size, padding=pad_type) + self.bn1 = norm_layer(mid_chs, **norm_kwargs) + self.act1 = act_layer(inplace=True) + + # Squeeze-and-excitation + if se_ratio is not None and se_ratio > 0.: + se_kwargs = resolve_se_args(se_kwargs, in_chs, act_layer) + self.se = SqueezeExcite(mid_chs, se_ratio=se_ratio, **se_kwargs) + else: + self.se = nn.Identity() + + # Point-wise linear projection + self.conv_pwl = select_conv2d(mid_chs, out_chs, pw_kernel_size, stride=stride, padding=pad_type) + self.bn2 = nn.BatchNorm2d(out_chs, **norm_kwargs) + + def forward(self, x): + residual = x + + # Expansion convolution + x = self.conv_exp(x) + x = self.bn1(x) + x = self.act1(x) + + # Squeeze-and-excitation + x = self.se(x) + + # Point-wise linear projection + x = self.conv_pwl(x) + x = self.bn2(x) + + if self.has_residual: + if self.drop_connect_rate > 0.: + x = drop_connect(x, self.training, self.drop_connect_rate) + x += residual + + return x + + +class EfficientNetBuilder: + """ Build Trunk Blocks for Efficient/Mobile Networks + + This ended up being somewhat of a cross between + https://github.com/tensorflow/tpu/blob/master/models/official/mnasnet/mnasnet_models.py + and + https://github.com/facebookresearch/maskrcnn-benchmark/blob/master/maskrcnn_benchmark/modeling/backbone/fbnet_builder.py + + """ + + def __init__(self, channel_multiplier=1.0, channel_divisor=8, channel_min=None, + pad_type='', act_layer=None, se_kwargs=None, + norm_layer=nn.BatchNorm2d, norm_kwargs=None, drop_connect_rate=0.): + self.channel_multiplier = channel_multiplier + self.channel_divisor = channel_divisor + self.channel_min = channel_min + self.pad_type = pad_type + self.act_layer = act_layer + self.se_kwargs = se_kwargs + self.norm_layer = norm_layer + self.norm_kwargs = norm_kwargs + self.drop_connect_rate = drop_connect_rate + + # updated during build + self.in_chs = None + self.block_idx = 0 + self.block_count = 0 + + def _round_channels(self, chs): + return round_channels(chs, self.channel_multiplier, self.channel_divisor, self.channel_min) + + def _make_block(self, ba): + bt = ba.pop('block_type') + ba['in_chs'] = self.in_chs + ba['out_chs'] = self._round_channels(ba['out_chs']) + if 'fake_in_chs' in ba and ba['fake_in_chs']: + # FIXME this is a hack to work around mismatch in origin impl input filters for EdgeTPU + ba['fake_in_chs'] = self._round_channels(ba['fake_in_chs']) + ba['norm_layer'] = self.norm_layer + ba['norm_kwargs'] = self.norm_kwargs + ba['pad_type'] = self.pad_type + # block act fn overrides the model default + ba['act_layer'] = ba['act_layer'] if ba['act_layer'] is not None else self.act_layer + assert ba['act_layer'] is not None + if bt == 'ir': + ba['drop_connect_rate'] = self.drop_connect_rate * self.block_idx / self.block_count + ba['se_kwargs'] = self.se_kwargs + if ba.get('num_experts', 0) > 0: + block = CondConvResidual(**ba) + else: + block = InvertedResidual(**ba) + elif bt == 'ds' or bt == 'dsa': + ba['drop_connect_rate'] = self.drop_connect_rate * self.block_idx / self.block_count + ba['se_kwargs'] = self.se_kwargs + block = DepthwiseSeparableConv(**ba) + elif bt == 'er': + ba['drop_connect_rate'] = self.drop_connect_rate * self.block_idx / self.block_count + ba['se_kwargs'] = self.se_kwargs + block = EdgeResidual(**ba) + elif bt == 'cn': + block = ConvBnAct(**ba) + else: + assert False, 'Uknkown block type (%s) while building model.' % bt + self.in_chs = ba['out_chs'] # update in_chs for arg of next block + return block + + def _make_stack(self, stack_args): + blocks = [] + # each stack (stage) contains a list of block arguments + for i, ba in enumerate(stack_args): + if i >= 1: + # only the first block in any stack can have a stride > 1 + ba['stride'] = 1 + block = self._make_block(ba) + blocks.append(block) + self.block_idx += 1 # incr global idx (across all stacks) + return nn.Sequential(*blocks) + + def __call__(self, in_chs, block_args): + """ Build the blocks + Args: + in_chs: Number of input-channels passed to first block + block_args: A list of lists, outer list defines stages, inner + list contains strings defining block configuration(s) + Return: + List of block stacks (each stack wrapped in nn.Sequential) + """ + self.in_chs = in_chs + self.block_count = sum([len(x) for x in block_args]) + self.block_idx = 0 + blocks = [] + # outer list of block_args defines the stacks ('stages' by some conventions) + for stack_idx, stack in enumerate(block_args): + assert isinstance(stack, list) + stack = self._make_stack(stack) + blocks.append(stack) + return blocks + + +def _parse_ksize(ss): + if ss.isdigit(): + return int(ss) + else: + return [int(k) for k in ss.split('.')] + + +def _decode_block_str(block_str): + """ Decode block definition string + + Gets a list of block arg (dicts) through a string notation of arguments. + E.g. ir_r2_k3_s2_e1_i32_o16_se0.25_noskip + + All args can exist in any order with the exception of the leading string which + is assumed to indicate the block type. + + leading string - block type ( + ir = InvertedResidual, ds = DepthwiseSep, dsa = DeptwhiseSep with pw act, cn = ConvBnAct) + r - number of repeat blocks, + k - kernel size, + s - strides (1-9), + e - expansion ratio, + c - output channels, + se - squeeze/excitation ratio + n - activation fn ('re', 'r6', 'hs', or 'sw') + Args: + block_str: a string representation of block arguments. + Returns: + A list of block args (dicts) + Raises: + ValueError: if the string def not properly specified (TODO) + """ + assert isinstance(block_str, str) + ops = block_str.split('_') + block_type = ops[0] # take the block type off the front + ops = ops[1:] + options = {} + noskip = False + for op in ops: + # string options being checked on individual basis, combine if they grow + if op == 'noskip': + noskip = True + elif op.startswith('n'): + # activation fn + key = op[0] + v = op[1:] + if v == 're': + value = get_act_layer('relu') + elif v == 'r6': + value = get_act_layer('relu6') + elif v == 'hs': + value = get_act_layer('hard_swish') + elif v == 'sw': + value = get_act_layer('swish') + else: + continue + options[key] = value + else: + # all numeric options + splits = re.split(r'(\d.*)', op) + if len(splits) >= 2: + key, value = splits[:2] + options[key] = value + + # if act_layer is None, the model default (passed to model init) will be used + act_layer = options['n'] if 'n' in options else None + exp_kernel_size = _parse_ksize(options['a']) if 'a' in options else 1 + pw_kernel_size = _parse_ksize(options['p']) if 'p' in options else 1 + fake_in_chs = int(options['fc']) if 'fc' in options else 0 # FIXME hack to deal with in_chs issue in TPU def + + num_repeat = int(options['r']) + # each type of block has different valid arguments, fill accordingly + if block_type == 'ir': + block_args = dict( + block_type=block_type, + dw_kernel_size=_parse_ksize(options['k']), + exp_kernel_size=exp_kernel_size, + pw_kernel_size=pw_kernel_size, + out_chs=int(options['c']), + exp_ratio=float(options['e']), + se_ratio=float(options['se']) if 'se' in options else None, + stride=int(options['s']), + act_layer=act_layer, + noskip=noskip, + ) + if 'cc' in options: + block_args['num_experts'] = int(options['cc']) + elif block_type == 'ds' or block_type == 'dsa': + block_args = dict( + block_type=block_type, + dw_kernel_size=_parse_ksize(options['k']), + pw_kernel_size=pw_kernel_size, + out_chs=int(options['c']), + se_ratio=float(options['se']) if 'se' in options else None, + stride=int(options['s']), + act_layer=act_layer, + pw_act=block_type == 'dsa', + noskip=block_type == 'dsa' or noskip, + ) + elif block_type == 'er': + block_args = dict( + block_type=block_type, + exp_kernel_size=_parse_ksize(options['k']), + pw_kernel_size=pw_kernel_size, + out_chs=int(options['c']), + exp_ratio=float(options['e']), + fake_in_chs=fake_in_chs, + se_ratio=float(options['se']) if 'se' in options else None, + stride=int(options['s']), + act_layer=act_layer, + noskip=noskip, + ) + elif block_type == 'cn': + block_args = dict( + block_type=block_type, + kernel_size=int(options['k']), + out_chs=int(options['c']), + stride=int(options['s']), + act_layer=act_layer, + ) + else: + assert False, 'Unknown block type (%s)' % block_type + + return block_args, num_repeat + + +def _scale_stage_depth(stack_args, repeats, depth_multiplier=1.0, depth_trunc='ceil'): + """ Per-stage depth scaling + Scales the block repeats in each stage. This depth scaling impl maintains + compatibility with the EfficientNet scaling method, while allowing sensible + scaling for other models that may have multiple block arg definitions in each stage. + """ + + # We scale the total repeat count for each stage, there may be multiple + # block arg defs per stage so we need to sum. + num_repeat = sum(repeats) + if depth_trunc == 'round': + # Truncating to int by rounding allows stages with few repeats to remain + # proportionally smaller for longer. This is a good choice when stage definitions + # include single repeat stages that we'd prefer to keep that way as long as possible + num_repeat_scaled = max(1, round(num_repeat * depth_multiplier)) + else: + # The default for EfficientNet truncates repeats to int via 'ceil'. + # Any multiplier > 1.0 will result in an increased depth for every stage. + num_repeat_scaled = int(math.ceil(num_repeat * depth_multiplier)) + + # Proportionally distribute repeat count scaling to each block definition in the stage. + # Allocation is done in reverse as it results in the first block being less likely to be scaled. + # The first block makes less sense to repeat in most of the arch definitions. + repeats_scaled = [] + for r in repeats[::-1]: + rs = max(1, round((r / num_repeat * num_repeat_scaled))) + repeats_scaled.append(rs) + num_repeat -= r + num_repeat_scaled -= rs + repeats_scaled = repeats_scaled[::-1] + + # Apply the calculated scaling to each block arg in the stage + sa_scaled = [] + for ba, rep in zip(stack_args, repeats_scaled): + sa_scaled.extend([deepcopy(ba) for _ in range(rep)]) + return sa_scaled + + +def decode_arch_def(arch_def, depth_multiplier=1.0, depth_trunc='ceil', experts_multiplier=1, fix_first_last=False): + arch_args = [] + for stack_idx, block_strings in enumerate(arch_def): + assert isinstance(block_strings, list) + stack_args = [] + repeats = [] + for block_str in block_strings: + assert isinstance(block_str, str) + ba, rep = _decode_block_str(block_str) + if ba.get('num_experts', 0) > 0 and experts_multiplier > 1: + ba['num_experts'] *= experts_multiplier + stack_args.append(ba) + repeats.append(rep) + if fix_first_last and (stack_idx == 0 or stack_idx == len(arch_def) - 1): + arch_args.append(_scale_stage_depth(stack_args, repeats, 1.0, depth_trunc)) + else: + arch_args.append(_scale_stage_depth(stack_args, repeats, depth_multiplier, depth_trunc)) + return arch_args + + +def initialize_weight_goog(m, n='', fix_group_fanout=True): + # weight init as per Tensorflow Official impl + # https://github.com/tensorflow/tpu/blob/master/models/official/mnasnet/mnasnet_model.py + if isinstance(m, CondConv2d): + fan_out = m.kernel_size[0] * m.kernel_size[1] * m.out_channels + if fix_group_fanout: + fan_out //= m.groups + init_weight_fn = get_condconv_initializer( + lambda w: w.data.normal_(0, math.sqrt(2.0 / fan_out)), m.num_experts, m.weight_shape) + init_weight_fn(m.weight) + if m.bias is not None: + m.bias.data.zero_() + elif isinstance(m, nn.Conv2d): + fan_out = m.kernel_size[0] * m.kernel_size[1] * m.out_channels + if fix_group_fanout: + fan_out //= m.groups + m.weight.data.normal_(0, math.sqrt(2.0 / fan_out)) + if m.bias is not None: + m.bias.data.zero_() + elif isinstance(m, nn.BatchNorm2d): + m.weight.data.fill_(1.0) + m.bias.data.zero_() + elif isinstance(m, nn.Linear): + fan_out = m.weight.size(0) # fan-out + fan_in = 0 + if 'routing_fn' in n: + fan_in = m.weight.size(1) + init_range = 1.0 / math.sqrt(fan_in + fan_out) + m.weight.data.uniform_(-init_range, init_range) + m.bias.data.zero_() + + +def initialize_weight_default(m, n=''): + if isinstance(m, CondConv2d): + init_fn = get_condconv_initializer(partial( + nn.init.kaiming_normal_, mode='fan_out', nonlinearity='relu'), m.num_experts, m.weight_shape) + init_fn(m.weight) + elif isinstance(m, nn.Conv2d): + nn.init.kaiming_normal_(m.weight, mode='fan_out', nonlinearity='relu') + elif isinstance(m, nn.BatchNorm2d): + m.weight.data.fill_(1.0) + m.bias.data.zero_() + elif isinstance(m, nn.Linear): + nn.init.kaiming_uniform_(m.weight, mode='fan_in', nonlinearity='linear') diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/gen_efficientnet.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/gen_efficientnet.py new file mode 100644 index 0000000000000000000000000000000000000000..cd170d4cc5bed6ca82b61539902b470d3320c691 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/gen_efficientnet.py @@ -0,0 +1,1450 @@ +""" Generic Efficient Networks + +A generic MobileNet class with building blocks to support a variety of models: + +* EfficientNet (B0-B8, L2 + Tensorflow pretrained AutoAug/RandAug/AdvProp/NoisyStudent ports) + - EfficientNet: Rethinking Model Scaling for CNNs - https://arxiv.org/abs/1905.11946 + - CondConv: Conditionally Parameterized Convolutions for Efficient Inference - https://arxiv.org/abs/1904.04971 + - Adversarial Examples Improve Image Recognition - https://arxiv.org/abs/1911.09665 + - Self-training with Noisy Student improves ImageNet classification - https://arxiv.org/abs/1911.04252 + +* EfficientNet-Lite + +* MixNet (Small, Medium, and Large) + - MixConv: Mixed Depthwise Convolutional Kernels - https://arxiv.org/abs/1907.09595 + +* MNasNet B1, A1 (SE), Small + - MnasNet: Platform-Aware Neural Architecture Search for Mobile - https://arxiv.org/abs/1807.11626 + +* FBNet-C + - FBNet: Hardware-Aware Efficient ConvNet Design via Differentiable NAS - https://arxiv.org/abs/1812.03443 + +* Single-Path NAS Pixel1 + - Single-Path NAS: Designing Hardware-Efficient ConvNets - https://arxiv.org/abs/1904.02877 + +* And likely more... + +Hacked together by / Copyright 2020 Ross Wightman +""" +import torch.nn as nn +import torch.nn.functional as F + +from .config import layer_config_kwargs, is_scriptable +from .conv2d_layers import select_conv2d +from .helpers import load_pretrained +from .efficientnet_builder import * + +__all__ = ['GenEfficientNet', 'mnasnet_050', 'mnasnet_075', 'mnasnet_100', 'mnasnet_b1', 'mnasnet_140', + 'semnasnet_050', 'semnasnet_075', 'semnasnet_100', 'mnasnet_a1', 'semnasnet_140', 'mnasnet_small', + 'mobilenetv2_100', 'mobilenetv2_140', 'mobilenetv2_110d', 'mobilenetv2_120d', + 'fbnetc_100', 'spnasnet_100', 'efficientnet_b0', 'efficientnet_b1', 'efficientnet_b2', 'efficientnet_b3', + 'efficientnet_b4', 'efficientnet_b5', 'efficientnet_b6', 'efficientnet_b7', 'efficientnet_b8', + 'efficientnet_l2', 'efficientnet_es', 'efficientnet_em', 'efficientnet_el', + 'efficientnet_cc_b0_4e', 'efficientnet_cc_b0_8e', 'efficientnet_cc_b1_8e', + 'efficientnet_lite0', 'efficientnet_lite1', 'efficientnet_lite2', 'efficientnet_lite3', 'efficientnet_lite4', + 'tf_efficientnet_b0', 'tf_efficientnet_b1', 'tf_efficientnet_b2', 'tf_efficientnet_b3', + 'tf_efficientnet_b4', 'tf_efficientnet_b5', 'tf_efficientnet_b6', 'tf_efficientnet_b7', 'tf_efficientnet_b8', + 'tf_efficientnet_b0_ap', 'tf_efficientnet_b1_ap', 'tf_efficientnet_b2_ap', 'tf_efficientnet_b3_ap', + 'tf_efficientnet_b4_ap', 'tf_efficientnet_b5_ap', 'tf_efficientnet_b6_ap', 'tf_efficientnet_b7_ap', + 'tf_efficientnet_b8_ap', 'tf_efficientnet_b0_ns', 'tf_efficientnet_b1_ns', 'tf_efficientnet_b2_ns', + 'tf_efficientnet_b3_ns', 'tf_efficientnet_b4_ns', 'tf_efficientnet_b5_ns', 'tf_efficientnet_b6_ns', + 'tf_efficientnet_b7_ns', 'tf_efficientnet_l2_ns', 'tf_efficientnet_l2_ns_475', + 'tf_efficientnet_es', 'tf_efficientnet_em', 'tf_efficientnet_el', + 'tf_efficientnet_cc_b0_4e', 'tf_efficientnet_cc_b0_8e', 'tf_efficientnet_cc_b1_8e', + 'tf_efficientnet_lite0', 'tf_efficientnet_lite1', 'tf_efficientnet_lite2', 'tf_efficientnet_lite3', + 'tf_efficientnet_lite4', + 'mixnet_s', 'mixnet_m', 'mixnet_l', 'mixnet_xl', 'tf_mixnet_s', 'tf_mixnet_m', 'tf_mixnet_l'] + + +model_urls = { + 'mnasnet_050': None, + 'mnasnet_075': None, + 'mnasnet_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mnasnet_b1-74cb7081.pth', + 'mnasnet_140': None, + 'mnasnet_small': None, + + 'semnasnet_050': None, + 'semnasnet_075': None, + 'semnasnet_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mnasnet_a1-d9418771.pth', + 'semnasnet_140': None, + + 'mobilenetv2_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mobilenetv2_100_ra-b33bc2c4.pth', + 'mobilenetv2_110d': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mobilenetv2_110d_ra-77090ade.pth', + 'mobilenetv2_120d': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mobilenetv2_120d_ra-5987e2ed.pth', + 'mobilenetv2_140': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mobilenetv2_140_ra-21a4e913.pth', + + 'fbnetc_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/fbnetc_100-c345b898.pth', + 'spnasnet_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/spnasnet_100-048bc3f4.pth', + + 'efficientnet_b0': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/efficientnet_b0_ra-3dd342df.pth', + 'efficientnet_b1': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/efficientnet_b1-533bc792.pth', + 'efficientnet_b2': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/efficientnet_b2_ra-bcdf34b7.pth', + 'efficientnet_b3': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/efficientnet_b3_ra2-cf984f9c.pth', + 'efficientnet_b4': None, + 'efficientnet_b5': None, + 'efficientnet_b6': None, + 'efficientnet_b7': None, + 'efficientnet_b8': None, + 'efficientnet_l2': None, + + 'efficientnet_es': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/efficientnet_es_ra-f111e99c.pth', + 'efficientnet_em': None, + 'efficientnet_el': None, + + 'efficientnet_cc_b0_4e': None, + 'efficientnet_cc_b0_8e': None, + 'efficientnet_cc_b1_8e': None, + + 'efficientnet_lite0': 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/efficientnet_lite0_ra-37913777.pth', + 'efficientnet_lite1': None, + 'efficientnet_lite2': None, + 'efficientnet_lite3': None, + 'efficientnet_lite4': None, + + 'tf_efficientnet_b0': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b0_aa-827b6e33.pth', + 'tf_efficientnet_b1': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b1_aa-ea7a6ee0.pth', + 'tf_efficientnet_b2': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b2_aa-60c94f97.pth', + 'tf_efficientnet_b3': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b3_aa-84b4657e.pth', + 'tf_efficientnet_b4': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b4_aa-818f208c.pth', + 'tf_efficientnet_b5': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b5_ra-9a3e5369.pth', + 'tf_efficientnet_b6': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b6_aa-80ba17e4.pth', + 'tf_efficientnet_b7': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b7_ra-6c08e654.pth', + 'tf_efficientnet_b8': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b8_ra-572d5dd9.pth', + + 'tf_efficientnet_b0_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b0_ap-f262efe1.pth', + 'tf_efficientnet_b1_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b1_ap-44ef0a3d.pth', + 'tf_efficientnet_b2_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b2_ap-2f8e7636.pth', + 'tf_efficientnet_b3_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b3_ap-aad25bdd.pth', + 'tf_efficientnet_b4_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b4_ap-dedb23e6.pth', + 'tf_efficientnet_b5_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b5_ap-9e82fae8.pth', + 'tf_efficientnet_b6_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b6_ap-4ffb161f.pth', + 'tf_efficientnet_b7_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b7_ap-ddb28fec.pth', + 'tf_efficientnet_b8_ap': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b8_ap-00e169fa.pth', + + 'tf_efficientnet_b0_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b0_ns-c0e6a31c.pth', + 'tf_efficientnet_b1_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b1_ns-99dd0c41.pth', + 'tf_efficientnet_b2_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b2_ns-00306e48.pth', + 'tf_efficientnet_b3_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b3_ns-9d44bf68.pth', + 'tf_efficientnet_b4_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b4_ns-d6313a46.pth', + 'tf_efficientnet_b5_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b5_ns-6f26d0cf.pth', + 'tf_efficientnet_b6_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b6_ns-51548356.pth', + 'tf_efficientnet_b7_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_b7_ns-1dbc32de.pth', + 'tf_efficientnet_l2_ns_475': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_l2_ns_475-bebbd00a.pth', + 'tf_efficientnet_l2_ns': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_l2_ns-df73bb44.pth', + + 'tf_efficientnet_es': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_es-ca1afbfe.pth', + 'tf_efficientnet_em': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_em-e78cfe58.pth', + 'tf_efficientnet_el': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_el-5143854e.pth', + + 'tf_efficientnet_cc_b0_4e': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_cc_b0_4e-4362b6b2.pth', + 'tf_efficientnet_cc_b0_8e': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_cc_b0_8e-66184a25.pth', + 'tf_efficientnet_cc_b1_8e': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_cc_b1_8e-f7c79ae1.pth', + + 'tf_efficientnet_lite0': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_lite0-0aa007d2.pth', + 'tf_efficientnet_lite1': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_lite1-bde8b488.pth', + 'tf_efficientnet_lite2': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_lite2-dcccb7df.pth', + 'tf_efficientnet_lite3': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_lite3-b733e338.pth', + 'tf_efficientnet_lite4': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_efficientnet_lite4-741542c3.pth', + + 'mixnet_s': 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mixnet_s-a907afbc.pth', + 'mixnet_m': 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mixnet_m-4647fc68.pth', + 'mixnet_l': 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mixnet_l-5a9a2ed8.pth', + 'mixnet_xl': 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mixnet_xl_ra-aac3c00c.pth', + + 'tf_mixnet_s': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mixnet_s-89d3354b.pth', + 'tf_mixnet_m': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mixnet_m-0f4d8805.pth', + 'tf_mixnet_l': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mixnet_l-6c92e0c8.pth', +} + + +class GenEfficientNet(nn.Module): + """ Generic EfficientNets + + An implementation of mobile optimized networks that covers: + * EfficientNet (B0-B8, L2, CondConv, EdgeTPU) + * MixNet (Small, Medium, and Large, XL) + * MNASNet A1, B1, and small + * FBNet C + * Single-Path NAS Pixel1 + """ + + def __init__(self, block_args, num_classes=1000, in_chans=3, num_features=1280, stem_size=32, fix_stem=False, + channel_multiplier=1.0, channel_divisor=8, channel_min=None, + pad_type='', act_layer=nn.ReLU, drop_rate=0., drop_connect_rate=0., + se_kwargs=None, norm_layer=nn.BatchNorm2d, norm_kwargs=None, + weight_init='goog'): + super(GenEfficientNet, self).__init__() + self.drop_rate = drop_rate + + if not fix_stem: + stem_size = round_channels(stem_size, channel_multiplier, channel_divisor, channel_min) + self.conv_stem = select_conv2d(in_chans, stem_size, 3, stride=2, padding=pad_type) + self.bn1 = norm_layer(stem_size, **norm_kwargs) + self.act1 = act_layer(inplace=True) + in_chs = stem_size + + builder = EfficientNetBuilder( + channel_multiplier, channel_divisor, channel_min, + pad_type, act_layer, se_kwargs, norm_layer, norm_kwargs, drop_connect_rate) + self.blocks = nn.Sequential(*builder(in_chs, block_args)) + in_chs = builder.in_chs + + self.conv_head = select_conv2d(in_chs, num_features, 1, padding=pad_type) + self.bn2 = norm_layer(num_features, **norm_kwargs) + self.act2 = act_layer(inplace=True) + self.global_pool = nn.AdaptiveAvgPool2d(1) + self.classifier = nn.Linear(num_features, num_classes) + + for n, m in self.named_modules(): + if weight_init == 'goog': + initialize_weight_goog(m, n) + else: + initialize_weight_default(m, n) + + def features(self, x): + x = self.conv_stem(x) + x = self.bn1(x) + x = self.act1(x) + x = self.blocks(x) + x = self.conv_head(x) + x = self.bn2(x) + x = self.act2(x) + return x + + def as_sequential(self): + layers = [self.conv_stem, self.bn1, self.act1] + layers.extend(self.blocks) + layers.extend([ + self.conv_head, self.bn2, self.act2, + self.global_pool, nn.Flatten(), nn.Dropout(self.drop_rate), self.classifier]) + return nn.Sequential(*layers) + + def forward(self, x): + x = self.features(x) + x = self.global_pool(x) + x = x.flatten(1) + if self.drop_rate > 0.: + x = F.dropout(x, p=self.drop_rate, training=self.training) + return self.classifier(x) + + +def _create_model(model_kwargs, variant, pretrained=False): + as_sequential = model_kwargs.pop('as_sequential', False) + model = GenEfficientNet(**model_kwargs) + if pretrained: + load_pretrained(model, model_urls[variant]) + if as_sequential: + model = model.as_sequential() + return model + + +def _gen_mnasnet_a1(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """Creates a mnasnet-a1 model. + + Ref impl: https://github.com/tensorflow/tpu/tree/master/models/official/mnasnet + Paper: https://arxiv.org/pdf/1807.11626.pdf. + + Args: + channel_multiplier: multiplier to number of channels per layer. + """ + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_e1_c16_noskip'], + # stage 1, 112x112 in + ['ir_r2_k3_s2_e6_c24'], + # stage 2, 56x56 in + ['ir_r3_k5_s2_e3_c40_se0.25'], + # stage 3, 28x28 in + ['ir_r4_k3_s2_e6_c80'], + # stage 4, 14x14in + ['ir_r2_k3_s1_e6_c112_se0.25'], + # stage 5, 14x14in + ['ir_r3_k5_s2_e6_c160_se0.25'], + # stage 6, 7x7 in + ['ir_r1_k3_s1_e6_c320'], + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + stem_size=32, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_mnasnet_b1(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """Creates a mnasnet-b1 model. + + Ref impl: https://github.com/tensorflow/tpu/tree/master/models/official/mnasnet + Paper: https://arxiv.org/pdf/1807.11626.pdf. + + Args: + channel_multiplier: multiplier to number of channels per layer. + """ + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_c16_noskip'], + # stage 1, 112x112 in + ['ir_r3_k3_s2_e3_c24'], + # stage 2, 56x56 in + ['ir_r3_k5_s2_e3_c40'], + # stage 3, 28x28 in + ['ir_r3_k5_s2_e6_c80'], + # stage 4, 14x14in + ['ir_r2_k3_s1_e6_c96'], + # stage 5, 14x14in + ['ir_r4_k5_s2_e6_c192'], + # stage 6, 7x7 in + ['ir_r1_k3_s1_e6_c320_noskip'] + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + stem_size=32, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_mnasnet_small(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """Creates a mnasnet-b1 model. + + Ref impl: https://github.com/tensorflow/tpu/tree/master/models/official/mnasnet + Paper: https://arxiv.org/pdf/1807.11626.pdf. + + Args: + channel_multiplier: multiplier to number of channels per layer. + """ + arch_def = [ + ['ds_r1_k3_s1_c8'], + ['ir_r1_k3_s2_e3_c16'], + ['ir_r2_k3_s2_e6_c16'], + ['ir_r4_k5_s2_e6_c32_se0.25'], + ['ir_r3_k3_s1_e6_c32_se0.25'], + ['ir_r3_k5_s2_e6_c88_se0.25'], + ['ir_r1_k3_s1_e6_c144'] + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + stem_size=8, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_mobilenet_v2( + variant, channel_multiplier=1.0, depth_multiplier=1.0, fix_stem_head=False, pretrained=False, **kwargs): + """ Generate MobileNet-V2 network + Ref impl: https://github.com/tensorflow/models/blob/master/research/slim/nets/mobilenet/mobilenet_v2.py + Paper: https://arxiv.org/abs/1801.04381 + """ + arch_def = [ + ['ds_r1_k3_s1_c16'], + ['ir_r2_k3_s2_e6_c24'], + ['ir_r3_k3_s2_e6_c32'], + ['ir_r4_k3_s2_e6_c64'], + ['ir_r3_k3_s1_e6_c96'], + ['ir_r3_k3_s2_e6_c160'], + ['ir_r1_k3_s1_e6_c320'], + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def, depth_multiplier=depth_multiplier, fix_first_last=fix_stem_head), + num_features=1280 if fix_stem_head else round_channels(1280, channel_multiplier, 8, None), + stem_size=32, + fix_stem=fix_stem_head, + channel_multiplier=channel_multiplier, + norm_kwargs=resolve_bn_args(kwargs), + act_layer=nn.ReLU6, + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_fbnetc(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """ FBNet-C + + Paper: https://arxiv.org/abs/1812.03443 + Ref Impl: https://github.com/facebookresearch/maskrcnn-benchmark/blob/master/maskrcnn_benchmark/modeling/backbone/fbnet_modeldef.py + + NOTE: the impl above does not relate to the 'C' variant here, that was derived from paper, + it was used to confirm some building block details + """ + arch_def = [ + ['ir_r1_k3_s1_e1_c16'], + ['ir_r1_k3_s2_e6_c24', 'ir_r2_k3_s1_e1_c24'], + ['ir_r1_k5_s2_e6_c32', 'ir_r1_k5_s1_e3_c32', 'ir_r1_k5_s1_e6_c32', 'ir_r1_k3_s1_e6_c32'], + ['ir_r1_k5_s2_e6_c64', 'ir_r1_k5_s1_e3_c64', 'ir_r2_k5_s1_e6_c64'], + ['ir_r3_k5_s1_e6_c112', 'ir_r1_k5_s1_e3_c112'], + ['ir_r4_k5_s2_e6_c184'], + ['ir_r1_k3_s1_e6_c352'], + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + stem_size=16, + num_features=1984, # paper suggests this, but is not 100% clear + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_spnasnet(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """Creates the Single-Path NAS model from search targeted for Pixel1 phone. + + Paper: https://arxiv.org/abs/1904.02877 + + Args: + channel_multiplier: multiplier to number of channels per layer. + """ + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_c16_noskip'], + # stage 1, 112x112 in + ['ir_r3_k3_s2_e3_c24'], + # stage 2, 56x56 in + ['ir_r1_k5_s2_e6_c40', 'ir_r3_k3_s1_e3_c40'], + # stage 3, 28x28 in + ['ir_r1_k5_s2_e6_c80', 'ir_r3_k3_s1_e3_c80'], + # stage 4, 14x14in + ['ir_r1_k5_s1_e6_c96', 'ir_r3_k5_s1_e3_c96'], + # stage 5, 14x14in + ['ir_r4_k5_s2_e6_c192'], + # stage 6, 7x7 in + ['ir_r1_k3_s1_e6_c320_noskip'] + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + stem_size=32, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_efficientnet(variant, channel_multiplier=1.0, depth_multiplier=1.0, pretrained=False, **kwargs): + """Creates an EfficientNet model. + + Ref impl: https://github.com/tensorflow/tpu/blob/master/models/official/efficientnet/efficientnet_model.py + Paper: https://arxiv.org/abs/1905.11946 + + EfficientNet params + name: (channel_multiplier, depth_multiplier, resolution, dropout_rate) + 'efficientnet-b0': (1.0, 1.0, 224, 0.2), + 'efficientnet-b1': (1.0, 1.1, 240, 0.2), + 'efficientnet-b2': (1.1, 1.2, 260, 0.3), + 'efficientnet-b3': (1.2, 1.4, 300, 0.3), + 'efficientnet-b4': (1.4, 1.8, 380, 0.4), + 'efficientnet-b5': (1.6, 2.2, 456, 0.4), + 'efficientnet-b6': (1.8, 2.6, 528, 0.5), + 'efficientnet-b7': (2.0, 3.1, 600, 0.5), + 'efficientnet-b8': (2.2, 3.6, 672, 0.5), + + Args: + channel_multiplier: multiplier to number of channels per layer + depth_multiplier: multiplier to number of repeats per stage + + """ + arch_def = [ + ['ds_r1_k3_s1_e1_c16_se0.25'], + ['ir_r2_k3_s2_e6_c24_se0.25'], + ['ir_r2_k5_s2_e6_c40_se0.25'], + ['ir_r3_k3_s2_e6_c80_se0.25'], + ['ir_r3_k5_s1_e6_c112_se0.25'], + ['ir_r4_k5_s2_e6_c192_se0.25'], + ['ir_r1_k3_s1_e6_c320_se0.25'], + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def, depth_multiplier), + num_features=round_channels(1280, channel_multiplier, 8, None), + stem_size=32, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'swish'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs, + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_efficientnet_edge(variant, channel_multiplier=1.0, depth_multiplier=1.0, pretrained=False, **kwargs): + arch_def = [ + # NOTE `fc` is present to override a mismatch between stem channels and in chs not + # present in other models + ['er_r1_k3_s1_e4_c24_fc24_noskip'], + ['er_r2_k3_s2_e8_c32'], + ['er_r4_k3_s2_e8_c48'], + ['ir_r5_k5_s2_e8_c96'], + ['ir_r4_k5_s1_e8_c144'], + ['ir_r2_k5_s2_e8_c192'], + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def, depth_multiplier), + num_features=round_channels(1280, channel_multiplier, 8, None), + stem_size=32, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs, + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_efficientnet_condconv( + variant, channel_multiplier=1.0, depth_multiplier=1.0, experts_multiplier=1, pretrained=False, **kwargs): + """Creates an efficientnet-condconv model.""" + arch_def = [ + ['ds_r1_k3_s1_e1_c16_se0.25'], + ['ir_r2_k3_s2_e6_c24_se0.25'], + ['ir_r2_k5_s2_e6_c40_se0.25'], + ['ir_r3_k3_s2_e6_c80_se0.25'], + ['ir_r3_k5_s1_e6_c112_se0.25_cc4'], + ['ir_r4_k5_s2_e6_c192_se0.25_cc4'], + ['ir_r1_k3_s1_e6_c320_se0.25_cc4'], + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def, depth_multiplier, experts_multiplier=experts_multiplier), + num_features=round_channels(1280, channel_multiplier, 8, None), + stem_size=32, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'swish'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs, + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_efficientnet_lite(variant, channel_multiplier=1.0, depth_multiplier=1.0, pretrained=False, **kwargs): + """Creates an EfficientNet-Lite model. + + Ref impl: https://github.com/tensorflow/tpu/tree/master/models/official/efficientnet/lite + Paper: https://arxiv.org/abs/1905.11946 + + EfficientNet params + name: (channel_multiplier, depth_multiplier, resolution, dropout_rate) + 'efficientnet-lite0': (1.0, 1.0, 224, 0.2), + 'efficientnet-lite1': (1.0, 1.1, 240, 0.2), + 'efficientnet-lite2': (1.1, 1.2, 260, 0.3), + 'efficientnet-lite3': (1.2, 1.4, 280, 0.3), + 'efficientnet-lite4': (1.4, 1.8, 300, 0.3), + + Args: + channel_multiplier: multiplier to number of channels per layer + depth_multiplier: multiplier to number of repeats per stage + """ + arch_def = [ + ['ds_r1_k3_s1_e1_c16'], + ['ir_r2_k3_s2_e6_c24'], + ['ir_r2_k5_s2_e6_c40'], + ['ir_r3_k3_s2_e6_c80'], + ['ir_r3_k5_s1_e6_c112'], + ['ir_r4_k5_s2_e6_c192'], + ['ir_r1_k3_s1_e6_c320'], + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def, depth_multiplier, fix_first_last=True), + num_features=1280, + stem_size=32, + fix_stem=True, + channel_multiplier=channel_multiplier, + act_layer=nn.ReLU6, + norm_kwargs=resolve_bn_args(kwargs), + **kwargs, + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_mixnet_s(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """Creates a MixNet Small model. + + Ref impl: https://github.com/tensorflow/tpu/tree/master/models/official/mnasnet/mixnet + Paper: https://arxiv.org/abs/1907.09595 + """ + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_e1_c16'], # relu + # stage 1, 112x112 in + ['ir_r1_k3_a1.1_p1.1_s2_e6_c24', 'ir_r1_k3_a1.1_p1.1_s1_e3_c24'], # relu + # stage 2, 56x56 in + ['ir_r1_k3.5.7_s2_e6_c40_se0.5_nsw', 'ir_r3_k3.5_a1.1_p1.1_s1_e6_c40_se0.5_nsw'], # swish + # stage 3, 28x28 in + ['ir_r1_k3.5.7_p1.1_s2_e6_c80_se0.25_nsw', 'ir_r2_k3.5_p1.1_s1_e6_c80_se0.25_nsw'], # swish + # stage 4, 14x14in + ['ir_r1_k3.5.7_a1.1_p1.1_s1_e6_c120_se0.5_nsw', 'ir_r2_k3.5.7.9_a1.1_p1.1_s1_e3_c120_se0.5_nsw'], # swish + # stage 5, 14x14in + ['ir_r1_k3.5.7.9.11_s2_e6_c200_se0.5_nsw', 'ir_r2_k3.5.7.9_p1.1_s1_e6_c200_se0.5_nsw'], # swish + # 7x7 + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + num_features=1536, + stem_size=16, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_mixnet_m(variant, channel_multiplier=1.0, depth_multiplier=1.0, pretrained=False, **kwargs): + """Creates a MixNet Medium-Large model. + + Ref impl: https://github.com/tensorflow/tpu/tree/master/models/official/mnasnet/mixnet + Paper: https://arxiv.org/abs/1907.09595 + """ + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_e1_c24'], # relu + # stage 1, 112x112 in + ['ir_r1_k3.5.7_a1.1_p1.1_s2_e6_c32', 'ir_r1_k3_a1.1_p1.1_s1_e3_c32'], # relu + # stage 2, 56x56 in + ['ir_r1_k3.5.7.9_s2_e6_c40_se0.5_nsw', 'ir_r3_k3.5_a1.1_p1.1_s1_e6_c40_se0.5_nsw'], # swish + # stage 3, 28x28 in + ['ir_r1_k3.5.7_s2_e6_c80_se0.25_nsw', 'ir_r3_k3.5.7.9_a1.1_p1.1_s1_e6_c80_se0.25_nsw'], # swish + # stage 4, 14x14in + ['ir_r1_k3_s1_e6_c120_se0.5_nsw', 'ir_r3_k3.5.7.9_a1.1_p1.1_s1_e3_c120_se0.5_nsw'], # swish + # stage 5, 14x14in + ['ir_r1_k3.5.7.9_s2_e6_c200_se0.5_nsw', 'ir_r3_k3.5.7.9_p1.1_s1_e6_c200_se0.5_nsw'], # swish + # 7x7 + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def, depth_multiplier, depth_trunc='round'), + num_features=1536, + stem_size=24, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'relu'), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def mnasnet_050(pretrained=False, **kwargs): + """ MNASNet B1, depth multiplier of 0.5. """ + model = _gen_mnasnet_b1('mnasnet_050', 0.5, pretrained=pretrained, **kwargs) + return model + + +def mnasnet_075(pretrained=False, **kwargs): + """ MNASNet B1, depth multiplier of 0.75. """ + model = _gen_mnasnet_b1('mnasnet_075', 0.75, pretrained=pretrained, **kwargs) + return model + + +def mnasnet_100(pretrained=False, **kwargs): + """ MNASNet B1, depth multiplier of 1.0. """ + model = _gen_mnasnet_b1('mnasnet_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mnasnet_b1(pretrained=False, **kwargs): + """ MNASNet B1, depth multiplier of 1.0. """ + return mnasnet_100(pretrained, **kwargs) + + +def mnasnet_140(pretrained=False, **kwargs): + """ MNASNet B1, depth multiplier of 1.4 """ + model = _gen_mnasnet_b1('mnasnet_140', 1.4, pretrained=pretrained, **kwargs) + return model + + +def semnasnet_050(pretrained=False, **kwargs): + """ MNASNet A1 (w/ SE), depth multiplier of 0.5 """ + model = _gen_mnasnet_a1('semnasnet_050', 0.5, pretrained=pretrained, **kwargs) + return model + + +def semnasnet_075(pretrained=False, **kwargs): + """ MNASNet A1 (w/ SE), depth multiplier of 0.75. """ + model = _gen_mnasnet_a1('semnasnet_075', 0.75, pretrained=pretrained, **kwargs) + return model + + +def semnasnet_100(pretrained=False, **kwargs): + """ MNASNet A1 (w/ SE), depth multiplier of 1.0. """ + model = _gen_mnasnet_a1('semnasnet_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mnasnet_a1(pretrained=False, **kwargs): + """ MNASNet A1 (w/ SE), depth multiplier of 1.0. """ + return semnasnet_100(pretrained, **kwargs) + + +def semnasnet_140(pretrained=False, **kwargs): + """ MNASNet A1 (w/ SE), depth multiplier of 1.4. """ + model = _gen_mnasnet_a1('semnasnet_140', 1.4, pretrained=pretrained, **kwargs) + return model + + +def mnasnet_small(pretrained=False, **kwargs): + """ MNASNet Small, depth multiplier of 1.0. """ + model = _gen_mnasnet_small('mnasnet_small', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv2_100(pretrained=False, **kwargs): + """ MobileNet V2 w/ 1.0 channel multiplier """ + model = _gen_mobilenet_v2('mobilenetv2_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv2_140(pretrained=False, **kwargs): + """ MobileNet V2 w/ 1.4 channel multiplier """ + model = _gen_mobilenet_v2('mobilenetv2_140', 1.4, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv2_110d(pretrained=False, **kwargs): + """ MobileNet V2 w/ 1.1 channel, 1.2 depth multipliers""" + model = _gen_mobilenet_v2( + 'mobilenetv2_110d', 1.1, depth_multiplier=1.2, fix_stem_head=True, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv2_120d(pretrained=False, **kwargs): + """ MobileNet V2 w/ 1.2 channel, 1.4 depth multipliers """ + model = _gen_mobilenet_v2( + 'mobilenetv2_120d', 1.2, depth_multiplier=1.4, fix_stem_head=True, pretrained=pretrained, **kwargs) + return model + + +def fbnetc_100(pretrained=False, **kwargs): + """ FBNet-C """ + if pretrained: + # pretrained model trained with non-default BN epsilon + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + model = _gen_fbnetc('fbnetc_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def spnasnet_100(pretrained=False, **kwargs): + """ Single-Path NAS Pixel1""" + model = _gen_spnasnet('spnasnet_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b0(pretrained=False, **kwargs): + """ EfficientNet-B0 """ + # NOTE for train set drop_rate=0.2, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b0', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b1(pretrained=False, **kwargs): + """ EfficientNet-B1 """ + # NOTE for train set drop_rate=0.2, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b1', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b2(pretrained=False, **kwargs): + """ EfficientNet-B2 """ + # NOTE for train set drop_rate=0.3, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b2', channel_multiplier=1.1, depth_multiplier=1.2, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b3(pretrained=False, **kwargs): + """ EfficientNet-B3 """ + # NOTE for train set drop_rate=0.3, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b3', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b4(pretrained=False, **kwargs): + """ EfficientNet-B4 """ + # NOTE for train set drop_rate=0.4, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b4', channel_multiplier=1.4, depth_multiplier=1.8, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b5(pretrained=False, **kwargs): + """ EfficientNet-B5 """ + # NOTE for train set drop_rate=0.4, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b5', channel_multiplier=1.6, depth_multiplier=2.2, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b6(pretrained=False, **kwargs): + """ EfficientNet-B6 """ + # NOTE for train set drop_rate=0.5, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b6', channel_multiplier=1.8, depth_multiplier=2.6, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b7(pretrained=False, **kwargs): + """ EfficientNet-B7 """ + # NOTE for train set drop_rate=0.5, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b7', channel_multiplier=2.0, depth_multiplier=3.1, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_b8(pretrained=False, **kwargs): + """ EfficientNet-B8 """ + # NOTE for train set drop_rate=0.5, drop_connect_rate=0.2 + model = _gen_efficientnet( + 'efficientnet_b8', channel_multiplier=2.2, depth_multiplier=3.6, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_l2(pretrained=False, **kwargs): + """ EfficientNet-L2. """ + # NOTE for train, drop_rate should be 0.5 + model = _gen_efficientnet( + 'efficientnet_l2', channel_multiplier=4.3, depth_multiplier=5.3, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_es(pretrained=False, **kwargs): + """ EfficientNet-Edge Small. """ + model = _gen_efficientnet_edge( + 'efficientnet_es', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_em(pretrained=False, **kwargs): + """ EfficientNet-Edge-Medium. """ + model = _gen_efficientnet_edge( + 'efficientnet_em', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_el(pretrained=False, **kwargs): + """ EfficientNet-Edge-Large. """ + model = _gen_efficientnet_edge( + 'efficientnet_el', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_cc_b0_4e(pretrained=False, **kwargs): + """ EfficientNet-CondConv-B0 w/ 8 Experts """ + # NOTE for train set drop_rate=0.25, drop_connect_rate=0.2 + model = _gen_efficientnet_condconv( + 'efficientnet_cc_b0_4e', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_cc_b0_8e(pretrained=False, **kwargs): + """ EfficientNet-CondConv-B0 w/ 8 Experts """ + # NOTE for train set drop_rate=0.25, drop_connect_rate=0.2 + model = _gen_efficientnet_condconv( + 'efficientnet_cc_b0_8e', channel_multiplier=1.0, depth_multiplier=1.0, experts_multiplier=2, + pretrained=pretrained, **kwargs) + return model + + +def efficientnet_cc_b1_8e(pretrained=False, **kwargs): + """ EfficientNet-CondConv-B1 w/ 8 Experts """ + # NOTE for train set drop_rate=0.25, drop_connect_rate=0.2 + model = _gen_efficientnet_condconv( + 'efficientnet_cc_b1_8e', channel_multiplier=1.0, depth_multiplier=1.1, experts_multiplier=2, + pretrained=pretrained, **kwargs) + return model + + +def efficientnet_lite0(pretrained=False, **kwargs): + """ EfficientNet-Lite0 """ + model = _gen_efficientnet_lite( + 'efficientnet_lite0', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_lite1(pretrained=False, **kwargs): + """ EfficientNet-Lite1 """ + model = _gen_efficientnet_lite( + 'efficientnet_lite1', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_lite2(pretrained=False, **kwargs): + """ EfficientNet-Lite2 """ + model = _gen_efficientnet_lite( + 'efficientnet_lite2', channel_multiplier=1.1, depth_multiplier=1.2, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_lite3(pretrained=False, **kwargs): + """ EfficientNet-Lite3 """ + model = _gen_efficientnet_lite( + 'efficientnet_lite3', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def efficientnet_lite4(pretrained=False, **kwargs): + """ EfficientNet-Lite4 """ + model = _gen_efficientnet_lite( + 'efficientnet_lite4', channel_multiplier=1.4, depth_multiplier=1.8, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b0(pretrained=False, **kwargs): + """ EfficientNet-B0 AutoAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b0', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b1(pretrained=False, **kwargs): + """ EfficientNet-B1 AutoAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b1', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b2(pretrained=False, **kwargs): + """ EfficientNet-B2 AutoAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b2', channel_multiplier=1.1, depth_multiplier=1.2, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b3(pretrained=False, **kwargs): + """ EfficientNet-B3 AutoAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b3', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b4(pretrained=False, **kwargs): + """ EfficientNet-B4 AutoAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b4', channel_multiplier=1.4, depth_multiplier=1.8, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b5(pretrained=False, **kwargs): + """ EfficientNet-B5 RandAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b5', channel_multiplier=1.6, depth_multiplier=2.2, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b6(pretrained=False, **kwargs): + """ EfficientNet-B6 AutoAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b6', channel_multiplier=1.8, depth_multiplier=2.6, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b7(pretrained=False, **kwargs): + """ EfficientNet-B7 RandAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b7', channel_multiplier=2.0, depth_multiplier=3.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b8(pretrained=False, **kwargs): + """ EfficientNet-B8 RandAug. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b8', channel_multiplier=2.2, depth_multiplier=3.6, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b0_ap(pretrained=False, **kwargs): + """ EfficientNet-B0 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b0_ap', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b1_ap(pretrained=False, **kwargs): + """ EfficientNet-B1 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b1_ap', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b2_ap(pretrained=False, **kwargs): + """ EfficientNet-B2 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b2_ap', channel_multiplier=1.1, depth_multiplier=1.2, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b3_ap(pretrained=False, **kwargs): + """ EfficientNet-B3 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b3_ap', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b4_ap(pretrained=False, **kwargs): + """ EfficientNet-B4 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b4_ap', channel_multiplier=1.4, depth_multiplier=1.8, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b5_ap(pretrained=False, **kwargs): + """ EfficientNet-B5 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b5_ap', channel_multiplier=1.6, depth_multiplier=2.2, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b6_ap(pretrained=False, **kwargs): + """ EfficientNet-B6 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + # NOTE for train, drop_rate should be 0.5 + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b6_ap', channel_multiplier=1.8, depth_multiplier=2.6, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b7_ap(pretrained=False, **kwargs): + """ EfficientNet-B7 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + # NOTE for train, drop_rate should be 0.5 + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b7_ap', channel_multiplier=2.0, depth_multiplier=3.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b8_ap(pretrained=False, **kwargs): + """ EfficientNet-B8 AdvProp. Tensorflow compatible variant + Paper: Adversarial Examples Improve Image Recognition (https://arxiv.org/abs/1911.09665) + """ + # NOTE for train, drop_rate should be 0.5 + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b8_ap', channel_multiplier=2.2, depth_multiplier=3.6, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b0_ns(pretrained=False, **kwargs): + """ EfficientNet-B0 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b0_ns', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b1_ns(pretrained=False, **kwargs): + """ EfficientNet-B1 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b1_ns', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b2_ns(pretrained=False, **kwargs): + """ EfficientNet-B2 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b2_ns', channel_multiplier=1.1, depth_multiplier=1.2, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b3_ns(pretrained=False, **kwargs): + """ EfficientNet-B3 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b3_ns', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b4_ns(pretrained=False, **kwargs): + """ EfficientNet-B4 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b4_ns', channel_multiplier=1.4, depth_multiplier=1.8, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b5_ns(pretrained=False, **kwargs): + """ EfficientNet-B5 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b5_ns', channel_multiplier=1.6, depth_multiplier=2.2, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b6_ns(pretrained=False, **kwargs): + """ EfficientNet-B6 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + # NOTE for train, drop_rate should be 0.5 + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b6_ns', channel_multiplier=1.8, depth_multiplier=2.6, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_b7_ns(pretrained=False, **kwargs): + """ EfficientNet-B7 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + # NOTE for train, drop_rate should be 0.5 + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_b7_ns', channel_multiplier=2.0, depth_multiplier=3.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_l2_ns_475(pretrained=False, **kwargs): + """ EfficientNet-L2 NoisyStudent @ 475x475. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + # NOTE for train, drop_rate should be 0.5 + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_l2_ns_475', channel_multiplier=4.3, depth_multiplier=5.3, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_l2_ns(pretrained=False, **kwargs): + """ EfficientNet-L2 NoisyStudent. Tensorflow compatible variant + Paper: Self-training with Noisy Student improves ImageNet classification (https://arxiv.org/abs/1911.04252) + """ + # NOTE for train, drop_rate should be 0.5 + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet( + 'tf_efficientnet_l2_ns', channel_multiplier=4.3, depth_multiplier=5.3, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_es(pretrained=False, **kwargs): + """ EfficientNet-Edge Small. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_edge( + 'tf_efficientnet_es', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_em(pretrained=False, **kwargs): + """ EfficientNet-Edge-Medium. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_edge( + 'tf_efficientnet_em', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_el(pretrained=False, **kwargs): + """ EfficientNet-Edge-Large. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_edge( + 'tf_efficientnet_el', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_cc_b0_4e(pretrained=False, **kwargs): + """ EfficientNet-CondConv-B0 w/ 4 Experts """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_condconv( + 'tf_efficientnet_cc_b0_4e', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_cc_b0_8e(pretrained=False, **kwargs): + """ EfficientNet-CondConv-B0 w/ 8 Experts """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_condconv( + 'tf_efficientnet_cc_b0_8e', channel_multiplier=1.0, depth_multiplier=1.0, experts_multiplier=2, + pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_cc_b1_8e(pretrained=False, **kwargs): + """ EfficientNet-CondConv-B1 w/ 8 Experts """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_condconv( + 'tf_efficientnet_cc_b1_8e', channel_multiplier=1.0, depth_multiplier=1.1, experts_multiplier=2, + pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_lite0(pretrained=False, **kwargs): + """ EfficientNet-Lite0. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_lite( + 'tf_efficientnet_lite0', channel_multiplier=1.0, depth_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_lite1(pretrained=False, **kwargs): + """ EfficientNet-Lite1. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_lite( + 'tf_efficientnet_lite1', channel_multiplier=1.0, depth_multiplier=1.1, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_lite2(pretrained=False, **kwargs): + """ EfficientNet-Lite2. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_lite( + 'tf_efficientnet_lite2', channel_multiplier=1.1, depth_multiplier=1.2, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_lite3(pretrained=False, **kwargs): + """ EfficientNet-Lite3. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_lite( + 'tf_efficientnet_lite3', channel_multiplier=1.2, depth_multiplier=1.4, pretrained=pretrained, **kwargs) + return model + + +def tf_efficientnet_lite4(pretrained=False, **kwargs): + """ EfficientNet-Lite4. Tensorflow compatible variant """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_efficientnet_lite( + 'tf_efficientnet_lite4', channel_multiplier=1.4, depth_multiplier=1.8, pretrained=pretrained, **kwargs) + return model + + +def mixnet_s(pretrained=False, **kwargs): + """Creates a MixNet Small model. + """ + # NOTE for train set drop_rate=0.2 + model = _gen_mixnet_s( + 'mixnet_s', channel_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def mixnet_m(pretrained=False, **kwargs): + """Creates a MixNet Medium model. + """ + # NOTE for train set drop_rate=0.25 + model = _gen_mixnet_m( + 'mixnet_m', channel_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def mixnet_l(pretrained=False, **kwargs): + """Creates a MixNet Large model. + """ + # NOTE for train set drop_rate=0.25 + model = _gen_mixnet_m( + 'mixnet_l', channel_multiplier=1.3, pretrained=pretrained, **kwargs) + return model + + +def mixnet_xl(pretrained=False, **kwargs): + """Creates a MixNet Extra-Large model. + Not a paper spec, experimental def by RW w/ depth scaling. + """ + # NOTE for train set drop_rate=0.25, drop_connect_rate=0.2 + model = _gen_mixnet_m( + 'mixnet_xl', channel_multiplier=1.6, depth_multiplier=1.2, pretrained=pretrained, **kwargs) + return model + + +def mixnet_xxl(pretrained=False, **kwargs): + """Creates a MixNet Double Extra Large model. + Not a paper spec, experimental def by RW w/ depth scaling. + """ + # NOTE for train set drop_rate=0.3, drop_connect_rate=0.2 + model = _gen_mixnet_m( + 'mixnet_xxl', channel_multiplier=2.4, depth_multiplier=1.3, pretrained=pretrained, **kwargs) + return model + + +def tf_mixnet_s(pretrained=False, **kwargs): + """Creates a MixNet Small model. Tensorflow compatible variant + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mixnet_s( + 'tf_mixnet_s', channel_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_mixnet_m(pretrained=False, **kwargs): + """Creates a MixNet Medium model. Tensorflow compatible variant + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mixnet_m( + 'tf_mixnet_m', channel_multiplier=1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_mixnet_l(pretrained=False, **kwargs): + """Creates a MixNet Large model. Tensorflow compatible variant + """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mixnet_m( + 'tf_mixnet_l', channel_multiplier=1.3, pretrained=pretrained, **kwargs) + return model diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/helpers.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/helpers.py new file mode 100644 index 0000000000000000000000000000000000000000..3f83a07d690c7ad681c777c19b1e7a5bb95da007 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/helpers.py @@ -0,0 +1,71 @@ +""" Checkpoint loading / state_dict helpers +Copyright 2020 Ross Wightman +""" +import torch +import os +from collections import OrderedDict +try: + from torch.hub import load_state_dict_from_url +except ImportError: + from torch.utils.model_zoo import load_url as load_state_dict_from_url + + +def load_checkpoint(model, checkpoint_path): + if checkpoint_path and os.path.isfile(checkpoint_path): + print("=> Loading checkpoint '{}'".format(checkpoint_path)) + checkpoint = torch.load(checkpoint_path) + if isinstance(checkpoint, dict) and 'state_dict' in checkpoint: + new_state_dict = OrderedDict() + for k, v in checkpoint['state_dict'].items(): + if k.startswith('module'): + name = k[7:] # remove `module.` + else: + name = k + new_state_dict[name] = v + model.load_state_dict(new_state_dict) + else: + model.load_state_dict(checkpoint) + print("=> Loaded checkpoint '{}'".format(checkpoint_path)) + else: + print("=> Error: No checkpoint found at '{}'".format(checkpoint_path)) + raise FileNotFoundError() + + +def load_pretrained(model, url, filter_fn=None, strict=True): + if not url: + print("=> Warning: Pretrained model URL is empty, using random initialization.") + return + + state_dict = load_state_dict_from_url(url, progress=False, map_location='cpu') + + input_conv = 'conv_stem' + classifier = 'classifier' + in_chans = getattr(model, input_conv).weight.shape[1] + num_classes = getattr(model, classifier).weight.shape[0] + + input_conv_weight = input_conv + '.weight' + pretrained_in_chans = state_dict[input_conv_weight].shape[1] + if in_chans != pretrained_in_chans: + if in_chans == 1: + print('=> Converting pretrained input conv {} from {} to 1 channel'.format( + input_conv_weight, pretrained_in_chans)) + conv1_weight = state_dict[input_conv_weight] + state_dict[input_conv_weight] = conv1_weight.sum(dim=1, keepdim=True) + else: + print('=> Discarding pretrained input conv {} since input channel count != {}'.format( + input_conv_weight, pretrained_in_chans)) + del state_dict[input_conv_weight] + strict = False + + classifier_weight = classifier + '.weight' + pretrained_num_classes = state_dict[classifier_weight].shape[0] + if num_classes != pretrained_num_classes: + print('=> Discarding pretrained classifier since num_classes != {}'.format(pretrained_num_classes)) + del state_dict[classifier_weight] + del state_dict[classifier + '.bias'] + strict = False + + if filter_fn is not None: + state_dict = filter_fn(state_dict) + + model.load_state_dict(state_dict, strict=strict) diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/mobilenetv3.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/mobilenetv3.py new file mode 100644 index 0000000000000000000000000000000000000000..b5966c28f7207e98ee50745b1bc8f3663c650f9d --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/mobilenetv3.py @@ -0,0 +1,364 @@ +""" MobileNet-V3 + +A PyTorch impl of MobileNet-V3, compatible with TF weights from official impl. + +Paper: Searching for MobileNetV3 - https://arxiv.org/abs/1905.02244 + +Hacked together by / Copyright 2020 Ross Wightman +""" +import torch.nn as nn +import torch.nn.functional as F + +from .activations import get_act_fn, get_act_layer, HardSwish +from .config import layer_config_kwargs +from .conv2d_layers import select_conv2d +from .helpers import load_pretrained +from .efficientnet_builder import * + +__all__ = ['mobilenetv3_rw', 'mobilenetv3_large_075', 'mobilenetv3_large_100', 'mobilenetv3_large_minimal_100', + 'mobilenetv3_small_075', 'mobilenetv3_small_100', 'mobilenetv3_small_minimal_100', + 'tf_mobilenetv3_large_075', 'tf_mobilenetv3_large_100', 'tf_mobilenetv3_large_minimal_100', + 'tf_mobilenetv3_small_075', 'tf_mobilenetv3_small_100', 'tf_mobilenetv3_small_minimal_100'] + +model_urls = { + 'mobilenetv3_rw': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mobilenetv3_100-35495452.pth', + 'mobilenetv3_large_075': None, + 'mobilenetv3_large_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/mobilenetv3_large_100_ra-f55367f5.pth', + 'mobilenetv3_large_minimal_100': None, + 'mobilenetv3_small_075': None, + 'mobilenetv3_small_100': None, + 'mobilenetv3_small_minimal_100': None, + 'tf_mobilenetv3_large_075': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mobilenetv3_large_075-150ee8b0.pth', + 'tf_mobilenetv3_large_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mobilenetv3_large_100-427764d5.pth', + 'tf_mobilenetv3_large_minimal_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mobilenetv3_large_minimal_100-8596ae28.pth', + 'tf_mobilenetv3_small_075': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mobilenetv3_small_075-da427f52.pth', + 'tf_mobilenetv3_small_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mobilenetv3_small_100-37f49e2b.pth', + 'tf_mobilenetv3_small_minimal_100': + 'https://github.com/rwightman/pytorch-image-models/releases/download/v0.1-weights/tf_mobilenetv3_small_minimal_100-922a7843.pth', +} + + +class MobileNetV3(nn.Module): + """ MobileNet-V3 + + A this model utilizes the MobileNet-v3 specific 'efficient head', where global pooling is done before the + head convolution without a final batch-norm layer before the classifier. + + Paper: https://arxiv.org/abs/1905.02244 + """ + + def __init__(self, block_args, num_classes=1000, in_chans=3, stem_size=16, num_features=1280, head_bias=True, + channel_multiplier=1.0, pad_type='', act_layer=HardSwish, drop_rate=0., drop_connect_rate=0., + se_kwargs=None, norm_layer=nn.BatchNorm2d, norm_kwargs=None, weight_init='goog'): + super(MobileNetV3, self).__init__() + self.drop_rate = drop_rate + + stem_size = round_channels(stem_size, channel_multiplier) + self.conv_stem = select_conv2d(in_chans, stem_size, 3, stride=2, padding=pad_type) + self.bn1 = nn.BatchNorm2d(stem_size, **norm_kwargs) + self.act1 = act_layer(inplace=True) + in_chs = stem_size + + builder = EfficientNetBuilder( + channel_multiplier, pad_type=pad_type, act_layer=act_layer, se_kwargs=se_kwargs, + norm_layer=norm_layer, norm_kwargs=norm_kwargs, drop_connect_rate=drop_connect_rate) + self.blocks = nn.Sequential(*builder(in_chs, block_args)) + in_chs = builder.in_chs + + self.global_pool = nn.AdaptiveAvgPool2d(1) + self.conv_head = select_conv2d(in_chs, num_features, 1, padding=pad_type, bias=head_bias) + self.act2 = act_layer(inplace=True) + self.classifier = nn.Linear(num_features, num_classes) + + for m in self.modules(): + if weight_init == 'goog': + initialize_weight_goog(m) + else: + initialize_weight_default(m) + + def as_sequential(self): + layers = [self.conv_stem, self.bn1, self.act1] + layers.extend(self.blocks) + layers.extend([ + self.global_pool, self.conv_head, self.act2, + nn.Flatten(), nn.Dropout(self.drop_rate), self.classifier]) + return nn.Sequential(*layers) + + def features(self, x): + x = self.conv_stem(x) + x = self.bn1(x) + x = self.act1(x) + x = self.blocks(x) + x = self.global_pool(x) + x = self.conv_head(x) + x = self.act2(x) + return x + + def forward(self, x): + x = self.features(x) + x = x.flatten(1) + if self.drop_rate > 0.: + x = F.dropout(x, p=self.drop_rate, training=self.training) + return self.classifier(x) + + +def _create_model(model_kwargs, variant, pretrained=False): + as_sequential = model_kwargs.pop('as_sequential', False) + model = MobileNetV3(**model_kwargs) + if pretrained and model_urls[variant]: + load_pretrained(model, model_urls[variant]) + if as_sequential: + model = model.as_sequential() + return model + + +def _gen_mobilenet_v3_rw(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """Creates a MobileNet-V3 model (RW variant). + + Paper: https://arxiv.org/abs/1905.02244 + + This was my first attempt at reproducing the MobileNet-V3 from paper alone. It came close to the + eventual Tensorflow reference impl but has a few differences: + 1. This model has no bias on the head convolution + 2. This model forces no residual (noskip) on the first DWS block, this is different than MnasNet + 3. This model always uses ReLU for the SE activation layer, other models in the family inherit their act layer + from their parent block + 4. This model does not enforce divisible by 8 limitation on the SE reduction channel count + + Overall the changes are fairly minor and result in a very small parameter count difference and no + top-1/5 + + Args: + channel_multiplier: multiplier to number of channels per layer. + """ + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_e1_c16_nre_noskip'], # relu + # stage 1, 112x112 in + ['ir_r1_k3_s2_e4_c24_nre', 'ir_r1_k3_s1_e3_c24_nre'], # relu + # stage 2, 56x56 in + ['ir_r3_k5_s2_e3_c40_se0.25_nre'], # relu + # stage 3, 28x28 in + ['ir_r1_k3_s2_e6_c80', 'ir_r1_k3_s1_e2.5_c80', 'ir_r2_k3_s1_e2.3_c80'], # hard-swish + # stage 4, 14x14in + ['ir_r2_k3_s1_e6_c112_se0.25'], # hard-swish + # stage 5, 14x14in + ['ir_r3_k5_s2_e6_c160_se0.25'], # hard-swish + # stage 6, 7x7 in + ['cn_r1_k1_s1_c960'], # hard-swish + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + head_bias=False, # one of my mistakes + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, 'hard_swish'), + se_kwargs=dict(gate_fn=get_act_fn('hard_sigmoid'), reduce_mid=True), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs, + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def _gen_mobilenet_v3(variant, channel_multiplier=1.0, pretrained=False, **kwargs): + """Creates a MobileNet-V3 large/small/minimal models. + + Ref impl: https://github.com/tensorflow/models/blob/master/research/slim/nets/mobilenet/mobilenet_v3.py + Paper: https://arxiv.org/abs/1905.02244 + + Args: + channel_multiplier: multiplier to number of channels per layer. + """ + if 'small' in variant: + num_features = 1024 + if 'minimal' in variant: + act_layer = 'relu' + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s2_e1_c16'], + # stage 1, 56x56 in + ['ir_r1_k3_s2_e4.5_c24', 'ir_r1_k3_s1_e3.67_c24'], + # stage 2, 28x28 in + ['ir_r1_k3_s2_e4_c40', 'ir_r2_k3_s1_e6_c40'], + # stage 3, 14x14 in + ['ir_r2_k3_s1_e3_c48'], + # stage 4, 14x14in + ['ir_r3_k3_s2_e6_c96'], + # stage 6, 7x7 in + ['cn_r1_k1_s1_c576'], + ] + else: + act_layer = 'hard_swish' + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s2_e1_c16_se0.25_nre'], # relu + # stage 1, 56x56 in + ['ir_r1_k3_s2_e4.5_c24_nre', 'ir_r1_k3_s1_e3.67_c24_nre'], # relu + # stage 2, 28x28 in + ['ir_r1_k5_s2_e4_c40_se0.25', 'ir_r2_k5_s1_e6_c40_se0.25'], # hard-swish + # stage 3, 14x14 in + ['ir_r2_k5_s1_e3_c48_se0.25'], # hard-swish + # stage 4, 14x14in + ['ir_r3_k5_s2_e6_c96_se0.25'], # hard-swish + # stage 6, 7x7 in + ['cn_r1_k1_s1_c576'], # hard-swish + ] + else: + num_features = 1280 + if 'minimal' in variant: + act_layer = 'relu' + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_e1_c16'], + # stage 1, 112x112 in + ['ir_r1_k3_s2_e4_c24', 'ir_r1_k3_s1_e3_c24'], + # stage 2, 56x56 in + ['ir_r3_k3_s2_e3_c40'], + # stage 3, 28x28 in + ['ir_r1_k3_s2_e6_c80', 'ir_r1_k3_s1_e2.5_c80', 'ir_r2_k3_s1_e2.3_c80'], + # stage 4, 14x14in + ['ir_r2_k3_s1_e6_c112'], + # stage 5, 14x14in + ['ir_r3_k3_s2_e6_c160'], + # stage 6, 7x7 in + ['cn_r1_k1_s1_c960'], + ] + else: + act_layer = 'hard_swish' + arch_def = [ + # stage 0, 112x112 in + ['ds_r1_k3_s1_e1_c16_nre'], # relu + # stage 1, 112x112 in + ['ir_r1_k3_s2_e4_c24_nre', 'ir_r1_k3_s1_e3_c24_nre'], # relu + # stage 2, 56x56 in + ['ir_r3_k5_s2_e3_c40_se0.25_nre'], # relu + # stage 3, 28x28 in + ['ir_r1_k3_s2_e6_c80', 'ir_r1_k3_s1_e2.5_c80', 'ir_r2_k3_s1_e2.3_c80'], # hard-swish + # stage 4, 14x14in + ['ir_r2_k3_s1_e6_c112_se0.25'], # hard-swish + # stage 5, 14x14in + ['ir_r3_k5_s2_e6_c160_se0.25'], # hard-swish + # stage 6, 7x7 in + ['cn_r1_k1_s1_c960'], # hard-swish + ] + with layer_config_kwargs(kwargs): + model_kwargs = dict( + block_args=decode_arch_def(arch_def), + num_features=num_features, + stem_size=16, + channel_multiplier=channel_multiplier, + act_layer=resolve_act_layer(kwargs, act_layer), + se_kwargs=dict( + act_layer=get_act_layer('relu'), gate_fn=get_act_fn('hard_sigmoid'), reduce_mid=True, divisor=8), + norm_kwargs=resolve_bn_args(kwargs), + **kwargs, + ) + model = _create_model(model_kwargs, variant, pretrained) + return model + + +def mobilenetv3_rw(pretrained=False, **kwargs): + """ MobileNet-V3 RW + Attn: See note in gen function for this variant. + """ + # NOTE for train set drop_rate=0.2 + if pretrained: + # pretrained model trained with non-default BN epsilon + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + model = _gen_mobilenet_v3_rw('mobilenetv3_rw', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv3_large_075(pretrained=False, **kwargs): + """ MobileNet V3 Large 0.75""" + # NOTE for train set drop_rate=0.2 + model = _gen_mobilenet_v3('mobilenetv3_large_075', 0.75, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv3_large_100(pretrained=False, **kwargs): + """ MobileNet V3 Large 1.0 """ + # NOTE for train set drop_rate=0.2 + model = _gen_mobilenet_v3('mobilenetv3_large_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv3_large_minimal_100(pretrained=False, **kwargs): + """ MobileNet V3 Large (Minimalistic) 1.0 """ + # NOTE for train set drop_rate=0.2 + model = _gen_mobilenet_v3('mobilenetv3_large_minimal_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv3_small_075(pretrained=False, **kwargs): + """ MobileNet V3 Small 0.75 """ + model = _gen_mobilenet_v3('mobilenetv3_small_075', 0.75, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv3_small_100(pretrained=False, **kwargs): + """ MobileNet V3 Small 1.0 """ + model = _gen_mobilenet_v3('mobilenetv3_small_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def mobilenetv3_small_minimal_100(pretrained=False, **kwargs): + """ MobileNet V3 Small (Minimalistic) 1.0 """ + model = _gen_mobilenet_v3('mobilenetv3_small_minimal_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_mobilenetv3_large_075(pretrained=False, **kwargs): + """ MobileNet V3 Large 0.75. Tensorflow compat variant. """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mobilenet_v3('tf_mobilenetv3_large_075', 0.75, pretrained=pretrained, **kwargs) + return model + + +def tf_mobilenetv3_large_100(pretrained=False, **kwargs): + """ MobileNet V3 Large 1.0. Tensorflow compat variant. """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mobilenet_v3('tf_mobilenetv3_large_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_mobilenetv3_large_minimal_100(pretrained=False, **kwargs): + """ MobileNet V3 Large Minimalistic 1.0. Tensorflow compat variant. """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mobilenet_v3('tf_mobilenetv3_large_minimal_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_mobilenetv3_small_075(pretrained=False, **kwargs): + """ MobileNet V3 Small 0.75. Tensorflow compat variant. """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mobilenet_v3('tf_mobilenetv3_small_075', 0.75, pretrained=pretrained, **kwargs) + return model + + +def tf_mobilenetv3_small_100(pretrained=False, **kwargs): + """ MobileNet V3 Small 1.0. Tensorflow compat variant.""" + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mobilenet_v3('tf_mobilenetv3_small_100', 1.0, pretrained=pretrained, **kwargs) + return model + + +def tf_mobilenetv3_small_minimal_100(pretrained=False, **kwargs): + """ MobileNet V3 Small Minimalistic 1.0. Tensorflow compat variant. """ + kwargs['bn_eps'] = BN_EPS_TF_DEFAULT + kwargs['pad_type'] = 'same' + model = _gen_mobilenet_v3('tf_mobilenetv3_small_minimal_100', 1.0, pretrained=pretrained, **kwargs) + return model diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/model_factory.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/model_factory.py new file mode 100644 index 0000000000000000000000000000000000000000..4d46ea8baedaf3d787826eb3bb314b4230514647 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/model_factory.py @@ -0,0 +1,27 @@ +from .config import set_layer_config +from .helpers import load_checkpoint + +from .gen_efficientnet import * +from .mobilenetv3 import * + + +def create_model( + model_name='mnasnet_100', + pretrained=None, + num_classes=1000, + in_chans=3, + checkpoint_path='', + **kwargs): + + model_kwargs = dict(num_classes=num_classes, in_chans=in_chans, pretrained=pretrained, **kwargs) + + if model_name in globals(): + create_fn = globals()[model_name] + model = create_fn(**model_kwargs) + else: + raise RuntimeError('Unknown model (%s)' % model_name) + + if checkpoint_path and not pretrained: + load_checkpoint(model, checkpoint_path) + + return model diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/version.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/version.py new file mode 100644 index 0000000000000000000000000000000000000000..a6221b3de7b1490c5e712e8b5fcc94c3d9d04295 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/geffnet/version.py @@ -0,0 +1 @@ +__version__ = '1.0.2' diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/hubconf.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/hubconf.py new file mode 100644 index 0000000000000000000000000000000000000000..45b17b99bbeba34596569e6e50f6e8a2ebc45c54 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/hubconf.py @@ -0,0 +1,84 @@ +dependencies = ['torch', 'math'] + +from geffnet import efficientnet_b0 +from geffnet import efficientnet_b1 +from geffnet import efficientnet_b2 +from geffnet import efficientnet_b3 + +from geffnet import efficientnet_es + +from geffnet import efficientnet_lite0 + +from geffnet import mixnet_s +from geffnet import mixnet_m +from geffnet import mixnet_l +from geffnet import mixnet_xl + +from geffnet import mobilenetv2_100 +from geffnet import mobilenetv2_110d +from geffnet import mobilenetv2_120d +from geffnet import mobilenetv2_140 + +from geffnet import mobilenetv3_large_100 +from geffnet import mobilenetv3_rw +from geffnet import mnasnet_a1 +from geffnet import mnasnet_b1 +from geffnet import fbnetc_100 +from geffnet import spnasnet_100 + +from geffnet import tf_efficientnet_b0 +from geffnet import tf_efficientnet_b1 +from geffnet import tf_efficientnet_b2 +from geffnet import tf_efficientnet_b3 +from geffnet import tf_efficientnet_b4 +from geffnet import tf_efficientnet_b5 +from geffnet import tf_efficientnet_b6 +from geffnet import tf_efficientnet_b7 +from geffnet import tf_efficientnet_b8 + +from geffnet import tf_efficientnet_b0_ap +from geffnet import tf_efficientnet_b1_ap +from geffnet import tf_efficientnet_b2_ap +from geffnet import tf_efficientnet_b3_ap +from geffnet import tf_efficientnet_b4_ap +from geffnet import tf_efficientnet_b5_ap +from geffnet import tf_efficientnet_b6_ap +from geffnet import tf_efficientnet_b7_ap +from geffnet import tf_efficientnet_b8_ap + +from geffnet import tf_efficientnet_b0_ns +from geffnet import tf_efficientnet_b1_ns +from geffnet import tf_efficientnet_b2_ns +from geffnet import tf_efficientnet_b3_ns +from geffnet import tf_efficientnet_b4_ns +from geffnet import tf_efficientnet_b5_ns +from geffnet import tf_efficientnet_b6_ns +from geffnet import tf_efficientnet_b7_ns +from geffnet import tf_efficientnet_l2_ns_475 +from geffnet import tf_efficientnet_l2_ns + +from geffnet import tf_efficientnet_es +from geffnet import tf_efficientnet_em +from geffnet import tf_efficientnet_el + +from geffnet import tf_efficientnet_cc_b0_4e +from geffnet import tf_efficientnet_cc_b0_8e +from geffnet import tf_efficientnet_cc_b1_8e + +from geffnet import tf_efficientnet_lite0 +from geffnet import tf_efficientnet_lite1 +from geffnet import tf_efficientnet_lite2 +from geffnet import tf_efficientnet_lite3 +from geffnet import tf_efficientnet_lite4 + +from geffnet import tf_mixnet_s +from geffnet import tf_mixnet_m +from geffnet import tf_mixnet_l + +from geffnet import tf_mobilenetv3_large_075 +from geffnet import tf_mobilenetv3_large_100 +from geffnet import tf_mobilenetv3_large_minimal_100 +from geffnet import tf_mobilenetv3_small_075 +from geffnet import tf_mobilenetv3_small_100 +from geffnet import tf_mobilenetv3_small_minimal_100 + diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_export.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_export.py new file mode 100644 index 0000000000000000000000000000000000000000..7a5162ce214830df501bdb81edb66c095122f69d --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_export.py @@ -0,0 +1,120 @@ +""" ONNX export script + +Export PyTorch models as ONNX graphs. + +This export script originally started as an adaptation of code snippets found at +https://pytorch.org/tutorials/advanced/super_resolution_with_onnxruntime.html + +The default parameters work with PyTorch 1.6 and ONNX 1.7 and produce an optimal ONNX graph +for hosting in the ONNX runtime (see onnx_validate.py). To export an ONNX model compatible +with caffe2 (see caffe2_benchmark.py and caffe2_validate.py), the --keep-init and --aten-fallback +flags are currently required. + +Older versions of PyTorch/ONNX (tested PyTorch 1.4, ONNX 1.5) do not need extra flags for +caffe2 compatibility, but they produce a model that isn't as fast running on ONNX runtime. + +Most new release of PyTorch and ONNX cause some sort of breakage in the export / usage of ONNX models. +Please do your research and search ONNX and PyTorch issue tracker before asking me. Thanks. + +Copyright 2020 Ross Wightman +""" +import argparse +import torch +import numpy as np + +import onnx +import geffnet + +parser = argparse.ArgumentParser(description='PyTorch ImageNet Validation') +parser.add_argument('output', metavar='ONNX_FILE', + help='output model filename') +parser.add_argument('--model', '-m', metavar='MODEL', default='mobilenetv3_large_100', + help='model architecture (default: mobilenetv3_large_100)') +parser.add_argument('--opset', type=int, default=10, + help='ONNX opset to use (default: 10)') +parser.add_argument('--keep-init', action='store_true', default=False, + help='Keep initializers as input. Needed for Caffe2 compatible export in newer PyTorch/ONNX.') +parser.add_argument('--aten-fallback', action='store_true', default=False, + help='Fallback to ATEN ops. Helps fix AdaptiveAvgPool issue with Caffe2 in newer PyTorch/ONNX.') +parser.add_argument('--dynamic-size', action='store_true', default=False, + help='Export model width dynamic width/height. Not recommended for "tf" models with SAME padding.') +parser.add_argument('-b', '--batch-size', default=1, type=int, + metavar='N', help='mini-batch size (default: 1)') +parser.add_argument('--img-size', default=None, type=int, + metavar='N', help='Input image dimension, uses model default if empty') +parser.add_argument('--mean', type=float, nargs='+', default=None, metavar='MEAN', + help='Override mean pixel value of dataset') +parser.add_argument('--std', type=float, nargs='+', default=None, metavar='STD', + help='Override std deviation of of dataset') +parser.add_argument('--num-classes', type=int, default=1000, + help='Number classes in dataset') +parser.add_argument('--checkpoint', default='', type=str, metavar='PATH', + help='path to checkpoint (default: none)') + + +def main(): + args = parser.parse_args() + + args.pretrained = True + if args.checkpoint: + args.pretrained = False + + print("==> Creating PyTorch {} model".format(args.model)) + # NOTE exportable=True flag disables autofn/jit scripted activations and uses Conv2dSameExport layers + # for models using SAME padding + model = geffnet.create_model( + args.model, + num_classes=args.num_classes, + in_chans=3, + pretrained=args.pretrained, + checkpoint_path=args.checkpoint, + exportable=True) + + model.eval() + + example_input = torch.randn((args.batch_size, 3, args.img_size or 224, args.img_size or 224), requires_grad=True) + + # Run model once before export trace, sets padding for models with Conv2dSameExport. This means + # that the padding for models with Conv2dSameExport (most models with tf_ prefix) is fixed for + # the input img_size specified in this script. + # Opset >= 11 should allow for dynamic padding, however I cannot get it to work due to + # issues in the tracing of the dynamic padding or errors attempting to export the model after jit + # scripting it (an approach that should work). Perhaps in a future PyTorch or ONNX versions... + model(example_input) + + print("==> Exporting model to ONNX format at '{}'".format(args.output)) + input_names = ["input0"] + output_names = ["output0"] + dynamic_axes = {'input0': {0: 'batch'}, 'output0': {0: 'batch'}} + if args.dynamic_size: + dynamic_axes['input0'][2] = 'height' + dynamic_axes['input0'][3] = 'width' + if args.aten_fallback: + export_type = torch.onnx.OperatorExportTypes.ONNX_ATEN_FALLBACK + else: + export_type = torch.onnx.OperatorExportTypes.ONNX + + torch_out = torch.onnx._export( + model, example_input, args.output, export_params=True, verbose=True, input_names=input_names, + output_names=output_names, keep_initializers_as_inputs=args.keep_init, dynamic_axes=dynamic_axes, + opset_version=args.opset, operator_export_type=export_type) + + print("==> Loading and checking exported model from '{}'".format(args.output)) + onnx_model = onnx.load(args.output) + onnx.checker.check_model(onnx_model) # assuming throw on error + print("==> Passed") + + if args.keep_init and args.aten_fallback: + import caffe2.python.onnx.backend as onnx_caffe2 + # Caffe2 loading only works properly in newer PyTorch/ONNX combos when + # keep_initializers_as_inputs and aten_fallback are set to True. + print("==> Loading model into Caffe2 backend and comparing forward pass.".format(args.output)) + caffe2_backend = onnx_caffe2.prepare(onnx_model) + B = {onnx_model.graph.input[0].name: x.data.numpy()} + c2_out = caffe2_backend.run(B)[0] + np.testing.assert_almost_equal(torch_out.data.numpy(), c2_out, decimal=5) + print("==> Passed") + + +if __name__ == '__main__': + main() diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_optimize.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_optimize.py new file mode 100644 index 0000000000000000000000000000000000000000..ee20bbf9f0f9473370489512eb96ca0b570b5388 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_optimize.py @@ -0,0 +1,84 @@ +""" ONNX optimization script + +Run ONNX models through the optimizer to prune unneeded nodes, fuse batchnorm layers into conv, etc. + +NOTE: This isn't working consistently in recent PyTorch/ONNX combos (ie PyTorch 1.6 and ONNX 1.7), +it seems time to switch to using the onnxruntime online optimizer (can also be saved for offline). + +Copyright 2020 Ross Wightman +""" +import argparse +import warnings + +import onnx +from onnx import optimizer + + +parser = argparse.ArgumentParser(description="Optimize ONNX model") + +parser.add_argument("model", help="The ONNX model") +parser.add_argument("--output", required=True, help="The optimized model output filename") + + +def traverse_graph(graph, prefix=''): + content = [] + indent = prefix + ' ' + graphs = [] + num_nodes = 0 + for node in graph.node: + pn, gs = onnx.helper.printable_node(node, indent, subgraphs=True) + assert isinstance(gs, list) + content.append(pn) + graphs.extend(gs) + num_nodes += 1 + for g in graphs: + g_count, g_str = traverse_graph(g) + content.append('\n' + g_str) + num_nodes += g_count + return num_nodes, '\n'.join(content) + + +def main(): + args = parser.parse_args() + onnx_model = onnx.load(args.model) + num_original_nodes, original_graph_str = traverse_graph(onnx_model.graph) + + # Optimizer passes to perform + passes = [ + #'eliminate_deadend', + 'eliminate_identity', + 'eliminate_nop_dropout', + 'eliminate_nop_pad', + 'eliminate_nop_transpose', + 'eliminate_unused_initializer', + 'extract_constant_to_initializer', + 'fuse_add_bias_into_conv', + 'fuse_bn_into_conv', + 'fuse_consecutive_concats', + 'fuse_consecutive_reduce_unsqueeze', + 'fuse_consecutive_squeezes', + 'fuse_consecutive_transposes', + #'fuse_matmul_add_bias_into_gemm', + 'fuse_pad_into_conv', + #'fuse_transpose_into_gemm', + #'lift_lexical_references', + ] + + # Apply the optimization on the original serialized model + # WARNING I've had issues with optimizer in recent versions of PyTorch / ONNX causing + # 'duplicate definition of name' errors, see: https://github.com/onnx/onnx/issues/2401 + # It may be better to rely on onnxruntime optimizations, see onnx_validate.py script. + warnings.warn("I've had issues with optimizer in recent versions of PyTorch / ONNX." + "Try onnxruntime optimization if this doesn't work.") + optimized_model = optimizer.optimize(onnx_model, passes) + + num_optimized_nodes, optimzied_graph_str = traverse_graph(optimized_model.graph) + print('==> The model after optimization:\n{}\n'.format(optimzied_graph_str)) + print('==> The optimized model has {} nodes, the original had {}.'.format(num_optimized_nodes, num_original_nodes)) + + # Save the ONNX model + onnx.save(optimized_model, args.output) + + +if __name__ == "__main__": + main() diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_to_caffe.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_to_caffe.py new file mode 100644 index 0000000000000000000000000000000000000000..44399aafababcdf6b84147a0613eb0909730db4b --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_to_caffe.py @@ -0,0 +1,27 @@ +import argparse + +import onnx +from caffe2.python.onnx.backend import Caffe2Backend + + +parser = argparse.ArgumentParser(description="Convert ONNX to Caffe2") + +parser.add_argument("model", help="The ONNX model") +parser.add_argument("--c2-prefix", required=True, + help="The output file prefix for the caffe2 model init and predict file. ") + + +def main(): + args = parser.parse_args() + onnx_model = onnx.load(args.model) + caffe2_init, caffe2_predict = Caffe2Backend.onnx_graph_to_caffe2_net(onnx_model) + caffe2_init_str = caffe2_init.SerializeToString() + with open(args.c2_prefix + '.init.pb', "wb") as f: + f.write(caffe2_init_str) + caffe2_predict_str = caffe2_predict.SerializeToString() + with open(args.c2_prefix + '.predict.pb', "wb") as f: + f.write(caffe2_predict_str) + + +if __name__ == "__main__": + main() diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_validate.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_validate.py new file mode 100644 index 0000000000000000000000000000000000000000..ab3e4fb141b6ef660dcc5b447fd9f368a2ea19a0 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/onnx_validate.py @@ -0,0 +1,112 @@ +""" ONNX-runtime validation script + +This script was created to verify accuracy and performance of exported ONNX +models running with the onnxruntime. It utilizes the PyTorch dataloader/processing +pipeline for a fair comparison against the originals. + +Copyright 2020 Ross Wightman +""" +import argparse +import numpy as np +import onnxruntime +from data import create_loader, resolve_data_config, Dataset +from utils import AverageMeter +import time + +parser = argparse.ArgumentParser(description='Caffe2 ImageNet Validation') +parser.add_argument('data', metavar='DIR', + help='path to dataset') +parser.add_argument('--onnx-input', default='', type=str, metavar='PATH', + help='path to onnx model/weights file') +parser.add_argument('--onnx-output-opt', default='', type=str, metavar='PATH', + help='path to output optimized onnx graph') +parser.add_argument('--profile', action='store_true', default=False, + help='Enable profiler output.') +parser.add_argument('-j', '--workers', default=2, type=int, metavar='N', + help='number of data loading workers (default: 2)') +parser.add_argument('-b', '--batch-size', default=256, type=int, + metavar='N', help='mini-batch size (default: 256)') +parser.add_argument('--img-size', default=None, type=int, + metavar='N', help='Input image dimension, uses model default if empty') +parser.add_argument('--mean', type=float, nargs='+', default=None, metavar='MEAN', + help='Override mean pixel value of dataset') +parser.add_argument('--std', type=float, nargs='+', default=None, metavar='STD', + help='Override std deviation of of dataset') +parser.add_argument('--crop-pct', type=float, default=None, metavar='PCT', + help='Override default crop pct of 0.875') +parser.add_argument('--interpolation', default='', type=str, metavar='NAME', + help='Image resize interpolation type (overrides model)') +parser.add_argument('--tf-preprocessing', dest='tf_preprocessing', action='store_true', + help='use tensorflow mnasnet preporcessing') +parser.add_argument('--print-freq', '-p', default=10, type=int, + metavar='N', help='print frequency (default: 10)') + + +def main(): + args = parser.parse_args() + args.gpu_id = 0 + + # Set graph optimization level + sess_options = onnxruntime.SessionOptions() + sess_options.graph_optimization_level = onnxruntime.GraphOptimizationLevel.ORT_ENABLE_ALL + if args.profile: + sess_options.enable_profiling = True + if args.onnx_output_opt: + sess_options.optimized_model_filepath = args.onnx_output_opt + + session = onnxruntime.InferenceSession(args.onnx_input, sess_options) + + data_config = resolve_data_config(None, args) + loader = create_loader( + Dataset(args.data, load_bytes=args.tf_preprocessing), + input_size=data_config['input_size'], + batch_size=args.batch_size, + use_prefetcher=False, + interpolation=data_config['interpolation'], + mean=data_config['mean'], + std=data_config['std'], + num_workers=args.workers, + crop_pct=data_config['crop_pct'], + tensorflow_preprocessing=args.tf_preprocessing) + + input_name = session.get_inputs()[0].name + + batch_time = AverageMeter() + top1 = AverageMeter() + top5 = AverageMeter() + end = time.time() + for i, (input, target) in enumerate(loader): + # run the net and return prediction + output = session.run([], {input_name: input.data.numpy()}) + output = output[0] + + # measure accuracy and record loss + prec1, prec5 = accuracy_np(output, target.numpy()) + top1.update(prec1.item(), input.size(0)) + top5.update(prec5.item(), input.size(0)) + + # measure elapsed time + batch_time.update(time.time() - end) + end = time.time() + + if i % args.print_freq == 0: + print('Test: [{0}/{1}]\t' + 'Time {batch_time.val:.3f} ({batch_time.avg:.3f}, {rate_avg:.3f}/s, {ms_avg:.3f} ms/sample) \t' + 'Prec@1 {top1.val:.3f} ({top1.avg:.3f})\t' + 'Prec@5 {top5.val:.3f} ({top5.avg:.3f})'.format( + i, len(loader), batch_time=batch_time, rate_avg=input.size(0) / batch_time.avg, + ms_avg=100 * batch_time.avg / input.size(0), top1=top1, top5=top5)) + + print(' * Prec@1 {top1.avg:.3f} ({top1a:.3f}) Prec@5 {top5.avg:.3f} ({top5a:.3f})'.format( + top1=top1, top1a=100-top1.avg, top5=top5, top5a=100.-top5.avg)) + + +def accuracy_np(output, target): + max_indices = np.argsort(output, axis=1)[:, ::-1] + top5 = 100 * np.equal(max_indices[:, :5], target[:, np.newaxis]).sum(axis=1).mean() + top1 = 100 * np.equal(max_indices[:, 0], target).mean() + return top1, top5 + + +if __name__ == '__main__': + main() diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/requirements.txt b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/requirements.txt new file mode 100644 index 0000000000000000000000000000000000000000..ac3ffc13bae15f9b11f7cbe3705760056ecd7f13 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/requirements.txt @@ -0,0 +1,2 @@ +torch>=1.2.0 +torchvision>=0.4.0 diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/setup.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/setup.py new file mode 100644 index 0000000000000000000000000000000000000000..023e4c30f98164595964423e3a83eefaf7ffdad6 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/setup.py @@ -0,0 +1,47 @@ +""" Setup +""" +from setuptools import setup, find_packages +from codecs import open +from os import path + +here = path.abspath(path.dirname(__file__)) + +# Get the long description from the README file +with open(path.join(here, 'README.md'), encoding='utf-8') as f: + long_description = f.read() + +exec(open('geffnet/version.py').read()) +setup( + name='geffnet', + version=__version__, + description='(Generic) EfficientNets for PyTorch', + long_description=long_description, + long_description_content_type='text/markdown', + url='https://github.com/rwightman/gen-efficientnet-pytorch', + author='Ross Wightman', + author_email='hello@rwightman.com', + classifiers=[ + # How mature is this project? Common values are + # 3 - Alpha + # 4 - Beta + # 5 - Production/Stable + 'Development Status :: 3 - Alpha', + 'Intended Audience :: Education', + 'Intended Audience :: Science/Research', + 'License :: OSI Approved :: Apache Software License', + 'Programming Language :: Python :: 3.6', + 'Programming Language :: Python :: 3.7', + 'Programming Language :: Python :: 3.8', + 'Topic :: Scientific/Engineering', + 'Topic :: Scientific/Engineering :: Artificial Intelligence', + 'Topic :: Software Development', + 'Topic :: Software Development :: Libraries', + 'Topic :: Software Development :: Libraries :: Python Modules', + ], + + # Note that this is a string of words separated by whitespace, not a list. + keywords='pytorch pretrained models efficientnet mixnet mobilenetv3 mnasnet', + packages=find_packages(exclude=['data']), + install_requires=['torch >= 1.4', 'torchvision'], + python_requires='>=3.6', +) diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/utils.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..d327e8bd8120c5cd09ae6c15c3991ccbe27f6c1f --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/utils.py @@ -0,0 +1,52 @@ +import os + + +class AverageMeter: + """Computes and stores the average and current value""" + def __init__(self): + self.reset() + + def reset(self): + self.val = 0 + self.avg = 0 + self.sum = 0 + self.count = 0 + + def update(self, val, n=1): + self.val = val + self.sum += val * n + self.count += n + self.avg = self.sum / self.count + + +def accuracy(output, target, topk=(1,)): + """Computes the precision@k for the specified values of k""" + maxk = max(topk) + batch_size = target.size(0) + + _, pred = output.topk(maxk, 1, True, True) + pred = pred.t() + correct = pred.eq(target.view(1, -1).expand_as(pred)) + + res = [] + for k in topk: + correct_k = correct[:k].reshape(-1).float().sum(0) + res.append(correct_k.mul_(100.0 / batch_size)) + return res + + +def get_outdir(path, *paths, inc=False): + outdir = os.path.join(path, *paths) + if not os.path.exists(outdir): + os.makedirs(outdir) + elif inc: + count = 1 + outdir_inc = outdir + '-' + str(count) + while os.path.exists(outdir_inc): + count = count + 1 + outdir_inc = outdir + '-' + str(count) + assert count < 100 + outdir = outdir_inc + os.makedirs(outdir) + return outdir + diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/validate.py b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/validate.py new file mode 100644 index 0000000000000000000000000000000000000000..5fd44fbb3165ef81ef81251b6299f6aaa80bf2c2 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/efficientnet_repo/validate.py @@ -0,0 +1,166 @@ +from __future__ import absolute_import +from __future__ import division +from __future__ import print_function + +import argparse +import time +import torch +import torch.nn as nn +import torch.nn.parallel +from contextlib import suppress + +import geffnet +from data import Dataset, create_loader, resolve_data_config +from utils import accuracy, AverageMeter + +has_native_amp = False +try: + if getattr(torch.cuda.amp, 'autocast') is not None: + has_native_amp = True +except AttributeError: + pass + +torch.backends.cudnn.benchmark = True + +parser = argparse.ArgumentParser(description='PyTorch ImageNet Validation') +parser.add_argument('data', metavar='DIR', + help='path to dataset') +parser.add_argument('--model', '-m', metavar='MODEL', default='spnasnet1_00', + help='model architecture (default: dpn92)') +parser.add_argument('-j', '--workers', default=4, type=int, metavar='N', + help='number of data loading workers (default: 2)') +parser.add_argument('-b', '--batch-size', default=256, type=int, + metavar='N', help='mini-batch size (default: 256)') +parser.add_argument('--img-size', default=None, type=int, + metavar='N', help='Input image dimension, uses model default if empty') +parser.add_argument('--mean', type=float, nargs='+', default=None, metavar='MEAN', + help='Override mean pixel value of dataset') +parser.add_argument('--std', type=float, nargs='+', default=None, metavar='STD', + help='Override std deviation of of dataset') +parser.add_argument('--crop-pct', type=float, default=None, metavar='PCT', + help='Override default crop pct of 0.875') +parser.add_argument('--interpolation', default='', type=str, metavar='NAME', + help='Image resize interpolation type (overrides model)') +parser.add_argument('--num-classes', type=int, default=1000, + help='Number classes in dataset') +parser.add_argument('--print-freq', '-p', default=10, type=int, + metavar='N', help='print frequency (default: 10)') +parser.add_argument('--checkpoint', default='', type=str, metavar='PATH', + help='path to latest checkpoint (default: none)') +parser.add_argument('--pretrained', dest='pretrained', action='store_true', + help='use pre-trained model') +parser.add_argument('--torchscript', dest='torchscript', action='store_true', + help='convert model torchscript for inference') +parser.add_argument('--num-gpu', type=int, default=1, + help='Number of GPUS to use') +parser.add_argument('--tf-preprocessing', dest='tf_preprocessing', action='store_true', + help='use tensorflow mnasnet preporcessing') +parser.add_argument('--no-cuda', dest='no_cuda', action='store_true', + help='') +parser.add_argument('--channels-last', action='store_true', default=False, + help='Use channels_last memory layout') +parser.add_argument('--amp', action='store_true', default=False, + help='Use native Torch AMP mixed precision.') + + +def main(): + args = parser.parse_args() + + if not args.checkpoint and not args.pretrained: + args.pretrained = True + + amp_autocast = suppress # do nothing + if args.amp: + if not has_native_amp: + print("Native Torch AMP is not available (requires torch >= 1.6), using FP32.") + else: + amp_autocast = torch.cuda.amp.autocast + + # create model + model = geffnet.create_model( + args.model, + num_classes=args.num_classes, + in_chans=3, + pretrained=args.pretrained, + checkpoint_path=args.checkpoint, + scriptable=args.torchscript) + + if args.channels_last: + model = model.to(memory_format=torch.channels_last) + + if args.torchscript: + torch.jit.optimized_execution(True) + model = torch.jit.script(model) + + print('Model %s created, param count: %d' % + (args.model, sum([m.numel() for m in model.parameters()]))) + + data_config = resolve_data_config(model, args) + + criterion = nn.CrossEntropyLoss() + + if not args.no_cuda: + if args.num_gpu > 1: + model = torch.nn.DataParallel(model, device_ids=list(range(args.num_gpu))).cuda() + else: + model = model.cuda() + criterion = criterion.cuda() + + loader = create_loader( + Dataset(args.data, load_bytes=args.tf_preprocessing), + input_size=data_config['input_size'], + batch_size=args.batch_size, + use_prefetcher=not args.no_cuda, + interpolation=data_config['interpolation'], + mean=data_config['mean'], + std=data_config['std'], + num_workers=args.workers, + crop_pct=data_config['crop_pct'], + tensorflow_preprocessing=args.tf_preprocessing) + + batch_time = AverageMeter() + losses = AverageMeter() + top1 = AverageMeter() + top5 = AverageMeter() + + model.eval() + end = time.time() + with torch.no_grad(): + for i, (input, target) in enumerate(loader): + if not args.no_cuda: + target = target.cuda() + input = input.cuda() + if args.channels_last: + input = input.contiguous(memory_format=torch.channels_last) + + # compute output + with amp_autocast(): + output = model(input) + loss = criterion(output, target) + + # measure accuracy and record loss + prec1, prec5 = accuracy(output.data, target, topk=(1, 5)) + losses.update(loss.item(), input.size(0)) + top1.update(prec1.item(), input.size(0)) + top5.update(prec5.item(), input.size(0)) + + # measure elapsed time + batch_time.update(time.time() - end) + end = time.time() + + if i % args.print_freq == 0: + print('Test: [{0}/{1}]\t' + 'Time {batch_time.val:.3f} ({batch_time.avg:.3f}, {rate_avg:.3f}/s) \t' + 'Loss {loss.val:.4f} ({loss.avg:.4f})\t' + 'Prec@1 {top1.val:.3f} ({top1.avg:.3f})\t' + 'Prec@5 {top5.val:.3f} ({top5.avg:.3f})'.format( + i, len(loader), batch_time=batch_time, + rate_avg=input.size(0) / batch_time.avg, + loss=losses, top1=top1, top5=top5)) + + print(' * Prec@1 {top1.avg:.3f} ({top1a:.3f}) Prec@5 {top5.avg:.3f} ({top5a:.3f})'.format( + top1=top1, top1a=100-top1.avg, top5=top5, top5a=100.-top5.avg)) + + +if __name__ == '__main__': + main() diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/encoder.py b/invokeai/backend/image_util/normal_bae/nets/submodules/encoder.py new file mode 100644 index 0000000000000000000000000000000000000000..7f7149ca3c0cf2b6e019105af7e645cfbb3eda11 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/encoder.py @@ -0,0 +1,34 @@ +import os +import torch +import torch.nn as nn +import torch.nn.functional as F + + +class Encoder(nn.Module): + def __init__(self): + super(Encoder, self).__init__() + + basemodel_name = 'tf_efficientnet_b5_ap' + print('Loading base model ()...'.format(basemodel_name), end='') + repo_path = os.path.join(os.path.dirname(__file__), 'efficientnet_repo') + basemodel = torch.hub.load(repo_path, basemodel_name, pretrained=False, source='local') + print('Done.') + + # Remove last layer + print('Removing last two layers (global_pool & classifier).') + basemodel.global_pool = nn.Identity() + basemodel.classifier = nn.Identity() + + self.original_model = basemodel + + def forward(self, x): + features = [x] + for k, v in self.original_model._modules.items(): + if (k == 'blocks'): + for ki, vi in v._modules.items(): + features.append(vi(features[-1])) + else: + features.append(v(features[-1])) + return features + + diff --git a/invokeai/backend/image_util/normal_bae/nets/submodules/submodules.py b/invokeai/backend/image_util/normal_bae/nets/submodules/submodules.py new file mode 100644 index 0000000000000000000000000000000000000000..409733351bd6ab5d191c800aff1bc05bfa4cb6f8 --- /dev/null +++ b/invokeai/backend/image_util/normal_bae/nets/submodules/submodules.py @@ -0,0 +1,140 @@ +import torch +import torch.nn as nn +import torch.nn.functional as F + + +######################################################################################################################## + + +# Upsample + BatchNorm +class UpSampleBN(nn.Module): + def __init__(self, skip_input, output_features): + super(UpSampleBN, self).__init__() + + self._net = nn.Sequential(nn.Conv2d(skip_input, output_features, kernel_size=3, stride=1, padding=1), + nn.BatchNorm2d(output_features), + nn.LeakyReLU(), + nn.Conv2d(output_features, output_features, kernel_size=3, stride=1, padding=1), + nn.BatchNorm2d(output_features), + nn.LeakyReLU()) + + def forward(self, x, concat_with): + up_x = F.interpolate(x, size=[concat_with.size(2), concat_with.size(3)], mode='bilinear', align_corners=True) + f = torch.cat([up_x, concat_with], dim=1) + return self._net(f) + + +# Upsample + GroupNorm + Weight Standardization +class UpSampleGN(nn.Module): + def __init__(self, skip_input, output_features): + super(UpSampleGN, self).__init__() + + self._net = nn.Sequential(Conv2d(skip_input, output_features, kernel_size=3, stride=1, padding=1), + nn.GroupNorm(8, output_features), + nn.LeakyReLU(), + Conv2d(output_features, output_features, kernel_size=3, stride=1, padding=1), + nn.GroupNorm(8, output_features), + nn.LeakyReLU()) + + def forward(self, x, concat_with): + up_x = F.interpolate(x, size=[concat_with.size(2), concat_with.size(3)], mode='bilinear', align_corners=True) + f = torch.cat([up_x, concat_with], dim=1) + return self._net(f) + + +# Conv2d with weight standardization +class Conv2d(nn.Conv2d): + def __init__(self, in_channels, out_channels, kernel_size, stride=1, + padding=0, dilation=1, groups=1, bias=True): + super(Conv2d, self).__init__(in_channels, out_channels, kernel_size, stride, + padding, dilation, groups, bias) + + def forward(self, x): + weight = self.weight + weight_mean = weight.mean(dim=1, keepdim=True).mean(dim=2, + keepdim=True).mean(dim=3, keepdim=True) + weight = weight - weight_mean + std = weight.view(weight.size(0), -1).std(dim=1).view(-1, 1, 1, 1) + 1e-5 + weight = weight / std.expand_as(weight) + return F.conv2d(x, weight, self.bias, self.stride, + self.padding, self.dilation, self.groups) + + +# normalize +def norm_normalize(norm_out): + min_kappa = 0.01 + norm_x, norm_y, norm_z, kappa = torch.split(norm_out, 1, dim=1) + norm = torch.sqrt(norm_x ** 2.0 + norm_y ** 2.0 + norm_z ** 2.0) + 1e-10 + kappa = F.elu(kappa) + 1.0 + min_kappa + final_out = torch.cat([norm_x / norm, norm_y / norm, norm_z / norm, kappa], dim=1) + return final_out + + +# uncertainty-guided sampling (only used during training) +@torch.no_grad() +def sample_points(init_normal, gt_norm_mask, sampling_ratio, beta): + device = init_normal.device + B, _, H, W = init_normal.shape + N = int(sampling_ratio * H * W) + beta = beta + + # uncertainty map + uncertainty_map = -1 * init_normal[:, 3, :, :] # B, H, W + + # gt_invalid_mask (B, H, W) + if gt_norm_mask is not None: + gt_invalid_mask = F.interpolate(gt_norm_mask.float(), size=[H, W], mode='nearest') + gt_invalid_mask = gt_invalid_mask[:, 0, :, :] < 0.5 + uncertainty_map[gt_invalid_mask] = -1e4 + + # (B, H*W) + _, idx = uncertainty_map.view(B, -1).sort(1, descending=True) + + # importance sampling + if int(beta * N) > 0: + importance = idx[:, :int(beta * N)] # B, beta*N + + # remaining + remaining = idx[:, int(beta * N):] # B, H*W - beta*N + + # coverage + num_coverage = N - int(beta * N) + + if num_coverage <= 0: + samples = importance + else: + coverage_list = [] + for i in range(B): + idx_c = torch.randperm(remaining.size()[1]) # shuffles "H*W - beta*N" + coverage_list.append(remaining[i, :][idx_c[:num_coverage]].view(1, -1)) # 1, N-beta*N + coverage = torch.cat(coverage_list, dim=0) # B, N-beta*N + samples = torch.cat((importance, coverage), dim=1) # B, N + + else: + # remaining + remaining = idx[:, :] # B, H*W + + # coverage + num_coverage = N + + coverage_list = [] + for i in range(B): + idx_c = torch.randperm(remaining.size()[1]) # shuffles "H*W - beta*N" + coverage_list.append(remaining[i, :][idx_c[:num_coverage]].view(1, -1)) # 1, N-beta*N + coverage = torch.cat(coverage_list, dim=0) # B, N-beta*N + samples = coverage + + # point coordinates + rows_int = samples // W # 0 for first row, H-1 for last row + rows_float = rows_int / float(H-1) # 0 to 1.0 + rows_float = (rows_float * 2.0) - 1.0 # -1.0 to 1.0 + + cols_int = samples % W # 0 for first column, W-1 for last column + cols_float = cols_int / float(W-1) # 0 to 1.0 + cols_float = (cols_float * 2.0) - 1.0 # -1.0 to 1.0 + + point_coords = torch.zeros(B, 1, N, 2) + point_coords[:, 0, :, 0] = cols_float # x coord + point_coords[:, 0, :, 1] = rows_float # y coord + point_coords = point_coords.to(device) + return point_coords, rows_int, cols_int \ No newline at end of file diff --git a/invokeai/backend/image_util/pidi/__init__.py b/invokeai/backend/image_util/pidi/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..8673b2191409d12386e886f615d7c68357e829a0 --- /dev/null +++ b/invokeai/backend/image_util/pidi/__init__.py @@ -0,0 +1,79 @@ +# Adapted from https://github.com/huggingface/controlnet_aux + +import pathlib + +import cv2 +import huggingface_hub +import numpy as np +import torch +from einops import rearrange +from PIL import Image + +from invokeai.backend.image_util.pidi.model import PiDiNet, pidinet +from invokeai.backend.image_util.util import nms, normalize_image_channel_count, np_to_pil, pil_to_np, safe_step + + +class PIDINetDetector: + """Simple wrapper around a PiDiNet model for edge detection.""" + + hf_repo_id = "lllyasviel/Annotators" + hf_filename = "table5_pidinet.pth" + + @classmethod + def get_model_url(cls) -> str: + """Get the URL to download the model from the Hugging Face Hub.""" + return huggingface_hub.hf_hub_url(cls.hf_repo_id, cls.hf_filename) + + @classmethod + def load_model(cls, model_path: pathlib.Path) -> PiDiNet: + """Load the model from a file.""" + + model = pidinet() + model.load_state_dict({k.replace("module.", ""): v for k, v in torch.load(model_path)["state_dict"].items()}) + model.eval() + return model + + def __init__(self, model: PiDiNet) -> None: + self.model = model + + def to(self, device: torch.device): + self.model.to(device) + return self + + def run( + self, image: Image.Image, quantize_edges: bool = False, scribble: bool = False, apply_filter: bool = False + ) -> Image.Image: + """Processes an image and returns the detected edges.""" + + device = next(iter(self.model.parameters())).device + + np_img = pil_to_np(image) + np_img = normalize_image_channel_count(np_img) + + assert np_img.ndim == 3 + + bgr_img = np_img[:, :, ::-1].copy() + + with torch.no_grad(): + image_pidi = torch.from_numpy(bgr_img).float().to(device) + image_pidi = image_pidi / 255.0 + image_pidi = rearrange(image_pidi, "h w c -> 1 c h w") + edge = self.model(image_pidi)[-1] + edge = edge.cpu().numpy() + if apply_filter: + edge = edge > 0.5 + if quantize_edges: + edge = safe_step(edge) + edge = (edge * 255.0).clip(0, 255).astype(np.uint8) + + detected_map = edge[0, 0] + + if scribble: + detected_map = nms(detected_map, 127, 3.0) + detected_map = cv2.GaussianBlur(detected_map, (0, 0), 3.0) + detected_map[detected_map > 4] = 255 + detected_map[detected_map < 255] = 0 + + output_img = np_to_pil(detected_map) + + return output_img diff --git a/invokeai/backend/image_util/pidi/model.py b/invokeai/backend/image_util/pidi/model.py new file mode 100644 index 0000000000000000000000000000000000000000..16595b35a4f75a6d2b0e832e24b6e11706d77326 --- /dev/null +++ b/invokeai/backend/image_util/pidi/model.py @@ -0,0 +1,681 @@ +""" +Author: Zhuo Su, Wenzhe Liu +Date: Feb 18, 2021 +""" + +import math + +import cv2 +import numpy as np +import torch +import torch.nn as nn +import torch.nn.functional as F + + +def img2tensor(imgs, bgr2rgb=True, float32=True): + """Numpy array to tensor. + + Args: + imgs (list[ndarray] | ndarray): Input images. + bgr2rgb (bool): Whether to change bgr to rgb. + float32 (bool): Whether to change to float32. + + Returns: + list[tensor] | tensor: Tensor images. If returned results only have + one element, just return tensor. + """ + + def _totensor(img, bgr2rgb, float32): + if img.shape[2] == 3 and bgr2rgb: + if img.dtype == 'float64': + img = img.astype('float32') + img = cv2.cvtColor(img, cv2.COLOR_BGR2RGB) + img = torch.from_numpy(img.transpose(2, 0, 1)) + if float32: + img = img.float() + return img + + if isinstance(imgs, list): + return [_totensor(img, bgr2rgb, float32) for img in imgs] + else: + return _totensor(imgs, bgr2rgb, float32) + +nets = { + 'baseline': { + 'layer0': 'cv', + 'layer1': 'cv', + 'layer2': 'cv', + 'layer3': 'cv', + 'layer4': 'cv', + 'layer5': 'cv', + 'layer6': 'cv', + 'layer7': 'cv', + 'layer8': 'cv', + 'layer9': 'cv', + 'layer10': 'cv', + 'layer11': 'cv', + 'layer12': 'cv', + 'layer13': 'cv', + 'layer14': 'cv', + 'layer15': 'cv', + }, + 'c-v15': { + 'layer0': 'cd', + 'layer1': 'cv', + 'layer2': 'cv', + 'layer3': 'cv', + 'layer4': 'cv', + 'layer5': 'cv', + 'layer6': 'cv', + 'layer7': 'cv', + 'layer8': 'cv', + 'layer9': 'cv', + 'layer10': 'cv', + 'layer11': 'cv', + 'layer12': 'cv', + 'layer13': 'cv', + 'layer14': 'cv', + 'layer15': 'cv', + }, + 'a-v15': { + 'layer0': 'ad', + 'layer1': 'cv', + 'layer2': 'cv', + 'layer3': 'cv', + 'layer4': 'cv', + 'layer5': 'cv', + 'layer6': 'cv', + 'layer7': 'cv', + 'layer8': 'cv', + 'layer9': 'cv', + 'layer10': 'cv', + 'layer11': 'cv', + 'layer12': 'cv', + 'layer13': 'cv', + 'layer14': 'cv', + 'layer15': 'cv', + }, + 'r-v15': { + 'layer0': 'rd', + 'layer1': 'cv', + 'layer2': 'cv', + 'layer3': 'cv', + 'layer4': 'cv', + 'layer5': 'cv', + 'layer6': 'cv', + 'layer7': 'cv', + 'layer8': 'cv', + 'layer9': 'cv', + 'layer10': 'cv', + 'layer11': 'cv', + 'layer12': 'cv', + 'layer13': 'cv', + 'layer14': 'cv', + 'layer15': 'cv', + }, + 'cvvv4': { + 'layer0': 'cd', + 'layer1': 'cv', + 'layer2': 'cv', + 'layer3': 'cv', + 'layer4': 'cd', + 'layer5': 'cv', + 'layer6': 'cv', + 'layer7': 'cv', + 'layer8': 'cd', + 'layer9': 'cv', + 'layer10': 'cv', + 'layer11': 'cv', + 'layer12': 'cd', + 'layer13': 'cv', + 'layer14': 'cv', + 'layer15': 'cv', + }, + 'avvv4': { + 'layer0': 'ad', + 'layer1': 'cv', + 'layer2': 'cv', + 'layer3': 'cv', + 'layer4': 'ad', + 'layer5': 'cv', + 'layer6': 'cv', + 'layer7': 'cv', + 'layer8': 'ad', + 'layer9': 'cv', + 'layer10': 'cv', + 'layer11': 'cv', + 'layer12': 'ad', + 'layer13': 'cv', + 'layer14': 'cv', + 'layer15': 'cv', + }, + 'rvvv4': { + 'layer0': 'rd', + 'layer1': 'cv', + 'layer2': 'cv', + 'layer3': 'cv', + 'layer4': 'rd', + 'layer5': 'cv', + 'layer6': 'cv', + 'layer7': 'cv', + 'layer8': 'rd', + 'layer9': 'cv', + 'layer10': 'cv', + 'layer11': 'cv', + 'layer12': 'rd', + 'layer13': 'cv', + 'layer14': 'cv', + 'layer15': 'cv', + }, + 'cccv4': { + 'layer0': 'cd', + 'layer1': 'cd', + 'layer2': 'cd', + 'layer3': 'cv', + 'layer4': 'cd', + 'layer5': 'cd', + 'layer6': 'cd', + 'layer7': 'cv', + 'layer8': 'cd', + 'layer9': 'cd', + 'layer10': 'cd', + 'layer11': 'cv', + 'layer12': 'cd', + 'layer13': 'cd', + 'layer14': 'cd', + 'layer15': 'cv', + }, + 'aaav4': { + 'layer0': 'ad', + 'layer1': 'ad', + 'layer2': 'ad', + 'layer3': 'cv', + 'layer4': 'ad', + 'layer5': 'ad', + 'layer6': 'ad', + 'layer7': 'cv', + 'layer8': 'ad', + 'layer9': 'ad', + 'layer10': 'ad', + 'layer11': 'cv', + 'layer12': 'ad', + 'layer13': 'ad', + 'layer14': 'ad', + 'layer15': 'cv', + }, + 'rrrv4': { + 'layer0': 'rd', + 'layer1': 'rd', + 'layer2': 'rd', + 'layer3': 'cv', + 'layer4': 'rd', + 'layer5': 'rd', + 'layer6': 'rd', + 'layer7': 'cv', + 'layer8': 'rd', + 'layer9': 'rd', + 'layer10': 'rd', + 'layer11': 'cv', + 'layer12': 'rd', + 'layer13': 'rd', + 'layer14': 'rd', + 'layer15': 'cv', + }, + 'c16': { + 'layer0': 'cd', + 'layer1': 'cd', + 'layer2': 'cd', + 'layer3': 'cd', + 'layer4': 'cd', + 'layer5': 'cd', + 'layer6': 'cd', + 'layer7': 'cd', + 'layer8': 'cd', + 'layer9': 'cd', + 'layer10': 'cd', + 'layer11': 'cd', + 'layer12': 'cd', + 'layer13': 'cd', + 'layer14': 'cd', + 'layer15': 'cd', + }, + 'a16': { + 'layer0': 'ad', + 'layer1': 'ad', + 'layer2': 'ad', + 'layer3': 'ad', + 'layer4': 'ad', + 'layer5': 'ad', + 'layer6': 'ad', + 'layer7': 'ad', + 'layer8': 'ad', + 'layer9': 'ad', + 'layer10': 'ad', + 'layer11': 'ad', + 'layer12': 'ad', + 'layer13': 'ad', + 'layer14': 'ad', + 'layer15': 'ad', + }, + 'r16': { + 'layer0': 'rd', + 'layer1': 'rd', + 'layer2': 'rd', + 'layer3': 'rd', + 'layer4': 'rd', + 'layer5': 'rd', + 'layer6': 'rd', + 'layer7': 'rd', + 'layer8': 'rd', + 'layer9': 'rd', + 'layer10': 'rd', + 'layer11': 'rd', + 'layer12': 'rd', + 'layer13': 'rd', + 'layer14': 'rd', + 'layer15': 'rd', + }, + 'carv4': { + 'layer0': 'cd', + 'layer1': 'ad', + 'layer2': 'rd', + 'layer3': 'cv', + 'layer4': 'cd', + 'layer5': 'ad', + 'layer6': 'rd', + 'layer7': 'cv', + 'layer8': 'cd', + 'layer9': 'ad', + 'layer10': 'rd', + 'layer11': 'cv', + 'layer12': 'cd', + 'layer13': 'ad', + 'layer14': 'rd', + 'layer15': 'cv', + }, + } + +def createConvFunc(op_type): + assert op_type in ['cv', 'cd', 'ad', 'rd'], 'unknown op type: %s' % str(op_type) + if op_type == 'cv': + return F.conv2d + + if op_type == 'cd': + def func(x, weights, bias=None, stride=1, padding=0, dilation=1, groups=1): + assert dilation in [1, 2], 'dilation for cd_conv should be in 1 or 2' + assert weights.size(2) == 3 and weights.size(3) == 3, 'kernel size for cd_conv should be 3x3' + assert padding == dilation, 'padding for cd_conv set wrong' + + weights_c = weights.sum(dim=[2, 3], keepdim=True) + yc = F.conv2d(x, weights_c, stride=stride, padding=0, groups=groups) + y = F.conv2d(x, weights, bias, stride=stride, padding=padding, dilation=dilation, groups=groups) + return y - yc + return func + elif op_type == 'ad': + def func(x, weights, bias=None, stride=1, padding=0, dilation=1, groups=1): + assert dilation in [1, 2], 'dilation for ad_conv should be in 1 or 2' + assert weights.size(2) == 3 and weights.size(3) == 3, 'kernel size for ad_conv should be 3x3' + assert padding == dilation, 'padding for ad_conv set wrong' + + shape = weights.shape + weights = weights.view(shape[0], shape[1], -1) + weights_conv = (weights - weights[:, :, [3, 0, 1, 6, 4, 2, 7, 8, 5]]).view(shape) # clock-wise + y = F.conv2d(x, weights_conv, bias, stride=stride, padding=padding, dilation=dilation, groups=groups) + return y + return func + elif op_type == 'rd': + def func(x, weights, bias=None, stride=1, padding=0, dilation=1, groups=1): + assert dilation in [1, 2], 'dilation for rd_conv should be in 1 or 2' + assert weights.size(2) == 3 and weights.size(3) == 3, 'kernel size for rd_conv should be 3x3' + padding = 2 * dilation + + shape = weights.shape + if weights.is_cuda: + buffer = torch.cuda.FloatTensor(shape[0], shape[1], 5 * 5).fill_(0) + else: + buffer = torch.zeros(shape[0], shape[1], 5 * 5).to(weights.device) + weights = weights.view(shape[0], shape[1], -1) + buffer[:, :, [0, 2, 4, 10, 14, 20, 22, 24]] = weights[:, :, 1:] + buffer[:, :, [6, 7, 8, 11, 13, 16, 17, 18]] = -weights[:, :, 1:] + buffer[:, :, 12] = 0 + buffer = buffer.view(shape[0], shape[1], 5, 5) + y = F.conv2d(x, buffer, bias, stride=stride, padding=padding, dilation=dilation, groups=groups) + return y + return func + else: + print('impossible to be here unless you force that') + return None + +class Conv2d(nn.Module): + def __init__(self, pdc, in_channels, out_channels, kernel_size, stride=1, padding=0, dilation=1, groups=1, bias=False): + super(Conv2d, self).__init__() + if in_channels % groups != 0: + raise ValueError('in_channels must be divisible by groups') + if out_channels % groups != 0: + raise ValueError('out_channels must be divisible by groups') + self.in_channels = in_channels + self.out_channels = out_channels + self.kernel_size = kernel_size + self.stride = stride + self.padding = padding + self.dilation = dilation + self.groups = groups + self.weight = nn.Parameter(torch.Tensor(out_channels, in_channels // groups, kernel_size, kernel_size)) + if bias: + self.bias = nn.Parameter(torch.Tensor(out_channels)) + else: + self.register_parameter('bias', None) + self.reset_parameters() + self.pdc = pdc + + def reset_parameters(self): + nn.init.kaiming_uniform_(self.weight, a=math.sqrt(5)) + if self.bias is not None: + fan_in, _ = nn.init._calculate_fan_in_and_fan_out(self.weight) + bound = 1 / math.sqrt(fan_in) + nn.init.uniform_(self.bias, -bound, bound) + + def forward(self, input): + + return self.pdc(input, self.weight, self.bias, self.stride, self.padding, self.dilation, self.groups) + +class CSAM(nn.Module): + """ + Compact Spatial Attention Module + """ + def __init__(self, channels): + super(CSAM, self).__init__() + + mid_channels = 4 + self.relu1 = nn.ReLU() + self.conv1 = nn.Conv2d(channels, mid_channels, kernel_size=1, padding=0) + self.conv2 = nn.Conv2d(mid_channels, 1, kernel_size=3, padding=1, bias=False) + self.sigmoid = nn.Sigmoid() + nn.init.constant_(self.conv1.bias, 0) + + def forward(self, x): + y = self.relu1(x) + y = self.conv1(y) + y = self.conv2(y) + y = self.sigmoid(y) + + return x * y + +class CDCM(nn.Module): + """ + Compact Dilation Convolution based Module + """ + def __init__(self, in_channels, out_channels): + super(CDCM, self).__init__() + + self.relu1 = nn.ReLU() + self.conv1 = nn.Conv2d(in_channels, out_channels, kernel_size=1, padding=0) + self.conv2_1 = nn.Conv2d(out_channels, out_channels, kernel_size=3, dilation=5, padding=5, bias=False) + self.conv2_2 = nn.Conv2d(out_channels, out_channels, kernel_size=3, dilation=7, padding=7, bias=False) + self.conv2_3 = nn.Conv2d(out_channels, out_channels, kernel_size=3, dilation=9, padding=9, bias=False) + self.conv2_4 = nn.Conv2d(out_channels, out_channels, kernel_size=3, dilation=11, padding=11, bias=False) + nn.init.constant_(self.conv1.bias, 0) + + def forward(self, x): + x = self.relu1(x) + x = self.conv1(x) + x1 = self.conv2_1(x) + x2 = self.conv2_2(x) + x3 = self.conv2_3(x) + x4 = self.conv2_4(x) + return x1 + x2 + x3 + x4 + + +class MapReduce(nn.Module): + """ + Reduce feature maps into a single edge map + """ + def __init__(self, channels): + super(MapReduce, self).__init__() + self.conv = nn.Conv2d(channels, 1, kernel_size=1, padding=0) + nn.init.constant_(self.conv.bias, 0) + + def forward(self, x): + return self.conv(x) + + +class PDCBlock(nn.Module): + def __init__(self, pdc, inplane, ouplane, stride=1): + super(PDCBlock, self).__init__() + self.stride=stride + + self.stride=stride + if self.stride > 1: + self.pool = nn.MaxPool2d(kernel_size=2, stride=2) + self.shortcut = nn.Conv2d(inplane, ouplane, kernel_size=1, padding=0) + self.conv1 = Conv2d(pdc, inplane, inplane, kernel_size=3, padding=1, groups=inplane, bias=False) + self.relu2 = nn.ReLU() + self.conv2 = nn.Conv2d(inplane, ouplane, kernel_size=1, padding=0, bias=False) + + def forward(self, x): + if self.stride > 1: + x = self.pool(x) + y = self.conv1(x) + y = self.relu2(y) + y = self.conv2(y) + if self.stride > 1: + x = self.shortcut(x) + y = y + x + return y + +class PDCBlock_converted(nn.Module): + """ + CPDC, APDC can be converted to vanilla 3x3 convolution + RPDC can be converted to vanilla 5x5 convolution + """ + def __init__(self, pdc, inplane, ouplane, stride=1): + super(PDCBlock_converted, self).__init__() + self.stride=stride + + if self.stride > 1: + self.pool = nn.MaxPool2d(kernel_size=2, stride=2) + self.shortcut = nn.Conv2d(inplane, ouplane, kernel_size=1, padding=0) + if pdc == 'rd': + self.conv1 = nn.Conv2d(inplane, inplane, kernel_size=5, padding=2, groups=inplane, bias=False) + else: + self.conv1 = nn.Conv2d(inplane, inplane, kernel_size=3, padding=1, groups=inplane, bias=False) + self.relu2 = nn.ReLU() + self.conv2 = nn.Conv2d(inplane, ouplane, kernel_size=1, padding=0, bias=False) + + def forward(self, x): + if self.stride > 1: + x = self.pool(x) + y = self.conv1(x) + y = self.relu2(y) + y = self.conv2(y) + if self.stride > 1: + x = self.shortcut(x) + y = y + x + return y + +class PiDiNet(nn.Module): + def __init__(self, inplane, pdcs, dil=None, sa=False, convert=False): + super(PiDiNet, self).__init__() + self.sa = sa + if dil is not None: + assert isinstance(dil, int), 'dil should be an int' + self.dil = dil + + self.fuseplanes = [] + + self.inplane = inplane + if convert: + if pdcs[0] == 'rd': + init_kernel_size = 5 + init_padding = 2 + else: + init_kernel_size = 3 + init_padding = 1 + self.init_block = nn.Conv2d(3, self.inplane, + kernel_size=init_kernel_size, padding=init_padding, bias=False) + block_class = PDCBlock_converted + else: + self.init_block = Conv2d(pdcs[0], 3, self.inplane, kernel_size=3, padding=1) + block_class = PDCBlock + + self.block1_1 = block_class(pdcs[1], self.inplane, self.inplane) + self.block1_2 = block_class(pdcs[2], self.inplane, self.inplane) + self.block1_3 = block_class(pdcs[3], self.inplane, self.inplane) + self.fuseplanes.append(self.inplane) # C + + inplane = self.inplane + self.inplane = self.inplane * 2 + self.block2_1 = block_class(pdcs[4], inplane, self.inplane, stride=2) + self.block2_2 = block_class(pdcs[5], self.inplane, self.inplane) + self.block2_3 = block_class(pdcs[6], self.inplane, self.inplane) + self.block2_4 = block_class(pdcs[7], self.inplane, self.inplane) + self.fuseplanes.append(self.inplane) # 2C + + inplane = self.inplane + self.inplane = self.inplane * 2 + self.block3_1 = block_class(pdcs[8], inplane, self.inplane, stride=2) + self.block3_2 = block_class(pdcs[9], self.inplane, self.inplane) + self.block3_3 = block_class(pdcs[10], self.inplane, self.inplane) + self.block3_4 = block_class(pdcs[11], self.inplane, self.inplane) + self.fuseplanes.append(self.inplane) # 4C + + self.block4_1 = block_class(pdcs[12], self.inplane, self.inplane, stride=2) + self.block4_2 = block_class(pdcs[13], self.inplane, self.inplane) + self.block4_3 = block_class(pdcs[14], self.inplane, self.inplane) + self.block4_4 = block_class(pdcs[15], self.inplane, self.inplane) + self.fuseplanes.append(self.inplane) # 4C + + self.conv_reduces = nn.ModuleList() + if self.sa and self.dil is not None: + self.attentions = nn.ModuleList() + self.dilations = nn.ModuleList() + for i in range(4): + self.dilations.append(CDCM(self.fuseplanes[i], self.dil)) + self.attentions.append(CSAM(self.dil)) + self.conv_reduces.append(MapReduce(self.dil)) + elif self.sa: + self.attentions = nn.ModuleList() + for i in range(4): + self.attentions.append(CSAM(self.fuseplanes[i])) + self.conv_reduces.append(MapReduce(self.fuseplanes[i])) + elif self.dil is not None: + self.dilations = nn.ModuleList() + for i in range(4): + self.dilations.append(CDCM(self.fuseplanes[i], self.dil)) + self.conv_reduces.append(MapReduce(self.dil)) + else: + for i in range(4): + self.conv_reduces.append(MapReduce(self.fuseplanes[i])) + + self.classifier = nn.Conv2d(4, 1, kernel_size=1) # has bias + nn.init.constant_(self.classifier.weight, 0.25) + nn.init.constant_(self.classifier.bias, 0) + + # print('initialization done') + + def get_weights(self): + conv_weights = [] + bn_weights = [] + relu_weights = [] + for pname, p in self.named_parameters(): + if 'bn' in pname: + bn_weights.append(p) + elif 'relu' in pname: + relu_weights.append(p) + else: + conv_weights.append(p) + + return conv_weights, bn_weights, relu_weights + + def forward(self, x): + H, W = x.size()[2:] + + x = self.init_block(x) + + x1 = self.block1_1(x) + x1 = self.block1_2(x1) + x1 = self.block1_3(x1) + + x2 = self.block2_1(x1) + x2 = self.block2_2(x2) + x2 = self.block2_3(x2) + x2 = self.block2_4(x2) + + x3 = self.block3_1(x2) + x3 = self.block3_2(x3) + x3 = self.block3_3(x3) + x3 = self.block3_4(x3) + + x4 = self.block4_1(x3) + x4 = self.block4_2(x4) + x4 = self.block4_3(x4) + x4 = self.block4_4(x4) + + x_fuses = [] + if self.sa and self.dil is not None: + for i, xi in enumerate([x1, x2, x3, x4]): + x_fuses.append(self.attentions[i](self.dilations[i](xi))) + elif self.sa: + for i, xi in enumerate([x1, x2, x3, x4]): + x_fuses.append(self.attentions[i](xi)) + elif self.dil is not None: + for i, xi in enumerate([x1, x2, x3, x4]): + x_fuses.append(self.dilations[i](xi)) + else: + x_fuses = [x1, x2, x3, x4] + + e1 = self.conv_reduces[0](x_fuses[0]) + e1 = F.interpolate(e1, (H, W), mode="bilinear", align_corners=False) + + e2 = self.conv_reduces[1](x_fuses[1]) + e2 = F.interpolate(e2, (H, W), mode="bilinear", align_corners=False) + + e3 = self.conv_reduces[2](x_fuses[2]) + e3 = F.interpolate(e3, (H, W), mode="bilinear", align_corners=False) + + e4 = self.conv_reduces[3](x_fuses[3]) + e4 = F.interpolate(e4, (H, W), mode="bilinear", align_corners=False) + + outputs = [e1, e2, e3, e4] + + output = self.classifier(torch.cat(outputs, dim=1)) + #if not self.training: + # return torch.sigmoid(output) + + outputs.append(output) + outputs = [torch.sigmoid(r) for r in outputs] + return outputs + +def config_model(model): + model_options = list(nets.keys()) + assert model in model_options, \ + 'unrecognized model, please choose from %s' % str(model_options) + + # print(str(nets[model])) + + pdcs = [] + for i in range(16): + layer_name = 'layer%d' % i + op = nets[model][layer_name] + pdcs.append(createConvFunc(op)) + + return pdcs + +def pidinet(): + pdcs = config_model('carv4') + dil = 24 #if args.dil else None + return PiDiNet(60, pdcs, dil=dil, sa=True) + + +if __name__ == '__main__': + model = pidinet() + ckp = torch.load('table5_pidinet.pth')['state_dict'] + model.load_state_dict({k.replace('module.',''):v for k, v in ckp.items()}) + im = cv2.imread('examples/test_my/cat_v4.png') + im = img2tensor(im).unsqueeze(0)/255. + res = model(im)[-1] + res = res>0.5 + res = res.float() + res = (res[0,0].cpu().data.numpy()*255.).astype(np.uint8) + print(res.shape) + cv2.imwrite('edge.png', res) diff --git a/invokeai/backend/image_util/pngwriter.py b/invokeai/backend/image_util/pngwriter.py new file mode 100644 index 0000000000000000000000000000000000000000..f537b4681c9fc9f1b9ad6bcf02f34688b1c7a7f7 --- /dev/null +++ b/invokeai/backend/image_util/pngwriter.py @@ -0,0 +1,118 @@ +""" +Two helper classes for dealing with PNG images and their path names. +PngWriter -- Converts Images generated by T2I into PNGs, finds + appropriate names for them, and writes prompt metadata + into the PNG. + +Exports function retrieve_metadata(path) +""" + +import json +import os +import re + +from PIL import Image, PngImagePlugin + +# -------------------image generation utils----- + + +class PngWriter: + def __init__(self, outdir): + self.outdir = outdir + os.makedirs(outdir, exist_ok=True) + + # gives the next unique prefix in outdir + def unique_prefix(self): + # sort reverse alphabetically until we find max+1 + dirlist = sorted(os.listdir(self.outdir), reverse=True) + # find the first filename that matches our pattern or return 000000.0.png + existing_name = next( + (f for f in dirlist if re.match(r"^(\d+)\..*\.png", f)), + "0000000.0.png", + ) + basecount = int(existing_name.split(".", 1)[0]) + 1 + return f"{basecount:06}" + + # saves image named _image_ to outdir/name, writing metadata from prompt + # returns full path of output + def save_image_and_prompt_to_png(self, image, dream_prompt, name, metadata=None, compress_level=6): + path = os.path.join(self.outdir, name) + info = PngImagePlugin.PngInfo() + info.add_text("Dream", dream_prompt) + if metadata: + info.add_text("sd-metadata", json.dumps(metadata)) + image.save(path, "PNG", pnginfo=info, compress_level=compress_level) + return path + + def retrieve_metadata(self, img_basename): + """ + Given a PNG filename stored in outdir, returns the "sd-metadata" + metadata stored there, as a dict + """ + path = os.path.join(self.outdir, img_basename) + all_metadata = retrieve_metadata(path) + return all_metadata["sd-metadata"] + + +def retrieve_metadata(img_path): + """ + Given a path to a PNG image, returns the "sd-metadata" + metadata stored there, as a dict + """ + im = Image.open(img_path) + if hasattr(im, "text"): + md = im.text.get("sd-metadata", "{}") + dream_prompt = im.text.get("Dream", "") + else: + # When trying to retrieve metadata from images without a 'text' payload, such as JPG images. + md = "{}" + dream_prompt = "" + return {"sd-metadata": json.loads(md), "Dream": dream_prompt} + + +def write_metadata(img_path: str, meta: dict): + im = Image.open(img_path) + info = PngImagePlugin.PngInfo() + info.add_text("sd-metadata", json.dumps(meta)) + im.save(img_path, "PNG", pnginfo=info) + + +class PromptFormatter: + def __init__(self, t2i, opt): + self.t2i = t2i + self.opt = opt + + # note: the t2i object should provide all these values. + # there should be no need to or against opt values + def normalize_prompt(self): + """Normalize the prompt and switches""" + t2i = self.t2i + opt = self.opt + + switches = [] + switches.append(f'"{opt.prompt}"') + switches.append(f"-s{opt.steps or t2i.steps}") + switches.append(f"-W{opt.width or t2i.width}") + switches.append(f"-H{opt.height or t2i.height}") + switches.append(f"-C{opt.cfg_scale or t2i.cfg_scale}") + switches.append(f"-A{opt.sampler_name or t2i.sampler_name}") + # to do: put model name into the t2i object + # switches.append(f'--model{t2i.model_name}') + if opt.seamless or t2i.seamless: + switches.append("--seamless") + if opt.init_img: + switches.append(f"-I{opt.init_img}") + if opt.fit: + switches.append("--fit") + if opt.strength and opt.init_img is not None: + switches.append(f"-f{opt.strength or t2i.strength}") + if opt.gfpgan_strength: + switches.append(f"-G{opt.gfpgan_strength}") + if opt.upscale: + switches.append(f'-U {" ".join([str(u) for u in opt.upscale])}') + if opt.variation_amount > 0: + switches.append(f"-v{opt.variation_amount}") + if opt.with_variations: + formatted_variations = ",".join(f"{seed}:{weight}" for seed, weight in opt.with_variations) + switches.append(f"-V{formatted_variations}") + return " ".join(switches) diff --git a/invokeai/backend/image_util/realesrgan/LICENSE b/invokeai/backend/image_util/realesrgan/LICENSE new file mode 100644 index 0000000000000000000000000000000000000000..552a1eeaf01f4e7077013ed3496600c608f35202 --- /dev/null +++ b/invokeai/backend/image_util/realesrgan/LICENSE @@ -0,0 +1,29 @@ +BSD 3-Clause License + +Copyright (c) 2021, Xintao Wang +All rights reserved. + +Redistribution and use in source and binary forms, with or without +modification, are permitted provided that the following conditions are met: + +1. Redistributions of source code must retain the above copyright notice, this + list of conditions and the following disclaimer. + +2. Redistributions in binary form must reproduce the above copyright notice, + this list of conditions and the following disclaimer in the documentation + and/or other materials provided with the distribution. + +3. Neither the name of the copyright holder nor the names of its + contributors may be used to endorse or promote products derived from + this software without specific prior written permission. + +THIS SOFTWARE IS PROVIDED BY THE COPYRIGHT HOLDERS AND CONTRIBUTORS "AS IS" +AND ANY EXPRESS OR IMPLIED WARRANTIES, INCLUDING, BUT NOT LIMITED TO, THE +IMPLIED WARRANTIES OF MERCHANTABILITY AND FITNESS FOR A PARTICULAR PURPOSE ARE +DISCLAIMED. IN NO EVENT SHALL THE COPYRIGHT HOLDER OR CONTRIBUTORS BE LIABLE +FOR ANY DIRECT, INDIRECT, INCIDENTAL, SPECIAL, EXEMPLARY, OR CONSEQUENTIAL +DAMAGES (INCLUDING, BUT NOT LIMITED TO, PROCUREMENT OF SUBSTITUTE GOODS OR +SERVICES; LOSS OF USE, DATA, OR PROFITS; OR BUSINESS INTERRUPTION) HOWEVER +CAUSED AND ON ANY THEORY OF LIABILITY, WHETHER IN CONTRACT, STRICT LIABILITY, +OR TORT (INCLUDING NEGLIGENCE OR OTHERWISE) ARISING IN ANY WAY OUT OF THE USE +OF THIS SOFTWARE, EVEN IF ADVISED OF THE POSSIBILITY OF SUCH DAMAGE. diff --git a/invokeai/backend/image_util/realesrgan/__init__.py b/invokeai/backend/image_util/realesrgan/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/image_util/realesrgan/realesrgan.py b/invokeai/backend/image_util/realesrgan/realesrgan.py new file mode 100644 index 0000000000000000000000000000000000000000..c5fe3fa598c1e2f2b4c1358691230fa9f80902e5 --- /dev/null +++ b/invokeai/backend/image_util/realesrgan/realesrgan.py @@ -0,0 +1,272 @@ +import math +from enum import Enum +from typing import Any, Optional + +import cv2 +import numpy as np +import numpy.typing as npt +import torch +from cv2.typing import MatLike +from tqdm import tqdm + +from invokeai.backend.image_util.basicsr.rrdbnet_arch import RRDBNet +from invokeai.backend.model_manager.config import AnyModel +from invokeai.backend.util.devices import TorchDevice + +""" +Adapted from https://github.com/xinntao/Real-ESRGAN/blob/master/realesrgan/utils.py +License is BSD3, copied to `LICENSE` in this directory. + +The adaptation here has a few changes: +- Remove print statements, use `tqdm` to show progress +- Remove unused "outscale" logic, which simply scales the final image to a given factor +- Remove `dni_weight` logic, which was only used when multiple models were used +- Remove logic to fetch models from network +- Add types, rename a few things +""" + + +class ImageMode(str, Enum): + L = "L" + RGB = "RGB" + RGBA = "RGBA" + + +class RealESRGAN: + """A helper class for upsampling images with RealESRGAN. + + Args: + scale (int): Upsampling scale factor used in the networks. It is usually 2 or 4. + model_path (str): The path to the pretrained model. It can be urls (will first download it automatically). + model (nn.Module): The defined network. Default: None. + tile (int): As too large images result in the out of GPU memory issue, so this tile option will first crop + input images into tiles, and then process each of them. Finally, they will be merged into one image. + 0 denotes for do not use tile. Default: 0. + tile_pad (int): The pad size for each tile, to remove border artifacts. Default: 10. + pre_pad (int): Pad the input images to avoid border artifacts. Default: 10. + half (float): Whether to use half precision during inference. Default: False. + """ + + output: torch.Tensor + + def __init__( + self, + scale: int, + loadnet: AnyModel, + model: RRDBNet, + tile: int = 0, + tile_pad: int = 10, + pre_pad: int = 10, + half: bool = False, + ) -> None: + self.scale = scale + self.tile_size = tile + self.tile_pad = tile_pad + self.pre_pad = pre_pad + self.mod_scale: Optional[int] = None + self.half = half + self.device = TorchDevice.choose_torch_device() + + # prefer to use params_ema + if "params_ema" in loadnet: + keyname = "params_ema" + else: + keyname = "params" + + model.load_state_dict(loadnet[keyname], strict=True) + model.eval() + self.model = model.to(self.device) + + if self.half: + self.model = self.model.half() + + def pre_process(self, img: MatLike) -> None: + """Pre-process, such as pre-pad and mod pad, so that the images can be divisible""" + img_tensor: torch.Tensor = torch.from_numpy(np.transpose(img, (2, 0, 1))).float() + self.img = img_tensor.unsqueeze(0).to(self.device) + if self.half: + self.img = self.img.half() + + # pre_pad + if self.pre_pad != 0: + self.img = torch.nn.functional.pad(self.img, (0, self.pre_pad, 0, self.pre_pad), "reflect") + # mod pad for divisible borders + if self.scale == 2: + self.mod_scale = 2 + elif self.scale == 1: + self.mod_scale = 4 + if self.mod_scale is not None: + self.mod_pad_h, self.mod_pad_w = 0, 0 + _, _, h, w = self.img.size() + if h % self.mod_scale != 0: + self.mod_pad_h = self.mod_scale - h % self.mod_scale + if w % self.mod_scale != 0: + self.mod_pad_w = self.mod_scale - w % self.mod_scale + self.img = torch.nn.functional.pad(self.img, (0, self.mod_pad_w, 0, self.mod_pad_h), "reflect") + + def process(self) -> None: + # model inference + self.output = self.model(self.img) + + def tile_process(self) -> None: + """It will first crop input images to tiles, and then process each tile. + Finally, all the processed tiles are merged into one images. + + Modified from: https://github.com/ata4/esrgan-launcher + """ + batch, channel, height, width = self.img.shape + output_height = height * self.scale + output_width = width * self.scale + output_shape = (batch, channel, output_height, output_width) + + # start with black image + self.output = self.img.new_zeros(output_shape) + tiles_x = math.ceil(width / self.tile_size) + tiles_y = math.ceil(height / self.tile_size) + + # loop over all tiles + total_steps = tiles_y * tiles_x + for i in tqdm(range(total_steps), desc="Upscaling"): + y = i // tiles_x + x = i % tiles_x + # extract tile from input image + ofs_x = x * self.tile_size + ofs_y = y * self.tile_size + # input tile area on total image + input_start_x = ofs_x + input_end_x = min(ofs_x + self.tile_size, width) + input_start_y = ofs_y + input_end_y = min(ofs_y + self.tile_size, height) + + # input tile area on total image with padding + input_start_x_pad = max(input_start_x - self.tile_pad, 0) + input_end_x_pad = min(input_end_x + self.tile_pad, width) + input_start_y_pad = max(input_start_y - self.tile_pad, 0) + input_end_y_pad = min(input_end_y + self.tile_pad, height) + + # input tile dimensions + input_tile_width = input_end_x - input_start_x + input_tile_height = input_end_y - input_start_y + input_tile = self.img[ + :, + :, + input_start_y_pad:input_end_y_pad, + input_start_x_pad:input_end_x_pad, + ] + + # upscale tile + with torch.no_grad(): + output_tile = self.model(input_tile) + + # output tile area on total image + output_start_x = input_start_x * self.scale + output_end_x = input_end_x * self.scale + output_start_y = input_start_y * self.scale + output_end_y = input_end_y * self.scale + + # output tile area without padding + output_start_x_tile = (input_start_x - input_start_x_pad) * self.scale + output_end_x_tile = output_start_x_tile + input_tile_width * self.scale + output_start_y_tile = (input_start_y - input_start_y_pad) * self.scale + output_end_y_tile = output_start_y_tile + input_tile_height * self.scale + + # put tile into output image + self.output[:, :, output_start_y:output_end_y, output_start_x:output_end_x] = output_tile[ + :, + :, + output_start_y_tile:output_end_y_tile, + output_start_x_tile:output_end_x_tile, + ] + + def post_process(self) -> torch.Tensor: + # remove extra pad + if self.mod_scale is not None: + _, _, h, w = self.output.size() + self.output = self.output[ + :, + :, + 0 : h - self.mod_pad_h * self.scale, + 0 : w - self.mod_pad_w * self.scale, + ] + # remove prepad + if self.pre_pad != 0: + _, _, h, w = self.output.size() + self.output = self.output[ + :, + :, + 0 : h - self.pre_pad * self.scale, + 0 : w - self.pre_pad * self.scale, + ] + return self.output + + @torch.no_grad() + def upscale(self, img: MatLike, esrgan_alpha_upscale: bool = True) -> npt.NDArray[Any]: + np_img = img.astype(np.float32) + alpha: Optional[np.ndarray] = None + if np.max(np_img) > 256: + # 16-bit image + max_range = 65535 + else: + max_range = 255 + np_img = np_img / max_range + if len(np_img.shape) == 2: + # grayscale image + img_mode = ImageMode.L + np_img = cv2.cvtColor(np_img, cv2.COLOR_GRAY2RGB) + elif np_img.shape[2] == 4: + # RGBA image with alpha channel + img_mode = ImageMode.RGBA + alpha = np_img[:, :, 3] + np_img = np_img[:, :, 0:3] + np_img = cv2.cvtColor(np_img, cv2.COLOR_BGR2RGB) + if esrgan_alpha_upscale: + alpha = cv2.cvtColor(alpha, cv2.COLOR_GRAY2RGB) + else: + img_mode = ImageMode.RGB + np_img = cv2.cvtColor(np_img, cv2.COLOR_BGR2RGB) + + # ------------------- process image (without the alpha channel) ------------------- # + self.pre_process(np_img) + if self.tile_size > 0: + self.tile_process() + else: + self.process() + output_tensor = self.post_process() + output_img: npt.NDArray[Any] = output_tensor.data.squeeze().float().cpu().clamp_(0, 1).numpy() + output_img = np.transpose(output_img[[2, 1, 0], :, :], (1, 2, 0)) + if img_mode is ImageMode.L: + output_img = cv2.cvtColor(output_img, cv2.COLOR_BGR2GRAY) + + # ------------------- process the alpha channel if necessary ------------------- # + if img_mode is ImageMode.RGBA: + if esrgan_alpha_upscale: + assert alpha is not None + self.pre_process(alpha) + if self.tile_size > 0: + self.tile_process() + else: + self.process() + output_alpha_tensor = self.post_process() + output_alpha: npt.NDArray[Any] = output_alpha_tensor.data.squeeze().float().cpu().clamp_(0, 1).numpy() + output_alpha = np.transpose(output_alpha[[2, 1, 0], :, :], (1, 2, 0)) + output_alpha = cv2.cvtColor(output_alpha, cv2.COLOR_BGR2GRAY) + else: # use the cv2 resize for alpha channel + assert alpha is not None + h, w = alpha.shape[0:2] + output_alpha = cv2.resize( + alpha, + (w * self.scale, h * self.scale), + interpolation=cv2.INTER_LINEAR, + ) + + # merge the alpha channel + output_img = cv2.cvtColor(output_img, cv2.COLOR_BGR2BGRA) + output_img[:, :, 3] = output_alpha + + # ------------------------------ return ------------------------------ # + if max_range == 65535: # 16-bit image + output = (output_img * 65535.0).round().astype(np.uint16) + else: + output = (output_img * 255.0).round().astype(np.uint8) + + return output diff --git a/invokeai/backend/image_util/safety_checker.py b/invokeai/backend/image_util/safety_checker.py new file mode 100644 index 0000000000000000000000000000000000000000..ab09a2961979ed357f7d8026fbefe891dde3f501 --- /dev/null +++ b/invokeai/backend/image_util/safety_checker.py @@ -0,0 +1,84 @@ +""" +This module defines a singleton object, "safety_checker" that +wraps the safety_checker model. It respects the global "nsfw_checker" +configuration variable, that allows the checker to be supressed. +""" + +from pathlib import Path + +import numpy as np +from diffusers.pipelines.stable_diffusion.safety_checker import StableDiffusionSafetyChecker +from PIL import Image, ImageFilter +from transformers import AutoFeatureExtractor + +import invokeai.backend.util.logging as logger +from invokeai.app.services.config.config_default import get_config +from invokeai.backend.util.devices import TorchDevice +from invokeai.backend.util.silence_warnings import SilenceWarnings + +repo_id = "CompVis/stable-diffusion-safety-checker" +CHECKER_PATH = "core/convert/stable-diffusion-safety-checker" + + +class SafetyChecker: + """ + Wrapper around SafetyChecker model. + """ + + feature_extractor = None + safety_checker = None + + @classmethod + def _load_safety_checker(cls): + if cls.safety_checker is not None and cls.feature_extractor is not None: + return + + try: + model_path = get_config().models_path / CHECKER_PATH + if model_path.exists(): + cls.feature_extractor = AutoFeatureExtractor.from_pretrained(model_path) + cls.safety_checker = StableDiffusionSafetyChecker.from_pretrained(model_path) + else: + model_path.mkdir(parents=True, exist_ok=True) + cls.feature_extractor = AutoFeatureExtractor.from_pretrained(repo_id) + cls.feature_extractor.save_pretrained(model_path, safe_serialization=True) + cls.safety_checker = StableDiffusionSafetyChecker.from_pretrained(repo_id) + cls.safety_checker.save_pretrained(model_path, safe_serialization=True) + except Exception as e: + logger.warning(f"Could not load NSFW checker: {str(e)}") + + @classmethod + def has_nsfw_concept(cls, image: Image.Image) -> bool: + cls._load_safety_checker() + if cls.safety_checker is None or cls.feature_extractor is None: + return False + device = TorchDevice.choose_torch_device() + features = cls.feature_extractor([image], return_tensors="pt") + features.to(device) + cls.safety_checker.to(device) + x_image = np.array(image).astype(np.float32) / 255.0 + x_image = x_image[None].transpose(0, 3, 1, 2) + with SilenceWarnings(): + checked_image, has_nsfw_concept = cls.safety_checker(images=x_image, clip_input=features.pixel_values) + return has_nsfw_concept[0] + + @classmethod + def blur_if_nsfw(cls, image: Image.Image) -> Image.Image: + if cls.has_nsfw_concept(image): + logger.warning("A potentially NSFW image has been detected. Image will be blurred.") + blurry_image = image.filter(filter=ImageFilter.GaussianBlur(radius=32)) + caution = cls._get_caution_img() + # Center the caution image on the blurred image + x = (blurry_image.width - caution.width) // 2 + y = (blurry_image.height - caution.height) // 2 + blurry_image.paste(caution, (x, y), caution) + image = blurry_image + + return image + + @classmethod + def _get_caution_img(cls) -> Image.Image: + import invokeai.app.assets.images as image_assets + + caution = Image.open(Path(image_assets.__path__[0]) / "caution.png") + return caution.resize((caution.width // 2, caution.height // 2)) diff --git a/invokeai/backend/image_util/segment_anything/__init__.py b/invokeai/backend/image_util/segment_anything/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/image_util/segment_anything/mask_refinement.py b/invokeai/backend/image_util/segment_anything/mask_refinement.py new file mode 100644 index 0000000000000000000000000000000000000000..2c8cf077d1c93aba5d1962f381601b5e2cd33fbc --- /dev/null +++ b/invokeai/backend/image_util/segment_anything/mask_refinement.py @@ -0,0 +1,50 @@ +# This file contains utilities for Grounded-SAM mask refinement based on: +# https://github.com/NielsRogge/Transformers-Tutorials/blob/a39f33ac1557b02ebfb191ea7753e332b5ca933f/Grounding%20DINO/GroundingDINO_with_Segment_Anything.ipynb + + +import cv2 +import numpy as np +import numpy.typing as npt + + +def mask_to_polygon(mask: npt.NDArray[np.uint8]) -> list[tuple[int, int]]: + """Convert a binary mask to a polygon. + + Returns: + list[list[int]]: List of (x, y) coordinates representing the vertices of the polygon. + """ + # Find contours in the binary mask. + contours, _ = cv2.findContours(mask.astype(np.uint8), cv2.RETR_EXTERNAL, cv2.CHAIN_APPROX_SIMPLE) + + # Find the contour with the largest area. + largest_contour = max(contours, key=cv2.contourArea) + + # Extract the vertices of the contour. + polygon = largest_contour.reshape(-1, 2).tolist() + + return polygon + + +def polygon_to_mask( + polygon: list[tuple[int, int]], image_shape: tuple[int, int], fill_value: int = 1 +) -> npt.NDArray[np.uint8]: + """Convert a polygon to a segmentation mask. + + Args: + polygon (list): List of (x, y) coordinates representing the vertices of the polygon. + image_shape (tuple): Shape of the image (height, width) for the mask. + fill_value (int): Value to fill the polygon with. + + Returns: + np.ndarray: Segmentation mask with the polygon filled (with value 255). + """ + # Create an empty mask. + mask = np.zeros(image_shape, dtype=np.uint8) + + # Convert polygon to an array of points. + pts = np.array(polygon, dtype=np.int32) + + # Fill the polygon with white color (255). + cv2.fillPoly(mask, [pts], color=(fill_value,)) + + return mask diff --git a/invokeai/backend/image_util/segment_anything/segment_anything_pipeline.py b/invokeai/backend/image_util/segment_anything/segment_anything_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..e10df5d18053361aaa420c912ca2eac19d7a4614 --- /dev/null +++ b/invokeai/backend/image_util/segment_anything/segment_anything_pipeline.py @@ -0,0 +1,94 @@ +from typing import Optional, TypeAlias + +import torch +from PIL import Image +from transformers.models.sam import SamModel +from transformers.models.sam.processing_sam import SamProcessor + +from invokeai.backend.raw_model import RawModel + +# Type aliases for the inputs to the SAM model. +ListOfBoundingBoxes: TypeAlias = list[list[int]] +"""A list of bounding boxes. Each bounding box is in the format [xmin, ymin, xmax, ymax].""" +ListOfPoints: TypeAlias = list[list[int]] +"""A list of points. Each point is in the format [x, y].""" +ListOfPointLabels: TypeAlias = list[int] +"""A list of SAM point labels. Each label is an integer where -1 is background, 0 is neutral, and 1 is foreground.""" + + +class SegmentAnythingPipeline(RawModel): + """A wrapper class for the transformers SAM model and processor that makes it compatible with the model manager.""" + + def __init__(self, sam_model: SamModel, sam_processor: SamProcessor): + self._sam_model = sam_model + self._sam_processor = sam_processor + + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None): + # HACK(ryand): The SAM pipeline does not work on MPS devices. We only allow it to be moved to CPU or CUDA. + if device is not None and device.type not in {"cpu", "cuda"}: + device = None + self._sam_model.to(device=device, dtype=dtype) + + def calc_size(self) -> int: + # HACK(ryand): Fix the circular import issue. + from invokeai.backend.model_manager.load.model_util import calc_module_size + + return calc_module_size(self._sam_model) + + def segment( + self, + image: Image.Image, + bounding_boxes: list[list[int]] | None = None, + point_lists: list[list[list[int]]] | None = None, + ) -> torch.Tensor: + """Run the SAM model. + + Either bounding_boxes or point_lists must be provided. If both are provided, bounding_boxes will be used and + point_lists will be ignored. + + Args: + image (Image.Image): The image to segment. + bounding_boxes (list[list[int]]): The bounding box prompts. Each bounding box is in the format + [xmin, ymin, xmax, ymax]. + point_lists (list[list[list[int]]]): The points prompts. Each point is in the format [x, y, label]. + `label` is an integer where -1 is background, 0 is neutral, and 1 is foreground. + + Returns: + torch.Tensor: The segmentation masks. dtype: torch.bool. shape: [num_masks, channels, height, width]. + """ + + # Prep the inputs: + # - Create a list of bounding boxes or points and labels. + # - Add a batch dimension of 1 to the inputs. + if bounding_boxes: + input_boxes: list[ListOfBoundingBoxes] | None = [bounding_boxes] + input_points: list[ListOfPoints] | None = None + input_labels: list[ListOfPointLabels] | None = None + elif point_lists: + input_boxes: list[ListOfBoundingBoxes] | None = None + input_points: list[ListOfPoints] | None = [] + input_labels: list[ListOfPointLabels] | None = [] + for point_list in point_lists: + input_points.append([[p[0], p[1]] for p in point_list]) + input_labels.append([p[2] for p in point_list]) + + else: + raise ValueError("Either bounding_boxes or points and labels must be provided.") + + inputs = self._sam_processor( + images=image, + input_boxes=input_boxes, + input_points=input_points, + input_labels=input_labels, + return_tensors="pt", + ).to(self._sam_model.device) + outputs = self._sam_model(**inputs) + masks = self._sam_processor.post_process_masks( + masks=outputs.pred_masks, + original_sizes=inputs.original_sizes, + reshaped_input_sizes=inputs.reshaped_input_sizes, + ) + + # There should be only one batch. + assert len(masks) == 1 + return masks[0] diff --git a/invokeai/backend/image_util/util.py b/invokeai/backend/image_util/util.py new file mode 100644 index 0000000000000000000000000000000000000000..1e7aad4eb459dc564125f46092fd75b4d76585bb --- /dev/null +++ b/invokeai/backend/image_util/util.py @@ -0,0 +1,247 @@ +from math import ceil, floor, sqrt +from typing import Optional + +import cv2 +import numpy as np +from PIL import Image + + +class InitImageResizer: + """Simple class to create resized copies of an Image while preserving the aspect ratio.""" + + def __init__(self, Image): + self.image = Image + + def resize(self, width=None, height=None) -> Image.Image: + """ + Return a copy of the image resized to fit within + a box width x height. The aspect ratio is + maintained. If neither width nor height are provided, + then returns a copy of the original image. If one or the other is + provided, then the other will be calculated from the + aspect ratio. + + Everything is floored to the nearest multiple of 64 so + that it can be passed to img2img() + """ + im = self.image + + ar = im.width / float(im.height) + + # Infer missing values from aspect ratio + if not (width or height): # both missing + width = im.width + height = im.height + elif not height: # height missing + height = int(width / ar) + elif not width: # width missing + width = int(height * ar) + + w_scale = width / im.width + h_scale = height / im.height + scale = min(w_scale, h_scale) + (rw, rh) = (int(scale * im.width), int(scale * im.height)) + + # round everything to multiples of 64 + width, height, rw, rh = (x - x % 64 for x in (width, height, rw, rh)) + + # no resize necessary, but return a copy + if im.width == width and im.height == height: + return im.copy() + + # otherwise resize the original image so that it fits inside the bounding box + resized_image = self.image.resize((rw, rh), resample=Image.Resampling.LANCZOS) + return resized_image + + +def make_grid(image_list, rows=None, cols=None): + image_cnt = len(image_list) + if None in (rows, cols): + rows = floor(sqrt(image_cnt)) # try to make it square + cols = ceil(image_cnt / rows) + width = image_list[0].width + height = image_list[0].height + + grid_img = Image.new("RGB", (width * cols, height * rows)) + i = 0 + for r in range(0, rows): + for c in range(0, cols): + if i >= len(image_list): + break + grid_img.paste(image_list[i], (c * width, r * height)) + i = i + 1 + + return grid_img + + +def pil_to_np(image: Image.Image) -> np.ndarray: + """Converts a PIL image to a numpy array.""" + return np.array(image, dtype=np.uint8) + + +def np_to_pil(image: np.ndarray) -> Image.Image: + """Converts a numpy array to a PIL image.""" + return Image.fromarray(image) + + +def pil_to_cv2(image: Image.Image) -> np.ndarray: + """Converts a PIL image to a CV2 image.""" + + if image.mode == "RGBA": + return cv2.cvtColor(np.array(image, dtype=np.uint8), cv2.COLOR_RGBA2BGRA) + else: + return cv2.cvtColor(np.array(image, dtype=np.uint8), cv2.COLOR_RGB2BGR) + + +def cv2_to_pil(image: np.ndarray) -> Image.Image: + """Converts a CV2 image to a PIL image.""" + + if image.ndim == 3 and image.shape[2] == 4: + return Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGRA2RGBA)) + else: + return Image.fromarray(cv2.cvtColor(image, cv2.COLOR_BGR2RGB)) + + +def normalize_image_channel_count(image: np.ndarray) -> np.ndarray: + """Normalizes an image to have 3 channels. + + If the image has 1 channel, it will be duplicated 3 times. + If the image has 1 channel, a third empty channel will be added. + If the image has 4 channels, the alpha channel will be used to blend the image with a white background. + + Adapted from https://github.com/huggingface/controlnet_aux (Apache-2.0 license). + + Args: + image: The input image. + + Returns: + The normalized image. + """ + assert image.dtype == np.uint8 + if image.ndim == 2: + image = image[:, :, None] + assert image.ndim == 3 + _height, _width, channels = image.shape + assert channels == 1 or channels == 3 or channels == 4 + if channels == 3: + return image + if channels == 1: + return np.concatenate([image, image, image], axis=2) + if channels == 4: + color = image[:, :, 0:3].astype(np.float32) + alpha = image[:, :, 3:4].astype(np.float32) / 255.0 + normalized = color * alpha + 255.0 * (1.0 - alpha) + normalized = normalized.clip(0, 255).astype(np.uint8) + return normalized + + raise ValueError("Invalid number of channels.") + + +def resize_image_to_resolution(input_image: np.ndarray, resolution: int) -> np.ndarray: + """Resizes an image, fitting it to the given resolution. + + Adapted from https://github.com/huggingface/controlnet_aux (Apache-2.0 license). + + Args: + input_image: The input image. + resolution: The resolution to fit the image to. + + Returns: + The resized image. + """ + h = float(input_image.shape[0]) + w = float(input_image.shape[1]) + scaling_factor = float(resolution) / min(h, w) + h = int(h * scaling_factor) + w = int(w * scaling_factor) + if scaling_factor > 1: + return cv2.resize(input_image, (w, h), interpolation=cv2.INTER_LANCZOS4) + else: + return cv2.resize(input_image, (w, h), interpolation=cv2.INTER_AREA) + + +def nms(np_img: np.ndarray, threshold: Optional[int] = None, sigma: Optional[float] = None) -> np.ndarray: + """ + Apply non-maximum suppression to an image. + + If both threshold and sigma are provided, the image will blurred before the suppression and thresholded afterwards, + resulting in a binary output image. + + This function is adapted from https://github.com/lllyasviel/ControlNet. + + Args: + image: The input image. + threshold: The threshold value for the suppression. Pixels with values greater than this will be set to 255. + sigma: The standard deviation for the Gaussian blur applied to the image. + + Returns: + The image after non-maximum suppression. + + Raises: + ValueError: If only one of threshold and sigma provided. + """ + + # Raise a value error if only one of threshold and sigma is provided + if (threshold is None) != (sigma is None): + raise ValueError("Both threshold and sigma must be provided if one is provided.") + + if sigma is not None and threshold is not None: + # Blurring the image can help to thin out features + np_img = cv2.GaussianBlur(np_img.astype(np.float32), (0, 0), sigma) + + filter_1 = np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0]], dtype=np.uint8) + filter_2 = np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8) + filter_3 = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.uint8) + filter_4 = np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=np.uint8) + + nms_img = np.zeros_like(np_img) + + for f in [filter_1, filter_2, filter_3, filter_4]: + np.putmask(nms_img, cv2.dilate(np_img, kernel=f) == np_img, np_img) + + if sigma is not None and threshold is not None: + # We blurred - now threshold to get a binary image + thresholded = np.zeros_like(nms_img, dtype=np.uint8) + thresholded[nms_img > threshold] = 255 + return thresholded + + return nms_img + + +def safe_step(x: np.ndarray, step: int = 2) -> np.ndarray: + """Apply the safe step operation to an array. + + I don't fully understand the purpose of this function, but it appears to be normalizing/quantizing the array. + + Adapted from https://github.com/huggingface/controlnet_aux (Apache-2.0 license). + + Args: + x: The input array. + step: The step value. + + Returns: + The array after the safe step operation. + """ + y = x.astype(np.float32) * float(step + 1) + y = y.astype(np.int32).astype(np.float32) / float(step) + return y + + +def resize_to_multiple(image: np.ndarray, multiple: int) -> np.ndarray: + """Resize an image to make its dimensions multiples of the given number.""" + + # Get the original dimensions + height, width = image.shape[:2] + + # Calculate the scaling factor to make the dimensions multiples of the given number + new_width = (width // multiple) * multiple + new_height = int((new_width / width) * height) + + # If new_height is not a multiple, adjust it + if new_height % multiple != 0: + new_height = (new_height // multiple) * multiple + + # Resize the image + resized_image = cv2.resize(image, (new_width, new_height), interpolation=cv2.INTER_AREA) + + return resized_image diff --git a/invokeai/backend/ip_adapter/README.md b/invokeai/backend/ip_adapter/README.md new file mode 100644 index 0000000000000000000000000000000000000000..c85acae4982c096fc14a37922d2674df8507aad8 --- /dev/null +++ b/invokeai/backend/ip_adapter/README.md @@ -0,0 +1,45 @@ +# IP-Adapter Model Formats + +The official IP-Adapter models are released here: [h94/IP-Adapter](https://huggingface.co/h94/IP-Adapter) + +This official model repo does not integrate well with InvokeAI's current approach to model management, so we have defined a new file structure for IP-Adapter models. The InvokeAI format is described below. + +## CLIP Vision Models + +CLIP Vision models are organized in `diffusers`` format. The expected directory structure is: + +```bash +ip_adapter_sd_image_encoder/ +├── config.json +└── model.safetensors +``` + +## IP-Adapter Models + +IP-Adapter models are stored in a directory containing two files +- `image_encoder.txt`: A text file containing the model identifier for the CLIP Vision encoder that is intended to be used with this IP-Adapter model. +- `ip_adapter.bin`: The IP-Adapter weights. + +Sample directory structure: +```bash +ip_adapter_sd15/ +├── image_encoder.txt +└── ip_adapter.bin +``` + +### Why save the weights in a .safetensors file? + +The weights in `ip_adapter.bin` are stored in a nested dict, which is not supported by `safetensors`. This could be solved by splitting `ip_adapter.bin` into multiple files, but for now we have decided to maintain consistency with the checkpoint structure used in the official [h94/IP-Adapter](https://huggingface.co/h94/IP-Adapter) repo. + +## InvokeAI Hosted IP-Adapters + +Image Encoders: +- [InvokeAI/ip_adapter_sd_image_encoder](https://huggingface.co/InvokeAI/ip_adapter_sd_image_encoder) +- [InvokeAI/ip_adapter_sdxl_image_encoder](https://huggingface.co/InvokeAI/ip_adapter_sdxl_image_encoder) + +IP-Adapters: +- [InvokeAI/ip_adapter_sd15](https://huggingface.co/InvokeAI/ip_adapter_sd15) +- [InvokeAI/ip_adapter_plus_sd15](https://huggingface.co/InvokeAI/ip_adapter_plus_sd15) +- [InvokeAI/ip_adapter_plus_face_sd15](https://huggingface.co/InvokeAI/ip_adapter_plus_face_sd15) +- [InvokeAI/ip_adapter_sdxl](https://huggingface.co/InvokeAI/ip_adapter_sdxl) +- [InvokeAI/ip_adapter_sdxl_vit_h](https://huggingface.co/InvokeAI/ip_adapter_sdxl_vit_h) \ No newline at end of file diff --git a/invokeai/backend/ip_adapter/__init__.py b/invokeai/backend/ip_adapter/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/ip_adapter/ip_adapter.py b/invokeai/backend/ip_adapter/ip_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..87ce029a87558965682d36287b74ccbc0258afbe --- /dev/null +++ b/invokeai/backend/ip_adapter/ip_adapter.py @@ -0,0 +1,253 @@ +# copied from https://github.com/tencent-ailab/IP-Adapter (Apache License 2.0) +# and modified as needed + +import pathlib +from typing import List, Optional, TypedDict, Union + +import safetensors +import safetensors.torch +import torch +from PIL import Image +from transformers import CLIPImageProcessor, CLIPVisionModelWithProjection + +from invokeai.backend.ip_adapter.ip_attention_weights import IPAttentionWeights +from invokeai.backend.ip_adapter.resampler import Resampler +from invokeai.backend.raw_model import RawModel + + +class IPAdapterStateDict(TypedDict): + ip_adapter: dict[str, torch.Tensor] + image_proj: dict[str, torch.Tensor] + + +class ImageProjModel(torch.nn.Module): + """Image Projection Model""" + + def __init__( + self, cross_attention_dim: int = 1024, clip_embeddings_dim: int = 1024, clip_extra_context_tokens: int = 4 + ): + super().__init__() + + self.cross_attention_dim = cross_attention_dim + self.clip_extra_context_tokens = clip_extra_context_tokens + self.proj = torch.nn.Linear(clip_embeddings_dim, self.clip_extra_context_tokens * cross_attention_dim) + self.norm = torch.nn.LayerNorm(cross_attention_dim) + + @classmethod + def from_state_dict(cls, state_dict: dict[str, torch.Tensor], clip_extra_context_tokens: int = 4): + """Initialize an ImageProjModel from a state_dict. + + The cross_attention_dim and clip_embeddings_dim are inferred from the shape of the tensors in the state_dict. + + Args: + state_dict (dict[torch.Tensor]): The state_dict of model weights. + clip_extra_context_tokens (int, optional): Defaults to 4. + + Returns: + ImageProjModel + """ + cross_attention_dim = state_dict["norm.weight"].shape[0] + clip_embeddings_dim = state_dict["proj.weight"].shape[-1] + + model = cls(cross_attention_dim, clip_embeddings_dim, clip_extra_context_tokens) + + model.load_state_dict(state_dict) + return model + + def forward(self, image_embeds: torch.Tensor): + embeds = image_embeds + clip_extra_context_tokens = self.proj(embeds).reshape( + -1, self.clip_extra_context_tokens, self.cross_attention_dim + ) + clip_extra_context_tokens = self.norm(clip_extra_context_tokens) + return clip_extra_context_tokens + + +class MLPProjModel(torch.nn.Module): + """SD model with image prompt""" + + def __init__(self, cross_attention_dim: int = 1024, clip_embeddings_dim: int = 1024): + super().__init__() + + self.proj = torch.nn.Sequential( + torch.nn.Linear(clip_embeddings_dim, clip_embeddings_dim), + torch.nn.GELU(), + torch.nn.Linear(clip_embeddings_dim, cross_attention_dim), + torch.nn.LayerNorm(cross_attention_dim), + ) + + @classmethod + def from_state_dict(cls, state_dict: dict[str, torch.Tensor]): + """Initialize an MLPProjModel from a state_dict. + + The cross_attention_dim and clip_embeddings_dim are inferred from the shape of the tensors in the state_dict. + + Args: + state_dict (dict[torch.Tensor]): The state_dict of model weights. + + Returns: + MLPProjModel + """ + cross_attention_dim = state_dict["proj.3.weight"].shape[0] + clip_embeddings_dim = state_dict["proj.0.weight"].shape[0] + + model = cls(cross_attention_dim, clip_embeddings_dim) + + model.load_state_dict(state_dict) + return model + + def forward(self, image_embeds: torch.Tensor): + clip_extra_context_tokens = self.proj(image_embeds) + return clip_extra_context_tokens + + +class IPAdapter(RawModel): + """IP-Adapter: https://arxiv.org/pdf/2308.06721.pdf""" + + def __init__( + self, + state_dict: IPAdapterStateDict, + device: torch.device, + dtype: torch.dtype = torch.float16, + num_tokens: int = 4, + ): + self.device = device + self.dtype = dtype + + self._num_tokens = num_tokens + + self._clip_image_processor = CLIPImageProcessor() + + self._image_proj_model = self._init_image_proj_model(state_dict["image_proj"]) + + self.attn_weights = IPAttentionWeights.from_state_dict(state_dict["ip_adapter"]).to( + self.device, dtype=self.dtype + ) + + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None): + if device is not None: + self.device = device + if dtype is not None: + self.dtype = dtype + + self._image_proj_model.to(device=self.device, dtype=self.dtype) + self.attn_weights.to(device=self.device, dtype=self.dtype) + + def calc_size(self) -> int: + # HACK(ryand): Fix this issue with circular imports. + from invokeai.backend.model_manager.load.model_util import calc_module_size + + return calc_module_size(self._image_proj_model) + calc_module_size(self.attn_weights) + + def _init_image_proj_model( + self, state_dict: dict[str, torch.Tensor] + ) -> Union[ImageProjModel, Resampler, MLPProjModel]: + return ImageProjModel.from_state_dict(state_dict, self._num_tokens).to(self.device, dtype=self.dtype) + + @torch.inference_mode() + def get_image_embeds(self, pil_image: List[Image.Image], image_encoder: CLIPVisionModelWithProjection): + clip_image = self._clip_image_processor(images=pil_image, return_tensors="pt").pixel_values + clip_image_embeds = image_encoder(clip_image.to(self.device, dtype=self.dtype)).image_embeds + try: + image_prompt_embeds = self._image_proj_model(clip_image_embeds) + uncond_image_prompt_embeds = self._image_proj_model(torch.zeros_like(clip_image_embeds)) + return image_prompt_embeds, uncond_image_prompt_embeds + except RuntimeError as e: + raise RuntimeError("Selected CLIP Vision Model is incompatible with the current IP Adapter") from e + + +class IPAdapterPlus(IPAdapter): + """IP-Adapter with fine-grained features""" + + def _init_image_proj_model(self, state_dict: dict[str, torch.Tensor]) -> Union[Resampler, MLPProjModel]: + return Resampler.from_state_dict( + state_dict=state_dict, + depth=4, + dim_head=64, + heads=12, + num_queries=self._num_tokens, + ff_mult=4, + ).to(self.device, dtype=self.dtype) + + @torch.inference_mode() + def get_image_embeds(self, pil_image: List[Image.Image], image_encoder: CLIPVisionModelWithProjection): + clip_image = self._clip_image_processor(images=pil_image, return_tensors="pt").pixel_values + clip_image = clip_image.to(self.device, dtype=self.dtype) + clip_image_embeds = image_encoder(clip_image, output_hidden_states=True).hidden_states[-2] + uncond_clip_image_embeds = image_encoder(torch.zeros_like(clip_image), output_hidden_states=True).hidden_states[ + -2 + ] + try: + image_prompt_embeds = self._image_proj_model(clip_image_embeds) + uncond_image_prompt_embeds = self._image_proj_model(uncond_clip_image_embeds) + return image_prompt_embeds, uncond_image_prompt_embeds + except RuntimeError as e: + raise RuntimeError("Selected CLIP Vision Model is incompatible with the current IP Adapter") from e + + +class IPAdapterFull(IPAdapterPlus): + """IP-Adapter Plus with full features.""" + + def _init_image_proj_model(self, state_dict: dict[str, torch.Tensor]): + return MLPProjModel.from_state_dict(state_dict).to(self.device, dtype=self.dtype) + + +class IPAdapterPlusXL(IPAdapterPlus): + """IP-Adapter Plus for SDXL.""" + + def _init_image_proj_model(self, state_dict: dict[str, torch.Tensor]): + return Resampler.from_state_dict( + state_dict=state_dict, + depth=4, + dim_head=64, + heads=20, + num_queries=self._num_tokens, + ff_mult=4, + ).to(self.device, dtype=self.dtype) + + +def load_ip_adapter_tensors(ip_adapter_ckpt_path: pathlib.Path, device: str) -> IPAdapterStateDict: + state_dict: IPAdapterStateDict = {"ip_adapter": {}, "image_proj": {}} + + if ip_adapter_ckpt_path.suffix == ".safetensors": + model = safetensors.torch.load_file(ip_adapter_ckpt_path, device=device) + for key in model.keys(): + if key.startswith("image_proj."): + state_dict["image_proj"][key.replace("image_proj.", "")] = model[key] + elif key.startswith("ip_adapter."): + state_dict["ip_adapter"][key.replace("ip_adapter.", "")] = model[key] + else: + raise RuntimeError(f"Encountered unexpected IP Adapter state dict key: '{key}'.") + else: + ip_adapter_diffusers_checkpoint_path = ip_adapter_ckpt_path / "ip_adapter.bin" + state_dict = torch.load(ip_adapter_diffusers_checkpoint_path, map_location="cpu") + + return state_dict + + +def build_ip_adapter( + ip_adapter_ckpt_path: pathlib.Path, device: torch.device, dtype: torch.dtype = torch.float16 +) -> Union[IPAdapter, IPAdapterPlus, IPAdapterPlusXL, IPAdapterPlus]: + state_dict = load_ip_adapter_tensors(ip_adapter_ckpt_path, device.type) + + # IPAdapter (with ImageProjModel) + if "proj.weight" in state_dict["image_proj"]: + return IPAdapter(state_dict, device=device, dtype=dtype) + + # IPAdaterPlus or IPAdapterPlusXL (with Resampler) + elif "proj_in.weight" in state_dict["image_proj"]: + cross_attention_dim = state_dict["ip_adapter"]["1.to_k_ip.weight"].shape[-1] + if cross_attention_dim == 768: + return IPAdapterPlus(state_dict, device=device, dtype=dtype) # SD1 IP-Adapter Plus + elif cross_attention_dim == 2048: + return IPAdapterPlusXL(state_dict, device=device, dtype=dtype) # SDXL IP-Adapter Plus + else: + raise Exception(f"Unsupported IP-Adapter Plus cross-attention dimension: {cross_attention_dim}.") + + # IPAdapterFull (with MLPProjModel) + elif "proj.0.weight" in state_dict["image_proj"]: + return IPAdapterFull(state_dict, device=device, dtype=dtype) + + # Unrecognized IP Adapter Architectures + else: + raise ValueError(f"'{ip_adapter_ckpt_path}' has an unrecognized IP-Adapter model architecture.") diff --git a/invokeai/backend/ip_adapter/ip_attention_weights.py b/invokeai/backend/ip_adapter/ip_attention_weights.py new file mode 100644 index 0000000000000000000000000000000000000000..9c3b8969c6897caf2dbc75195d45e9135e9b51b3 --- /dev/null +++ b/invokeai/backend/ip_adapter/ip_attention_weights.py @@ -0,0 +1,46 @@ +import torch + + +class IPAttentionProcessorWeights(torch.nn.Module): + """The IP-Adapter weights for a single attention processor. + + This class is a torch.nn.Module sub-class to facilitate loading from a state_dict. It does not have a forward(...) + method. + """ + + def __init__(self, in_dim: int, out_dim: int): + super().__init__() + self.to_k_ip = torch.nn.Linear(in_dim, out_dim, bias=False) + self.to_v_ip = torch.nn.Linear(in_dim, out_dim, bias=False) + + +class IPAttentionWeights(torch.nn.Module): + """A collection of all the `IPAttentionProcessorWeights` objects for an IP-Adapter model. + + This class is a torch.nn.Module sub-class so that it inherits the `.to(...)` functionality. It does not have a + forward(...) method. + """ + + def __init__(self, weights: torch.nn.ModuleDict): + super().__init__() + self._weights = weights + + def get_attention_processor_weights(self, idx: int) -> IPAttentionProcessorWeights: + """Get the `IPAttentionProcessorWeights` for the idx'th attention processor.""" + # Cast to int first, because we expect the key to represent an int. Then cast back to str, because + # `torch.nn.ModuleDict` only supports str keys. + return self._weights[str(int(idx))] + + @classmethod + def from_state_dict(cls, state_dict: dict[str, torch.Tensor]): + attn_proc_weights: dict[str, IPAttentionProcessorWeights] = {} + + for tensor_name, tensor in state_dict.items(): + if "to_k_ip.weight" in tensor_name: + index = str(int(tensor_name.split(".")[0])) + attn_proc_weights[index] = IPAttentionProcessorWeights(tensor.shape[1], tensor.shape[0]) + + attn_proc_weights_module = torch.nn.ModuleDict(attn_proc_weights) + attn_proc_weights_module.load_state_dict(state_dict) + + return cls(attn_proc_weights_module) diff --git a/invokeai/backend/ip_adapter/resampler.py b/invokeai/backend/ip_adapter/resampler.py new file mode 100644 index 0000000000000000000000000000000000000000..a32eeacfdc2240f1491a7a860b086207d2e2cc18 --- /dev/null +++ b/invokeai/backend/ip_adapter/resampler.py @@ -0,0 +1,166 @@ +# copied from https://github.com/tencent-ailab/IP-Adapter (Apache License 2.0) + +# tencent ailab comment: modified from +# https://github.com/mlfoundations/open_flamingo/blob/main/open_flamingo/src/helpers.py +import math + +import torch +import torch.nn as nn + + +# FFN +def FeedForward(dim: int, mult: int = 4): + inner_dim = dim * mult + return nn.Sequential( + nn.LayerNorm(dim), + nn.Linear(dim, inner_dim, bias=False), + nn.GELU(), + nn.Linear(inner_dim, dim, bias=False), + ) + + +def reshape_tensor(x: torch.Tensor, heads: int): + bs, length, _ = x.shape + # (bs, length, width) --> (bs, length, n_heads, dim_per_head) + x = x.view(bs, length, heads, -1) + # (bs, length, n_heads, dim_per_head) --> (bs, n_heads, length, dim_per_head) + x = x.transpose(1, 2) + # (bs, n_heads, length, dim_per_head) --> (bs*n_heads, length, dim_per_head) + x = x.reshape(bs, heads, length, -1) + return x + + +class PerceiverAttention(nn.Module): + def __init__(self, *, dim: int, dim_head: int = 64, heads: int = 8): + super().__init__() + self.scale = dim_head**-0.5 + self.dim_head = dim_head + self.heads = heads + inner_dim = dim_head * heads + + self.norm1 = nn.LayerNorm(dim) + self.norm2 = nn.LayerNorm(dim) + + self.to_q = nn.Linear(dim, inner_dim, bias=False) + self.to_kv = nn.Linear(dim, inner_dim * 2, bias=False) + self.to_out = nn.Linear(inner_dim, dim, bias=False) + + def forward(self, x: torch.Tensor, latents: torch.Tensor): + """ + Args: + x (torch.Tensor): image features + shape (b, n1, D) + latent (torch.Tensor): latent features + shape (b, n2, D) + """ + x = self.norm1(x) + latents = self.norm2(latents) + + b, L, _ = latents.shape + + q = self.to_q(latents) + kv_input = torch.cat((x, latents), dim=-2) + k, v = self.to_kv(kv_input).chunk(2, dim=-1) + + q = reshape_tensor(q, self.heads) + k = reshape_tensor(k, self.heads) + v = reshape_tensor(v, self.heads) + + # attention + scale = 1 / math.sqrt(math.sqrt(self.dim_head)) + weight = (q * scale) @ (k * scale).transpose(-2, -1) # More stable with f16 than dividing afterwards + weight = torch.softmax(weight.float(), dim=-1).type(weight.dtype) + out = weight @ v + + out = out.permute(0, 2, 1, 3).reshape(b, L, -1) + + return self.to_out(out) + + +class Resampler(nn.Module): + def __init__( + self, + dim: int = 1024, + depth: int = 8, + dim_head: int = 64, + heads: int = 16, + num_queries: int = 8, + embedding_dim: int = 768, + output_dim: int = 1024, + ff_mult: int = 4, + ): + super().__init__() + + self.latents = nn.Parameter(torch.randn(1, num_queries, dim) / dim**0.5) + + self.proj_in = nn.Linear(embedding_dim, dim) + + self.proj_out = nn.Linear(dim, output_dim) + self.norm_out = nn.LayerNorm(output_dim) + + self.layers = nn.ModuleList([]) + for _ in range(depth): + self.layers.append( + nn.ModuleList( + [ + PerceiverAttention(dim=dim, dim_head=dim_head, heads=heads), + FeedForward(dim=dim, mult=ff_mult), + ] + ) + ) + + @classmethod + def from_state_dict( + cls, + state_dict: dict[str, torch.Tensor], + depth: int = 8, + dim_head: int = 64, + heads: int = 16, + num_queries: int = 8, + ff_mult: int = 4, + ): + """A convenience function that initializes a Resampler from a state_dict. + + Some of the shape parameters are inferred from the state_dict (e.g. dim, embedding_dim, etc.). At the time of + writing, we did not have a need for inferring ALL of the shape parameters from the state_dict, but this would be + possible if needed in the future. + + Args: + state_dict (dict[torch.Tensor]): The state_dict to load. + depth (int, optional): + dim_head (int, optional): + heads (int, optional): + ff_mult (int, optional): + + Returns: + Resampler + """ + dim = state_dict["latents"].shape[2] + num_queries = state_dict["latents"].shape[1] + embedding_dim = state_dict["proj_in.weight"].shape[-1] + output_dim = state_dict["norm_out.weight"].shape[0] + + model = cls( + dim=dim, + depth=depth, + dim_head=dim_head, + heads=heads, + num_queries=num_queries, + embedding_dim=embedding_dim, + output_dim=output_dim, + ff_mult=ff_mult, + ) + model.load_state_dict(state_dict) + return model + + def forward(self, x: torch.Tensor): + latents = self.latents.repeat(x.size(0), 1, 1) + + x = self.proj_in(x) + + for attn, ff in self.layers: + latents = attn(x, latents) + latents + latents = ff(latents) + latents + + latents = self.proj_out(latents) + return self.norm_out(latents) diff --git a/invokeai/backend/lora/__init__.py b/invokeai/backend/lora/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/lora/conversions/__init__.py b/invokeai/backend/lora/conversions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/lora/conversions/flux_diffusers_lora_conversion_utils.py b/invokeai/backend/lora/conversions/flux_diffusers_lora_conversion_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..a0237cc6f08d00fde39f22bfcd4570d1e2210110 --- /dev/null +++ b/invokeai/backend/lora/conversions/flux_diffusers_lora_conversion_utils.py @@ -0,0 +1,232 @@ +from typing import Dict + +import torch + +from invokeai.backend.lora.conversions.flux_lora_constants import FLUX_LORA_TRANSFORMER_PREFIX +from invokeai.backend.lora.layers.any_lora_layer import AnyLoRALayer +from invokeai.backend.lora.layers.concatenated_lora_layer import ConcatenatedLoRALayer +from invokeai.backend.lora.layers.lora_layer import LoRALayer +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw + + +def is_state_dict_likely_in_flux_diffusers_format(state_dict: Dict[str, torch.Tensor]) -> bool: + """Checks if the provided state dict is likely in the Diffusers FLUX LoRA format. + + This is intended to be a reasonably high-precision detector, but it is not guaranteed to have perfect precision. (A + perfect-precision detector would require checking all keys against a whitelist and verifying tensor shapes.) + """ + # First, check that all keys end in "lora_A.weight" or "lora_B.weight" (i.e. are in PEFT format). + all_keys_in_peft_format = all(k.endswith(("lora_A.weight", "lora_B.weight")) for k in state_dict.keys()) + + # Next, check that this is likely a FLUX model by spot-checking a few keys. + expected_keys = [ + "transformer.single_transformer_blocks.0.attn.to_q.lora_A.weight", + "transformer.single_transformer_blocks.0.attn.to_q.lora_B.weight", + "transformer.transformer_blocks.0.attn.add_q_proj.lora_A.weight", + "transformer.transformer_blocks.0.attn.add_q_proj.lora_B.weight", + ] + all_expected_keys_present = all(k in state_dict for k in expected_keys) + + return all_keys_in_peft_format and all_expected_keys_present + + +def lora_model_from_flux_diffusers_state_dict(state_dict: Dict[str, torch.Tensor], alpha: float | None) -> LoRAModelRaw: + """Loads a state dict in the Diffusers FLUX LoRA format into a LoRAModelRaw object. + + This function is based on: + https://github.com/huggingface/diffusers/blob/55ac421f7bb12fd00ccbef727be4dc2f3f920abb/scripts/convert_flux_to_diffusers.py + """ + # Group keys by layer. + grouped_state_dict: dict[str, dict[str, torch.Tensor]] = _group_by_layer(state_dict) + + # Remove the "transformer." prefix from all keys. + grouped_state_dict = {k.replace("transformer.", ""): v for k, v in grouped_state_dict.items()} + + # Constants for FLUX.1 + num_double_layers = 19 + num_single_layers = 38 + hidden_size = 3072 + mlp_ratio = 4.0 + mlp_hidden_dim = int(hidden_size * mlp_ratio) + + layers: dict[str, AnyLoRALayer] = {} + + def add_lora_layer_if_present(src_key: str, dst_key: str) -> None: + if src_key in grouped_state_dict: + src_layer_dict = grouped_state_dict.pop(src_key) + value = { + "lora_down.weight": src_layer_dict.pop("lora_A.weight"), + "lora_up.weight": src_layer_dict.pop("lora_B.weight"), + } + if alpha is not None: + value["alpha"] = torch.tensor(alpha) + layers[dst_key] = LoRALayer.from_state_dict_values(values=value) + assert len(src_layer_dict) == 0 + + def add_qkv_lora_layer_if_present( + src_keys: list[str], + src_weight_shapes: list[tuple[int, int]], + dst_qkv_key: str, + allow_missing_keys: bool = False, + ) -> None: + """Handle the Q, K, V matrices for a transformer block. We need special handling because the diffusers format + stores them in separate matrices, whereas the BFL format used internally by InvokeAI concatenates them. + """ + # If none of the keys are present, return early. + keys_present = [key in grouped_state_dict for key in src_keys] + if not any(keys_present): + return + + sub_layers: list[LoRALayer] = [] + for src_key, src_weight_shape in zip(src_keys, src_weight_shapes, strict=True): + src_layer_dict = grouped_state_dict.pop(src_key, None) + if src_layer_dict is not None: + values = { + "lora_down.weight": src_layer_dict.pop("lora_A.weight"), + "lora_up.weight": src_layer_dict.pop("lora_B.weight"), + } + if alpha is not None: + values["alpha"] = torch.tensor(alpha) + assert values["lora_down.weight"].shape[1] == src_weight_shape[1] + assert values["lora_up.weight"].shape[0] == src_weight_shape[0] + sub_layers.append(LoRALayer.from_state_dict_values(values=values)) + assert len(src_layer_dict) == 0 + else: + if not allow_missing_keys: + raise ValueError(f"Missing LoRA layer: '{src_key}'.") + values = { + "lora_up.weight": torch.zeros((src_weight_shape[0], 1)), + "lora_down.weight": torch.zeros((1, src_weight_shape[1])), + } + sub_layers.append(LoRALayer.from_state_dict_values(values=values)) + layers[dst_qkv_key] = ConcatenatedLoRALayer(lora_layers=sub_layers) + + # time_text_embed.timestep_embedder -> time_in. + add_lora_layer_if_present("time_text_embed.timestep_embedder.linear_1", "time_in.in_layer") + add_lora_layer_if_present("time_text_embed.timestep_embedder.linear_2", "time_in.out_layer") + + # time_text_embed.text_embedder -> vector_in. + add_lora_layer_if_present("time_text_embed.text_embedder.linear_1", "vector_in.in_layer") + add_lora_layer_if_present("time_text_embed.text_embedder.linear_2", "vector_in.out_layer") + + # time_text_embed.guidance_embedder -> guidance_in. + add_lora_layer_if_present("time_text_embed.guidance_embedder.linear_1", "guidance_in") + add_lora_layer_if_present("time_text_embed.guidance_embedder.linear_2", "guidance_in") + + # context_embedder -> txt_in. + add_lora_layer_if_present("context_embedder", "txt_in") + + # x_embedder -> img_in. + add_lora_layer_if_present("x_embedder", "img_in") + + # Double transformer blocks. + for i in range(num_double_layers): + # norms. + add_lora_layer_if_present(f"transformer_blocks.{i}.norm1.linear", f"double_blocks.{i}.img_mod.lin") + add_lora_layer_if_present(f"transformer_blocks.{i}.norm1_context.linear", f"double_blocks.{i}.txt_mod.lin") + + # Q, K, V + add_qkv_lora_layer_if_present( + [ + f"transformer_blocks.{i}.attn.to_q", + f"transformer_blocks.{i}.attn.to_k", + f"transformer_blocks.{i}.attn.to_v", + ], + [(hidden_size, hidden_size), (hidden_size, hidden_size), (hidden_size, hidden_size)], + f"double_blocks.{i}.img_attn.qkv", + ) + add_qkv_lora_layer_if_present( + [ + f"transformer_blocks.{i}.attn.add_q_proj", + f"transformer_blocks.{i}.attn.add_k_proj", + f"transformer_blocks.{i}.attn.add_v_proj", + ], + [(hidden_size, hidden_size), (hidden_size, hidden_size), (hidden_size, hidden_size)], + f"double_blocks.{i}.txt_attn.qkv", + ) + + # ff img_mlp + add_lora_layer_if_present( + f"transformer_blocks.{i}.ff.net.0.proj", + f"double_blocks.{i}.img_mlp.0", + ) + add_lora_layer_if_present( + f"transformer_blocks.{i}.ff.net.2", + f"double_blocks.{i}.img_mlp.2", + ) + + # ff txt_mlp + add_lora_layer_if_present( + f"transformer_blocks.{i}.ff_context.net.0.proj", + f"double_blocks.{i}.txt_mlp.0", + ) + add_lora_layer_if_present( + f"transformer_blocks.{i}.ff_context.net.2", + f"double_blocks.{i}.txt_mlp.2", + ) + + # output projections. + add_lora_layer_if_present( + f"transformer_blocks.{i}.attn.to_out.0", + f"double_blocks.{i}.img_attn.proj", + ) + add_lora_layer_if_present( + f"transformer_blocks.{i}.attn.to_add_out", + f"double_blocks.{i}.txt_attn.proj", + ) + + # Single transformer blocks. + for i in range(num_single_layers): + # norms + add_lora_layer_if_present( + f"single_transformer_blocks.{i}.norm.linear", + f"single_blocks.{i}.modulation.lin", + ) + + # Q, K, V, mlp + add_qkv_lora_layer_if_present( + [ + f"single_transformer_blocks.{i}.attn.to_q", + f"single_transformer_blocks.{i}.attn.to_k", + f"single_transformer_blocks.{i}.attn.to_v", + f"single_transformer_blocks.{i}.proj_mlp", + ], + [ + (hidden_size, hidden_size), + (hidden_size, hidden_size), + (hidden_size, hidden_size), + (mlp_hidden_dim, hidden_size), + ], + f"single_blocks.{i}.linear1", + allow_missing_keys=True, + ) + + # Output projections. + add_lora_layer_if_present( + f"single_transformer_blocks.{i}.proj_out", + f"single_blocks.{i}.linear2", + ) + + # Final layer. + add_lora_layer_if_present("proj_out", "final_layer.linear") + + # Assert that all keys were processed. + assert len(grouped_state_dict) == 0 + + layers_with_prefix = {f"{FLUX_LORA_TRANSFORMER_PREFIX}{k}": v for k, v in layers.items()} + + return LoRAModelRaw(layers=layers_with_prefix) + + +def _group_by_layer(state_dict: Dict[str, torch.Tensor]) -> dict[str, dict[str, torch.Tensor]]: + """Groups the keys in the state dict by layer.""" + layer_dict: dict[str, dict[str, torch.Tensor]] = {} + for key in state_dict: + # Split the 'lora_A.weight' or 'lora_B.weight' suffix from the layer name. + parts = key.rsplit(".", maxsplit=2) + layer_name = parts[0] + key_name = ".".join(parts[1:]) + if layer_name not in layer_dict: + layer_dict[layer_name] = {} + layer_dict[layer_name][key_name] = state_dict[key] + return layer_dict diff --git a/invokeai/backend/lora/conversions/flux_kohya_lora_conversion_utils.py b/invokeai/backend/lora/conversions/flux_kohya_lora_conversion_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..bc2515cfccd4752900280ad49bf93bb2e27e72a1 --- /dev/null +++ b/invokeai/backend/lora/conversions/flux_kohya_lora_conversion_utils.py @@ -0,0 +1,122 @@ +import re +from typing import Any, Dict, TypeVar + +import torch + +from invokeai.backend.lora.conversions.flux_lora_constants import FLUX_LORA_CLIP_PREFIX, FLUX_LORA_TRANSFORMER_PREFIX +from invokeai.backend.lora.layers.any_lora_layer import AnyLoRALayer +from invokeai.backend.lora.layers.utils import any_lora_layer_from_state_dict +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw + +# A regex pattern that matches all of the transformer keys in the Kohya FLUX LoRA format. +# Example keys: +# lora_unet_double_blocks_0_img_attn_proj.alpha +# lora_unet_double_blocks_0_img_attn_proj.lora_down.weight +# lora_unet_double_blocks_0_img_attn_proj.lora_up.weight +FLUX_KOHYA_TRANSFORMER_KEY_REGEX = ( + r"lora_unet_(\w+_blocks)_(\d+)_(img_attn|img_mlp|img_mod|txt_attn|txt_mlp|txt_mod|linear1|linear2|modulation)_?(.*)" +) +# A regex pattern that matches all of the CLIP keys in the Kohya FLUX LoRA format. +# Example keys: +# lora_te1_text_model_encoder_layers_0_mlp_fc1.alpha +# lora_te1_text_model_encoder_layers_0_mlp_fc1.lora_down.weight +# lora_te1_text_model_encoder_layers_0_mlp_fc1.lora_up.weight +FLUX_KOHYA_CLIP_KEY_REGEX = r"lora_te1_text_model_encoder_layers_(\d+)_(mlp|self_attn)_(\w+)\.?.*" + + +def is_state_dict_likely_in_flux_kohya_format(state_dict: Dict[str, Any]) -> bool: + """Checks if the provided state dict is likely in the Kohya FLUX LoRA format. + + This is intended to be a high-precision detector, but it is not guaranteed to have perfect precision. (A + perfect-precision detector would require checking all keys against a whitelist and verifying tensor shapes.) + """ + return all( + re.match(FLUX_KOHYA_TRANSFORMER_KEY_REGEX, k) or re.match(FLUX_KOHYA_CLIP_KEY_REGEX, k) + for k in state_dict.keys() + ) + + +def lora_model_from_flux_kohya_state_dict(state_dict: Dict[str, torch.Tensor]) -> LoRAModelRaw: + # Group keys by layer. + grouped_state_dict: dict[str, dict[str, torch.Tensor]] = {} + for key, value in state_dict.items(): + layer_name, param_name = key.split(".", 1) + if layer_name not in grouped_state_dict: + grouped_state_dict[layer_name] = {} + grouped_state_dict[layer_name][param_name] = value + + # Split the grouped state dict into transformer and CLIP state dicts. + transformer_grouped_sd: dict[str, dict[str, torch.Tensor]] = {} + clip_grouped_sd: dict[str, dict[str, torch.Tensor]] = {} + for layer_name, layer_state_dict in grouped_state_dict.items(): + if layer_name.startswith("lora_unet"): + transformer_grouped_sd[layer_name] = layer_state_dict + elif layer_name.startswith("lora_te1"): + clip_grouped_sd[layer_name] = layer_state_dict + else: + raise ValueError(f"Layer '{layer_name}' does not match the expected pattern for FLUX LoRA weights.") + + # Convert the state dicts to the InvokeAI format. + transformer_grouped_sd = _convert_flux_transformer_kohya_state_dict_to_invoke_format(transformer_grouped_sd) + clip_grouped_sd = _convert_flux_clip_kohya_state_dict_to_invoke_format(clip_grouped_sd) + + # Create LoRA layers. + layers: dict[str, AnyLoRALayer] = {} + for layer_key, layer_state_dict in transformer_grouped_sd.items(): + layers[FLUX_LORA_TRANSFORMER_PREFIX + layer_key] = any_lora_layer_from_state_dict(layer_state_dict) + for layer_key, layer_state_dict in clip_grouped_sd.items(): + layers[FLUX_LORA_CLIP_PREFIX + layer_key] = any_lora_layer_from_state_dict(layer_state_dict) + + # Create and return the LoRAModelRaw. + return LoRAModelRaw(layers=layers) + + +T = TypeVar("T") + + +def _convert_flux_clip_kohya_state_dict_to_invoke_format(state_dict: Dict[str, T]) -> Dict[str, T]: + """Converts a CLIP LoRA state dict from the Kohya FLUX LoRA format to LoRA weight format used internally by + InvokeAI. + + Example key conversions: + + "lora_te1_text_model_encoder_layers_0_mlp_fc1" -> "text_model.encoder.layers.0.mlp.fc1", + "lora_te1_text_model_encoder_layers_0_self_attn_k_proj" -> "text_model.encoder.layers.0.self_attn.k_proj" + """ + converted_sd: dict[str, T] = {} + for k, v in state_dict.items(): + match = re.match(FLUX_KOHYA_CLIP_KEY_REGEX, k) + if match: + new_key = f"text_model.encoder.layers.{match.group(1)}.{match.group(2)}.{match.group(3)}" + converted_sd[new_key] = v + else: + raise ValueError(f"Key '{k}' does not match the expected pattern for FLUX LoRA weights.") + + return converted_sd + + +def _convert_flux_transformer_kohya_state_dict_to_invoke_format(state_dict: Dict[str, T]) -> Dict[str, T]: + """Converts a FLUX tranformer LoRA state dict from the Kohya FLUX LoRA format to LoRA weight format used internally + by InvokeAI. + + Example key conversions: + "lora_unet_double_blocks_0_img_attn_proj" -> "double_blocks.0.img_attn.proj" + "lora_unet_double_blocks_0_img_attn_qkv" -> "double_blocks.0.img_attn.qkv" + """ + + def replace_func(match: re.Match[str]) -> str: + s = f"{match.group(1)}.{match.group(2)}.{match.group(3)}" + if match.group(4): + s += f".{match.group(4)}" + return s + + converted_dict: dict[str, T] = {} + for k, v in state_dict.items(): + match = re.match(FLUX_KOHYA_TRANSFORMER_KEY_REGEX, k) + if match: + new_key = re.sub(FLUX_KOHYA_TRANSFORMER_KEY_REGEX, replace_func, k) + converted_dict[new_key] = v + else: + raise ValueError(f"Key '{k}' does not match the expected pattern for FLUX LoRA weights.") + + return converted_dict diff --git a/invokeai/backend/lora/conversions/flux_lora_constants.py b/invokeai/backend/lora/conversions/flux_lora_constants.py new file mode 100644 index 0000000000000000000000000000000000000000..4f854d14421393fd681283a15dd234d17bdb82fe --- /dev/null +++ b/invokeai/backend/lora/conversions/flux_lora_constants.py @@ -0,0 +1,3 @@ +# Prefixes used to distinguish between transformer and CLIP text encoder keys in the FLUX InvokeAI LoRA format. +FLUX_LORA_TRANSFORMER_PREFIX = "lora_transformer-" +FLUX_LORA_CLIP_PREFIX = "lora_clip-" diff --git a/invokeai/backend/lora/conversions/sd_lora_conversion_utils.py b/invokeai/backend/lora/conversions/sd_lora_conversion_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..0563854ef07584e173af11db2368adfca9eea1bf --- /dev/null +++ b/invokeai/backend/lora/conversions/sd_lora_conversion_utils.py @@ -0,0 +1,29 @@ +from typing import Dict + +import torch + +from invokeai.backend.lora.layers.any_lora_layer import AnyLoRALayer +from invokeai.backend.lora.layers.utils import any_lora_layer_from_state_dict +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw + + +def lora_model_from_sd_state_dict(state_dict: Dict[str, torch.Tensor]) -> LoRAModelRaw: + grouped_state_dict: dict[str, dict[str, torch.Tensor]] = _group_state(state_dict) + + layers: dict[str, AnyLoRALayer] = {} + for layer_key, values in grouped_state_dict.items(): + layers[layer_key] = any_lora_layer_from_state_dict(values) + + return LoRAModelRaw(layers=layers) + + +def _group_state(state_dict: Dict[str, torch.Tensor]) -> Dict[str, Dict[str, torch.Tensor]]: + state_dict_groupped: Dict[str, Dict[str, torch.Tensor]] = {} + + for key, value in state_dict.items(): + stem, leaf = key.split(".", 1) + if stem not in state_dict_groupped: + state_dict_groupped[stem] = {} + state_dict_groupped[stem][leaf] = value + + return state_dict_groupped diff --git a/invokeai/backend/lora/conversions/sdxl_lora_conversion_utils.py b/invokeai/backend/lora/conversions/sdxl_lora_conversion_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..e3780a7e8a4ea720196c4533edc0e3366a8ef114 --- /dev/null +++ b/invokeai/backend/lora/conversions/sdxl_lora_conversion_utils.py @@ -0,0 +1,154 @@ +import bisect +from typing import Dict, List, Tuple, TypeVar + +T = TypeVar("T") + + +def convert_sdxl_keys_to_diffusers_format(state_dict: Dict[str, T]) -> dict[str, T]: + """Convert the keys of an SDXL LoRA state_dict to diffusers format. + + The input state_dict can be in either Stability AI format or diffusers format. If the state_dict is already in + diffusers format, then this function will have no effect. + + This function is adapted from: + https://github.com/bmaltais/kohya_ss/blob/2accb1305979ba62f5077a23aabac23b4c37e935/networks/lora_diffusers.py#L385-L409 + + Args: + state_dict (Dict[str, Tensor]): The SDXL LoRA state_dict. + + Raises: + ValueError: If state_dict contains an unrecognized key, or not all keys could be converted. + + Returns: + Dict[str, Tensor]: The diffusers-format state_dict. + """ + converted_count = 0 # The number of Stability AI keys converted to diffusers format. + not_converted_count = 0 # The number of keys that were not converted. + + # Get a sorted list of Stability AI UNet keys so that we can efficiently search for keys with matching prefixes. + # For example, we want to efficiently find `input_blocks_4_1` in the list when searching for + # `input_blocks_4_1_proj_in`. + stability_unet_keys = list(SDXL_UNET_STABILITY_TO_DIFFUSERS_MAP) + stability_unet_keys.sort() + + new_state_dict: dict[str, T] = {} + for full_key, value in state_dict.items(): + if full_key.startswith("lora_unet_"): + search_key = full_key.replace("lora_unet_", "") + # Use bisect to find the key in stability_unet_keys that *may* match the search_key's prefix. + position = bisect.bisect_right(stability_unet_keys, search_key) + map_key = stability_unet_keys[position - 1] + # Now, check if the map_key *actually* matches the search_key. + if search_key.startswith(map_key): + new_key = full_key.replace(map_key, SDXL_UNET_STABILITY_TO_DIFFUSERS_MAP[map_key]) + new_state_dict[new_key] = value + converted_count += 1 + else: + new_state_dict[full_key] = value + not_converted_count += 1 + elif full_key.startswith("lora_te1_") or full_key.startswith("lora_te2_"): + # The CLIP text encoders have the same keys in both Stability AI and diffusers formats. + new_state_dict[full_key] = value + continue + else: + raise ValueError(f"Unrecognized SDXL LoRA key prefix: '{full_key}'.") + + if converted_count > 0 and not_converted_count > 0: + raise ValueError( + f"The SDXL LoRA could only be partially converted to diffusers format. converted={converted_count}," + f" not_converted={not_converted_count}" + ) + + return new_state_dict + + +# code from +# https://github.com/bmaltais/kohya_ss/blob/2accb1305979ba62f5077a23aabac23b4c37e935/networks/lora_diffusers.py#L15C1-L97C32 +def _make_sdxl_unet_conversion_map() -> List[Tuple[str, str]]: + """Create a dict mapping state_dict keys from Stability AI SDXL format to diffusers SDXL format.""" + unet_conversion_map_layer: list[tuple[str, str]] = [] + + for i in range(3): # num_blocks is 3 in sdxl + # loop over downblocks/upblocks + for j in range(2): + # loop over resnets/attentions for downblocks + hf_down_res_prefix = f"down_blocks.{i}.resnets.{j}." + sd_down_res_prefix = f"input_blocks.{3*i + j + 1}.0." + unet_conversion_map_layer.append((sd_down_res_prefix, hf_down_res_prefix)) + + if i < 3: + # no attention layers in down_blocks.3 + hf_down_atn_prefix = f"down_blocks.{i}.attentions.{j}." + sd_down_atn_prefix = f"input_blocks.{3*i + j + 1}.1." + unet_conversion_map_layer.append((sd_down_atn_prefix, hf_down_atn_prefix)) + + for j in range(3): + # loop over resnets/attentions for upblocks + hf_up_res_prefix = f"up_blocks.{i}.resnets.{j}." + sd_up_res_prefix = f"output_blocks.{3*i + j}.0." + unet_conversion_map_layer.append((sd_up_res_prefix, hf_up_res_prefix)) + + # if i > 0: commentout for sdxl + # no attention layers in up_blocks.0 + hf_up_atn_prefix = f"up_blocks.{i}.attentions.{j}." + sd_up_atn_prefix = f"output_blocks.{3*i + j}.1." + unet_conversion_map_layer.append((sd_up_atn_prefix, hf_up_atn_prefix)) + + if i < 3: + # no downsample in down_blocks.3 + hf_downsample_prefix = f"down_blocks.{i}.downsamplers.0.conv." + sd_downsample_prefix = f"input_blocks.{3*(i+1)}.0.op." + unet_conversion_map_layer.append((sd_downsample_prefix, hf_downsample_prefix)) + + # no upsample in up_blocks.3 + hf_upsample_prefix = f"up_blocks.{i}.upsamplers.0." + sd_upsample_prefix = f"output_blocks.{3*i + 2}.{2}." # change for sdxl + unet_conversion_map_layer.append((sd_upsample_prefix, hf_upsample_prefix)) + + hf_mid_atn_prefix = "mid_block.attentions.0." + sd_mid_atn_prefix = "middle_block.1." + unet_conversion_map_layer.append((sd_mid_atn_prefix, hf_mid_atn_prefix)) + + for j in range(2): + hf_mid_res_prefix = f"mid_block.resnets.{j}." + sd_mid_res_prefix = f"middle_block.{2*j}." + unet_conversion_map_layer.append((sd_mid_res_prefix, hf_mid_res_prefix)) + + unet_conversion_map_resnet = [ + # (stable-diffusion, HF Diffusers) + ("in_layers.0.", "norm1."), + ("in_layers.2.", "conv1."), + ("out_layers.0.", "norm2."), + ("out_layers.3.", "conv2."), + ("emb_layers.1.", "time_emb_proj."), + ("skip_connection.", "conv_shortcut."), + ] + + unet_conversion_map: list[tuple[str, str]] = [] + for sd, hf in unet_conversion_map_layer: + if "resnets" in hf: + for sd_res, hf_res in unet_conversion_map_resnet: + unet_conversion_map.append((sd + sd_res, hf + hf_res)) + else: + unet_conversion_map.append((sd, hf)) + + for j in range(2): + hf_time_embed_prefix = f"time_embedding.linear_{j+1}." + sd_time_embed_prefix = f"time_embed.{j*2}." + unet_conversion_map.append((sd_time_embed_prefix, hf_time_embed_prefix)) + + for j in range(2): + hf_label_embed_prefix = f"add_embedding.linear_{j+1}." + sd_label_embed_prefix = f"label_emb.0.{j*2}." + unet_conversion_map.append((sd_label_embed_prefix, hf_label_embed_prefix)) + + unet_conversion_map.append(("input_blocks.0.0.", "conv_in.")) + unet_conversion_map.append(("out.0.", "conv_norm_out.")) + unet_conversion_map.append(("out.2.", "conv_out.")) + + return unet_conversion_map + + +SDXL_UNET_STABILITY_TO_DIFFUSERS_MAP = { + sd.rstrip(".").replace(".", "_"): hf.rstrip(".").replace(".", "_") for sd, hf in _make_sdxl_unet_conversion_map() +} diff --git a/invokeai/backend/lora/layers/__init__.py b/invokeai/backend/lora/layers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/lora/layers/any_lora_layer.py b/invokeai/backend/lora/layers/any_lora_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..997fcd4e06f8fe0bf31bec84d2099d5bd0cd86bc --- /dev/null +++ b/invokeai/backend/lora/layers/any_lora_layer.py @@ -0,0 +1,11 @@ +from typing import Union + +from invokeai.backend.lora.layers.concatenated_lora_layer import ConcatenatedLoRALayer +from invokeai.backend.lora.layers.full_layer import FullLayer +from invokeai.backend.lora.layers.ia3_layer import IA3Layer +from invokeai.backend.lora.layers.loha_layer import LoHALayer +from invokeai.backend.lora.layers.lokr_layer import LoKRLayer +from invokeai.backend.lora.layers.lora_layer import LoRALayer +from invokeai.backend.lora.layers.norm_layer import NormLayer + +AnyLoRALayer = Union[LoRALayer, LoHALayer, LoKRLayer, FullLayer, IA3Layer, NormLayer, ConcatenatedLoRALayer] diff --git a/invokeai/backend/lora/layers/concatenated_lora_layer.py b/invokeai/backend/lora/layers/concatenated_lora_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..d764843f5b4202f368caeb24eac15c90ffcd34fa --- /dev/null +++ b/invokeai/backend/lora/layers/concatenated_lora_layer.py @@ -0,0 +1,55 @@ +from typing import Optional, Sequence + +import torch + +from invokeai.backend.lora.layers.lora_layer import LoRALayer +from invokeai.backend.lora.layers.lora_layer_base import LoRALayerBase + + +class ConcatenatedLoRALayer(LoRALayerBase): + """A LoRA layer that is composed of multiple LoRA layers concatenated along a specified axis. + + This class was created to handle a special case with FLUX LoRA models. In the BFL FLUX model format, the attention + Q, K, V matrices are concatenated along the first dimension. In the diffusers LoRA format, the Q, K, V matrices are + stored as separate tensors. This class enables diffusers LoRA layers to be used in BFL FLUX models. + """ + + def __init__(self, lora_layers: Sequence[LoRALayer], concat_axis: int = 0): + super().__init__(alpha=None, bias=None) + + self.lora_layers = lora_layers + self.concat_axis = concat_axis + + def rank(self) -> int | None: + return None + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + # TODO(ryand): Currently, we pass orig_weight=None to the sub-layers. If we want to support sub-layers that + # require this value, we will need to implement chunking of the original weight tensor here. + # Note that we must apply the sub-layer scales here. + layer_weights = [lora_layer.get_weight(None) * lora_layer.scale() for lora_layer in self.lora_layers] # pyright: ignore[reportArgumentType] + return torch.cat(layer_weights, dim=self.concat_axis) + + def get_bias(self, orig_bias: torch.Tensor) -> Optional[torch.Tensor]: + # TODO(ryand): Currently, we pass orig_bias=None to the sub-layers. If we want to support sub-layers that + # require this value, we will need to implement chunking of the original bias tensor here. + # Note that we must apply the sub-layer scales here. + layer_biases: list[torch.Tensor] = [] + for lora_layer in self.lora_layers: + layer_bias = lora_layer.get_bias(None) + if layer_bias is not None: + layer_biases.append(layer_bias * lora_layer.scale()) + + if len(layer_biases) == 0: + return None + + assert len(layer_biases) == len(self.lora_layers) + return torch.cat(layer_biases, dim=self.concat_axis) + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + super().to(device=device, dtype=dtype) + for lora_layer in self.lora_layers: + lora_layer.to(device=device, dtype=dtype) + + def calc_size(self) -> int: + return super().calc_size() + sum(lora_layer.calc_size() for lora_layer in self.lora_layers) diff --git a/invokeai/backend/lora/layers/full_layer.py b/invokeai/backend/lora/layers/full_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..af68a0b393ffe5c7a16f8f07d1fc1b1be4513c85 --- /dev/null +++ b/invokeai/backend/lora/layers/full_layer.py @@ -0,0 +1,34 @@ +from typing import Dict, Optional + +import torch + +from invokeai.backend.lora.layers.lora_layer_base import LoRALayerBase +from invokeai.backend.util.calc_tensor_size import calc_tensor_size + + +class FullLayer(LoRALayerBase): + def __init__(self, weight: torch.Tensor, bias: Optional[torch.Tensor]): + super().__init__(alpha=None, bias=bias) + self.weight = torch.nn.Parameter(weight) + + @classmethod + def from_state_dict_values( + cls, + values: Dict[str, torch.Tensor], + ): + layer = cls(weight=values["diff"], bias=values.get("diff_b", None)) + cls.warn_on_unhandled_keys(values=values, handled_keys={"diff", "diff_b"}) + return layer + + def rank(self) -> int | None: + return None + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + return self.weight + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + super().to(device=device, dtype=dtype) + self.weight = self.weight.to(device=device, dtype=dtype) + + def calc_size(self) -> int: + return super().calc_size() + calc_tensor_size(self.weight) diff --git a/invokeai/backend/lora/layers/ia3_layer.py b/invokeai/backend/lora/layers/ia3_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..b2edb8f4a2840fce0d02085491cc26e8b0c3910f --- /dev/null +++ b/invokeai/backend/lora/layers/ia3_layer.py @@ -0,0 +1,58 @@ +from typing import Dict, Optional + +import torch + +from invokeai.backend.lora.layers.lora_layer_base import LoRALayerBase + + +class IA3Layer(LoRALayerBase): + """IA3 Layer + + Example model for testing this layer type: https://civitai.com/models/123930/gwendolyn-tennyson-ben-10-ia3 + """ + + def __init__(self, weight: torch.Tensor, on_input: torch.Tensor, bias: Optional[torch.Tensor]): + super().__init__(alpha=None, bias=bias) + self.weight = weight + self.on_input = on_input + + def rank(self) -> int | None: + return None + + @classmethod + def from_state_dict_values( + cls, + values: Dict[str, torch.Tensor], + ): + bias = cls._parse_bias( + values.get("bias_indices", None), values.get("bias_values", None), values.get("bias_size", None) + ) + layer = cls( + weight=values["weight"], + on_input=values["on_input"], + bias=bias, + ) + cls.warn_on_unhandled_keys( + values=values, + handled_keys={ + # Default keys. + "bias_indices", + "bias_values", + "bias_size", + # Layer-specific keys. + "weight", + "on_input", + }, + ) + return layer + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + weight = self.weight + if not self.on_input: + weight = weight.reshape(-1, 1) + return orig_weight * weight + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + super().to(device, dtype) + self.weight = self.weight.to(device, dtype) + self.on_input = self.on_input.to(device, dtype) diff --git a/invokeai/backend/lora/layers/loha_layer.py b/invokeai/backend/lora/layers/loha_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..d3be51322ef986434400c657221c9ff65916a92d --- /dev/null +++ b/invokeai/backend/lora/layers/loha_layer.py @@ -0,0 +1,98 @@ +from typing import Dict + +import torch + +from invokeai.backend.lora.layers.lora_layer_base import LoRALayerBase +from invokeai.backend.util.calc_tensor_size import calc_tensors_size + + +class LoHALayer(LoRALayerBase): + """LoHA LyCoris layer. + + Example model for testing this layer type: https://civitai.com/models/27397/loha-renoir-the-dappled-light-style + """ + + def __init__( + self, + w1_a: torch.Tensor, + w1_b: torch.Tensor, + w2_a: torch.Tensor, + w2_b: torch.Tensor, + t1: torch.Tensor | None, + t2: torch.Tensor | None, + alpha: float | None, + bias: torch.Tensor | None, + ): + super().__init__(alpha=alpha, bias=bias) + self.w1_a = w1_a + self.w1_b = w1_b + self.w2_a = w2_a + self.w2_b = w2_b + self.t1 = t1 + self.t2 = t2 + assert (self.t1 is None) == (self.t2 is None) + + def rank(self) -> int | None: + return self.w1_b.shape[0] + + @classmethod + def from_state_dict_values( + cls, + values: Dict[str, torch.Tensor], + ): + alpha = cls._parse_alpha(values.get("alpha", None)) + bias = cls._parse_bias( + values.get("bias_indices", None), values.get("bias_values", None), values.get("bias_size", None) + ) + layer = cls( + w1_a=values["hada_w1_a"], + w1_b=values["hada_w1_b"], + w2_a=values["hada_w2_a"], + w2_b=values["hada_w2_b"], + t1=values.get("hada_t1", None), + t2=values.get("hada_t2", None), + alpha=alpha, + bias=bias, + ) + + cls.warn_on_unhandled_keys( + values=values, + handled_keys={ + # Default keys. + "alpha", + "bias_indices", + "bias_values", + "bias_size", + # Layer-specific keys. + "hada_w1_a", + "hada_w1_b", + "hada_w2_a", + "hada_w2_b", + "hada_t1", + "hada_t2", + }, + ) + + return layer + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + if self.t1 is None: + weight: torch.Tensor = (self.w1_a @ self.w1_b) * (self.w2_a @ self.w2_b) + else: + rebuild1 = torch.einsum("i j k l, j r, i p -> p r k l", self.t1, self.w1_b, self.w1_a) + rebuild2 = torch.einsum("i j k l, j r, i p -> p r k l", self.t2, self.w2_b, self.w2_a) + weight = rebuild1 * rebuild2 + + return weight + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + super().to(device=device, dtype=dtype) + self.w1_a = self.w1_a.to(device=device, dtype=dtype) + self.w1_b = self.w1_b.to(device=device, dtype=dtype) + self.w2_a = self.w2_a.to(device=device, dtype=dtype) + self.w2_b = self.w2_b.to(device=device, dtype=dtype) + self.t1 = self.t1.to(device=device, dtype=dtype) if self.t1 is not None else self.t1 + self.t2 = self.t2.to(device=device, dtype=dtype) if self.t2 is not None else self.t2 + + def calc_size(self) -> int: + return super().calc_size() + calc_tensors_size([self.w1_a, self.w1_b, self.w2_a, self.w2_b, self.t1, self.t2]) diff --git a/invokeai/backend/lora/layers/lokr_layer.py b/invokeai/backend/lora/layers/lokr_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..001194e8ee1ff751ebfceaefb83b08382a5ec9e9 --- /dev/null +++ b/invokeai/backend/lora/layers/lokr_layer.py @@ -0,0 +1,127 @@ +from typing import Dict + +import torch + +from invokeai.backend.lora.layers.lora_layer_base import LoRALayerBase +from invokeai.backend.util.calc_tensor_size import calc_tensors_size + + +class LoKRLayer(LoRALayerBase): + """LoKR LyCoris layer. + + Example model for testing this layer type: https://civitai.com/models/346747/lokrnekopara-allgirl-for-jru2 + """ + + def __init__( + self, + w1: torch.Tensor | None, + w1_a: torch.Tensor | None, + w1_b: torch.Tensor | None, + w2: torch.Tensor | None, + w2_a: torch.Tensor | None, + w2_b: torch.Tensor | None, + t2: torch.Tensor | None, + alpha: float | None, + bias: torch.Tensor | None, + ): + super().__init__(alpha=alpha, bias=bias) + self.w1 = w1 + self.w1_a = w1_a + self.w1_b = w1_b + self.w2 = w2 + self.w2_a = w2_a + self.w2_b = w2_b + self.t2 = t2 + + # Validate parameters. + assert (self.w1 is None) != (self.w1_a is None) + assert (self.w1_a is None) == (self.w1_b is None) + assert (self.w2 is None) != (self.w2_a is None) + assert (self.w2_a is None) == (self.w2_b is None) + + def rank(self) -> int | None: + if self.w1_b is not None: + return self.w1_b.shape[0] + elif self.w2_b is not None: + return self.w2_b.shape[0] + else: + return None + + @classmethod + def from_state_dict_values( + cls, + values: Dict[str, torch.Tensor], + ): + alpha = cls._parse_alpha(values.get("alpha", None)) + bias = cls._parse_bias( + values.get("bias_indices", None), values.get("bias_values", None), values.get("bias_size", None) + ) + layer = cls( + w1=values.get("lokr_w1", None), + w1_a=values.get("lokr_w1_a", None), + w1_b=values.get("lokr_w1_b", None), + w2=values.get("lokr_w2", None), + w2_a=values.get("lokr_w2_a", None), + w2_b=values.get("lokr_w2_b", None), + t2=values.get("lokr_t2", None), + alpha=alpha, + bias=bias, + ) + + cls.warn_on_unhandled_keys( + values, + { + # Default keys. + "alpha", + "bias_indices", + "bias_values", + "bias_size", + # Layer-specific keys. + "lokr_w1", + "lokr_w1_a", + "lokr_w1_b", + "lokr_w2", + "lokr_w2_a", + "lokr_w2_b", + "lokr_t2", + }, + ) + + return layer + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + w1 = self.w1 + if w1 is None: + assert self.w1_a is not None + assert self.w1_b is not None + w1 = self.w1_a @ self.w1_b + + w2 = self.w2 + if w2 is None: + if self.t2 is None: + assert self.w2_a is not None + assert self.w2_b is not None + w2 = self.w2_a @ self.w2_b + else: + w2 = torch.einsum("i j k l, i p, j r -> p r k l", self.t2, self.w2_a, self.w2_b) + + if len(w2.shape) == 4: + w1 = w1.unsqueeze(2).unsqueeze(2) + w2 = w2.contiguous() + weight = torch.kron(w1, w2) + return weight + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + super().to(device=device, dtype=dtype) + self.w1 = self.w1.to(device=device, dtype=dtype) if self.w1 is not None else self.w1 + self.w1_a = self.w1_a.to(device=device, dtype=dtype) if self.w1_a is not None else self.w1_a + self.w1_b = self.w1_b.to(device=device, dtype=dtype) if self.w1_b is not None else self.w1_b + self.w2 = self.w2.to(device=device, dtype=dtype) if self.w2 is not None else self.w2 + self.w2_a = self.w2_a.to(device=device, dtype=dtype) if self.w2_a is not None else self.w2_a + self.w2_b = self.w2_b.to(device=device, dtype=dtype) if self.w2_b is not None else self.w2_b + self.t2 = self.t2.to(device=device, dtype=dtype) if self.t2 is not None else self.t2 + + def calc_size(self) -> int: + return super().calc_size() + calc_tensors_size( + [self.w1, self.w1_a, self.w1_b, self.w2, self.w2_a, self.w2_b, self.t2] + ) diff --git a/invokeai/backend/lora/layers/lora_layer.py b/invokeai/backend/lora/layers/lora_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..95270e359c5197b5039a5c44365cb52ea77169a6 --- /dev/null +++ b/invokeai/backend/lora/layers/lora_layer.py @@ -0,0 +1,79 @@ +from typing import Dict, Optional + +import torch + +from invokeai.backend.lora.layers.lora_layer_base import LoRALayerBase +from invokeai.backend.util.calc_tensor_size import calc_tensors_size + + +class LoRALayer(LoRALayerBase): + def __init__( + self, + up: torch.Tensor, + mid: Optional[torch.Tensor], + down: torch.Tensor, + alpha: float | None, + bias: Optional[torch.Tensor], + ): + super().__init__(alpha, bias) + self.up = up + self.mid = mid + self.down = down + + @classmethod + def from_state_dict_values( + cls, + values: Dict[str, torch.Tensor], + ): + alpha = cls._parse_alpha(values.get("alpha", None)) + bias = cls._parse_bias( + values.get("bias_indices", None), values.get("bias_values", None), values.get("bias_size", None) + ) + + layer = cls( + up=values["lora_up.weight"], + down=values["lora_down.weight"], + mid=values.get("lora_mid.weight", None), + alpha=alpha, + bias=bias, + ) + + cls.warn_on_unhandled_keys( + values=values, + handled_keys={ + # Default keys. + "alpha", + "bias_indices", + "bias_values", + "bias_size", + # Layer-specific keys. + "lora_up.weight", + "lora_down.weight", + "lora_mid.weight", + }, + ) + + return layer + + def rank(self) -> int: + return self.down.shape[0] + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + if self.mid is not None: + up = self.up.reshape(self.up.shape[0], self.up.shape[1]) + down = self.down.reshape(self.down.shape[0], self.down.shape[1]) + weight = torch.einsum("m n w h, i m, n j -> i j w h", self.mid, up, down) + else: + weight = self.up.reshape(self.up.shape[0], -1) @ self.down.reshape(self.down.shape[0], -1) + + return weight + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + super().to(device=device, dtype=dtype) + self.up = self.up.to(device=device, dtype=dtype) + if self.mid is not None: + self.mid = self.mid.to(device=device, dtype=dtype) + self.down = self.down.to(device=device, dtype=dtype) + + def calc_size(self) -> int: + return super().calc_size() + calc_tensors_size([self.up, self.mid, self.down]) diff --git a/invokeai/backend/lora/layers/lora_layer_base.py b/invokeai/backend/lora/layers/lora_layer_base.py new file mode 100644 index 0000000000000000000000000000000000000000..ce4ba3083328739999aa5963292e003ef2cc4758 --- /dev/null +++ b/invokeai/backend/lora/layers/lora_layer_base.py @@ -0,0 +1,76 @@ +from typing import Dict, Optional, Set + +import torch + +import invokeai.backend.util.logging as logger +from invokeai.backend.util.calc_tensor_size import calc_tensors_size + + +class LoRALayerBase: + """Base class for all LoRA-like patching layers.""" + + # Note: It is tempting to make this a torch.nn.Module sub-class and make all tensors 'torch.nn.Parameter's. Then we + # could inherit automatic .to(...) behavior for this class, its subclasses, and all sidecar layers that wrap a + # LoRALayerBase. We would also be able to implement a single calc_size() method that could be inherited by all + # subclasses. But, it turns out that the speed overhead of the default .to(...) implementation in torch.nn.Module is + # noticeable, so for now we have opted not to use torch.nn.Module. + + def __init__(self, alpha: float | None, bias: torch.Tensor | None): + self._alpha = alpha + self.bias = bias + + @classmethod + def _parse_bias( + cls, bias_indices: torch.Tensor | None, bias_values: torch.Tensor | None, bias_size: torch.Tensor | None + ) -> torch.Tensor | None: + assert (bias_indices is None) == (bias_values is None) == (bias_size is None) + + bias = None + if bias_indices is not None: + bias = torch.sparse_coo_tensor(bias_indices, bias_values, tuple(bias_size)) + return bias + + @classmethod + def _parse_alpha( + cls, + alpha: torch.Tensor | None, + ) -> float | None: + return alpha.item() if alpha is not None else None + + def rank(self) -> int | None: + raise NotImplementedError() + + def scale(self) -> float: + rank = self.rank() + if self._alpha is None or rank is None: + return 1.0 + return self._alpha / rank + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + raise NotImplementedError() + + def get_bias(self, orig_bias: torch.Tensor) -> Optional[torch.Tensor]: + return self.bias + + def get_parameters(self, orig_module: torch.nn.Module) -> Dict[str, torch.Tensor]: + params = {"weight": self.get_weight(orig_module.weight)} + bias = self.get_bias(orig_module.bias) + if bias is not None: + params["bias"] = bias + return params + + @classmethod + def warn_on_unhandled_keys(cls, values: Dict[str, torch.Tensor], handled_keys: Set[str]): + """Log a warning if values contains unhandled keys.""" + unknown_keys = set(values.keys()) - handled_keys + if unknown_keys: + logger.warning( + f"Unexpected keys found in LoRA/LyCORIS layer, model might work incorrectly! Unexpected keys: {unknown_keys}" + ) + + def calc_size(self) -> int: + return calc_tensors_size([self.bias]) + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + if self.bias is not None: + self.bias = self.bias.to(device=device, dtype=dtype) diff --git a/invokeai/backend/lora/layers/norm_layer.py b/invokeai/backend/lora/layers/norm_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..fa7c16b30456362f983806b7fb9ead2500adbfbb --- /dev/null +++ b/invokeai/backend/lora/layers/norm_layer.py @@ -0,0 +1,34 @@ +from typing import Dict + +import torch + +from invokeai.backend.lora.layers.lora_layer_base import LoRALayerBase +from invokeai.backend.util.calc_tensor_size import calc_tensor_size + + +class NormLayer(LoRALayerBase): + def __init__(self, weight: torch.Tensor, bias: torch.Tensor | None): + super().__init__(alpha=None, bias=bias) + self.weight = weight + + @classmethod + def from_state_dict_values( + cls, + values: Dict[str, torch.Tensor], + ): + layer = cls(weight=values["w_norm"], bias=values.get("b_norm", None)) + cls.warn_on_unhandled_keys(values, {"w_norm", "b_norm"}) + return layer + + def rank(self) -> int | None: + return None + + def get_weight(self, orig_weight: torch.Tensor) -> torch.Tensor: + return self.weight + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + super().to(device=device, dtype=dtype) + self.weight = self.weight.to(device=device, dtype=dtype) + + def calc_size(self) -> int: + return super().calc_size() + calc_tensor_size(self.weight) diff --git a/invokeai/backend/lora/layers/utils.py b/invokeai/backend/lora/layers/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..24879abd9d7278e87d96cea4f5e677ec6b0ab531 --- /dev/null +++ b/invokeai/backend/lora/layers/utils.py @@ -0,0 +1,33 @@ +from typing import Dict + +import torch + +from invokeai.backend.lora.layers.any_lora_layer import AnyLoRALayer +from invokeai.backend.lora.layers.full_layer import FullLayer +from invokeai.backend.lora.layers.ia3_layer import IA3Layer +from invokeai.backend.lora.layers.loha_layer import LoHALayer +from invokeai.backend.lora.layers.lokr_layer import LoKRLayer +from invokeai.backend.lora.layers.lora_layer import LoRALayer +from invokeai.backend.lora.layers.norm_layer import NormLayer + + +def any_lora_layer_from_state_dict(state_dict: Dict[str, torch.Tensor]) -> AnyLoRALayer: + # Detect layers according to LyCORIS detection logic(`weight_list_det`) + # https://github.com/KohakuBlueleaf/LyCORIS/tree/8ad8000efb79e2b879054da8c9356e6143591bad/lycoris/modules + + if "lora_up.weight" in state_dict: + # LoRA a.k.a LoCon + return LoRALayer.from_state_dict_values(state_dict) + elif "hada_w1_a" in state_dict: + return LoHALayer.from_state_dict_values(state_dict) + elif "lokr_w1" in state_dict or "lokr_w1_a" in state_dict: + return LoKRLayer.from_state_dict_values(state_dict) + elif "diff" in state_dict: + # Full a.k.a Diff + return FullLayer.from_state_dict_values(state_dict) + elif "on_input" in state_dict: + return IA3Layer.from_state_dict_values(state_dict) + elif "w_norm" in state_dict: + return NormLayer.from_state_dict_values(state_dict) + else: + raise ValueError(f"Unsupported lora format: {state_dict.keys()}") diff --git a/invokeai/backend/lora/lora_model_raw.py b/invokeai/backend/lora/lora_model_raw.py new file mode 100644 index 0000000000000000000000000000000000000000..cc8f942bfeb5a3ccd0cc041ce0d0523cef8baec3 --- /dev/null +++ b/invokeai/backend/lora/lora_model_raw.py @@ -0,0 +1,22 @@ +# Copyright (c) 2024 The InvokeAI Development team +from typing import Mapping, Optional + +import torch + +from invokeai.backend.lora.layers.any_lora_layer import AnyLoRALayer +from invokeai.backend.raw_model import RawModel + + +class LoRAModelRaw(RawModel): # (torch.nn.Module): + def __init__(self, layers: Mapping[str, AnyLoRALayer]): + self.layers = layers + + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None) -> None: + for _key, layer in self.layers.items(): + layer.to(device=device, dtype=dtype) + + def calc_size(self) -> int: + model_size = 0 + for _, layer in self.layers.items(): + model_size += layer.calc_size() + return model_size diff --git a/invokeai/backend/lora/lora_patcher.py b/invokeai/backend/lora/lora_patcher.py new file mode 100644 index 0000000000000000000000000000000000000000..c0a584a81c07bd9eaba6eae600a097924bf733f5 --- /dev/null +++ b/invokeai/backend/lora/lora_patcher.py @@ -0,0 +1,302 @@ +from contextlib import contextmanager +from typing import Dict, Iterable, Optional, Tuple + +import torch + +from invokeai.backend.lora.layers.any_lora_layer import AnyLoRALayer +from invokeai.backend.lora.layers.concatenated_lora_layer import ConcatenatedLoRALayer +from invokeai.backend.lora.layers.lora_layer import LoRALayer +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.sidecar_layers.concatenated_lora.concatenated_lora_linear_sidecar_layer import ( + ConcatenatedLoRALinearSidecarLayer, +) +from invokeai.backend.lora.sidecar_layers.lora.lora_linear_sidecar_layer import LoRALinearSidecarLayer +from invokeai.backend.lora.sidecar_layers.lora_sidecar_module import LoRASidecarModule +from invokeai.backend.util.devices import TorchDevice +from invokeai.backend.util.original_weights_storage import OriginalWeightsStorage + + +class LoRAPatcher: + @staticmethod + @torch.no_grad() + @contextmanager + def apply_lora_patches( + model: torch.nn.Module, + patches: Iterable[Tuple[LoRAModelRaw, float]], + prefix: str, + cached_weights: Optional[Dict[str, torch.Tensor]] = None, + ): + """Apply one or more LoRA patches to a model within a context manager. + + Args: + model (torch.nn.Module): The model to patch. + patches (Iterable[Tuple[LoRAModelRaw, float]]): An iterator that returns tuples of LoRA patches and + associated weights. An iterator is used so that the LoRA patches do not need to be loaded into memory + all at once. + prefix (str): The keys in the patches will be filtered to only include weights with this prefix. + cached_weights (Optional[Dict[str, torch.Tensor]], optional): Read-only copy of the model's state dict in + CPU RAM, for efficient unpatching purposes. + """ + original_weights = OriginalWeightsStorage(cached_weights) + try: + for patch, patch_weight in patches: + LoRAPatcher.apply_lora_patch( + model=model, + prefix=prefix, + patch=patch, + patch_weight=patch_weight, + original_weights=original_weights, + ) + del patch + + yield + finally: + for param_key, weight in original_weights.get_changed_weights(): + model.get_parameter(param_key).copy_(weight) + + @staticmethod + @torch.no_grad() + def apply_lora_patch( + model: torch.nn.Module, + prefix: str, + patch: LoRAModelRaw, + patch_weight: float, + original_weights: OriginalWeightsStorage, + ): + """Apply a single LoRA patch to a model. + + Args: + model (torch.nn.Module): The model to patch. + prefix (str): A string prefix that precedes keys used in the LoRAs weight layers. + patch (LoRAModelRaw): The LoRA model to patch in. + patch_weight (float): The weight of the LoRA patch. + original_weights (OriginalWeightsStorage): Storage for the original weights of the model, for unpatching. + """ + if patch_weight == 0: + return + + # If the layer keys contain a dot, then they are not flattened, and can be directly used to access model + # submodules. If the layer keys do not contain a dot, then they are flattened, meaning that all '.' have been + # replaced with '_'. Non-flattened keys are preferred, because they allow submodules to be accessed directly + # without searching, but some legacy code still uses flattened keys. + layer_keys_are_flattened = "." not in next(iter(patch.layers.keys())) + + prefix_len = len(prefix) + + for layer_key, layer in patch.layers.items(): + if not layer_key.startswith(prefix): + continue + + module_key, module = LoRAPatcher._get_submodule( + model, layer_key[prefix_len:], layer_key_is_flattened=layer_keys_are_flattened + ) + + # All of the LoRA weight calculations will be done on the same device as the module weight. + # (Performance will be best if this is a CUDA device.) + device = module.weight.device + dtype = module.weight.dtype + + layer_scale = layer.scale() + + # We intentionally move to the target device first, then cast. Experimentally, this was found to + # be significantly faster for 16-bit CPU tensors being moved to a CUDA device than doing the + # same thing in a single call to '.to(...)'. + layer.to(device=device) + layer.to(dtype=torch.float32) + + # TODO(ryand): Using torch.autocast(...) over explicit casting may offer a speed benefit on CUDA + # devices here. Experimentally, it was found to be very slow on CPU. More investigation needed. + for param_name, lora_param_weight in layer.get_parameters(module).items(): + param_key = module_key + "." + param_name + module_param = module.get_parameter(param_name) + + # Save original weight + original_weights.save(param_key, module_param) + + if module_param.shape != lora_param_weight.shape: + lora_param_weight = lora_param_weight.reshape(module_param.shape) + + lora_param_weight *= patch_weight * layer_scale + module_param += lora_param_weight.to(dtype=dtype) + + layer.to(device=TorchDevice.CPU_DEVICE) + + @staticmethod + @torch.no_grad() + @contextmanager + def apply_lora_sidecar_patches( + model: torch.nn.Module, + patches: Iterable[Tuple[LoRAModelRaw, float]], + prefix: str, + dtype: torch.dtype, + ): + """Apply one or more LoRA sidecar patches to a model within a context manager. Sidecar patches incur some + overhead compared to normal LoRA patching, but they allow for LoRA layers to applied to base layers in any + quantization format. + + Args: + model (torch.nn.Module): The model to patch. + patches (Iterable[Tuple[LoRAModelRaw, float]]): An iterator that returns tuples of LoRA patches and + associated weights. An iterator is used so that the LoRA patches do not need to be loaded into memory + all at once. + prefix (str): The keys in the patches will be filtered to only include weights with this prefix. + dtype (torch.dtype): The compute dtype of the sidecar layers. This cannot easily be inferred from the model, + since the sidecar layers are typically applied on top of quantized layers whose weight dtype is + different from their compute dtype. + """ + original_modules: dict[str, torch.nn.Module] = {} + try: + for patch, patch_weight in patches: + LoRAPatcher._apply_lora_sidecar_patch( + model=model, + prefix=prefix, + patch=patch, + patch_weight=patch_weight, + original_modules=original_modules, + dtype=dtype, + ) + yield + finally: + # Restore original modules. + # Note: This logic assumes no nested modules in original_modules. + for module_key, orig_module in original_modules.items(): + module_parent_key, module_name = LoRAPatcher._split_parent_key(module_key) + parent_module = model.get_submodule(module_parent_key) + LoRAPatcher._set_submodule(parent_module, module_name, orig_module) + + @staticmethod + def _apply_lora_sidecar_patch( + model: torch.nn.Module, + patch: LoRAModelRaw, + patch_weight: float, + prefix: str, + original_modules: dict[str, torch.nn.Module], + dtype: torch.dtype, + ): + """Apply a single LoRA sidecar patch to a model.""" + + if patch_weight == 0: + return + + # If the layer keys contain a dot, then they are not flattened, and can be directly used to access model + # submodules. If the layer keys do not contain a dot, then they are flattened, meaning that all '.' have been + # replaced with '_'. Non-flattened keys are preferred, because they allow submodules to be accessed directly + # without searching, but some legacy code still uses flattened keys. + layer_keys_are_flattened = "." not in next(iter(patch.layers.keys())) + + prefix_len = len(prefix) + + for layer_key, layer in patch.layers.items(): + if not layer_key.startswith(prefix): + continue + + module_key, module = LoRAPatcher._get_submodule( + model, layer_key[prefix_len:], layer_key_is_flattened=layer_keys_are_flattened + ) + + # Initialize the LoRA sidecar layer. + lora_sidecar_layer = LoRAPatcher._initialize_lora_sidecar_layer(module, layer, patch_weight) + + # Replace the original module with a LoRASidecarModule if it has not already been done. + if module_key in original_modules: + # The module has already been patched with a LoRASidecarModule. Append to it. + assert isinstance(module, LoRASidecarModule) + lora_sidecar_module = module + else: + # The module has not yet been patched with a LoRASidecarModule. Create one. + lora_sidecar_module = LoRASidecarModule(module, []) + original_modules[module_key] = module + module_parent_key, module_name = LoRAPatcher._split_parent_key(module_key) + module_parent = model.get_submodule(module_parent_key) + LoRAPatcher._set_submodule(module_parent, module_name, lora_sidecar_module) + + # Move the LoRA sidecar layer to the same device/dtype as the orig module. + # TODO(ryand): Experiment with moving to the device first, then casting. This could be faster. + lora_sidecar_layer.to(device=lora_sidecar_module.orig_module.weight.device, dtype=dtype) + + # Add the LoRA sidecar layer to the LoRASidecarModule. + lora_sidecar_module.add_lora_layer(lora_sidecar_layer) + + @staticmethod + def _split_parent_key(module_key: str) -> tuple[str, str]: + """Split a module key into its parent key and module name. + + Args: + module_key (str): The module key to split. + + Returns: + tuple[str, str]: A tuple containing the parent key and module name. + """ + split_key = module_key.rsplit(".", 1) + if len(split_key) == 2: + return tuple(split_key) + elif len(split_key) == 1: + return "", split_key[0] + else: + raise ValueError(f"Invalid module key: {module_key}") + + @staticmethod + def _initialize_lora_sidecar_layer(orig_layer: torch.nn.Module, lora_layer: AnyLoRALayer, patch_weight: float): + # TODO(ryand): Add support for more original layer types and LoRA layer types. + if isinstance(orig_layer, torch.nn.Linear) or ( + isinstance(orig_layer, LoRASidecarModule) and isinstance(orig_layer.orig_module, torch.nn.Linear) + ): + if isinstance(lora_layer, LoRALayer): + return LoRALinearSidecarLayer(lora_layer=lora_layer, weight=patch_weight) + elif isinstance(lora_layer, ConcatenatedLoRALayer): + return ConcatenatedLoRALinearSidecarLayer(concatenated_lora_layer=lora_layer, weight=patch_weight) + else: + raise ValueError(f"Unsupported Linear LoRA layer type: {type(lora_layer)}") + else: + raise ValueError(f"Unsupported layer type: {type(orig_layer)}") + + @staticmethod + def _set_submodule(parent_module: torch.nn.Module, module_name: str, submodule: torch.nn.Module): + try: + submodule_index = int(module_name) + # If the module name is an integer, then we use the __setitem__ method to set the submodule. + parent_module[submodule_index] = submodule # type: ignore + except ValueError: + # If the module name is not an integer, then we use the setattr method to set the submodule. + setattr(parent_module, module_name, submodule) + + @staticmethod + def _get_submodule( + model: torch.nn.Module, layer_key: str, layer_key_is_flattened: bool + ) -> tuple[str, torch.nn.Module]: + """Get the submodule corresponding to the given layer key. + + Args: + model (torch.nn.Module): The model to search. + layer_key (str): The layer key to search for. + layer_key_is_flattened (bool): Whether the layer key is flattened. If flattened, then all '.' have been + replaced with '_'. Non-flattened keys are preferred, because they allow submodules to be accessed + directly without searching, but some legacy code still uses flattened keys. + + Returns: + tuple[str, torch.nn.Module]: A tuple containing the module key and the submodule. + """ + if not layer_key_is_flattened: + return layer_key, model.get_submodule(layer_key) + + # Handle flattened keys. + assert "." not in layer_key + + module = model + module_key = "" + key_parts = layer_key.split("_") + + submodule_name = key_parts.pop(0) + + while len(key_parts) > 0: + try: + module = module.get_submodule(submodule_name) + module_key += "." + submodule_name + submodule_name = key_parts.pop(0) + except Exception: + submodule_name += "_" + key_parts.pop(0) + + module = module.get_submodule(submodule_name) + module_key = (module_key + "." + submodule_name).lstrip(".") + + return module_key, module diff --git a/invokeai/backend/lora/sidecar_layers/__init__.py b/invokeai/backend/lora/sidecar_layers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/lora/sidecar_layers/concatenated_lora/__init__.py b/invokeai/backend/lora/sidecar_layers/concatenated_lora/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/lora/sidecar_layers/concatenated_lora/concatenated_lora_linear_sidecar_layer.py b/invokeai/backend/lora/sidecar_layers/concatenated_lora/concatenated_lora_linear_sidecar_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..aa9246444873b883381f0dd82aa145c2f9ceaf4d --- /dev/null +++ b/invokeai/backend/lora/sidecar_layers/concatenated_lora/concatenated_lora_linear_sidecar_layer.py @@ -0,0 +1,34 @@ +import torch + +from invokeai.backend.lora.layers.concatenated_lora_layer import ConcatenatedLoRALayer + + +class ConcatenatedLoRALinearSidecarLayer(torch.nn.Module): + def __init__( + self, + concatenated_lora_layer: ConcatenatedLoRALayer, + weight: float, + ): + super().__init__() + + self._concatenated_lora_layer = concatenated_lora_layer + self._weight = weight + + def forward(self, input: torch.Tensor) -> torch.Tensor: + x_chunks: list[torch.Tensor] = [] + for lora_layer in self._concatenated_lora_layer.lora_layers: + x_chunk = torch.nn.functional.linear(input, lora_layer.down) + if lora_layer.mid is not None: + x_chunk = torch.nn.functional.linear(x_chunk, lora_layer.mid) + x_chunk = torch.nn.functional.linear(x_chunk, lora_layer.up, bias=lora_layer.bias) + x_chunk *= self._weight * lora_layer.scale() + x_chunks.append(x_chunk) + + # TODO(ryand): Generalize to support concat_axis != 0. + assert self._concatenated_lora_layer.concat_axis == 0 + x = torch.cat(x_chunks, dim=-1) + return x + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + self._concatenated_lora_layer.to(device=device, dtype=dtype) + return self diff --git a/invokeai/backend/lora/sidecar_layers/lora/__init__.py b/invokeai/backend/lora/sidecar_layers/lora/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/lora/sidecar_layers/lora/lora_linear_sidecar_layer.py b/invokeai/backend/lora/sidecar_layers/lora/lora_linear_sidecar_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..8bf96c97b613a78518883d1e888569f71736f8e3 --- /dev/null +++ b/invokeai/backend/lora/sidecar_layers/lora/lora_linear_sidecar_layer.py @@ -0,0 +1,27 @@ +import torch + +from invokeai.backend.lora.layers.lora_layer import LoRALayer + + +class LoRALinearSidecarLayer(torch.nn.Module): + def __init__( + self, + lora_layer: LoRALayer, + weight: float, + ): + super().__init__() + + self._lora_layer = lora_layer + self._weight = weight + + def forward(self, x: torch.Tensor) -> torch.Tensor: + x = torch.nn.functional.linear(x, self._lora_layer.down) + if self._lora_layer.mid is not None: + x = torch.nn.functional.linear(x, self._lora_layer.mid) + x = torch.nn.functional.linear(x, self._lora_layer.up, bias=self._lora_layer.bias) + x *= self._weight * self._lora_layer.scale() + return x + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + self._lora_layer.to(device=device, dtype=dtype) + return self diff --git a/invokeai/backend/lora/sidecar_layers/lora_sidecar_layer.py b/invokeai/backend/lora/sidecar_layers/lora_sidecar_layer.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/lora/sidecar_layers/lora_sidecar_module.py b/invokeai/backend/lora/sidecar_layers/lora_sidecar_module.py new file mode 100644 index 0000000000000000000000000000000000000000..80cd9125edf4870efcf6d149613bb21217c0d0f5 --- /dev/null +++ b/invokeai/backend/lora/sidecar_layers/lora_sidecar_module.py @@ -0,0 +1,24 @@ +import torch + + +class LoRASidecarModule(torch.nn.Module): + """A LoRA sidecar module that wraps an original module and adds LoRA layers to it.""" + + def __init__(self, orig_module: torch.nn.Module, lora_layers: list[torch.nn.Module]): + super().__init__() + self.orig_module = orig_module + self._lora_layers = lora_layers + + def add_lora_layer(self, lora_layer: torch.nn.Module): + self._lora_layers.append(lora_layer) + + def forward(self, input: torch.Tensor) -> torch.Tensor: + x = self.orig_module(input) + for lora_layer in self._lora_layers: + x += lora_layer(input) + return x + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + self._orig_module.to(device=device, dtype=dtype) + for lora_layer in self._lora_layers: + lora_layer.to(device=device, dtype=dtype) diff --git a/invokeai/backend/model_hash/hash_validator.py b/invokeai/backend/model_hash/hash_validator.py new file mode 100644 index 0000000000000000000000000000000000000000..622cdbbddfbffdb4ef4dd8890ee05f7d34a4dc4f --- /dev/null +++ b/invokeai/backend/model_hash/hash_validator.py @@ -0,0 +1,26 @@ +import json +from base64 import b64decode + + +def validate_hash(hash: str): + if ":" not in hash: + return + for enc_hash in hashes: + alg, hash_ = hash.split(":") + if alg == "blake3": + alg = "blake3_single" + map = json.loads(b64decode(enc_hash)) + if alg in map: + if hash_ == map[alg]: + raise Exception( + "This model can not be loaded. If you're looking for help, consider visiting https://www.redirectionprogram.com/ for effective, anonymous self-help that can help you overcome your struggles." + ) + + +hashes: list[str] = [ + "eyJibGFrZTNfbXVsdGkiOiI3Yjc5ODZmM2QyNTk3MDZiMjVhZDRhM2NmNGM2MTcyNGNhZmQ0Yjc4NjI4MjIwNjMyZGU4NjVlM2UxNDEyMTVlIiwiYmxha2UzX3NpbmdsZSI6IjdiNzk4NmYzZDI1OTcwNmIyNWFkNGEzY2Y0YzYxNzI0Y2FmZDRiNzg2MjgyMjA2MzJkZTg2NWUzZTE0MTIxNWUiLCJyYW5kb20iOiJhNDQxYjE1ZmU5YTNjZjU2NjYxMTkwYTBiOTNiOWRlYzdkMDQxMjcyODhjYzg3MjUwOTY3Y2YzYjUyODk0ZDExIiwibWQ1IjoiNzdlZmU5MzRhZGQ3YmU5Njc3NmJkODM3NWJhZDQxN2QiLCJzaGExIjoiYmM2YzYxYzgwNDgyMTE2ZTY2ZGQyNTYwNjRkYTgxYjFlY2U4NzMzOCIsInNoYTIyNCI6IjgzNzNlZGM4ZTg4Y2UxMTljODdlOTM2OTY4ZWViMWNmMzdjZGY4NTBmZjhjOTZkYjNmMDc4YmE0Iiwic2hhMjU2IjoiNzNjYWMxZWRlZmUyZjdlODFkNjRiMTI2YjIxMmY2Yzk2ZTAwNjgyNGJjZmJkZDI3Y2E5NmUyNTk5ZTQwNzUwZiIsInNoYTM4NCI6IjlmNmUwNzlmOTNiNDlkMTg1YzEyNzY0OGQwNzE3YTA0N2E3MzYyNDI4YzY4MzBhNDViNzExODAwZDE4NjIwZDZjMjcwZGE3ZmY0Y2FjOTRmNGVmZDdiZWQ5OTlkOWU0ZCIsInNoYTUxMiI6IjAwNzE5MGUyYjk5ZjVlN2Q1OGZiYWI2YTk1YmY0NjJiODhkOTg1N2NlNjY4MTMyMGJmM2M0Y2ZiZmY0MjkxZmEzNTMyMTk3YzdkODc2YWQ3NjZhOTQyOTQ2Zjc1OWY2YTViNDBlM2I2MzM3YzIwNWI0M2JkOWMyN2JiMTljNzk0IiwiYmxha2UyYiI6IjlhN2VhNTQzY2ZhMmMzMWYyZDIyNjg2MjUwNzUyNDE0Mjc1OWJiZTA0MWZlMWJkMzQzNDM1MWQwNWZlYjI2OGY2MjU0OTFlMzlmMzdkYWQ4MGM2Y2UzYTE4ZjAxNGEzZjJiMmQ2OGU2OTc0MjRmNTU2M2Y5ZjlhYzc1MzJiMjEwIiwiYmxha2UycyI6ImYxZmMwMjA0YjdjNzIwNGJlNWI1YzY3NDEyYjQ2MjY5NWE3YjFlYWQ2M2E5ZGVkMjEzYjZmYTU0NGZjNjJlYzUiLCJzaGEzXzIyNCI6IjljZDQ3YTBhMzA3NmNmYzI0NjJhNTAzMjVmMjg4ZjFiYzJjMmY2NmU2ODIxODc5NjJhNzU0NjFmIiwic2hhM18yNTYiOiI4NTFlNGI1ZDI1MWZlZTFiYzk0ODU1OWNjMDNiNjhlNTllYWU5YWI1ZTUyYjA0OTgxYTRhOTU4YWQyMDdkYjYwIiwic2hhM18zODQiOiJiZDA2ZTRhZGFlMWQ0MTJmZjFjOTcxMDJkZDFlN2JmY2UzMDViYTgxMTgyNzM3NWY5NTI4OWJkOGIyYTUxNjdiMmUyNzZjODNjNTU3ODFhMTEyMDRhNzc5MTUwMzM5ZTEiLCJzaGEzXzUxMiI6ImQ1ZGQ2OGZmZmY5NGRhZjJhMDkzZTliNmM1MTBlZmZkNThmZTA0ODMyZGQzMzEyOTZmN2NkZmYzNmRhZmQ3NGMxY2VmNjUxNTBkZjk5OGM1ODgyY2MzMzk2MTk1ZTViYjc5OTY1OGFkMTQ3MzFiMjJmZWZiMWQzNmY2MWJjYzJjIiwic2hha2VfMTI4IjoiOWJlNTgwNWMwNjg1MmZmNDUzNGQ4ZDZmODYyMmFkOTJkMGUwMWE2Y2JmYjIwN2QxOTRmM2JkYThiOGNmNWU4ZiIsInNoYWtlXzI1NiI6IjRhYjgwYjY2MzcxYzdhNjBhYWM4NDVkMTZlNWMzZDNhMmM4M2FjM2FjZDNiNTBiNzdjYWYyYTNmMWMyY2ZjZjc5OGNjYjkxN2FjZjQzNzBmZDdjN2ZmODQ5M2Q3NGY1MWM4NGU3M2ViZGQ4MTRmM2MwMzk3YzI4ODlmNTI0Mzg3In0K", + "eyJibGFrZTNfbXVsdGkiOiI4ODlmYzIwMDA4NWY1NWY4YTA4MjhiODg3MDM0OTRhMGFmNWZkZGI5N2E2YmYwMDRjM2VkYTdiYzBkNDU0MjQzIiwiYmxha2UzX3NpbmdsZSI6Ijg4OWZjMjAwMDg1ZjU1ZjhhMDgyOGI4ODcwMzQ5NGEwYWY1ZmRkYjk3YTZiZjAwNGMzZWRhN2JjMGQ0NTQyNDMiLCJyYW5kb20iOiJhNDQxYjE1ZmU5YTNjZjU2NjYxMTkwYTBiOTNiOWRlYzdkMDQxMjcyODhjYzg3MjUwOTY3Y2YzYjUyODk0ZDExIiwibWQ1IjoiNTIzNTRhMzkzYTVmOGNjNmMyMzQ0OThiYjcxMDljYzEiLCJzaGExIjoiMTJmYmRhOGE3ZGUwOGMwNDc2NTA5OWY2NGNmMGIzYjcxMjc1MGM1NyIsInNoYTIyNCI6IjEyZWU3N2U0Y2NhODViMDk4YjdjNWJlMWFjNGMwNzljNGM3MmJmODA2YjdlZjU1NGI0NzgxZDkxIiwic2hhMjU2IjoiMjU1NTMwZDAyYTY4MjY4OWE5ZTZjMjRhOWZhMDM2OGNhODMxZTI1OTAyYjM2NzQyNzkwZTk3NzU1ZjEzMmNmNSIsInNoYTM4NCI6IjhkMGEyMTRlNDk0NGE2NGY3ZmZjNTg3MGY0ZWUyZTA0OGIzYjRjMmQ0MGRmMWFmYTVlOGE1ZWNkN2IwOTY3M2ZjNWI5YzM5Yzg4Yjc2YmIwY2I4ZjQ1ZjAxY2MwNjZkNCIsInNoYTUxMiI6Ijg3NTM3OWNiYzdlOGYyNzU4YjVjMDY5ZTU2ZWRjODY1ODE4MGFkNDEzNGMwMzY1NzM4ZjM1YjQwYzI2M2JkMTMwMzcwZTE0MzZkNDNmOGFhMTgyMTg5MzgzMTg1ODNhOWJhYTUyYTBjMTk1Mjg5OTQzYzZiYTY2NTg1Yjg5M2ZiIiwiYmxha2UyYiI6IjBhY2MwNWEwOGE5YjhhODNmZTVjYTk4ZmExMTg3NTYwNjk0MjY0YWUxNTI4NDliYzFkNzQzNTYzMzMyMTlhYTg3N2ZiNjc4MmRjZDZiOGIyYjM1MTkyNDQzNDE2ODJiMTQ3YmY2YTY3MDU2ZWIwOTQ4MzE1M2E4Y2ZiNTNmMTI0IiwiYmxha2UycyI6ImY5ZTRhZGRlNGEzZDRhOTZhOWUyNjVjMGVmMjdmZDNiNjA0NzI1NDllMTEyMWQzOGQwMTkxNTY5ZDY5YzdhYzAiLCJzaGEzXzIyNCI6ImM0NjQ3MGRjMjkyNGI0YjZkMTA2NDY5MDRiNWM2OGVjNTU2YmQ4MTA5NmVkMTA4YjZiMzQyZmU1Iiwic2hhM18yNTYiOiIwMDBlMThiZTI1MzYxYTk0NGExZTIwNjQ5ZmY0ZGM2OGRiZTk0OGNkNTYwY2I5MTFhODU1OTE3ODdkNWQ5YWYwIiwic2hhM18zODQiOiIzNDljZmVhMGUxZGE0NWZlMmYzNjJhMWFjZjI1ZTczOWNiNGQ0NDdiM2NiODUzZDVkYWNjMzU5ZmRhMWE1M2FhYWU5OTM2ZmFhZWM1NmFhZDkwMThhYjgxMTI4ZjI3N2YiLCJzaGEzXzUxMiI6ImMxNDgwNGY1YTNjNWE4ZGEyMTAyODk1YTFjZGU4MmIwNGYwZmY4OTczMTc0MmY2NDQyY2NmNzQ1OTQzYWQ5NGViOWZmMTNhZDg3YjRmODkxN2M5NmY5ZjMwZjkwYTFhYTI4OTI3OTkwMjg0ZDJhMzcyMjA0NjE4MTNiNDI0MzEyIiwic2hha2VfMTI4IjoiN2IxY2RkMWUyMzUzMzk0OTg5M2UyMmZkMTAwZmU0YjJhMTU1MDJmMTNjMTI0YzhiZDgxY2QwZDdlOWEzMGNmOCIsInNoYWtlXzI1NiI6ImI0NjMzZThhMjNkZDM0ODk0ZTIyNzc0ODYyNTE1MzVjYWFlNjkyMTdmOTQ0NTc3MzE1NTljODBjNWQ3M2ZkOTMxZTFjMDJlZDI0Yjc3MzE3OTJjMjVlNTZhYjg3NjI4YmJiMDgxNTU0MjU2MWY5ZGI2NWE0NDk4NDFmNGQzYTU4In0K", + "eyJibGFrZTNfbXVsdGkiOiI2Y2M0MmU4NGRiOGQyZTliYjA4YjUxNWUwYzlmYzg2NTViNDUwNGRlZDM1MzBlZjFjNTFjZWEwOWUxYThiNGYxIiwiYmxha2UzX3NpbmdsZSI6IjZjYzQyZTg0ZGI4ZDJlOWJiMDhiNTE1ZTBjOWZjODY1NWI0NTA0ZGVkMzUzMGVmMWM1MWNlYTA5ZTFhOGI0ZjEiLCJyYW5kb20iOiJhNDQxYjE1ZmU5YTNjZjU2NjYxMTkwYTBiOTNiOWRlYzdkMDQxMjcyODhjYzg3MjUwOTY3Y2YzYjUyODk0ZDExIiwibWQ1IjoiZDQwNjk3NTJhYjQ0NzFhZDliMDY3YmUxMmRjNTM2ZjYiLCJzaGExIjoiOGRjZmVlMjZjZjUyOTllMDBjN2QwZjJiZTc0NmVmMTlkZjliZGExNCIsInNoYTIyNCI6IjhjMzAzOTU3ZjI3NDNiMjUwNmQyYzIzY2VmNmU4MTQ5MTllZmE2MWM0MTFiMDk5ZmMzODc2MmRjIiwic2hhMjU2IjoiZDk3ZjQ2OWJjMWZkMjhjMjZkMjJhN2Y3ODczNzlhZmM4NjY3ZmZmM2FhYTQ5NTE4NmQyZTM4OTU2MTBjZDJmMyIsInNoYTM4NCI6IjY0NmY0YWM0ZDA2YWJkZmE2MDAwN2VjZWNiOWNjOTk4ZmJkOTBiYzYwMmY3NTk2M2RhZDUzMGMzNGE5ZGE1YzY4NjhlMGIwMDJkZDNlMTM4ZjhmMjA2ODcyNzFkMDVjMSIsInNoYTUxMiI6ImYzZTU4NTA0YzYyOGUwYjViNzBhOTYxYThmODA1MDA1NjQ1M2E5NDlmNTgzNDhiYTNhZTVlMjdkNDRhNGJkMjc5ZjA3MmU1OGQ5YjEyOGE1NDc1MTU2ZmM3YzcxMGJkYjI3OWQ5OGFmN2EwYTI4Y2Y1ZDY2MmQxODY4Zjg3ZjI3IiwiYmxha2UyYiI6ImFhNjgyYmJjM2U1ZGRjNDZkNWUxN2VjMzRlNmEzZGY5ZjhiNWQyNzk0YTZkNmY0M2VjODMxZjhjOTU2OGYyY2RiOGE4YjAyNTE4MDA4YmY0Y2FhYTlhY2FhYjNkNzRmZmRiNGZlNDgwOTcwODU3OGJiZjNlNzJjYTc5ZDQwYzZmIiwiYmxha2UycyI6ImQ0ZGJlZTJkMmZlNDMwOGViYTkwMTY1MDdmMzI1ZmJiODZlMWQzNDQ0MjgzNzRlMjAwNjNiNWQ1MzkzZTExNjMiLCJzaGEzXzIyNCI6ImE1ZTM5NWZlNGRlYjIyY2JhNjgwMWFiZTliZjljMjM2YmMzYjkwZDdiN2ZjMTRhZDhjZjQ0NzBlIiwic2hhM18yNTYiOiIwOWYwZGVjODk0OWEzYmQzYzU3N2RjYzUyMTMwMGRiY2UwMjVjM2VjOTJkNzQ0MDJkNTE1ZDA4NTQwODg2NGY1Iiwic2hhM18zODQiOiJmMjEyNmM5NTcxODQ3NDZmNjYyMjE4MTRkMDZkZWQ3NDBhYWU3MDA4MTc0YjI0OTEzY2YwOTQzY2IwMTA5Y2QxNWI4YmMwOGY1YjUwMWYwYzhhOTY4MzUwYzgzY2I1ZWUiLCJzaGEzXzUxMiI6ImU1ZmEwMzIwMzk2YTJjMThjN2UxZjVlZmJiODYwYTU1M2NlMTlkMDQ0MWMxNWEwZTI1M2RiNjJkM2JmNjg0ZDI1OWIxYmQ4OTJkYTcyMDVjYTYyODQ2YzU0YWI1ODYxOTBmNDUxZDlmZmNkNDA5YmU5MzlhNWM1YWIyZDdkM2ZkIiwic2hha2VfMTI4IjoiNGI2MTllM2I4N2U1YTY4OTgxMjk0YzgzMmU0NzljZGI4MWFmODdlZTE4YzM1Zjc5ZjExODY5ZWEzNWUxN2I3MiIsInNoYWtlXzI1NiI6ImYzOWVkNmMxZmQ2NzVmMDg3ODAyYTc4ZTUwYWFkN2ZiYTZiM2QxNzhlZWYzMjRkMTI3ZTZjYmEwMGRjNzkwNTkxNjQ1Y2U1Y2NmMjhjYzVkNWRkODU1OWIzMDMxYTM3ZjE5NjhmYmFhNDQzMmI2ZWU0Yzg3ZWE2YTdkMmE2NWM2In0K", + "eyJibGFrZTNfbXVsdGkiOiJhNDRiZjJkMzVkZDI3OTZlZTI1NmY0MzVkODFhNTdhOGM0MjZhMzM5ZDc3NTVkMmNiMjdmMzU4ZjM0NTM4OWM2IiwiYmxha2UzX3NpbmdsZSI6ImE0NGJmMmQzNWRkMjc5NmVlMjU2ZjQzNWQ4MWE1N2E4YzQyNmEzMzlkNzc1NWQyY2IyN2YzNThmMzQ1Mzg5YzYiLCJyYW5kb20iOiJhNDQxYjE1ZmU5YTNjZjU2NjYxMTkwYTBiOTNiOWRlYzdkMDQxMjcyODhjYzg3MjUwOTY3Y2YzYjUyODk0ZDExIiwibWQ1IjoiOGU5OTMzMzEyZjg4NDY4MDg0ZmRiZWNjNDYyMTMxZTgiLCJzaGExIjoiNmI0MmZjZDFmMmQyNzUwYWNkY2JkMTUzMmQ4NjQ5YTM1YWI2NDYzNCIsInNoYTIyNCI6ImQ2Y2E2OTUxNzIzZjdjZjg0NzBjZWRjMmVhNjA2ODNmMWU4NDMzM2Q2NDM2MGIzOWIyMjZlZmQzIiwic2hhMjU2IjoiMDAxNGY5Yzg0YjcwMTFhMGJkNzliNzU0NGVjNzg4NDQzNWQ4ZGY0NmRjMDBiNDk0ZmFkYzA4NWQzNDM1NjI4MyIsInNoYTM4NCI6IjMxODg2OTYxODc4NWY3MWJlM2RlZjkyZDgyNzY2NjBhZGE0MGViYTdkMDk1M2Y0YTc5ODdlMThhNzFlNjBlY2EwY2YyM2YwMjVhMmQ4ZjUyMmNkZGY3MTcxODFhMTQxNSIsInNoYTUxMiI6IjdmZGQxN2NmOWU3ZTBhZDcwMzJjMDg1MTkyYWMxZmQ0ZmFhZjZkNWNlYzAzOTE5ZDk0MmZiZTIyNWNhNmIwZTg0NmQ4ZGI0ZjllYTQ5MjJlMTdhNTg4MTY4YzExMTM1NWZiZDQ1NTlmMmU5NDcwNjAwZWE1MzBhMDdiMzY0YWQwIiwiYmxha2UyYiI6IjI0ZjExZWI5M2VlN2YxOTI5NWZiZGU5MTczMmE0NGJkZGYxOWE1ZTQ4MWNmOWFhMjQ2M2UzNDllYjg0Mzc4ZDBkODFjNzY0YWQ1NTk1YjkxZjQzYzgxODcxNTRlYWU5NTZkY2ZjZTlkMWU2MTZjNTFkZThhZDZjZTBhODcyY2Q0IiwiYmxha2UycyI6IjVkZTUwZDUwMGYwYTBmOGRlMTEwOGE2ZmFkZGM4ODNlMTA3NmQ3MThiNmQxN2E4ZDVkMjgzZDdiNGYzZDU2OGEiLCJzaGEzXzIyNCI6IjFhNTA0OGNlYWZiYjg2ZDc4ZmNiNTI0ZTViYTc4NWQ2ZmY5NzY1ZTNlMzdhZWRjZmYxZGVjNGJhIiwic2hhM18yNTYiOiI0YjA0YjE1NTRmMzRkYTlmMjBmZDczM2IzNDg4NjE0ZWNhM2IwOWU1OTJjOGJlMmM0NjA1NjYyMWU0MjJmZDllIiwic2hhM18zODQiOiI1NjMwYjM2OGQ4MGM1YmM5MTgzM2VmNWM2YWUzOTJhNDE4NTNjYmM2MWJiNTI4ZDE4YWM1OWFjZGZiZWU1YThkMWMyZDE4MTM1ZGI2ZWQ2OTJlODFkZThmYTM3MzkxN2MiLCJzaGEzXzUxMiI6IjA2ODg4MGE1MmNiNDkzODYwZDhjOTVhOTFhZGFmZTYwZGYxODc2ZDhjYjFhNmI3NTU2ZjJjM2Y1NjFmMGYwZjMyZjZhYTA1YmVmN2FhYjQ5OWEwNTM0Zjk0Njc4MDEzODlmNDc0ODFiNzcxMjdjMDFiOGFhOTY4NGJhZGUzYmY2Iiwic2hha2VfMTI4IjoiODlmYTdjNDcwNGI4NGZkMWQ1M2E0MTBlN2ZjMzU3NWRhNmUxMGU1YzkzMjM1NWYyZWEyMWM4NDVhZDBlM2UxOCIsInNoYWtlXzI1NiI6IjE4NGNlMWY2NjdmYmIyODA5NWJhZmVkZTQzNTUzZjhkYzBhNGY1MDQwYWJlMjcxMzkzMzcwNDEyZWFiZTg0ZGJhNjI0Y2ZiZWE4YzUxZDU2YzkwMTM2Mjg2ODgyZmQ0Y2E3MzA3NzZjNWUzODFlYzI5MWYxYTczOTE1MDkyMTFmIn0K", + "eyJibGFrZTNfbXVsdGkiOiJhYjA2YjNmMDliNTExOTAzMTMzMzY5NDE2MTc4ZDk2ZjlkYTc3ZGEwOTgyNDJmN2VlMTVjNTNhNTRkMDZhNWVmIiwiYmxha2UzX3NpbmdsZSI6ImFiMDZiM2YwOWI1MTE5MDMxMzMzNjk0MTYxNzhkOTZmOWRhNzdkYTA5ODI0MmY3ZWUxNWM1M2E1NGQwNmE1ZWYiLCJyYW5kb20iOiJhNDQxYjE1ZmU5YTNjZjU2NjYxMTkwYTBiOTNiOWRlYzdkMDQxMjcyODhjYzg3MjUwOTY3Y2YzYjUyODk0ZDExIiwibWQ1IjoiZWY0MjcxYjU3NTQwMjU4NGQ2OTI5ZWJkMGI3Nzk5NzYiLCJzaGExIjoiMzgzNzliYWQzZjZiZjc4MmM4OTgzOGY3YWVkMzRkNDNkMzNlYWM2MSIsInNoYTIyNCI6ImQ5ZDNiMjJkYmZlY2M1NTdlODAzNjg5M2M3ZWE0N2I0NTQzYzM2NzZhMDk4NzMxMzRhNjQ0OWEwIiwic2hhMjU2IjoiMjYxZGI3NmJlMGYxMzdlZWJkYmI5OGRlYWM0ZjcyMDdiOGUxMjdiY2MyZmMwODI5OGVjZDczYjQ3MjYxNjQ1NiIsInNoYTM4NCI6IjMzMjkwYWQxYjlhMmRkYmU0ODY3MWZiMTIxNDdiZWJhNjI4MjA1MDcwY2VkNjNiZTFmNGU5YWRhMjgwYWU2ZjZjNDkzYTY2MDllMGQ2YTIzMWU2ODU5ZmIyNGZhM2FjMCIsInNoYTUxMiI6IjAzMDZhMWI1NmNiYTdjNjJiNTNmNTk4MTAwMTQ3MDQ5ODBhNGRmZTdjZjQ5NTU4ZmMyMmQxZDczZDc5NzJmZTllODk2ZWRjMmEyYTQxYWVjNjRjZjkwZGUwYjI1NGM0MDBlZTU1YzcwZjk3OGVlMzk5NmM2YzhkNTBjYTI4YTdiIiwiYmxha2UyYiI6IjY1MDZhMDg1YWQ5MGZkZjk2NGJmMGE5NTFkZmVkMTllZTc0NGVjY2EyODQzZjQzYTI5NmFjZDM0M2RiODhhMDNlNTlkNmFmMGM1YWJkNTEzMzc4MTQ5Yjg3OTExMTVmODRmMDIyZWM1M2JmNGFjNDZhZDczNWIwMmJlYTM0MDk5IiwiYmxha2UycyI6IjdlZDQ3ZWQxOTg3MTk0YWFmNGIwMjQ3MWFkNTMyMmY3NTE3ZjI0OTcwMDc2Y2NmNDkzMWI0MzYxMDU1NzBlNDAiLCJzaGEzXzIyNCI6Ijk2MGM4MDExOTlhMGUzYWExNjdiNmU2MWVkMzE2ZDUzMDM2Yjk4M2UyOThkNWI5MjZmMDc3NDlhIiwic2hhM18yNTYiOiIzYzdmYWE1ZDE3Zjk2MGYxOTI2ZjNlNGIyZjc1ZjdiOWIyZDQ4NGFhNmEwM2ViOWNlMTI4NmM2OTE2YWEyM2RlIiwic2hhM18zODQiOiI5Y2Y0NDA1NWFjYzFlYjZmMDY1YjRjODcxYTYzNTM1MGE1ZjY0ODQwM2YwYTU0MWEzYzZhNjI3N2ViZjZmYTNjYmM1YmJiNjQwMDE4OGFlMWIxMTI2OGZmMDJiMzYzZDUiLCJzaGEzXzUxMiI6ImEyZDk3ZDRlYjYxM2UwZDViYTc2OTk2MzE2MzcxOGEwNDIxZDkxNTNiNjllYjM5MDRmZjI4ODRhZDdjNGJiYmIwNGY2Nzc1OTA1YmQxNGI2NTJmZTQ1Njg0YmI5MTQ3ZjBkYWViZjAxZjIzY2MzZDhkMjIzMTE0MGUzNjI4NTE5Iiwic2hha2VfMTI4IjoiNjkwMWMwYjg1MTg5ZTkyNTJiODI3MTc5NjE2MjRlMTM0MDQ1ZjlkMmI5MzM0MzVkM2Y0OThiZWIyN2Q3N2JiNSIsInNoYWtlXzI1NiI6ImIwMjA4ZTFkNDVjZWI0ODdiZDUwNzk3MWJiNWI3MjdjN2UyYmE3ZDliNWM2ZTEyYWE5YTNhOTY5YzcyNDRjODIwZDcyNDY1ODhlZWU3Yjk4ZWM1NzhjZWIxNjc3OTkxODljMWRkMmZkMmZmYWM4MWExZDAzZDFiNjMxOGRkMjBiIn0K", +] diff --git a/invokeai/backend/model_hash/model_hash.py b/invokeai/backend/model_hash/model_hash.py new file mode 100644 index 0000000000000000000000000000000000000000..40046c28f390545217e2f7a78ac19ab53033cb22 --- /dev/null +++ b/invokeai/backend/model_hash/model_hash.py @@ -0,0 +1,229 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team + +import hashlib +import os +from pathlib import Path +from typing import Callable, Literal, Optional, Union + +from blake3 import blake3 +from tqdm import tqdm + +from invokeai.app.util.misc import uuid_string + +HASHING_ALGORITHMS = 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", +] +MODEL_FILE_EXTENSIONS = (".ckpt", ".safetensors", ".bin", ".pt", ".pth") + + +class ModelHash: + """ + Creates a hash of a model using a specified algorithm. The hash is prefixed by the algorithm used. + + Args: + algorithm: Hashing algorithm to use. Defaults to BLAKE3. + file_filter: A function that takes a file name and returns True if the file should be included in the hash. + + If the model is a single file, it is hashed directly using the provided algorithm. + + If the model is a directory, each model weights file in the directory is hashed using the provided algorithm. + + Only files with the following extensions are hashed: .ckpt, .safetensors, .bin, .pt, .pth + + The final hash is computed by hashing the hashes of all model files in the directory using BLAKE3, ensuring + that directory hashes are never weaker than the file hashes. + + A convenience algorithm choice of "random" is also available, which returns a random string. This is not a hash. + + Usage: + ```py + # BLAKE3 hash + ModelHash().hash("path/to/some/model.safetensors") # "blake3:ce3f0c5f3c05d119f4a5dcaf209b50d3149046a0d3a9adee9fed4c83cad6b4d0" + # MD5 + ModelHash("md5").hash("path/to/model/dir/") # "md5:a0cd925fc063f98dbf029eee315060c3" + ``` + """ + + def __init__( + self, algorithm: HASHING_ALGORITHMS = "blake3_single", file_filter: Optional[Callable[[str], bool]] = None + ) -> None: + self.algorithm: HASHING_ALGORITHMS = algorithm + if algorithm == "blake3_multi": + self._hash_file = self._blake3 + elif algorithm == "blake3_single": + self._hash_file = self._blake3_single + elif algorithm in hashlib.algorithms_available: + self._hash_file = self._get_hashlib(algorithm) + elif algorithm == "random": + self._hash_file = self._random + else: + raise ValueError(f"Algorithm {algorithm} not available") + + self._file_filter = file_filter or self._default_file_filter + + def hash(self, model_path: Union[str, Path]) -> str: + """ + Return hexdigest of hash of model located at model_path using the algorithm provided at class instantiation. + + If model_path is a directory, the hash is computed by hashing the hashes of all model files in the + directory. The final composite hash is always computed using BLAKE3. + + Args: + model_path: Path to the model + + Returns: + str: Hexdigest of the hash of the model + """ + + model_path = Path(model_path) + # blake3_single is a single-threaded version of blake3, prefix should still be "blake3:" + prefix = self._get_prefix(self.algorithm) + if model_path.is_file(): + hash_ = None + # To give a similar user experience for single files and directories, we use a progress bar even for single files + pbar = tqdm([model_path], desc=f"Hashing {model_path.name}", unit="file") + for component in pbar: + pbar.set_description(f"Hashing {component.name}") + hash_ = prefix + self._hash_file(model_path) + assert hash_ is not None + return hash_ + elif model_path.is_dir(): + return prefix + self._hash_dir(model_path) + else: + raise OSError(f"Not a valid file or directory: {model_path}") + + def _hash_dir(self, dir: Path) -> str: + """Compute the hash for all files in a directory and return a hexdigest. + + Args: + dir: Path to the directory + + Returns: + str: Hexdigest of the hash of the directory + """ + model_component_paths = self._get_file_paths(dir, self._file_filter) + + component_hashes: list[str] = [] + pbar = tqdm(sorted(model_component_paths), desc=f"Hashing {dir.name}", unit="file") + for component in pbar: + pbar.set_description(f"Hashing {component.name}") + component_hashes.append(self._hash_file(component)) + + # BLAKE3 is cryptographically secure. We may as well fall back on a secure algorithm + # for the composite hash + composite_hasher = blake3() + for h in component_hashes: + composite_hasher.update(h.encode("utf-8")) + + return composite_hasher.hexdigest() + + @staticmethod + def _get_file_paths(model_path: Path, file_filter: Callable[[str], bool]) -> list[Path]: + """Return a list of all model files in the directory. + + Args: + model_path: Path to the model + file_filter: Function that takes a file name and returns True if the file should be included in the list. + + Returns: + List of all model files in the directory + """ + + files: list[Path] = [] + for root, _dirs, _files in os.walk(model_path): + for file in _files: + if file_filter(file): + files.append(Path(root, file)) + return files + + @staticmethod + def _blake3(file_path: Path) -> str: + """Hashes a file using BLAKE3, using parallelized and memory-mapped I/O to avoid reading the entire file into memory. + + Args: + file_path: Path to the file to hash + + Returns: + Hexdigest of the hash of the file + """ + file_hasher = blake3(max_threads=blake3.AUTO) + file_hasher.update_mmap(file_path) + return file_hasher.hexdigest() + + @staticmethod + def _blake3_single(file_path: Path) -> str: + """Hashes a file using BLAKE3, without parallelism. Suitable for spinning hard drives. + + Args: + file_path: Path to the file to hash + + Returns: + Hexdigest of the hash of the file + """ + file_hasher = blake3() + file_hasher.update_mmap(file_path) + return file_hasher.hexdigest() + + @staticmethod + def _get_hashlib(algorithm: HASHING_ALGORITHMS) -> Callable[[Path], str]: + """Factory function that returns a function to hash a file with the given algorithm. + + Args: + algorithm: Hashing algorithm to use + + Returns: + A function that hashes a file using the given algorithm + """ + + def hashlib_hasher(file_path: Path) -> str: + """Hashes a file using a hashlib algorithm. Uses `memoryview` to avoid reading the entire file into memory.""" + hasher = hashlib.new(algorithm) + buffer = bytearray(128 * 1024) + mv = memoryview(buffer) + with open(file_path, "rb", buffering=0) as f: + while n := f.readinto(mv): + hasher.update(mv[:n]) + return hasher.hexdigest() + + return hashlib_hasher + + @staticmethod + def _random(_file_path: Path) -> str: + """Returns a random string. This is not a hash. + + The string is a UUID, hashed with BLAKE3 to ensure that it is unique.""" + return blake3(uuid_string().encode()).hexdigest() + + @staticmethod + def _default_file_filter(file_path: str) -> bool: + """A default file filter that only includes files with the following extensions: .ckpt, .safetensors, .bin, .pt, .pth + + Args: + file_path: Path to the file + + Returns: + True if the file matches the given extensions, otherwise False + """ + return file_path.endswith(MODEL_FILE_EXTENSIONS) + + @staticmethod + def _get_prefix(algorithm: HASHING_ALGORITHMS) -> str: + """Return the prefix for the given algorithm, e.g. \"blake3:\" or \"md5:\".""" + # blake3_single is a single-threaded version of blake3, prefix should still be "blake3:" + return "blake3:" if algorithm == "blake3_single" or algorithm == "blake3_multi" else f"{algorithm}:" diff --git a/invokeai/backend/model_manager/__init__.py b/invokeai/backend/model_manager/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..199c0c01f767d29617769b7c267ca501000b4275 --- /dev/null +++ b/invokeai/backend/model_manager/__init__.py @@ -0,0 +1,35 @@ +"""Re-export frequently-used symbols from the Model Manager backend.""" + +from invokeai.backend.model_manager.config import ( + AnyModel, + AnyModelConfig, + BaseModelType, + InvalidModelConfigException, + ModelConfigFactory, + ModelFormat, + ModelRepoVariant, + ModelType, + ModelVariantType, + SchedulerPredictionType, + SubModelType, +) +from invokeai.backend.model_manager.load import LoadedModel +from invokeai.backend.model_manager.probe import ModelProbe +from invokeai.backend.model_manager.search import ModelSearch + +__all__ = [ + "AnyModel", + "AnyModelConfig", + "BaseModelType", + "ModelRepoVariant", + "InvalidModelConfigException", + "LoadedModel", + "ModelConfigFactory", + "ModelFormat", + "ModelProbe", + "ModelSearch", + "ModelType", + "ModelVariantType", + "SchedulerPredictionType", + "SubModelType", +] diff --git a/invokeai/backend/model_manager/config.py b/invokeai/backend/model_manager/config.py new file mode 100644 index 0000000000000000000000000000000000000000..947a7dd39f9708bcf1decbacb107dacc871d730b --- /dev/null +++ b/invokeai/backend/model_manager/config.py @@ -0,0 +1,594 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team +""" +Configuration definitions for image generation models. + +Typical usage: + + from invokeai.backend.model_manager import ModelConfigFactory + raw = dict(path='models/sd-1/main/foo.ckpt', + name='foo', + base='sd-1', + type='main', + config='configs/stable-diffusion/v1-inference.yaml', + variant='normal', + format='checkpoint' + ) + config = ModelConfigFactory.make_config(raw) + print(config.name) + +Validation errors will raise an InvalidModelConfigException error. + +""" + +import time +from enum import Enum +from typing import Literal, Optional, Type, TypeAlias, Union + +import diffusers +import onnxruntime as ort +import torch +from diffusers.models.modeling_utils import ModelMixin +from pydantic import BaseModel, ConfigDict, Discriminator, Field, Tag, TypeAdapter +from typing_extensions import Annotated, Any, Dict + +from invokeai.app.util.misc import uuid_string +from invokeai.backend.model_hash.hash_validator import validate_hash +from invokeai.backend.raw_model import RawModel +from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_NAME_VALUES + +# ModelMixin is the base class for all diffusers and transformers models +# RawModel is the InvokeAI wrapper class for ip_adapters, loras, textual_inversion and onnx runtime +AnyModel = Union[ + ModelMixin, RawModel, torch.nn.Module, Dict[str, torch.Tensor], diffusers.DiffusionPipeline, ort.InferenceSession +] + + +class InvalidModelConfigException(Exception): + """Exception for when config parser doesn't recognized this combination of model type and format.""" + + +class BaseModelType(str, Enum): + """Base model type.""" + + Any = "any" + StableDiffusion1 = "sd-1" + StableDiffusion2 = "sd-2" + StableDiffusion3 = "sd-3" + StableDiffusionXL = "sdxl" + StableDiffusionXLRefiner = "sdxl-refiner" + Flux = "flux" + # Kandinsky2_1 = "kandinsky-2.1" + + +class ModelType(str, Enum): + """Model type.""" + + ONNX = "onnx" + Main = "main" + VAE = "vae" + LoRA = "lora" + ControlNet = "controlnet" # used by model_probe + TextualInversion = "embedding" + IPAdapter = "ip_adapter" + CLIPVision = "clip_vision" + CLIPEmbed = "clip_embed" + T2IAdapter = "t2i_adapter" + T5Encoder = "t5_encoder" + SpandrelImageToImage = "spandrel_image_to_image" + + +class SubModelType(str, Enum): + """Submodel type.""" + + UNet = "unet" + Transformer = "transformer" + TextEncoder = "text_encoder" + TextEncoder2 = "text_encoder_2" + TextEncoder3 = "text_encoder_3" + Tokenizer = "tokenizer" + Tokenizer2 = "tokenizer_2" + Tokenizer3 = "tokenizer_3" + VAE = "vae" + VAEDecoder = "vae_decoder" + VAEEncoder = "vae_encoder" + Scheduler = "scheduler" + SafetyChecker = "safety_checker" + + +class ClipVariantType(str, Enum): + """Variant type.""" + + L = "large" + G = "gigantic" + + +class ModelVariantType(str, Enum): + """Variant type.""" + + Normal = "normal" + Inpaint = "inpaint" + Depth = "depth" + + +class ModelFormat(str, Enum): + """Storage format of model.""" + + Diffusers = "diffusers" + Checkpoint = "checkpoint" + LyCORIS = "lycoris" + ONNX = "onnx" + Olive = "olive" + EmbeddingFile = "embedding_file" + EmbeddingFolder = "embedding_folder" + InvokeAI = "invokeai" + T5Encoder = "t5_encoder" + BnbQuantizedLlmInt8b = "bnb_quantized_int8b" + BnbQuantizednf4b = "bnb_quantized_nf4b" + GGUFQuantized = "gguf_quantized" + + +class SchedulerPredictionType(str, Enum): + """Scheduler prediction type.""" + + Epsilon = "epsilon" + VPrediction = "v_prediction" + Sample = "sample" + + +class ModelRepoVariant(str, Enum): + """Various hugging face variants on the diffusers format.""" + + Default = "" # model files without "fp16" or other qualifier + FP16 = "fp16" + FP32 = "fp32" + ONNX = "onnx" + OpenVINO = "openvino" + Flax = "flax" + + +class ModelSourceType(str, Enum): + """Model source type.""" + + Path = "path" + Url = "url" + HFRepoID = "hf_repo_id" + + +DEFAULTS_PRECISION = Literal["fp16", "fp32"] + + +AnyVariant: TypeAlias = Union[ModelVariantType, ClipVariantType, None] + + +class SubmodelDefinition(BaseModel): + path_or_prefix: str + model_type: ModelType + variant: AnyVariant = None + + model_config = ConfigDict(protected_namespaces=()) + + +class MainModelDefaultSettings(BaseModel): + vae: str | None = Field(default=None, description="Default VAE for this model (model key)") + vae_precision: DEFAULTS_PRECISION | None = Field(default=None, description="Default VAE precision for this model") + scheduler: SCHEDULER_NAME_VALUES | None = Field(default=None, description="Default scheduler for this model") + steps: int | None = Field(default=None, gt=0, description="Default number of steps for this model") + cfg_scale: float | None = Field(default=None, ge=1, description="Default CFG Scale for this model") + cfg_rescale_multiplier: float | None = Field( + default=None, ge=0, lt=1, description="Default CFG Rescale Multiplier for this model" + ) + width: int | None = Field(default=None, multiple_of=8, ge=64, description="Default width for this model") + height: int | None = Field(default=None, multiple_of=8, ge=64, description="Default height for this model") + guidance: float | None = Field(default=None, ge=1, description="Default Guidance for this model") + + model_config = ConfigDict(extra="forbid") + + +class ControlAdapterDefaultSettings(BaseModel): + # This could be narrowed to controlnet processor nodes, but they change. Leaving this a string is safer. + preprocessor: str | None + + model_config = ConfigDict(extra="forbid") + + +class ModelConfigBase(BaseModel): + """Base class for model configuration information.""" + + key: str = Field(description="A unique key for this model.", default_factory=uuid_string) + hash: str = Field(description="The hash of the model file(s).") + path: str = Field( + description="Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + ) + name: str = Field(description="Name of the model.") + base: BaseModelType = Field(description="The base model.") + description: Optional[str] = Field(description="Model description", default=None) + source: str = Field(description="The original source of the model (path, URL or repo_id).") + source_type: ModelSourceType = Field(description="The type of source") + source_api_response: Optional[str] = Field( + description="The original API response from the source, as stringified JSON.", default=None + ) + cover_image: Optional[str] = Field(description="Url for image to preview model", default=None) + + @staticmethod + def json_schema_extra(schema: dict[str, Any], model_class: Type[BaseModel]) -> None: + schema["required"].extend(["key", "type", "format"]) + + model_config = ConfigDict(validate_assignment=True, json_schema_extra=json_schema_extra) + submodels: Optional[Dict[SubModelType, SubmodelDefinition]] = Field( + description="Loadable submodels in this model", default=None + ) + + +class CheckpointConfigBase(ModelConfigBase): + """Model config for checkpoint-style models.""" + + format: Literal[ModelFormat.Checkpoint, ModelFormat.BnbQuantizednf4b, ModelFormat.GGUFQuantized] = Field( + description="Format of the provided checkpoint model", default=ModelFormat.Checkpoint + ) + config_path: str = Field(description="path to the checkpoint model config file") + converted_at: Optional[float] = Field( + description="When this model was last converted to diffusers", default_factory=time.time + ) + + +class DiffusersConfigBase(ModelConfigBase): + """Model config for diffusers-style models.""" + + format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers + repo_variant: Optional[ModelRepoVariant] = ModelRepoVariant.Default + + +class LoRAConfigBase(ModelConfigBase): + type: Literal[ModelType.LoRA] = ModelType.LoRA + trigger_phrases: Optional[set[str]] = Field(description="Set of trigger phrases for this model", default=None) + + +class T5EncoderConfigBase(ModelConfigBase): + type: Literal[ModelType.T5Encoder] = ModelType.T5Encoder + + +class T5EncoderConfig(T5EncoderConfigBase): + format: Literal[ModelFormat.T5Encoder] = ModelFormat.T5Encoder + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.T5Encoder.value}.{ModelFormat.T5Encoder.value}") + + +class T5EncoderBnbQuantizedLlmInt8bConfig(T5EncoderConfigBase): + format: Literal[ModelFormat.BnbQuantizedLlmInt8b] = ModelFormat.BnbQuantizedLlmInt8b + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.T5Encoder.value}.{ModelFormat.BnbQuantizedLlmInt8b.value}") + + +class LoRALyCORISConfig(LoRAConfigBase): + """Model config for LoRA/Lycoris models.""" + + format: Literal[ModelFormat.LyCORIS] = ModelFormat.LyCORIS + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.LoRA.value}.{ModelFormat.LyCORIS.value}") + + +class LoRADiffusersConfig(LoRAConfigBase): + """Model config for LoRA/Diffusers models.""" + + format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.LoRA.value}.{ModelFormat.Diffusers.value}") + + +class VAECheckpointConfig(CheckpointConfigBase): + """Model config for standalone VAE models.""" + + type: Literal[ModelType.VAE] = ModelType.VAE + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.VAE.value}.{ModelFormat.Checkpoint.value}") + + +class VAEDiffusersConfig(ModelConfigBase): + """Model config for standalone VAE models (diffusers version).""" + + type: Literal[ModelType.VAE] = ModelType.VAE + format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.VAE.value}.{ModelFormat.Diffusers.value}") + + +class ControlAdapterConfigBase(BaseModel): + default_settings: Optional[ControlAdapterDefaultSettings] = Field( + description="Default settings for this model", default=None + ) + + +class ControlNetDiffusersConfig(DiffusersConfigBase, ControlAdapterConfigBase): + """Model config for ControlNet models (diffusers version).""" + + type: Literal[ModelType.ControlNet] = ModelType.ControlNet + format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.ControlNet.value}.{ModelFormat.Diffusers.value}") + + +class ControlNetCheckpointConfig(CheckpointConfigBase, ControlAdapterConfigBase): + """Model config for ControlNet models (diffusers version).""" + + type: Literal[ModelType.ControlNet] = ModelType.ControlNet + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.ControlNet.value}.{ModelFormat.Checkpoint.value}") + + +class TextualInversionFileConfig(ModelConfigBase): + """Model config for textual inversion embeddings.""" + + type: Literal[ModelType.TextualInversion] = ModelType.TextualInversion + format: Literal[ModelFormat.EmbeddingFile] = ModelFormat.EmbeddingFile + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.TextualInversion.value}.{ModelFormat.EmbeddingFile.value}") + + +class TextualInversionFolderConfig(ModelConfigBase): + """Model config for textual inversion embeddings.""" + + type: Literal[ModelType.TextualInversion] = ModelType.TextualInversion + format: Literal[ModelFormat.EmbeddingFolder] = ModelFormat.EmbeddingFolder + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.TextualInversion.value}.{ModelFormat.EmbeddingFolder.value}") + + +class MainConfigBase(ModelConfigBase): + type: Literal[ModelType.Main] = ModelType.Main + trigger_phrases: Optional[set[str]] = Field(description="Set of trigger phrases for this model", default=None) + default_settings: Optional[MainModelDefaultSettings] = Field( + description="Default settings for this model", default=None + ) + variant: AnyVariant = ModelVariantType.Normal + + +class MainCheckpointConfig(CheckpointConfigBase, MainConfigBase): + """Model config for main checkpoint models.""" + + prediction_type: SchedulerPredictionType = SchedulerPredictionType.Epsilon + upcast_attention: bool = False + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.Main.value}.{ModelFormat.Checkpoint.value}") + + +class MainBnbQuantized4bCheckpointConfig(CheckpointConfigBase, MainConfigBase): + """Model config for main checkpoint models.""" + + prediction_type: SchedulerPredictionType = SchedulerPredictionType.Epsilon + upcast_attention: bool = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.format = ModelFormat.BnbQuantizednf4b + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.Main.value}.{ModelFormat.BnbQuantizednf4b.value}") + + +class MainGGUFCheckpointConfig(CheckpointConfigBase, MainConfigBase): + """Model config for main checkpoint models.""" + + prediction_type: SchedulerPredictionType = SchedulerPredictionType.Epsilon + upcast_attention: bool = False + + def __init__(self, *args, **kwargs): + super().__init__(*args, **kwargs) + self.format = ModelFormat.GGUFQuantized + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.Main.value}.{ModelFormat.GGUFQuantized.value}") + + +class MainDiffusersConfig(DiffusersConfigBase, MainConfigBase): + """Model config for main diffusers models.""" + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.Main.value}.{ModelFormat.Diffusers.value}") + + +class IPAdapterBaseConfig(ModelConfigBase): + type: Literal[ModelType.IPAdapter] = ModelType.IPAdapter + + +class IPAdapterInvokeAIConfig(IPAdapterBaseConfig): + """Model config for IP Adapter diffusers format models.""" + + # TODO(ryand): Should we deprecate this field? From what I can tell, it hasn't been probed correctly for a long + # time. Need to go through the history to make sure I'm understanding this fully. + image_encoder_model_id: str + format: Literal[ModelFormat.InvokeAI] + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.IPAdapter.value}.{ModelFormat.InvokeAI.value}") + + +class IPAdapterCheckpointConfig(IPAdapterBaseConfig): + """Model config for IP Adapter checkpoint format models.""" + + format: Literal[ModelFormat.Checkpoint] + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.IPAdapter.value}.{ModelFormat.Checkpoint.value}") + + +class CLIPEmbedDiffusersConfig(DiffusersConfigBase): + """Model config for Clip Embeddings.""" + + type: Literal[ModelType.CLIPEmbed] = ModelType.CLIPEmbed + format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers + variant: ClipVariantType = ClipVariantType.L + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.CLIPEmbed.value}.{ModelFormat.Diffusers.value}") + + +class CLIPGEmbedDiffusersConfig(CLIPEmbedDiffusersConfig): + """Model config for CLIP-G Embeddings.""" + + variant: ClipVariantType = ClipVariantType.G + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.CLIPEmbed.value}.{ModelFormat.Diffusers.value}.{ClipVariantType.G}") + + +class CLIPLEmbedDiffusersConfig(CLIPEmbedDiffusersConfig): + """Model config for CLIP-L Embeddings.""" + + variant: ClipVariantType = ClipVariantType.L + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.CLIPEmbed.value}.{ModelFormat.Diffusers.value}.{ClipVariantType.L}") + + +class CLIPVisionDiffusersConfig(DiffusersConfigBase): + """Model config for CLIPVision.""" + + type: Literal[ModelType.CLIPVision] = ModelType.CLIPVision + format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.CLIPVision.value}.{ModelFormat.Diffusers.value}") + + +class T2IAdapterConfig(DiffusersConfigBase, ControlAdapterConfigBase): + """Model config for T2I.""" + + type: Literal[ModelType.T2IAdapter] = ModelType.T2IAdapter + format: Literal[ModelFormat.Diffusers] = ModelFormat.Diffusers + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.T2IAdapter.value}.{ModelFormat.Diffusers.value}") + + +class SpandrelImageToImageConfig(ModelConfigBase): + """Model config for Spandrel Image to Image models.""" + + type: Literal[ModelType.SpandrelImageToImage] = ModelType.SpandrelImageToImage + format: Literal[ModelFormat.Checkpoint] = ModelFormat.Checkpoint + + @staticmethod + def get_tag() -> Tag: + return Tag(f"{ModelType.SpandrelImageToImage.value}.{ModelFormat.Checkpoint.value}") + + +def get_model_discriminator_value(v: Any) -> str: + """ + Computes the discriminator value for a model config. + https://docs.pydantic.dev/latest/concepts/unions/#discriminated-unions-with-callable-discriminator + """ + format_ = None + type_ = None + if isinstance(v, dict): + format_ = v.get("format") + if isinstance(format_, Enum): + format_ = format_.value + type_ = v.get("type") + if isinstance(type_, Enum): + type_ = type_.value + else: + format_ = v.format.value + type_ = v.type.value + v = f"{type_}.{format_}" + return v + + +AnyModelConfig = Annotated[ + Union[ + Annotated[MainDiffusersConfig, MainDiffusersConfig.get_tag()], + Annotated[MainCheckpointConfig, MainCheckpointConfig.get_tag()], + Annotated[MainBnbQuantized4bCheckpointConfig, MainBnbQuantized4bCheckpointConfig.get_tag()], + Annotated[MainGGUFCheckpointConfig, MainGGUFCheckpointConfig.get_tag()], + Annotated[VAEDiffusersConfig, VAEDiffusersConfig.get_tag()], + Annotated[VAECheckpointConfig, VAECheckpointConfig.get_tag()], + Annotated[ControlNetDiffusersConfig, ControlNetDiffusersConfig.get_tag()], + Annotated[ControlNetCheckpointConfig, ControlNetCheckpointConfig.get_tag()], + Annotated[LoRALyCORISConfig, LoRALyCORISConfig.get_tag()], + Annotated[LoRADiffusersConfig, LoRADiffusersConfig.get_tag()], + Annotated[T5EncoderConfig, T5EncoderConfig.get_tag()], + Annotated[T5EncoderBnbQuantizedLlmInt8bConfig, T5EncoderBnbQuantizedLlmInt8bConfig.get_tag()], + Annotated[TextualInversionFileConfig, TextualInversionFileConfig.get_tag()], + Annotated[TextualInversionFolderConfig, TextualInversionFolderConfig.get_tag()], + Annotated[IPAdapterInvokeAIConfig, IPAdapterInvokeAIConfig.get_tag()], + Annotated[IPAdapterCheckpointConfig, IPAdapterCheckpointConfig.get_tag()], + Annotated[T2IAdapterConfig, T2IAdapterConfig.get_tag()], + Annotated[SpandrelImageToImageConfig, SpandrelImageToImageConfig.get_tag()], + Annotated[CLIPVisionDiffusersConfig, CLIPVisionDiffusersConfig.get_tag()], + Annotated[CLIPEmbedDiffusersConfig, CLIPEmbedDiffusersConfig.get_tag()], + Annotated[CLIPLEmbedDiffusersConfig, CLIPLEmbedDiffusersConfig.get_tag()], + Annotated[CLIPGEmbedDiffusersConfig, CLIPGEmbedDiffusersConfig.get_tag()], + ], + Discriminator(get_model_discriminator_value), +] + +AnyModelConfigValidator = TypeAdapter(AnyModelConfig) +AnyDefaultSettings: TypeAlias = Union[MainModelDefaultSettings, ControlAdapterDefaultSettings] + + +class ModelConfigFactory(object): + """Class for parsing config dicts into StableDiffusion Config obects.""" + + @classmethod + def make_config( + cls, + model_data: Union[Dict[str, Any], AnyModelConfig], + key: Optional[str] = None, + dest_class: Optional[Type[ModelConfigBase]] = None, + timestamp: Optional[float] = None, + ) -> AnyModelConfig: + """ + Return the appropriate config object from raw dict values. + + :param model_data: A raw dict corresponding the obect fields to be + parsed into a ModelConfigBase obect (or descendent), or a ModelConfigBase + object, which will be passed through unchanged. + :param dest_class: The config class to be returned. If not provided, will + be selected automatically. + """ + model: Optional[ModelConfigBase] = None + if isinstance(model_data, ModelConfigBase): + model = model_data + elif dest_class: + model = dest_class.model_validate(model_data) + else: + # mypy doesn't typecheck TypeAdapters well? + model = AnyModelConfigValidator.validate_python(model_data) # type: ignore + assert model is not None + if key: + model.key = key + if isinstance(model, CheckpointConfigBase) and timestamp is not None: + model.converted_at = timestamp + if model: + validate_hash(model.hash) + return model # type: ignore diff --git a/invokeai/backend/model_manager/load/__init__.py b/invokeai/backend/model_manager/load/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..d9a07bc25021dde24c95b9fb04755953412e40f3 --- /dev/null +++ b/invokeai/backend/model_manager/load/__init__.py @@ -0,0 +1,27 @@ +# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Development Team +""" +Init file for the model loader. +""" + +from importlib import import_module +from pathlib import Path + +from invokeai.backend.model_manager.load.load_base import LoadedModel, LoadedModelWithoutConfig, ModelLoaderBase +from invokeai.backend.model_manager.load.load_default import ModelLoader +from invokeai.backend.model_manager.load.model_cache.model_cache_default import ModelCache +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry, ModelLoaderRegistryBase + +# This registers the subclasses that implement loaders of specific model types +loaders = [x.stem for x in Path(Path(__file__).parent, "model_loaders").glob("*.py") if x.stem != "__init__"] +for module in loaders: + import_module(f"{__package__}.model_loaders.{module}") + +__all__ = [ + "LoadedModel", + "LoadedModelWithoutConfig", + "ModelCache", + "ModelLoaderBase", + "ModelLoader", + "ModelLoaderRegistryBase", + "ModelLoaderRegistry", +] diff --git a/invokeai/backend/model_manager/load/load_base.py b/invokeai/backend/model_manager/load/load_base.py new file mode 100644 index 0000000000000000000000000000000000000000..92191299f1999dc61f5f5725fb52bfec0d0b3e83 --- /dev/null +++ b/invokeai/backend/model_manager/load/load_base.py @@ -0,0 +1,143 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +""" +Base class for model loading in InvokeAI. +""" + +from abc import ABC, abstractmethod +from contextlib import contextmanager +from dataclasses import dataclass +from logging import Logger +from pathlib import Path +from typing import Any, Dict, Generator, Optional, Tuple + +import torch + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.backend.model_manager.config import ( + AnyModel, + AnyModelConfig, + SubModelType, +) +from invokeai.backend.model_manager.load.model_cache.model_cache_base import ModelCacheBase, ModelLockerBase + + +@dataclass +class LoadedModelWithoutConfig: + """ + Context manager object that mediates transfer from RAM<->VRAM. + + This is a context manager object that has two distinct APIs: + + 1. Older API (deprecated): + Use the LoadedModel object directly as a context manager. + It will move the model into VRAM (on CUDA devices), and + return the model in a form suitable for passing to torch. + Example: + ``` + loaded_model_= loader.get_model_by_key('f13dd932', SubModelType('vae')) + with loaded_model as vae: + image = vae.decode(latents)[0] + ``` + + 2. Newer API (recommended): + Call the LoadedModel's `model_on_device()` method in a + context. It returns a tuple consisting of a copy of + the model's state dict in CPU RAM followed by a copy + of the model in VRAM. The state dict is provided to allow + LoRAs and other model patchers to return the model to + its unpatched state without expensive copy and restore + operations. + + Example: + ``` + loaded_model_= loader.get_model_by_key('f13dd932', SubModelType('vae')) + with loaded_model.model_on_device() as (state_dict, vae): + image = vae.decode(latents)[0] + ``` + + The state_dict should be treated as a read-only object and + never modified. Also be aware that some loadable models do + not have a state_dict, in which case this value will be None. + """ + + _locker: ModelLockerBase + + def __enter__(self) -> AnyModel: + """Context entry.""" + self._locker.lock() + return self.model + + def __exit__(self, *args: Any, **kwargs: Any) -> None: + """Context exit.""" + self._locker.unlock() + + @contextmanager + def model_on_device(self) -> Generator[Tuple[Optional[Dict[str, torch.Tensor]], AnyModel], None, None]: + """Return a tuple consisting of the model's state dict (if it exists) and the locked model on execution device.""" + locked_model = self._locker.lock() + try: + state_dict = self._locker.get_state_dict() + yield (state_dict, locked_model) + finally: + self._locker.unlock() + + @property + def model(self) -> AnyModel: + """Return the model without locking it.""" + return self._locker.model + + +@dataclass +class LoadedModel(LoadedModelWithoutConfig): + """Context manager object that mediates transfer from RAM<->VRAM.""" + + config: Optional[AnyModelConfig] = None + + +# TODO(MM2): +# Some "intermediary" subclasses in the ModelLoaderBase class hierarchy define methods that their subclasses don't +# know about. I think the problem may be related to this class being an ABC. +# +# For example, GenericDiffusersLoader defines `get_hf_load_class()`, and StableDiffusionDiffusersModel attempts to +# call it. However, the method is not defined in the ABC, so it is not guaranteed to be implemented. + + +class ModelLoaderBase(ABC): + """Abstract base class for loading models into RAM/VRAM.""" + + @abstractmethod + def __init__( + self, + app_config: InvokeAIAppConfig, + logger: Logger, + ram_cache: ModelCacheBase[AnyModel], + ): + """Initialize the loader.""" + pass + + @abstractmethod + def load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> LoadedModel: + """ + Return a model given its confguration. + + Given a model identified in the model configuration backend, + return a ModelInfo object that can be used to retrieve the model. + + :param model_config: Model configuration, as returned by ModelConfigRecordStore + :param submodel_type: an ModelType enum indicating the portion of + the model to retrieve (e.g. ModelType.Vae) + """ + pass + + @abstractmethod + def get_size_fs( + self, config: AnyModelConfig, model_path: Path, submodel_type: Optional[SubModelType] = None + ) -> int: + """Return size in bytes of the model, calculated before loading.""" + pass + + @property + @abstractmethod + def ram_cache(self) -> ModelCacheBase[AnyModel]: + """Return the ram cache associated with this loader.""" + pass diff --git a/invokeai/backend/model_manager/load/load_default.py b/invokeai/backend/model_manager/load/load_default.py new file mode 100644 index 0000000000000000000000000000000000000000..c46e94bccb7ccd95e2f38b2e62c556af49a1111d --- /dev/null +++ b/invokeai/backend/model_manager/load/load_default.py @@ -0,0 +1,108 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Default implementation of model loading in InvokeAI.""" + +from logging import Logger +from pathlib import Path +from typing import Optional + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, + InvalidModelConfigException, + SubModelType, +) +from invokeai.backend.model_manager.config import DiffusersConfigBase +from invokeai.backend.model_manager.load.load_base import LoadedModel, ModelLoaderBase +from invokeai.backend.model_manager.load.model_cache.model_cache_base import ModelCacheBase, ModelLockerBase +from invokeai.backend.model_manager.load.model_util import calc_model_size_by_fs +from invokeai.backend.model_manager.load.optimizations import skip_torch_weight_init +from invokeai.backend.util.devices import TorchDevice + + +# TO DO: The loader is not thread safe! +class ModelLoader(ModelLoaderBase): + """Default implementation of ModelLoaderBase.""" + + def __init__( + self, + app_config: InvokeAIAppConfig, + logger: Logger, + ram_cache: ModelCacheBase[AnyModel], + ): + """Initialize the loader.""" + self._app_config = app_config + self._logger = logger + self._ram_cache = ram_cache + self._torch_dtype = TorchDevice.choose_torch_dtype() + self._torch_device = TorchDevice.choose_torch_device() + + def load_model(self, model_config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> LoadedModel: + """ + Return a model given its configuration. + + Given a model's configuration as returned by the ModelRecordConfigStore service, + return a LoadedModel object that can be used for inference. + + :param model config: Configuration record for this model + :param submodel_type: an ModelType enum indicating the portion of + the model to retrieve (e.g. ModelType.Vae) + """ + model_path = self._get_model_path(model_config) + + if not model_path.exists(): + raise InvalidModelConfigException(f"Files for model '{model_config.name}' not found at {model_path}") + + with skip_torch_weight_init(): + locker = self._load_and_cache(model_config, submodel_type) + return LoadedModel(config=model_config, _locker=locker) + + @property + def ram_cache(self) -> ModelCacheBase[AnyModel]: + """Return the ram cache associated with this loader.""" + return self._ram_cache + + def _get_model_path(self, config: AnyModelConfig) -> Path: + model_base = self._app_config.models_path + return (model_base / config.path).resolve() + + def _load_and_cache(self, config: AnyModelConfig, submodel_type: Optional[SubModelType] = None) -> ModelLockerBase: + stats_name = ":".join([config.base, config.type, config.name, (submodel_type or "")]) + try: + return self._ram_cache.get(config.key, submodel_type, stats_name=stats_name) + except IndexError: + pass + + config.path = str(self._get_model_path(config)) + self._ram_cache.make_room(self.get_size_fs(config, Path(config.path), submodel_type)) + loaded_model = self._load_model(config, submodel_type) + + self._ram_cache.put( + config.key, + submodel_type=submodel_type, + model=loaded_model, + ) + + return self._ram_cache.get( + key=config.key, + submodel_type=submodel_type, + stats_name=stats_name, + ) + + def get_size_fs( + self, config: AnyModelConfig, model_path: Path, submodel_type: Optional[SubModelType] = None + ) -> int: + """Get the size of the model on disk.""" + return calc_model_size_by_fs( + model_path=model_path, + subfolder=submodel_type.value if submodel_type else None, + variant=config.repo_variant if isinstance(config, DiffusersConfigBase) else None, + ) + + # This needs to be implemented in the subclass + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + raise NotImplementedError diff --git a/invokeai/backend/model_manager/load/memory_snapshot.py b/invokeai/backend/model_manager/load/memory_snapshot.py new file mode 100644 index 0000000000000000000000000000000000000000..66dd0709632bc10f5eca76aea2e22ff88aec0016 --- /dev/null +++ b/invokeai/backend/model_manager/load/memory_snapshot.py @@ -0,0 +1,100 @@ +import gc +from typing import Optional + +import psutil +import torch +from typing_extensions import Self + +from invokeai.backend.model_manager.util.libc_util import LibcUtil, Struct_mallinfo2 + +GB = 2**30 # 1 GB + + +class MemorySnapshot: + """A snapshot of RAM and VRAM usage. All values are in bytes.""" + + def __init__(self, process_ram: int, vram: Optional[int], malloc_info: Optional[Struct_mallinfo2]): + """Initialize a MemorySnapshot. + + Most of the time, `MemorySnapshot` will be constructed with `MemorySnapshot.capture()`. + + Args: + process_ram (int): CPU RAM used by the current process. + vram (Optional[int]): VRAM used by torch. + malloc_info (Optional[Struct_mallinfo2]): Malloc info obtained from LibcUtil. + """ + self.process_ram = process_ram + self.vram = vram + self.malloc_info = malloc_info + + @classmethod + def capture(cls, run_garbage_collector: bool = True) -> Self: + """Capture and return a MemorySnapshot. + + Note: This function has significant overhead, particularly if `run_garbage_collector == True`. + + Args: + run_garbage_collector (bool, optional): If true, gc.collect() will be run before checking the process RAM + usage. Defaults to True. + + Returns: + MemorySnapshot + """ + if run_garbage_collector: + gc.collect() + + # According to the psutil docs (https://psutil.readthedocs.io/en/latest/#psutil.Process.memory_info), rss is + # supported on all platforms. + process_ram = psutil.Process().memory_info().rss + + if torch.cuda.is_available(): + vram = torch.cuda.memory_allocated() + else: + # TODO: We could add support for mps.current_allocated_memory() as well. Leaving out for now until we have + # time to test it properly. + vram = None + + try: + malloc_info = LibcUtil().mallinfo2() + except (OSError, AttributeError): + # OSError: This is expected in environments that do not have the 'libc.so.6' shared library. + # AttributeError: This is expected in environments that have `libc.so.6` but do not have the `mallinfo2` (e.g. glibc < 2.33) + # TODO: Does `mallinfo` work? + malloc_info = None + + return cls(process_ram, vram, malloc_info) + + +def get_pretty_snapshot_diff(snapshot_1: Optional[MemorySnapshot], snapshot_2: Optional[MemorySnapshot]) -> str: + """Get a pretty string describing the difference between two `MemorySnapshot`s.""" + + def get_msg_line(prefix: str, val1: int, val2: int) -> str: + diff = val2 - val1 + return f"{prefix: <30} ({(diff/GB):+5.3f}): {(val1/GB):5.3f}GB -> {(val2/GB):5.3f}GB\n" + + msg = "" + + if snapshot_1 is None or snapshot_2 is None: + return msg + + msg += get_msg_line("Process RAM", snapshot_1.process_ram, snapshot_2.process_ram) + + if snapshot_1.malloc_info is not None and snapshot_2.malloc_info is not None: + msg += get_msg_line("libc mmap allocated", snapshot_1.malloc_info.hblkhd, snapshot_2.malloc_info.hblkhd) + + msg += get_msg_line("libc arena used", snapshot_1.malloc_info.uordblks, snapshot_2.malloc_info.uordblks) + + msg += get_msg_line("libc arena free", snapshot_1.malloc_info.fordblks, snapshot_2.malloc_info.fordblks) + + libc_total_allocated_1 = snapshot_1.malloc_info.arena + snapshot_1.malloc_info.hblkhd + libc_total_allocated_2 = snapshot_2.malloc_info.arena + snapshot_2.malloc_info.hblkhd + msg += get_msg_line("libc total allocated", libc_total_allocated_1, libc_total_allocated_2) + + libc_total_used_1 = snapshot_1.malloc_info.uordblks + snapshot_1.malloc_info.hblkhd + libc_total_used_2 = snapshot_2.malloc_info.uordblks + snapshot_2.malloc_info.hblkhd + msg += get_msg_line("libc total used", libc_total_used_1, libc_total_used_2) + + if snapshot_1.vram is not None and snapshot_2.vram is not None: + msg += get_msg_line("VRAM", snapshot_1.vram, snapshot_2.vram) + + return msg diff --git a/invokeai/backend/model_manager/load/model_cache/__init__.py b/invokeai/backend/model_manager/load/model_cache/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..32c682d04247b81f77d643a144313d7fa3f997a1 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_cache/__init__.py @@ -0,0 +1,6 @@ +"""Init file for ModelCache.""" + +from .model_cache_base import ModelCacheBase, CacheStats # noqa F401 +from .model_cache_default import ModelCache # noqa F401 + +_all__ = ["ModelCacheBase", "ModelCache", "CacheStats"] diff --git a/invokeai/backend/model_manager/load/model_cache/model_cache_base.py b/invokeai/backend/model_manager/load/model_cache/model_cache_base.py new file mode 100644 index 0000000000000000000000000000000000000000..29f8b04512859c1bc97bfc24d1d285531ca91ee7 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_cache/model_cache_base.py @@ -0,0 +1,221 @@ +# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Development team +# TODO: Add Stalker's proper name to copyright +""" +Manage a RAM cache of diffusion/transformer models for fast switching. +They are moved between GPU VRAM and CPU RAM as necessary. If the cache +grows larger than a preset maximum, then the least recently used +model will be cleared and (re)loaded from disk when next needed. +""" + +from abc import ABC, abstractmethod +from dataclasses import dataclass, field +from logging import Logger +from typing import Dict, Generic, Optional, TypeVar + +import torch + +from invokeai.backend.model_manager.config import AnyModel, SubModelType + + +class ModelLockerBase(ABC): + """Base class for the model locker used by the loader.""" + + @abstractmethod + def lock(self) -> AnyModel: + """Lock the contained model and move it into VRAM.""" + pass + + @abstractmethod + def unlock(self) -> None: + """Unlock the contained model, and remove it from VRAM.""" + pass + + @abstractmethod + def get_state_dict(self) -> Optional[Dict[str, torch.Tensor]]: + """Return the state dict (if any) for the cached model.""" + pass + + @property + @abstractmethod + def model(self) -> AnyModel: + """Return the model.""" + pass + + +T = TypeVar("T") + + +@dataclass +class CacheRecord(Generic[T]): + """ + Elements of the cache: + + key: Unique key for each model, same as used in the models database. + model: Model in memory. + state_dict: A read-only copy of the model's state dict in RAM. It will be + used as a template for creating a copy in the VRAM. + size: Size of the model + loaded: True if the model's state dict is currently in VRAM + + Before a model is executed, the state_dict template is copied into VRAM, + and then injected into the model. When the model is finished, the VRAM + copy of the state dict is deleted, and the RAM version is reinjected + into the model. + + The state_dict should be treated as a read-only attribute. Do not attempt + to patch or otherwise modify it. Instead, patch the copy of the state_dict + after it is loaded into the execution device (e.g. CUDA) using the `LoadedModel` + context manager call `model_on_device()`. + """ + + key: str + model: T + device: torch.device + state_dict: Optional[Dict[str, torch.Tensor]] + size: int + loaded: bool = False + _locks: int = 0 + + def lock(self) -> None: + """Lock this record.""" + self._locks += 1 + + def unlock(self) -> None: + """Unlock this record.""" + self._locks -= 1 + assert self._locks >= 0 + + @property + def locked(self) -> bool: + """Return true if record is locked.""" + return self._locks > 0 + + +@dataclass +class CacheStats(object): + """Collect statistics on cache performance.""" + + hits: int = 0 # cache hits + misses: int = 0 # cache misses + high_watermark: int = 0 # amount of cache used + in_cache: int = 0 # number of models in cache + cleared: int = 0 # number of models cleared to make space + cache_size: int = 0 # total size of cache + loaded_model_sizes: Dict[str, int] = field(default_factory=dict) + + +class ModelCacheBase(ABC, Generic[T]): + """Virtual base class for RAM model cache.""" + + @property + @abstractmethod + def storage_device(self) -> torch.device: + """Return the storage device (e.g. "CPU" for RAM).""" + pass + + @property + @abstractmethod + def execution_device(self) -> torch.device: + """Return the exection device (e.g. "cuda" for VRAM).""" + pass + + @property + @abstractmethod + def lazy_offloading(self) -> bool: + """Return true if the cache is configured to lazily offload models in VRAM.""" + pass + + @property + @abstractmethod + def max_cache_size(self) -> float: + """Return the maximum size the RAM cache can grow to.""" + pass + + @max_cache_size.setter + @abstractmethod + def max_cache_size(self, value: float) -> None: + """Set the cap on vram cache size.""" + + @property + @abstractmethod + def max_vram_cache_size(self) -> float: + """Return the maximum size the VRAM cache can grow to.""" + pass + + @max_vram_cache_size.setter + @abstractmethod + def max_vram_cache_size(self, value: float) -> float: + """Set the maximum size the VRAM cache can grow to.""" + pass + + @abstractmethod + def offload_unlocked_models(self, size_required: int) -> None: + """Offload from VRAM any models not actively in use.""" + pass + + @abstractmethod + def move_model_to_device(self, cache_entry: CacheRecord[AnyModel], target_device: torch.device) -> None: + """Move model into the indicated device.""" + pass + + @property + @abstractmethod + def stats(self) -> Optional[CacheStats]: + """Return collected CacheStats object.""" + pass + + @stats.setter + @abstractmethod + def stats(self, stats: CacheStats) -> None: + """Set the CacheStats object for collectin cache statistics.""" + pass + + @property + @abstractmethod + def logger(self) -> Logger: + """Return the logger used by the cache.""" + pass + + @abstractmethod + def make_room(self, size: int) -> None: + """Make enough room in the cache to accommodate a new model of indicated size.""" + pass + + @abstractmethod + def put( + self, + key: str, + model: T, + submodel_type: Optional[SubModelType] = None, + ) -> None: + """Store model under key and optional submodel_type.""" + pass + + @abstractmethod + def get( + self, + key: str, + submodel_type: Optional[SubModelType] = None, + stats_name: Optional[str] = None, + ) -> ModelLockerBase: + """ + Retrieve model using key and optional submodel_type. + + :param key: Opaque model key + :param submodel_type: Type of the submodel to fetch + :param stats_name: A human-readable id for the model for the purposes of + stats reporting. + + This may raise an IndexError if the model is not in the cache. + """ + pass + + @abstractmethod + def cache_size(self) -> int: + """Get the total size of the models currently cached.""" + pass + + @abstractmethod + def print_cuda_stats(self) -> None: + """Log debugging information on CUDA usage.""" + pass diff --git a/invokeai/backend/model_manager/load/model_cache/model_cache_default.py b/invokeai/backend/model_manager/load/model_cache/model_cache_default.py new file mode 100644 index 0000000000000000000000000000000000000000..9e766b15bef673d9ea274cdfae507835fa9b1b86 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_cache/model_cache_default.py @@ -0,0 +1,426 @@ +# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Development team +# TODO: Add Stalker's proper name to copyright +""" """ + +import gc +import math +import time +from contextlib import suppress +from logging import Logger +from typing import Dict, List, Optional + +import torch + +from invokeai.backend.model_manager import AnyModel, SubModelType +from invokeai.backend.model_manager.load.memory_snapshot import MemorySnapshot, get_pretty_snapshot_diff +from invokeai.backend.model_manager.load.model_cache.model_cache_base import ( + CacheRecord, + CacheStats, + ModelCacheBase, + ModelLockerBase, +) +from invokeai.backend.model_manager.load.model_cache.model_locker import ModelLocker +from invokeai.backend.model_manager.load.model_util import calc_model_size_by_data +from invokeai.backend.util.devices import TorchDevice +from invokeai.backend.util.logging import InvokeAILogger + +# Size of a GB in bytes. +GB = 2**30 + +# Size of a MB in bytes. +MB = 2**20 + + +class ModelCache(ModelCacheBase[AnyModel]): + """A cache for managing models in memory. + + The cache is based on two levels of model storage: + - execution_device: The device where most models are executed (typically "cuda", "mps", or "cpu"). + - storage_device: The device where models are offloaded when not in active use (typically "cpu"). + + The model cache is based on the following assumptions: + - storage_device_mem_size > execution_device_mem_size + - disk_to_storage_device_transfer_time >> storage_device_to_execution_device_transfer_time + + A copy of all models in the cache is always kept on the storage_device. A subset of the models also have a copy on + the execution_device. + + Models are moved between the storage_device and the execution_device as necessary. Cache size limits are enforced + on both the storage_device and the execution_device. The execution_device cache uses a smallest-first offload + policy. The storage_device cache uses a least-recently-used (LRU) offload policy. + + Note: Neither of these offload policies has really been compared against alternatives. It's likely that different + policies would be better, although the optimal policies are likely heavily dependent on usage patterns and HW + configuration. + + The cache returns context manager generators designed to load the model into the execution device (often GPU) within + the context, and unload outside the context. + + Example usage: + ``` + cache = ModelCache(max_cache_size=7.5, max_vram_cache_size=6.0) + with cache.get_model('runwayml/stable-diffusion-1-5') as SD1: + do_something_on_gpu(SD1) + ``` + """ + + def __init__( + self, + max_cache_size: float, + max_vram_cache_size: float, + execution_device: torch.device = torch.device("cuda"), + storage_device: torch.device = torch.device("cpu"), + precision: torch.dtype = torch.float16, + lazy_offloading: bool = True, + log_memory_usage: bool = False, + logger: Optional[Logger] = None, + ): + """ + Initialize the model RAM cache. + + :param max_cache_size: Maximum size of the storage_device cache in GBs. + :param max_vram_cache_size: Maximum size of the execution_device cache in GBs. + :param execution_device: Torch device to load active model into [torch.device('cuda')] + :param storage_device: Torch device to save inactive model in [torch.device('cpu')] + :param precision: Precision for loaded models [torch.float16] + :param lazy_offloading: Keep model in VRAM until another model needs to be loaded + :param 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 disable this feature unless you are actively inspecting the model cache's + behaviour. + :param logger: InvokeAILogger to use (otherwise creates one) + """ + # allow lazy offloading only when vram cache enabled + self._lazy_offloading = lazy_offloading and max_vram_cache_size > 0 + self._max_cache_size: float = max_cache_size + self._max_vram_cache_size: float = max_vram_cache_size + self._execution_device: torch.device = execution_device + self._storage_device: torch.device = storage_device + self._logger = logger or InvokeAILogger.get_logger(self.__class__.__name__) + self._log_memory_usage = log_memory_usage + self._stats: Optional[CacheStats] = None + + self._cached_models: Dict[str, CacheRecord[AnyModel]] = {} + self._cache_stack: List[str] = [] + + @property + def logger(self) -> Logger: + """Return the logger used by the cache.""" + return self._logger + + @property + def lazy_offloading(self) -> bool: + """Return true if the cache is configured to lazily offload models in VRAM.""" + return self._lazy_offloading + + @property + def storage_device(self) -> torch.device: + """Return the storage device (e.g. "CPU" for RAM).""" + return self._storage_device + + @property + def execution_device(self) -> torch.device: + """Return the exection device (e.g. "cuda" for VRAM).""" + return self._execution_device + + @property + def max_cache_size(self) -> float: + """Return the cap on cache size.""" + return self._max_cache_size + + @max_cache_size.setter + def max_cache_size(self, value: float) -> None: + """Set the cap on cache size.""" + self._max_cache_size = value + + @property + def max_vram_cache_size(self) -> float: + """Return the cap on vram cache size.""" + return self._max_vram_cache_size + + @max_vram_cache_size.setter + def max_vram_cache_size(self, value: float) -> None: + """Set the cap on vram cache size.""" + self._max_vram_cache_size = value + + @property + def stats(self) -> Optional[CacheStats]: + """Return collected CacheStats object.""" + return self._stats + + @stats.setter + def stats(self, stats: CacheStats) -> None: + """Set the CacheStats object for collectin cache statistics.""" + self._stats = stats + + def cache_size(self) -> int: + """Get the total size of the models currently cached.""" + total = 0 + for cache_record in self._cached_models.values(): + total += cache_record.size + return total + + def put( + self, + key: str, + model: AnyModel, + submodel_type: Optional[SubModelType] = None, + ) -> None: + """Store model under key and optional submodel_type.""" + key = self._make_cache_key(key, submodel_type) + if key in self._cached_models: + return + size = calc_model_size_by_data(self.logger, model) + self.make_room(size) + + running_on_cpu = self.execution_device == torch.device("cpu") + state_dict = model.state_dict() if isinstance(model, torch.nn.Module) and not running_on_cpu else None + cache_record = CacheRecord(key=key, model=model, device=self.storage_device, state_dict=state_dict, size=size) + self._cached_models[key] = cache_record + self._cache_stack.append(key) + + def get( + self, + key: str, + submodel_type: Optional[SubModelType] = None, + stats_name: Optional[str] = None, + ) -> ModelLockerBase: + """ + Retrieve model using key and optional submodel_type. + + :param key: Opaque model key + :param submodel_type: Type of the submodel to fetch + :param stats_name: A human-readable id for the model for the purposes of + stats reporting. + + This may raise an IndexError if the model is not in the cache. + """ + key = self._make_cache_key(key, submodel_type) + if key in self._cached_models: + if self.stats: + self.stats.hits += 1 + else: + if self.stats: + self.stats.misses += 1 + raise IndexError(f"The model with key {key} is not in the cache.") + + cache_entry = self._cached_models[key] + + # more stats + if self.stats: + stats_name = stats_name or key + self.stats.cache_size = int(self._max_cache_size * GB) + self.stats.high_watermark = max(self.stats.high_watermark, self.cache_size()) + self.stats.in_cache = len(self._cached_models) + self.stats.loaded_model_sizes[stats_name] = max( + self.stats.loaded_model_sizes.get(stats_name, 0), cache_entry.size + ) + + # this moves the entry to the top (right end) of the stack + with suppress(Exception): + self._cache_stack.remove(key) + self._cache_stack.append(key) + return ModelLocker( + cache=self, + cache_entry=cache_entry, + ) + + def _capture_memory_snapshot(self) -> Optional[MemorySnapshot]: + if self._log_memory_usage: + return MemorySnapshot.capture() + return None + + def _make_cache_key(self, model_key: str, submodel_type: Optional[SubModelType] = None) -> str: + if submodel_type: + return f"{model_key}:{submodel_type.value}" + else: + return model_key + + def offload_unlocked_models(self, size_required: int) -> None: + """Offload models from the execution_device to make room for size_required. + + :param size_required: The amount of space to clear in the execution_device cache, in bytes. + """ + reserved = self._max_vram_cache_size * GB + vram_in_use = torch.cuda.memory_allocated() + size_required + self.logger.debug(f"{(vram_in_use/GB):.2f}GB VRAM needed for models; max allowed={(reserved/GB):.2f}GB") + for _, cache_entry in sorted(self._cached_models.items(), key=lambda x: x[1].size): + if vram_in_use <= reserved: + break + if not cache_entry.loaded: + continue + if not cache_entry.locked: + self.move_model_to_device(cache_entry, self.storage_device) + cache_entry.loaded = False + vram_in_use = torch.cuda.memory_allocated() + size_required + self.logger.debug( + f"Removing {cache_entry.key} from VRAM to free {(cache_entry.size/GB):.2f}GB; vram free = {(torch.cuda.memory_allocated()/GB):.2f}GB" + ) + + TorchDevice.empty_cache() + + def move_model_to_device(self, cache_entry: CacheRecord[AnyModel], target_device: torch.device) -> None: + """Move model into the indicated device. + + :param cache_entry: The CacheRecord for the model + :param target_device: The torch.device to move the model into + + May raise a torch.cuda.OutOfMemoryError + """ + self.logger.debug(f"Called to move {cache_entry.key} to {target_device}") + source_device = cache_entry.device + + # Note: We compare device types only so that 'cuda' == 'cuda:0'. + # This would need to be revised to support multi-GPU. + if torch.device(source_device).type == torch.device(target_device).type: + return + + # Some models don't have a `to` method, in which case they run in RAM/CPU. + if not hasattr(cache_entry.model, "to"): + return + + # This roundabout method for moving the model around is done to avoid + # the cost of moving the model from RAM to VRAM and then back from VRAM to RAM. + # When moving to VRAM, we copy (not move) each element of the state dict from + # RAM to a new state dict in VRAM, and then inject it into the model. + # This operation is slightly faster than running `to()` on the whole model. + # + # When the model needs to be removed from VRAM we simply delete the copy + # of the state dict in VRAM, and reinject the state dict that is cached + # in RAM into the model. So this operation is very fast. + start_model_to_time = time.time() + snapshot_before = self._capture_memory_snapshot() + + try: + if cache_entry.state_dict is not None: + assert hasattr(cache_entry.model, "load_state_dict") + if target_device == self.storage_device: + cache_entry.model.load_state_dict(cache_entry.state_dict, assign=True) + else: + new_dict: Dict[str, torch.Tensor] = {} + for k, v in cache_entry.state_dict.items(): + new_dict[k] = v.to(target_device, copy=True) + cache_entry.model.load_state_dict(new_dict, assign=True) + cache_entry.model.to(target_device) + cache_entry.device = target_device + except Exception as e: # blow away cache entry + self._delete_cache_entry(cache_entry) + raise e + + snapshot_after = self._capture_memory_snapshot() + end_model_to_time = time.time() + self.logger.debug( + f"Moved model '{cache_entry.key}' from {source_device} to" + f" {target_device} in {(end_model_to_time-start_model_to_time):.2f}s." + f"Estimated model size: {(cache_entry.size/GB):.3f} GB." + f"{get_pretty_snapshot_diff(snapshot_before, snapshot_after)}" + ) + + if ( + snapshot_before is not None + and snapshot_after is not None + and snapshot_before.vram is not None + and snapshot_after.vram is not None + ): + vram_change = abs(snapshot_before.vram - snapshot_after.vram) + + # If the estimated model size does not match the change in VRAM, log a warning. + if not math.isclose( + vram_change, + cache_entry.size, + rel_tol=0.1, + abs_tol=10 * MB, + ): + self.logger.debug( + f"Moving model '{cache_entry.key}' from {source_device} to" + f" {target_device} caused an unexpected change in VRAM usage. The model's" + " estimated size may be incorrect. Estimated model size:" + f" {(cache_entry.size/GB):.3f} GB.\n" + f"{get_pretty_snapshot_diff(snapshot_before, snapshot_after)}" + ) + + def print_cuda_stats(self) -> None: + """Log CUDA diagnostics.""" + vram = "%4.2fG" % (torch.cuda.memory_allocated() / GB) + ram = "%4.2fG" % (self.cache_size() / GB) + + in_ram_models = 0 + in_vram_models = 0 + locked_in_vram_models = 0 + for cache_record in self._cached_models.values(): + if hasattr(cache_record.model, "device"): + if cache_record.model.device == self.storage_device: + in_ram_models += 1 + else: + in_vram_models += 1 + if cache_record.locked: + locked_in_vram_models += 1 + + self.logger.debug( + f"Current VRAM/RAM usage: {vram}/{ram}; models_in_ram/models_in_vram(locked) =" + f" {in_ram_models}/{in_vram_models}({locked_in_vram_models})" + ) + + def make_room(self, size: int) -> None: + """Make enough room in the cache to accommodate a new model of indicated size. + + Note: This function deletes all of the cache's internal references to a model in order to free it. If there are + external references to the model, there's nothing that the cache can do about it, and those models will not be + garbage-collected. + """ + bytes_needed = size + maximum_size = self.max_cache_size * GB # stored in GB, convert to bytes + current_size = self.cache_size() + + if current_size + bytes_needed > maximum_size: + self.logger.debug( + f"Max cache size exceeded: {(current_size/GB):.2f}/{self.max_cache_size:.2f} GB, need an additional" + f" {(bytes_needed/GB):.2f} GB" + ) + + self.logger.debug(f"Before making_room: cached_models={len(self._cached_models)}") + + pos = 0 + models_cleared = 0 + while current_size + bytes_needed > maximum_size and pos < len(self._cache_stack): + model_key = self._cache_stack[pos] + cache_entry = self._cached_models[model_key] + device = cache_entry.model.device if hasattr(cache_entry.model, "device") else None + self.logger.debug( + f"Model: {model_key}, locks: {cache_entry._locks}, device: {device}, loaded: {cache_entry.loaded}" + ) + + if not cache_entry.locked: + self.logger.debug( + f"Removing {model_key} from RAM cache to free at least {(size/GB):.2f} GB (-{(cache_entry.size/GB):.2f} GB)" + ) + current_size -= cache_entry.size + models_cleared += 1 + self._delete_cache_entry(cache_entry) + del cache_entry + + else: + pos += 1 + + if models_cleared > 0: + # There would likely be some 'garbage' to be collected regardless of whether a model was cleared or not, but + # there is a significant time cost to calling `gc.collect()`, so we want to use it sparingly. (The time cost + # is high even if no garbage gets collected.) + # + # Calling gc.collect(...) when a model is cleared seems like a good middle-ground: + # - If models had to be cleared, it's a signal that we are close to our memory limit. + # - If models were cleared, there's a good chance that there's a significant amount of garbage to be + # collected. + # + # Keep in mind that gc is only responsible for handling reference cycles. Most objects should be cleaned up + # immediately when their reference count hits 0. + if self.stats: + self.stats.cleared = models_cleared + gc.collect() + + TorchDevice.empty_cache() + self.logger.debug(f"After making room: cached_models={len(self._cached_models)}") + + def _delete_cache_entry(self, cache_entry: CacheRecord[AnyModel]) -> None: + self._cache_stack.remove(cache_entry.key) + del self._cached_models[cache_entry.key] diff --git a/invokeai/backend/model_manager/load/model_cache/model_locker.py b/invokeai/backend/model_manager/load/model_cache/model_locker.py new file mode 100644 index 0000000000000000000000000000000000000000..efbfc726f72508defb643e45ae289bc613c5795f --- /dev/null +++ b/invokeai/backend/model_manager/load/model_cache/model_locker.py @@ -0,0 +1,64 @@ +""" +Base class and implementation of a class that moves models in and out of VRAM. +""" + +from typing import Dict, Optional + +import torch + +from invokeai.backend.model_manager import AnyModel +from invokeai.backend.model_manager.load.model_cache.model_cache_base import ( + CacheRecord, + ModelCacheBase, + ModelLockerBase, +) + + +class ModelLocker(ModelLockerBase): + """Internal class that mediates movement in and out of GPU.""" + + def __init__(self, cache: ModelCacheBase[AnyModel], cache_entry: CacheRecord[AnyModel]): + """ + Initialize the model locker. + + :param cache: The ModelCache object + :param cache_entry: The entry in the model cache + """ + self._cache = cache + self._cache_entry = cache_entry + + @property + def model(self) -> AnyModel: + """Return the model without moving it around.""" + return self._cache_entry.model + + def get_state_dict(self) -> Optional[Dict[str, torch.Tensor]]: + """Return the state dict (if any) for the cached model.""" + return self._cache_entry.state_dict + + def lock(self) -> AnyModel: + """Move the model into the execution device (GPU) and lock it.""" + self._cache_entry.lock() + try: + if self._cache.lazy_offloading: + self._cache.offload_unlocked_models(self._cache_entry.size) + self._cache.move_model_to_device(self._cache_entry, self._cache.execution_device) + self._cache_entry.loaded = True + self._cache.logger.debug(f"Locking {self._cache_entry.key} in {self._cache.execution_device}") + self._cache.print_cuda_stats() + except torch.cuda.OutOfMemoryError: + self._cache.logger.warning("Insufficient GPU memory to load model. Aborting") + self._cache_entry.unlock() + raise + except Exception: + self._cache_entry.unlock() + raise + + return self.model + + def unlock(self) -> None: + """Call upon exit from context.""" + self._cache_entry.unlock() + if not self._cache.lazy_offloading: + self._cache.offload_unlocked_models(0) + self._cache.print_cuda_stats() diff --git a/invokeai/backend/model_manager/load/model_loader_registry.py b/invokeai/backend/model_manager/load/model_loader_registry.py new file mode 100644 index 0000000000000000000000000000000000000000..0ce8f8a6b4374988f2d9c7320e2cbefcfe4ea140 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loader_registry.py @@ -0,0 +1,104 @@ +# Copyright (c) 2024 Lincoln D. Stein and the InvokeAI Development team +""" +This module implements a system in which model loaders register the +type, base and format of models that they know how to load. + +Use like this: + + cls, model_config, submodel_type = ModelLoaderRegistry.get_implementation(model_config, submodel_type) # type: ignore + loaded_model = cls( + app_config=app_config, + logger=logger, + ram_cache=ram_cache, + convert_cache=convert_cache + ).load_model(model_config, submodel_type) + +""" + +from abc import ABC, abstractmethod +from typing import Callable, Dict, Optional, Tuple, Type, TypeVar + +from invokeai.backend.model_manager.config import ( + AnyModelConfig, + BaseModelType, + ModelConfigBase, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load import ModelLoaderBase + + +class ModelLoaderRegistryBase(ABC): + """This class allows model loaders to register their type, base and format.""" + + @classmethod + @abstractmethod + def register( + cls, type: ModelType, format: ModelFormat, base: BaseModelType = BaseModelType.Any + ) -> Callable[[Type[ModelLoaderBase]], Type[ModelLoaderBase]]: + """Define a decorator which registers the subclass of loader.""" + + @classmethod + @abstractmethod + def get_implementation( + cls, config: AnyModelConfig, submodel_type: Optional[SubModelType] + ) -> Tuple[Type[ModelLoaderBase], ModelConfigBase, Optional[SubModelType]]: + """ + Get subclass of ModelLoaderBase registered to handle base and type. + + Parameters: + :param config: Model configuration record, as returned by ModelRecordService + :param submodel_type: Submodel to fetch (main models only) + :return: tuple(loader_class, model_config, submodel_type) + + Note that the returned model config may be different from one what passed + in, in the event that a submodel type is provided. + """ + + +TModelLoader = TypeVar("TModelLoader", bound=ModelLoaderBase) + + +class ModelLoaderRegistry(ModelLoaderRegistryBase): + """ + This class allows model loaders to register their type, base and format. + """ + + _registry: Dict[str, Type[ModelLoaderBase]] = {} + + @classmethod + def register( + cls, type: ModelType, format: ModelFormat, base: BaseModelType = BaseModelType.Any + ) -> Callable[[Type[TModelLoader]], Type[TModelLoader]]: + """Define a decorator which registers the subclass of loader.""" + + def decorator(subclass: Type[TModelLoader]) -> Type[TModelLoader]: + key = cls._to_registry_key(base, type, format) + if key in cls._registry: + raise Exception( + f"{subclass.__name__} is trying to register as a loader for {base}/{type}/{format}, but this type of model has already been registered by {cls._registry[key].__name__}" + ) + cls._registry[key] = subclass + return subclass + + return decorator + + @classmethod + def get_implementation( + cls, config: AnyModelConfig, submodel_type: Optional[SubModelType] + ) -> Tuple[Type[ModelLoaderBase], ModelConfigBase, Optional[SubModelType]]: + """Get subclass of ModelLoaderBase registered to handle base and type.""" + + key1 = cls._to_registry_key(config.base, config.type, config.format) # for a specific base type + key2 = cls._to_registry_key(BaseModelType.Any, config.type, config.format) # with wildcard Any + implementation = cls._registry.get(key1) or cls._registry.get(key2) + if not implementation: + raise NotImplementedError( + f"No subclass of LoadedModel is registered for base={config.base}, type={config.type}, format={config.format}" + ) + return implementation, config, submodel_type + + @staticmethod + def _to_registry_key(base: BaseModelType, type: ModelType, format: ModelFormat) -> str: + return "-".join([base.value, type.value, format.value]) diff --git a/invokeai/backend/model_manager/load/model_loaders/__init__.py b/invokeai/backend/model_manager/load/model_loaders/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..962cba54811717261400d5fca55eaa1749066449 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/__init__.py @@ -0,0 +1,3 @@ +""" +Init file for model_loaders. +""" diff --git a/invokeai/backend/model_manager/load/model_loaders/clip_vision.py b/invokeai/backend/model_manager/load/model_loaders/clip_vision.py new file mode 100644 index 0000000000000000000000000000000000000000..cef1c962f9ae619df935d884f7b4f236ce7c44b0 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/clip_vision.py @@ -0,0 +1,41 @@ +from pathlib import Path +from typing import Optional + +from transformers import CLIPVisionModelWithProjection + +from invokeai.backend.model_manager.config import ( + AnyModel, + AnyModelConfig, + BaseModelType, + DiffusersConfigBase, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load.load_default import ModelLoader +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.CLIPVision, format=ModelFormat.Diffusers) +class ClipVisionLoader(ModelLoader): + """Class to load CLIPVision models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, DiffusersConfigBase): + raise ValueError("Only DiffusersConfigBase models are currently supported here.") + + if submodel_type is not None: + raise Exception("There are no submodels in CLIP Vision models.") + + model_path = Path(config.path) + + model = CLIPVisionModelWithProjection.from_pretrained( + model_path, torch_dtype=self._torch_dtype, local_files_only=True + ) + assert isinstance(model, CLIPVisionModelWithProjection) + + return model diff --git a/invokeai/backend/model_manager/load/model_loaders/controlnet.py b/invokeai/backend/model_manager/load/model_loaders/controlnet.py new file mode 100644 index 0000000000000000000000000000000000000000..5db952089225a86bf785af6a113d1e6829ed7ece --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/controlnet.py @@ -0,0 +1,55 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for ControlNet model loading in InvokeAI.""" + +from typing import Optional + +from diffusers import ControlNetModel + +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, +) +from invokeai.backend.model_manager.config import ( + BaseModelType, + ControlNetCheckpointConfig, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry +from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader + + +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusion1, type=ModelType.ControlNet, format=ModelFormat.Diffusers +) +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusion1, type=ModelType.ControlNet, format=ModelFormat.Checkpoint +) +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusion2, type=ModelType.ControlNet, format=ModelFormat.Diffusers +) +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusion2, type=ModelType.ControlNet, format=ModelFormat.Checkpoint +) +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusionXL, type=ModelType.ControlNet, format=ModelFormat.Diffusers +) +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusionXL, type=ModelType.ControlNet, format=ModelFormat.Checkpoint +) +class ControlNetLoader(GenericDiffusersLoader): + """Class to load ControlNet models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if isinstance(config, ControlNetCheckpointConfig): + return ControlNetModel.from_single_file( + config.path, + torch_dtype=self._torch_dtype, + ) + else: + return super()._load_model(config, submodel_type) diff --git a/invokeai/backend/model_manager/load/model_loaders/flux.py b/invokeai/backend/model_manager/load/model_loaders/flux.py new file mode 100644 index 0000000000000000000000000000000000000000..edf14ec48cc2e6c4553369cc1cd4cd4ab14d434d --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/flux.py @@ -0,0 +1,390 @@ +# Copyright (c) 2024, Brandon W. Rising and the InvokeAI Development Team +"""Class for Flux model loading in InvokeAI.""" + +from pathlib import Path +from typing import Optional + +import accelerate +import torch +from safetensors.torch import load_file +from transformers import AutoConfig, AutoModelForTextEncoding, CLIPTextModel, CLIPTokenizer, T5EncoderModel, T5Tokenizer + +from invokeai.app.services.config.config_default import get_config +from invokeai.backend.flux.controlnet.instantx_controlnet_flux import InstantXControlNetFlux +from invokeai.backend.flux.controlnet.state_dict_utils import ( + convert_diffusers_instantx_state_dict_to_bfl_format, + infer_flux_params_from_state_dict, + infer_instantx_num_control_modes_from_state_dict, + is_state_dict_instantx_controlnet, + is_state_dict_xlabs_controlnet, +) +from invokeai.backend.flux.controlnet.xlabs_controlnet_flux import XLabsControlNetFlux +from invokeai.backend.flux.ip_adapter.state_dict_utils import infer_xlabs_ip_adapter_params_from_state_dict +from invokeai.backend.flux.ip_adapter.xlabs_ip_adapter_flux import ( + XlabsIpAdapterFlux, +) +from invokeai.backend.flux.model import Flux +from invokeai.backend.flux.modules.autoencoder import AutoEncoder +from invokeai.backend.flux.util import ae_params, params +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.config import ( + CheckpointConfigBase, + CLIPEmbedDiffusersConfig, + ControlNetCheckpointConfig, + ControlNetDiffusersConfig, + IPAdapterCheckpointConfig, + MainBnbQuantized4bCheckpointConfig, + MainCheckpointConfig, + MainGGUFCheckpointConfig, + T5EncoderBnbQuantizedLlmInt8bConfig, + T5EncoderConfig, + VAECheckpointConfig, +) +from invokeai.backend.model_manager.load.load_default import ModelLoader +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry +from invokeai.backend.model_manager.util.model_util import ( + convert_bundle_to_flux_transformer_checkpoint, +) +from invokeai.backend.quantization.gguf.loaders import gguf_sd_loader +from invokeai.backend.quantization.gguf.utils import TORCH_COMPATIBLE_QTYPES +from invokeai.backend.util.silence_warnings import SilenceWarnings + +try: + from invokeai.backend.quantization.bnb_llm_int8 import quantize_model_llm_int8 + from invokeai.backend.quantization.bnb_nf4 import quantize_model_nf4 + + bnb_available = True +except ImportError: + bnb_available = False + +app_config = get_config() + + +@ModelLoaderRegistry.register(base=BaseModelType.Flux, type=ModelType.VAE, format=ModelFormat.Checkpoint) +class FluxVAELoader(ModelLoader): + """Class to load VAE models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, VAECheckpointConfig): + raise ValueError("Only VAECheckpointConfig models are currently supported here.") + model_path = Path(config.path) + + with SilenceWarnings(): + model = AutoEncoder(ae_params[config.config_path]) + sd = load_file(model_path) + model.load_state_dict(sd, assign=True) + # VAE is broken in float16, which mps defaults to + if self._torch_dtype == torch.float16: + try: + vae_dtype = torch.tensor([1.0], dtype=torch.bfloat16, device=self._torch_device).dtype + except TypeError: + vae_dtype = torch.float32 + else: + vae_dtype = self._torch_dtype + model.to(vae_dtype) + + return model + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.CLIPEmbed, format=ModelFormat.Diffusers) +class ClipCheckpointModel(ModelLoader): + """Class to load main models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, CLIPEmbedDiffusersConfig): + raise ValueError("Only CLIPEmbedDiffusersConfig models are currently supported here.") + + match submodel_type: + case SubModelType.Tokenizer: + return CLIPTokenizer.from_pretrained(Path(config.path) / "tokenizer") + case SubModelType.TextEncoder: + return CLIPTextModel.from_pretrained(Path(config.path) / "text_encoder") + + raise ValueError( + f"Only Tokenizer and TextEncoder submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}" + ) + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.T5Encoder, format=ModelFormat.BnbQuantizedLlmInt8b) +class BnbQuantizedLlmInt8bCheckpointModel(ModelLoader): + """Class to load main models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, T5EncoderBnbQuantizedLlmInt8bConfig): + raise ValueError("Only T5EncoderBnbQuantizedLlmInt8bConfig models are currently supported here.") + if not bnb_available: + raise ImportError( + "The bnb modules are not available. Please install bitsandbytes if available on your platform." + ) + match submodel_type: + case SubModelType.Tokenizer2 | SubModelType.Tokenizer3: + return T5Tokenizer.from_pretrained(Path(config.path) / "tokenizer_2", max_length=512) + case SubModelType.TextEncoder2 | SubModelType.TextEncoder3: + te2_model_path = Path(config.path) / "text_encoder_2" + model_config = AutoConfig.from_pretrained(te2_model_path) + with accelerate.init_empty_weights(): + model = AutoModelForTextEncoding.from_config(model_config) + model = quantize_model_llm_int8(model, modules_to_not_convert=set()) + + state_dict_path = te2_model_path / "bnb_llm_int8_model.safetensors" + state_dict = load_file(state_dict_path) + self._load_state_dict_into_t5(model, state_dict) + + return model + + raise ValueError( + f"Only Tokenizer and TextEncoder submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}" + ) + + @classmethod + def _load_state_dict_into_t5(cls, model: T5EncoderModel, state_dict: dict[str, torch.Tensor]): + # There is a shared reference to a single weight tensor in the model. + # Both "encoder.embed_tokens.weight" and "shared.weight" refer to the same tensor, so only the latter should + # be present in the state_dict. + missing_keys, unexpected_keys = model.load_state_dict(state_dict, strict=False, assign=True) + assert len(unexpected_keys) == 0 + assert set(missing_keys) == {"encoder.embed_tokens.weight"} + # Assert that the layers we expect to be shared are actually shared. + assert model.encoder.embed_tokens.weight is model.shared.weight + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.T5Encoder, format=ModelFormat.T5Encoder) +class T5EncoderCheckpointModel(ModelLoader): + """Class to load main models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, T5EncoderConfig): + raise ValueError("Only T5EncoderConfig models are currently supported here.") + + match submodel_type: + case SubModelType.Tokenizer2 | SubModelType.Tokenizer3: + return T5Tokenizer.from_pretrained(Path(config.path) / "tokenizer_2", max_length=512) + case SubModelType.TextEncoder2 | SubModelType.TextEncoder3: + return T5EncoderModel.from_pretrained(Path(config.path) / "text_encoder_2", torch_dtype="auto") + + raise ValueError( + f"Only Tokenizer and TextEncoder submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}" + ) + + +@ModelLoaderRegistry.register(base=BaseModelType.Flux, type=ModelType.Main, format=ModelFormat.Checkpoint) +class FluxCheckpointModel(ModelLoader): + """Class to load main models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, CheckpointConfigBase): + raise ValueError("Only CheckpointConfigBase models are currently supported here.") + + match submodel_type: + case SubModelType.Transformer: + return self._load_from_singlefile(config) + + raise ValueError( + f"Only Transformer submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}" + ) + + def _load_from_singlefile( + self, + config: AnyModelConfig, + ) -> AnyModel: + assert isinstance(config, MainCheckpointConfig) + model_path = Path(config.path) + + with SilenceWarnings(): + model = Flux(params[config.config_path]) + sd = load_file(model_path) + if "model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.scale" in sd: + sd = convert_bundle_to_flux_transformer_checkpoint(sd) + new_sd_size = sum([ten.nelement() * torch.bfloat16.itemsize for ten in sd.values()]) + self._ram_cache.make_room(new_sd_size) + for k in sd.keys(): + # We need to cast to bfloat16 due to it being the only currently supported dtype for inference + sd[k] = sd[k].to(torch.bfloat16) + model.load_state_dict(sd, assign=True) + return model + + +@ModelLoaderRegistry.register(base=BaseModelType.Flux, type=ModelType.Main, format=ModelFormat.GGUFQuantized) +class FluxGGUFCheckpointModel(ModelLoader): + """Class to load GGUF main models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, CheckpointConfigBase): + raise ValueError("Only CheckpointConfigBase models are currently supported here.") + + match submodel_type: + case SubModelType.Transformer: + return self._load_from_singlefile(config) + + raise ValueError( + f"Only Transformer submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}" + ) + + def _load_from_singlefile( + self, + config: AnyModelConfig, + ) -> AnyModel: + assert isinstance(config, MainGGUFCheckpointConfig) + model_path = Path(config.path) + + with SilenceWarnings(): + model = Flux(params[config.config_path]) + + # HACK(ryand): We shouldn't be hard-coding the compute_dtype here. + sd = gguf_sd_loader(model_path, compute_dtype=torch.bfloat16) + + # HACK(ryand): There are some broken GGUF models in circulation that have the wrong shape for img_in.weight. + # We override the shape here to fix the issue. + # Example model with this issue (Q4_K_M): https://civitai.com/models/705823/ggufk-flux-unchained-km-quants + img_in_weight = sd.get("img_in.weight", None) + if img_in_weight is not None and img_in_weight._ggml_quantization_type in TORCH_COMPATIBLE_QTYPES: + expected_img_in_weight_shape = model.img_in.weight.shape + img_in_weight.quantized_data = img_in_weight.quantized_data.view(expected_img_in_weight_shape) + img_in_weight.tensor_shape = expected_img_in_weight_shape + + model.load_state_dict(sd, assign=True) + return model + + +@ModelLoaderRegistry.register(base=BaseModelType.Flux, type=ModelType.Main, format=ModelFormat.BnbQuantizednf4b) +class FluxBnbQuantizednf4bCheckpointModel(ModelLoader): + """Class to load main models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, CheckpointConfigBase): + raise ValueError("Only CheckpointConfigBase models are currently supported here.") + + match submodel_type: + case SubModelType.Transformer: + return self._load_from_singlefile(config) + + raise ValueError( + f"Only Transformer submodels are currently supported. Received: {submodel_type.value if submodel_type else 'None'}" + ) + + def _load_from_singlefile( + self, + config: AnyModelConfig, + ) -> AnyModel: + assert isinstance(config, MainBnbQuantized4bCheckpointConfig) + if not bnb_available: + raise ImportError( + "The bnb modules are not available. Please install bitsandbytes if available on your platform." + ) + model_path = Path(config.path) + + with SilenceWarnings(): + with accelerate.init_empty_weights(): + model = Flux(params[config.config_path]) + model = quantize_model_nf4(model, modules_to_not_convert=set(), compute_dtype=torch.bfloat16) + sd = load_file(model_path) + if "model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.scale" in sd: + sd = convert_bundle_to_flux_transformer_checkpoint(sd) + model.load_state_dict(sd, assign=True) + return model + + +@ModelLoaderRegistry.register(base=BaseModelType.Flux, type=ModelType.ControlNet, format=ModelFormat.Checkpoint) +@ModelLoaderRegistry.register(base=BaseModelType.Flux, type=ModelType.ControlNet, format=ModelFormat.Diffusers) +class FluxControlnetModel(ModelLoader): + """Class to load FLUX ControlNet models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if isinstance(config, ControlNetCheckpointConfig): + model_path = Path(config.path) + elif isinstance(config, ControlNetDiffusersConfig): + # If this is a diffusers directory, we simply ignore the config file and load from the weight file. + model_path = Path(config.path) / "diffusion_pytorch_model.safetensors" + else: + raise ValueError(f"Unexpected ControlNet model config type: {type(config)}") + + sd = load_file(model_path) + + # Detect the FLUX ControlNet model type from the state dict. + if is_state_dict_xlabs_controlnet(sd): + return self._load_xlabs_controlnet(sd) + elif is_state_dict_instantx_controlnet(sd): + return self._load_instantx_controlnet(sd) + else: + raise ValueError("Do not recognize the state dict as an XLabs or InstantX ControlNet model.") + + def _load_xlabs_controlnet(self, sd: dict[str, torch.Tensor]) -> AnyModel: + with accelerate.init_empty_weights(): + # HACK(ryand): Is it safe to assume dev here? + model = XLabsControlNetFlux(params["flux-dev"]) + + model.load_state_dict(sd, assign=True) + return model + + def _load_instantx_controlnet(self, sd: dict[str, torch.Tensor]) -> AnyModel: + sd = convert_diffusers_instantx_state_dict_to_bfl_format(sd) + flux_params = infer_flux_params_from_state_dict(sd) + num_control_modes = infer_instantx_num_control_modes_from_state_dict(sd) + + with accelerate.init_empty_weights(): + model = InstantXControlNetFlux(flux_params, num_control_modes) + + model.load_state_dict(sd, assign=True) + return model + + +@ModelLoaderRegistry.register(base=BaseModelType.Flux, type=ModelType.IPAdapter, format=ModelFormat.Checkpoint) +class FluxIpAdapterModel(ModelLoader): + """Class to load FLUX IP-Adapter models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not isinstance(config, IPAdapterCheckpointConfig): + raise ValueError(f"Unexpected model config type: {type(config)}.") + + sd = load_file(Path(config.path)) + + params = infer_xlabs_ip_adapter_params_from_state_dict(sd) + + with accelerate.init_empty_weights(): + model = XlabsIpAdapterFlux(params=params) + + model.load_xlabs_state_dict(sd, assign=True) + return model diff --git a/invokeai/backend/model_manager/load/model_loaders/generic_diffusers.py b/invokeai/backend/model_manager/load/model_loaders/generic_diffusers.py new file mode 100644 index 0000000000000000000000000000000000000000..4ce51a56d044e99d160bcd825060047c3cf71d70 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/generic_diffusers.py @@ -0,0 +1,104 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for simple diffusers model loading in InvokeAI.""" + +import sys +from pathlib import Path +from typing import Any, Optional + +from diffusers.configuration_utils import ConfigMixin +from diffusers.models.modeling_utils import ModelMixin + +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, + BaseModelType, + InvalidModelConfigException, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.config import DiffusersConfigBase +from invokeai.backend.model_manager.load.load_default import ModelLoader +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.T2IAdapter, format=ModelFormat.Diffusers) +class GenericDiffusersLoader(ModelLoader): + """Class to load simple diffusers models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + model_path = Path(config.path) + 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}") + repo_variant = config.repo_variant if isinstance(config, DiffusersConfigBase) else None + variant = repo_variant.value if repo_variant else None + try: + result: AnyModel = model_class.from_pretrained(model_path, torch_dtype=self._torch_dtype, variant=variant) + except OSError as e: + if variant and "no file named" in str( + e + ): # try without the variant, just in case user's preferences changed + result = model_class.from_pretrained(model_path, torch_dtype=self._torch_dtype) + else: + raise e + return result + + # TO DO: Add exception handling + def get_hf_load_class(self, model_path: Path, submodel_type: Optional[SubModelType] = None) -> ModelMixin: + """Given the model path and submodel, returns the diffusers ModelMixin subclass needed to load.""" + result = None + if submodel_type: + try: + config = self._load_diffusers_config(model_path, config_name="model_index.json") + module, class_name = config[submodel_type.value] + result = self._hf_definition_to_type(module=module, class_name=class_name) + except KeyError as e: + raise InvalidModelConfigException( + f'The "{submodel_type}" submodel is not available for this model.' + ) from e + else: + try: + config = self._load_diffusers_config(model_path, config_name="config.json") + if class_name := config.get("_class_name"): + result = self._hf_definition_to_type(module="diffusers", class_name=class_name) + elif class_name := config.get("architectures"): + result = self._hf_definition_to_type(module="transformers", class_name=class_name[0]) + else: + raise InvalidModelConfigException("Unable to decipher Load Class based on given config.json") + except KeyError as e: + raise InvalidModelConfigException("An expected config.json file is missing from this model.") from e + assert result is not None + return result + + # TO DO: Add exception handling + def _hf_definition_to_type(self, module: str, class_name: str) -> ModelMixin: # fix with correct type + if module in [ + "diffusers", + "transformers", + "invokeai.backend.quantization.fast_quantized_transformers_model", + "invokeai.backend.quantization.fast_quantized_diffusion_model", + ]: + res_type = sys.modules[module] + else: + res_type = sys.modules["diffusers"].pipelines + result: ModelMixin = getattr(res_type, class_name) + return result + + def _load_diffusers_config(self, model_path: Path, config_name: str = "config.json") -> dict[str, Any]: + return ConfigLoader.load_config(model_path, config_name=config_name) + + +class ConfigLoader(ConfigMixin): + """Subclass of ConfigMixin for loading diffusers configuration files.""" + + @classmethod + def load_config(cls, *args: Any, **kwargs: Any) -> dict[str, Any]: # pyright: ignore [reportIncompatibleMethodOverride] + """Load a diffusrs ConfigMixin configuration.""" + cls.config_name = kwargs.pop("config_name") + # TODO(psyche): the types on this diffusers method are not correct + return super().load_config(*args, **kwargs) # type: ignore diff --git a/invokeai/backend/model_manager/load/model_loaders/ip_adapter.py b/invokeai/backend/model_manager/load/model_loaders/ip_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..55eed81fcd55fdc0d3ed0e6ddd1075c98050a56d --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/ip_adapter.py @@ -0,0 +1,33 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for IP Adapter model loading in InvokeAI.""" + +from pathlib import Path +from typing import Optional + +import torch + +from invokeai.backend.ip_adapter.ip_adapter import build_ip_adapter +from invokeai.backend.model_manager import AnyModel, AnyModelConfig, BaseModelType, ModelFormat, ModelType, SubModelType +from invokeai.backend.model_manager.load import ModelLoader, ModelLoaderRegistry +from invokeai.backend.raw_model import RawModel + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.IPAdapter, format=ModelFormat.InvokeAI) +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.IPAdapter, format=ModelFormat.Checkpoint) +class IPAdapterInvokeAILoader(ModelLoader): + """Class to load IP Adapter diffusers models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if submodel_type is not None: + raise ValueError("There are no submodels in an IP-Adapter model.") + model_path = Path(config.path) + model: RawModel = build_ip_adapter( + ip_adapter_ckpt_path=model_path, + device=torch.device("cpu"), + dtype=self._torch_dtype, + ) + return model diff --git a/invokeai/backend/model_manager/load/model_loaders/lora.py b/invokeai/backend/model_manager/load/model_loaders/lora.py new file mode 100644 index 0000000000000000000000000000000000000000..2ff26d5301adf69a7f0aeb330974b1bc6b55d027 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/lora.py @@ -0,0 +1,104 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for LoRA model loading in InvokeAI.""" + +from logging import Logger +from pathlib import Path +from typing import Optional + +import torch +from safetensors.torch import load_file + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.backend.lora.conversions.flux_diffusers_lora_conversion_utils import ( + lora_model_from_flux_diffusers_state_dict, +) +from invokeai.backend.lora.conversions.flux_kohya_lora_conversion_utils import ( + lora_model_from_flux_kohya_state_dict, +) +from invokeai.backend.lora.conversions.sd_lora_conversion_utils import lora_model_from_sd_state_dict +from invokeai.backend.lora.conversions.sdxl_lora_conversion_utils import convert_sdxl_keys_to_diffusers_format +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load.load_default import ModelLoader +from invokeai.backend.model_manager.load.model_cache.model_cache_base import ModelCacheBase +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.LoRA, format=ModelFormat.Diffusers) +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.LoRA, format=ModelFormat.LyCORIS) +class LoRALoader(ModelLoader): + """Class to load LoRA models.""" + + # We cheat a little bit to get access to the model base + def __init__( + self, + app_config: InvokeAIAppConfig, + logger: Logger, + ram_cache: ModelCacheBase[AnyModel], + ): + """Initialize the loader.""" + super().__init__(app_config, logger, ram_cache) + self._model_base: Optional[BaseModelType] = None + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if submodel_type is not None: + raise ValueError("There are no submodels in a LoRA model.") + model_path = Path(config.path) + assert self._model_base is not None + + # Load the state dict from the model file. + if model_path.suffix == ".safetensors": + state_dict = load_file(model_path.absolute().as_posix(), device="cpu") + else: + state_dict = torch.load(model_path, map_location="cpu") + + # Apply state_dict key conversions, if necessary. + if self._model_base == BaseModelType.StableDiffusionXL: + state_dict = convert_sdxl_keys_to_diffusers_format(state_dict) + model = lora_model_from_sd_state_dict(state_dict=state_dict) + elif self._model_base == BaseModelType.Flux: + if config.format == ModelFormat.Diffusers: + # HACK(ryand): We set alpha=None for diffusers PEFT format models. These models are typically + # distributed as a single file without the associated metadata containing the alpha value. We chose + # alpha=None, because this is treated as alpha=rank internally in `LoRALayerBase.scale()`. alpha=rank + # is a popular choice. For example, in the diffusers training scripts: + # https://github.com/huggingface/diffusers/blob/main/examples/dreambooth/train_dreambooth_lora_flux.py#L1194 + model = lora_model_from_flux_diffusers_state_dict(state_dict=state_dict, alpha=None) + elif config.format == ModelFormat.LyCORIS: + model = lora_model_from_flux_kohya_state_dict(state_dict=state_dict) + else: + raise ValueError(f"LoRA model is in unsupported FLUX format: {config.format}") + elif self._model_base in [BaseModelType.StableDiffusion1, BaseModelType.StableDiffusion2]: + # Currently, we don't apply any conversions for SD1 and SD2 LoRA models. + model = lora_model_from_sd_state_dict(state_dict=state_dict) + else: + raise ValueError(f"Unsupported LoRA base model: {self._model_base}") + + model.to(dtype=self._torch_dtype) + return model + + def _get_model_path(self, config: AnyModelConfig) -> Path: + # cheating a little - we remember this variable for using in the subsequent call to _load_model() + self._model_base = config.base + + model_base_path = self._app_config.models_path + model_path = model_base_path / config.path + + if config.format == ModelFormat.Diffusers: + for ext in ["safetensors", "bin"]: # return path to the safetensors file inside the folder + path = model_base_path / config.path / f"pytorch_lora_weights.{ext}" + if path.exists(): + model_path = path + break + + return model_path.resolve() diff --git a/invokeai/backend/model_manager/load/model_loaders/onnx.py b/invokeai/backend/model_manager/load/model_loaders/onnx.py new file mode 100644 index 0000000000000000000000000000000000000000..0a5d8477c483e1d3700a854dad7605917d3ac067 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/onnx.py @@ -0,0 +1,42 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for Onnx model loading in InvokeAI.""" + +# This should work the same as Stable Diffusion pipelines +from pathlib import Path +from typing import Optional + +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry +from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.ONNX, format=ModelFormat.ONNX) +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.ONNX, format=ModelFormat.Olive) +class OnnyxDiffusersModel(GenericDiffusersLoader): + """Class to load onnx models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if not submodel_type is not None: + raise Exception("A submodel type must be provided when loading onnx pipelines.") + model_path = Path(config.path) + load_class = self.get_hf_load_class(model_path, submodel_type) + repo_variant = getattr(config, "repo_variant", None) + variant = repo_variant.value if repo_variant else None + model_path = model_path / submodel_type.value + result: AnyModel = load_class.from_pretrained( + model_path, + torch_dtype=self._torch_dtype, + variant=variant, + ) + return result diff --git a/invokeai/backend/model_manager/load/model_loaders/spandrel_image_to_image.py b/invokeai/backend/model_manager/load/model_loaders/spandrel_image_to_image.py new file mode 100644 index 0000000000000000000000000000000000000000..7a57c5cf5995f6ed4538180d7214fa397638d58a --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/spandrel_image_to_image.py @@ -0,0 +1,45 @@ +from pathlib import Path +from typing import Optional + +import torch + +from invokeai.backend.model_manager.config import ( + AnyModel, + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load.load_default import ModelLoader +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry +from invokeai.backend.spandrel_image_to_image_model import SpandrelImageToImageModel + + +@ModelLoaderRegistry.register( + base=BaseModelType.Any, type=ModelType.SpandrelImageToImage, format=ModelFormat.Checkpoint +) +class SpandrelImageToImageModelLoader(ModelLoader): + """Class for loading Spandrel Image-to-Image models (i.e. models wrapped by spandrel.ImageModelDescriptor).""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if submodel_type is not None: + raise ValueError("Unexpected submodel requested for Spandrel model.") + + model_path = Path(config.path) + model = SpandrelImageToImageModel.load_from_file(model_path) + + torch_dtype = self._torch_dtype + if not model.supports_dtype(torch_dtype): + self._logger.warning( + f"The configured dtype ('{self._torch_dtype}') is not supported by the {model.get_model_type_name()} " + "model. Falling back to 'float32'." + ) + torch_dtype = torch.float32 + model.to(dtype=torch_dtype) + + return model diff --git a/invokeai/backend/model_manager/load/model_loaders/stable_diffusion.py b/invokeai/backend/model_manager/load/model_loaders/stable_diffusion.py new file mode 100644 index 0000000000000000000000000000000000000000..1f57d5c199c75a5769eccce9aa85852b703f8691 --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/stable_diffusion.py @@ -0,0 +1,136 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for StableDiffusion model loading in InvokeAI.""" + +from pathlib import Path +from typing import Optional + +from diffusers import ( + StableDiffusionInpaintPipeline, + StableDiffusionPipeline, + StableDiffusionXLInpaintPipeline, + StableDiffusionXLPipeline, +) + +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, + ModelVariantType, + SubModelType, +) +from invokeai.backend.model_manager.config import ( + CheckpointConfigBase, + DiffusersConfigBase, + MainCheckpointConfig, +) +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry +from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader +from invokeai.backend.util.silence_warnings import SilenceWarnings + +VARIANT_TO_IN_CHANNEL_MAP = { + ModelVariantType.Normal: 4, + ModelVariantType.Depth: 5, + ModelVariantType.Inpaint: 9, +} + + +@ModelLoaderRegistry.register(base=BaseModelType.StableDiffusion1, type=ModelType.Main, format=ModelFormat.Diffusers) +@ModelLoaderRegistry.register(base=BaseModelType.StableDiffusion2, type=ModelType.Main, format=ModelFormat.Diffusers) +@ModelLoaderRegistry.register(base=BaseModelType.StableDiffusionXL, type=ModelType.Main, format=ModelFormat.Diffusers) +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusionXLRefiner, type=ModelType.Main, format=ModelFormat.Diffusers +) +@ModelLoaderRegistry.register(base=BaseModelType.StableDiffusion3, type=ModelType.Main, format=ModelFormat.Diffusers) +@ModelLoaderRegistry.register(base=BaseModelType.StableDiffusion1, type=ModelType.Main, format=ModelFormat.Checkpoint) +@ModelLoaderRegistry.register(base=BaseModelType.StableDiffusion2, type=ModelType.Main, format=ModelFormat.Checkpoint) +@ModelLoaderRegistry.register(base=BaseModelType.StableDiffusionXL, type=ModelType.Main, format=ModelFormat.Checkpoint) +@ModelLoaderRegistry.register( + base=BaseModelType.StableDiffusionXLRefiner, type=ModelType.Main, format=ModelFormat.Checkpoint +) +class StableDiffusionDiffusersModel(GenericDiffusersLoader): + """Class to load main models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if isinstance(config, CheckpointConfigBase): + return self._load_from_singlefile(config, submodel_type) + + if submodel_type is None: + raise Exception("A submodel type must be provided when loading main pipelines.") + + model_path = Path(config.path) + load_class = self.get_hf_load_class(model_path, submodel_type) + repo_variant = config.repo_variant if isinstance(config, DiffusersConfigBase) else None + variant = repo_variant.value if repo_variant else None + model_path = model_path / submodel_type.value + try: + result: AnyModel = load_class.from_pretrained( + model_path, + torch_dtype=self._torch_dtype, + variant=variant, + ) + except OSError as e: + if variant and "no file named" in str( + e + ): # try without the variant, just in case user's preferences changed + result = load_class.from_pretrained(model_path, torch_dtype=self._torch_dtype) + else: + raise e + + return result + + def _load_from_singlefile( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + load_classes = { + BaseModelType.StableDiffusion1: { + ModelVariantType.Normal: StableDiffusionPipeline, + ModelVariantType.Inpaint: StableDiffusionInpaintPipeline, + }, + BaseModelType.StableDiffusion2: { + ModelVariantType.Normal: StableDiffusionPipeline, + ModelVariantType.Inpaint: StableDiffusionInpaintPipeline, + }, + BaseModelType.StableDiffusionXL: { + ModelVariantType.Normal: StableDiffusionXLPipeline, + ModelVariantType.Inpaint: StableDiffusionXLInpaintPipeline, + }, + BaseModelType.StableDiffusionXLRefiner: { + ModelVariantType.Normal: StableDiffusionXLPipeline, + }, + } + assert isinstance(config, MainCheckpointConfig) + try: + load_class = load_classes[config.base][config.variant] + except KeyError as e: + raise Exception(f"No diffusers pipeline known for base={config.base}, variant={config.variant}") from e + + # Without SilenceWarnings we get log messages like this: + # site-packages/huggingface_hub/file_download.py:1132: FutureWarning: `resume_download` is deprecated and will be removed in version 1.0.0. Downloads always resume when possible. If you want to force a new download, use `force_download=True`. + # warnings.warn( + # Some weights of the model checkpoint were not used when initializing CLIPTextModel: + # ['text_model.embeddings.position_ids'] + # Some weights of the model checkpoint were not used when initializing CLIPTextModelWithProjection: + # ['text_model.embeddings.position_ids'] + + with SilenceWarnings(): + pipeline = load_class.from_single_file(config.path, torch_dtype=self._torch_dtype) + + if not submodel_type: + return pipeline + + # Proactively load the various submodels into the RAM cache so that we don't have to re-load + # the entire pipeline every time a new submodel is needed. + for subtype in SubModelType: + if subtype == submodel_type: + continue + if submodel := getattr(pipeline, subtype.value, None): + self._ram_cache.put(config.key, submodel_type=subtype, model=submodel) + return getattr(pipeline, submodel_type.value) diff --git a/invokeai/backend/model_manager/load/model_loaders/textual_inversion.py b/invokeai/backend/model_manager/load/model_loaders/textual_inversion.py new file mode 100644 index 0000000000000000000000000000000000000000..8d0f08f91a50455e68a5f3e69d04c4cb0885b1ff --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/textual_inversion.py @@ -0,0 +1,52 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for TI model loading in InvokeAI.""" + +from pathlib import Path +from typing import Optional + +from invokeai.backend.model_manager import ( + AnyModel, + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, + SubModelType, +) +from invokeai.backend.model_manager.load.load_default import ModelLoader +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry +from invokeai.backend.textual_inversion import TextualInversionModelRaw + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.TextualInversion, format=ModelFormat.EmbeddingFile) +@ModelLoaderRegistry.register( + base=BaseModelType.Any, type=ModelType.TextualInversion, format=ModelFormat.EmbeddingFolder +) +class TextualInversionLoader(ModelLoader): + """Class to load TI models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if submodel_type is not None: + raise ValueError("There are no submodels in a TI model.") + model = TextualInversionModelRaw.from_checkpoint( + file_path=config.path, + dtype=self._torch_dtype, + ) + return model + + # override + def _get_model_path(self, config: AnyModelConfig) -> Path: + model_path = self._app_config.models_path / config.path + + if config.format == ModelFormat.EmbeddingFolder: + path = model_path / "learned_embeds.bin" + else: + path = model_path + + if not path.exists(): + raise OSError(f"The embedding file at {path} was not found") + + return path diff --git a/invokeai/backend/model_manager/load/model_loaders/vae.py b/invokeai/backend/model_manager/load/model_loaders/vae.py new file mode 100644 index 0000000000000000000000000000000000000000..bae29ea77312cda1c6066d416157087ed86e3e6c --- /dev/null +++ b/invokeai/backend/model_manager/load/model_loaders/vae.py @@ -0,0 +1,35 @@ +# Copyright (c) 2024, Lincoln D. Stein and the InvokeAI Development Team +"""Class for VAE model loading in InvokeAI.""" + +from typing import Optional + +from diffusers import AutoencoderKL + +from invokeai.backend.model_manager import ( + AnyModelConfig, + BaseModelType, + ModelFormat, + ModelType, +) +from invokeai.backend.model_manager.config import AnyModel, SubModelType, VAECheckpointConfig +from invokeai.backend.model_manager.load.model_loader_registry import ModelLoaderRegistry +from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import GenericDiffusersLoader + + +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.VAE, format=ModelFormat.Diffusers) +@ModelLoaderRegistry.register(base=BaseModelType.Any, type=ModelType.VAE, format=ModelFormat.Checkpoint) +class VAELoader(GenericDiffusersLoader): + """Class to load VAE models.""" + + def _load_model( + self, + config: AnyModelConfig, + submodel_type: Optional[SubModelType] = None, + ) -> AnyModel: + if isinstance(config, VAECheckpointConfig): + return AutoencoderKL.from_single_file( + config.path, + torch_dtype=self._torch_dtype, + ) + else: + return super()._load_model(config, submodel_type) diff --git a/invokeai/backend/model_manager/load/model_util.py b/invokeai/backend/model_manager/load/model_util.py new file mode 100644 index 0000000000000000000000000000000000000000..f7d20d20c65f904ddb014995a06674bcfe04db8a --- /dev/null +++ b/invokeai/backend/model_manager/load/model_util.py @@ -0,0 +1,161 @@ +# Copyright (c) 2024 The InvokeAI Development Team +"""Various utility functions needed by the loader and caching system.""" + +import json +import logging +from pathlib import Path +from typing import Optional + +import torch +from diffusers.pipelines.pipeline_utils import DiffusionPipeline +from diffusers.schedulers.scheduling_utils import SchedulerMixin +from transformers import CLIPTokenizer, T5Tokenizer, T5TokenizerFast + +from invokeai.backend.image_util.depth_anything.depth_anything_pipeline import DepthAnythingPipeline +from invokeai.backend.image_util.grounding_dino.grounding_dino_pipeline import GroundingDinoPipeline +from invokeai.backend.image_util.segment_anything.segment_anything_pipeline import SegmentAnythingPipeline +from invokeai.backend.ip_adapter.ip_adapter import IPAdapter +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.model_manager.config import AnyModel +from invokeai.backend.onnx.onnx_runtime import IAIOnnxRuntimeModel +from invokeai.backend.spandrel_image_to_image_model import SpandrelImageToImageModel +from invokeai.backend.textual_inversion import TextualInversionModelRaw +from invokeai.backend.util.calc_tensor_size import calc_tensor_size + + +def calc_model_size_by_data(logger: logging.Logger, model: AnyModel) -> int: + """Get size of a model in memory in bytes.""" + # TODO(ryand): We should create a CacheableModel interface for all models, and move the size calculations down to + # the models themselves. + if isinstance(model, DiffusionPipeline): + return _calc_pipeline_by_data(model) + elif isinstance(model, torch.nn.Module): + return calc_module_size(model) + elif isinstance(model, IAIOnnxRuntimeModel): + return _calc_onnx_model_by_data(model) + elif isinstance(model, SchedulerMixin): + return 0 + elif isinstance(model, CLIPTokenizer): + # TODO(ryand): Accurately calculate the tokenizer's size. It's small enough that it shouldn't matter for now. + return 0 + elif isinstance( + model, + ( + TextualInversionModelRaw, + IPAdapter, + LoRAModelRaw, + SpandrelImageToImageModel, + GroundingDinoPipeline, + SegmentAnythingPipeline, + DepthAnythingPipeline, + ), + ): + return model.calc_size() + elif isinstance( + model, + ( + T5TokenizerFast, + T5Tokenizer, + ), + ): + # HACK(ryand): len(model) just returns the vocabulary size, so this is blatantly wrong. It should be small + # relative to the text encoder that it's used with, so shouldn't matter too much, but we should fix this at some + # point. + return len(model) + else: + # TODO(ryand): Promote this from a log to an exception once we are confident that we are handling all of the + # supported model types. + logger.warning( + f"Failed to calculate model size for unexpected model type: {type(model)}. The model will be treated as " + "having size 0." + ) + return 0 + + +def _calc_pipeline_by_data(pipeline: DiffusionPipeline) -> int: + res = 0 + assert hasattr(pipeline, "components") + for submodel_key in pipeline.components.keys(): + submodel = getattr(pipeline, submodel_key) + if submodel is not None and isinstance(submodel, torch.nn.Module): + res += calc_module_size(submodel) + return res + + +def calc_module_size(model: torch.nn.Module) -> int: + """Calculate the size (in bytes) of a torch.nn.Module.""" + mem_params = sum([calc_tensor_size(param) for param in model.parameters()]) + mem_bufs = sum([calc_tensor_size(buf) for buf in model.buffers()]) + return mem_params + mem_bufs + + +def _calc_onnx_model_by_data(model: IAIOnnxRuntimeModel) -> int: + tensor_size = model.tensors.size() * 2 # The session doubles this + mem = tensor_size # in bytes + return mem + + +def calc_model_size_by_fs(model_path: Path, subfolder: Optional[str] = None, variant: Optional[str] = None) -> int: + """Estimate the size of a model on disk in bytes.""" + if model_path.is_file(): + return model_path.stat().st_size + + if subfolder is not None: + model_path = model_path / subfolder + + # this can happen when, for example, the safety checker is not downloaded. + if not model_path.exists(): + return 0 + + all_files = [f for f in model_path.iterdir() if (model_path / f).is_file()] + + fp16_files = {f for f in all_files if ".fp16." in f.name or ".fp16-" in f.name} + bit8_files = {f for f in all_files if ".8bit." in f.name or ".8bit-" in f.name} + other_files = set(all_files) - fp16_files - bit8_files + + if not variant: # ModelRepoVariant.DEFAULT evaluates to empty string for compatability with HF + files = other_files + elif variant == "fp16": + files = fp16_files + elif variant == "8bit": + files = bit8_files + else: + raise NotImplementedError(f"Unknown variant: {variant}") + + # try read from index if exists + index_postfix = ".index.json" + if variant is not None: + index_postfix = f".index.{variant}.json" + + for file in files: + if not file.name.endswith(index_postfix): + continue + try: + with open(model_path / file, "r") as f: + index_data = json.loads(f.read()) + return int(index_data["metadata"]["total_size"]) + except Exception: + pass + + # calculate files size if there is no index file + formats = [ + (".safetensors",), # safetensors + (".bin",), # torch + (".onnx", ".pb"), # onnx + (".msgpack",), # flax + (".ckpt",), # tf + (".h5",), # tf2 + ] + + for file_format in formats: + model_files = [f for f in files if f.suffix in file_format] + if len(model_files) == 0: + continue + + model_size = 0 + for model_file in model_files: + file_stats = (model_path / model_file).stat() + model_size += file_stats.st_size + return model_size + + return 0 # scheduler/feature_extractor/tokenizer - models without loading to gpu diff --git a/invokeai/backend/model_manager/load/optimizations.py b/invokeai/backend/model_manager/load/optimizations.py new file mode 100644 index 0000000000000000000000000000000000000000..030fcfa639a9ed09be45d77a608865403aa927ec --- /dev/null +++ b/invokeai/backend/model_manager/load/optimizations.py @@ -0,0 +1,31 @@ +from contextlib import contextmanager +from typing import Any, Generator + +import torch + + +def _no_op(*args: Any, **kwargs: Any) -> None: + pass + + +@contextmanager +def skip_torch_weight_init() -> Generator[None, None, None]: + """Monkey patch several of the common torch layers (torch.nn.Linear, torch.nn.Conv1d, etc.) to skip weight initialization. + + By default, `torch.nn.Linear` and `torch.nn.ConvNd` layers initialize their weights (according to a particular + distribution) when __init__ is called. This weight initialization step can take a significant amount of time, and is + completely unnecessary if the intent is to load checkpoint weights from disk for the layer. This context manager + monkey-patches common torch layers to skip the weight initialization step. + """ + torch_modules = [torch.nn.Linear, torch.nn.modules.conv._ConvNd, torch.nn.Embedding] + saved_functions = [hasattr(m, "reset_parameters") and m.reset_parameters for m in torch_modules] + + try: + for torch_module in torch_modules: + assert hasattr(torch_module, "reset_parameters") + torch_module.reset_parameters = _no_op + yield None + finally: + for torch_module, saved_function in zip(torch_modules, saved_functions, strict=True): + assert hasattr(torch_module, "reset_parameters") + torch_module.reset_parameters = saved_function diff --git a/invokeai/backend/model_manager/merge.py b/invokeai/backend/model_manager/merge.py new file mode 100644 index 0000000000000000000000000000000000000000..b00bc99f3e2d40bfb2bbb823da5e3f6f6a4dc40c --- /dev/null +++ b/invokeai/backend/model_manager/merge.py @@ -0,0 +1,163 @@ +""" +invokeai.backend.model_manager.merge exports: +merge_diffusion_models() -- combine multiple models by location and return a pipeline object +merge_diffusion_models_and_commit() -- combine multiple models by ModelManager ID and write to the models tables + +Copyright (c) 2023 Lincoln Stein and the InvokeAI Development Team +""" + +import warnings +from enum import Enum +from pathlib import Path +from typing import Any, List, Optional, Set + +import torch +from diffusers import AutoPipelineForText2Image +from diffusers.utils import logging as dlogging + +from invokeai.app.services.model_install import ModelInstallServiceBase +from invokeai.app.services.model_records.model_records_base import ModelRecordChanges +from invokeai.backend.model_manager import AnyModelConfig, BaseModelType, ModelType, ModelVariantType +from invokeai.backend.model_manager.config import MainDiffusersConfig +from invokeai.backend.util.devices import TorchDevice + + +class MergeInterpolationMethod(str, Enum): + WeightedSum = "weighted_sum" + Sigmoid = "sigmoid" + InvSigmoid = "inv_sigmoid" + AddDifference = "add_difference" + + +class ModelMerger(object): + """Wrapper class for model merge function.""" + + def __init__(self, installer: ModelInstallServiceBase): + """ + Initialize a ModelMerger object with the model installer. + """ + self._installer = installer + self._dtype = TorchDevice.choose_torch_dtype() + + def merge_diffusion_models( + self, + model_paths: List[Path], + alpha: float = 0.5, + interp: Optional[MergeInterpolationMethod] = None, + force: bool = False, + variant: Optional[str] = None, + **kwargs: Any, + ) -> Any: # pipe.merge is an untyped function. + """ + :param model_paths: up to three models, designated by their local paths or HuggingFace repo_ids + :param alpha: The interpolation parameter. Ranges from 0 to 1. It affects the ratio in which the checkpoints are merged. A 0.8 alpha + would mean that the first model checkpoints would affect the final result far less than an alpha of 0.2 + :param interp: The interpolation method to use for the merging. Supports "sigmoid", "inv_sigmoid", "add_difference" and None. + Passing None uses the default interpolation which is weighted sum interpolation. For merging three checkpoints, only "add_difference" is supported. + :param force: Whether to ignore mismatch in model_config.json for the current models. Defaults to False. + + **kwargs - the default DiffusionPipeline.get_config_dict kwargs: + cache_dir, resume_download, force_download, proxies, local_files_only, use_auth_token, revision, torch_dtype, device_map + """ + with warnings.catch_warnings(): + warnings.simplefilter("ignore") + verbosity = dlogging.get_verbosity() + dlogging.set_verbosity_error() + dtype = torch.float16 if variant == "fp16" else self._dtype + + # Note that checkpoint_merger will not work with downloaded HuggingFace fp16 models + # until upstream https://github.com/huggingface/diffusers/pull/6670 is merged and released. + pipe = AutoPipelineForText2Image.from_pretrained( + model_paths[0], + custom_pipeline="checkpoint_merger", + torch_dtype=dtype, + variant=variant, + ) # type: ignore + merged_pipe = pipe.merge( + pretrained_model_name_or_path_list=model_paths, + alpha=alpha, + interp=interp.value if interp else None, # diffusers API treats None as "weighted sum" + force=force, + torch_dtype=dtype, + variant=variant, + **kwargs, + ) + dlogging.set_verbosity(verbosity) + return merged_pipe + + def merge_diffusion_models_and_save( + self, + model_keys: List[str], + merged_model_name: str, + alpha: float = 0.5, + force: bool = False, + interp: Optional[MergeInterpolationMethod] = None, + merge_dest_directory: Optional[Path] = None, + variant: Optional[str] = None, + **kwargs: Any, + ) -> AnyModelConfig: + """ + :param models: up to three models, designated by their registered InvokeAI model name + :param merged_model_name: name for new model + :param alpha: The interpolation parameter. Ranges from 0 to 1. It affects the ratio in which the checkpoints are merged. A 0.8 alpha + would mean that the first model checkpoints would affect the final result far less than an alpha of 0.2 + :param interp: The interpolation method to use for the merging. Supports "weighted_average", "sigmoid", "inv_sigmoid", "add_difference" and None. + Passing None uses the default interpolation which is weighted sum interpolation. For merging three checkpoints, only "add_difference" is supported. Add_difference is A+(B-C). + :param force: Whether to ignore mismatch in model_config.json for the current models. Defaults to False. + :param merge_dest_directory: Save the merged model to the designated directory (with 'merged_model_name' appended) + **kwargs - the default DiffusionPipeline.get_config_dict kwargs: + cache_dir, resume_download, force_download, proxies, local_files_only, use_auth_token, revision, torch_dtype, device_map + """ + model_paths: List[Path] = [] + model_names: List[str] = [] + config = self._installer.app_config + store = self._installer.record_store + base_models: Set[BaseModelType] = set() + variant = None if self._installer.app_config.precision == "float32" else "fp16" + + assert ( + len(model_keys) <= 2 or interp == MergeInterpolationMethod.AddDifference + ), "When merging three models, only the 'add_difference' merge method is supported" + + for key in model_keys: + info = store.get_model(key) + model_names.append(info.name) + assert isinstance( + info, MainDiffusersConfig + ), f"{info.name} ({info.key}) is not a diffusers model. It must be optimized before merging" + assert info.variant == ModelVariantType( + "normal" + ), f"{info.name} ({info.key}) is a {info.variant} model, which cannot currently be merged" + + # tally base models used + base_models.add(info.base) + model_paths.extend([config.models_path / info.path]) + + assert len(base_models) == 1, f"All models to merge must have same base model, but found bases {base_models}" + base_model = base_models.pop() + + merge_method = None if interp == "weighted_sum" else MergeInterpolationMethod(interp) + merged_pipe = self.merge_diffusion_models(model_paths, alpha, merge_method, force, variant=variant, **kwargs) + dump_path = ( + Path(merge_dest_directory) + if merge_dest_directory + else config.models_path / base_model.value / ModelType.Main.value + ) + dump_path.mkdir(parents=True, exist_ok=True) + dump_path = dump_path / merged_model_name + + dtype = torch.float16 if variant == "fp16" else self._dtype + merged_pipe.save_pretrained(dump_path.as_posix(), safe_serialization=True, torch_dtype=dtype, variant=variant) + + # register model and get its unique key + key = self._installer.register_path(dump_path) + + # update model's config + model_config = self._installer.record_store.get_model(key) + model_config.name = merged_model_name + model_config.description = f"Merge of models {', '.join(model_names)}" + + self._installer.record_store.update_model( + key, ModelRecordChanges(name=model_config.name, description=model_config.description) + ) + return model_config diff --git a/invokeai/backend/model_manager/metadata/__init__.py b/invokeai/backend/model_manager/metadata/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..76da268153ae8df3b83eecba55abc37699746e24 --- /dev/null +++ b/invokeai/backend/model_manager/metadata/__init__.py @@ -0,0 +1,40 @@ +""" +Initialization file for invokeai.backend.model_manager.metadata + +Usage: + +from invokeai.backend.model_manager.metadata import( + AnyModelRepoMetadata, + CommercialUsage, + LicenseRestrictions, + HuggingFaceMetadata, +) + +from invokeai.backend.model_manager.metadata.fetch import HuggingFaceMetadataFetch + +data = HuggingFaceMetadataFetch().from_id("") +assert isinstance(data, HuggingFaceMetadata) +""" + +from invokeai.backend.model_manager.metadata.fetch import HuggingFaceMetadataFetch, ModelMetadataFetchBase +from invokeai.backend.model_manager.metadata.metadata_base import ( + AnyModelRepoMetadata, + AnyModelRepoMetadataValidator, + BaseMetadata, + HuggingFaceMetadata, + ModelMetadataWithFiles, + RemoteModelFile, + UnknownMetadataException, +) + +__all__ = [ + "AnyModelRepoMetadata", + "AnyModelRepoMetadataValidator", + "HuggingFaceMetadata", + "HuggingFaceMetadataFetch", + "ModelMetadataFetchBase", + "BaseMetadata", + "ModelMetadataWithFiles", + "RemoteModelFile", + "UnknownMetadataException", +] diff --git a/invokeai/backend/model_manager/metadata/fetch/__init__.py b/invokeai/backend/model_manager/metadata/fetch/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..62b3dc4d5406580c6082d5afd299c948c8b2cdd6 --- /dev/null +++ b/invokeai/backend/model_manager/metadata/fetch/__init__.py @@ -0,0 +1,16 @@ +""" +Initialization file for invokeai.backend.model_manager.metadata.fetch + +Usage: +from invokeai.backend.model_manager.metadata.fetch import ( + HuggingFaceMetadataFetch, +) + +data = HuggingFaceMetadataFetch().from_id("") +assert isinstance(data, HuggingFaceMetadata) +""" + +from invokeai.backend.model_manager.metadata.fetch.fetch_base import ModelMetadataFetchBase +from invokeai.backend.model_manager.metadata.fetch.huggingface import HuggingFaceMetadataFetch + +__all__ = ["ModelMetadataFetchBase", "HuggingFaceMetadataFetch"] diff --git a/invokeai/backend/model_manager/metadata/fetch/fetch_base.py b/invokeai/backend/model_manager/metadata/fetch/fetch_base.py new file mode 100644 index 0000000000000000000000000000000000000000..b86a029b3e4a90c2d3dc1eb589c5245a7db2f8fd --- /dev/null +++ b/invokeai/backend/model_manager/metadata/fetch/fetch_base.py @@ -0,0 +1,68 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team + +""" +This module is the base class for subclasses that fetch metadata from model repositories + +Usage: + +from invokeai.backend.model_manager.metadata.fetch import HuggingFaceMetadataFetch + +data = HuggingFaceMetadataFetch().from_id("") +assert isinstance(data, HuggingFaceMetadata) +""" + +from abc import ABC, abstractmethod +from typing import Optional + +from pydantic.networks import AnyHttpUrl +from requests.sessions import Session + +from invokeai.backend.model_manager import ModelRepoVariant +from invokeai.backend.model_manager.metadata.metadata_base import ( + AnyModelRepoMetadata, + AnyModelRepoMetadataValidator, + BaseMetadata, +) + + +class ModelMetadataFetchBase(ABC): + """Fetch metadata from remote generative model repositories.""" + + @abstractmethod + def __init__(self, session: Optional[Session] = None): + """ + Initialize the fetcher with an optional requests.sessions.Session object. + + By providing a configurable Session object, we can support unit tests on + this module without an internet connection. + """ + pass + + @abstractmethod + def from_url(self, url: AnyHttpUrl) -> AnyModelRepoMetadata: + """ + Given a URL to a model repository, return a ModelMetadata object. + + This method will raise a `UnknownMetadataException` + in the event that the requested model metadata is not found at the provided location. + """ + pass + + @abstractmethod + def from_id(self, id: str, variant: Optional[ModelRepoVariant] = None) -> AnyModelRepoMetadata: + """ + Given an ID for a model, return a ModelMetadata object. + + :param id: An ID. + :param variant: A model variant from the ModelRepoVariant enum. + + This method will raise a `UnknownMetadataException` + in the event that the requested model's metadata is not found at the provided id. + """ + pass + + @classmethod + def from_json(cls, json: str) -> AnyModelRepoMetadata: + """Given the JSON representation of the metadata, return the corresponding Pydantic object.""" + metadata: BaseMetadata = AnyModelRepoMetadataValidator.validate_json(json) # type: ignore + return metadata diff --git a/invokeai/backend/model_manager/metadata/fetch/huggingface.py b/invokeai/backend/model_manager/metadata/fetch/huggingface.py new file mode 100644 index 0000000000000000000000000000000000000000..8c1d6e74aea5f5b2f890074e28cb379e8a2d34c5 --- /dev/null +++ b/invokeai/backend/model_manager/metadata/fetch/huggingface.py @@ -0,0 +1,133 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team + +""" +This module fetches model metadata objects from the HuggingFace model repository, +using either a `repo_id` or the model page URL. + +Usage: + +from invokeai.backend.model_manager.metadata.fetch import HuggingFaceMetadataFetch + +fetcher = HuggingFaceMetadataFetch() +metadata = fetcher.from_url("https://huggingface.co/stabilityai/sdxl-turbo") +print(metadata.tags) +""" + +import json +import re +from pathlib import Path +from typing import Optional + +import requests +from huggingface_hub import HfApi, configure_http_backend, hf_hub_url +from huggingface_hub.errors import RepositoryNotFoundError, RevisionNotFoundError +from pydantic.networks import AnyHttpUrl +from requests.sessions import Session + +from invokeai.backend.model_manager.config import ModelRepoVariant +from invokeai.backend.model_manager.metadata.fetch.fetch_base import ModelMetadataFetchBase +from invokeai.backend.model_manager.metadata.metadata_base import ( + AnyModelRepoMetadata, + HuggingFaceMetadata, + RemoteModelFile, + UnknownMetadataException, +) + +HF_MODEL_RE = r"https?://huggingface.co/([\w\-.]+/[\w\-.]+)" + + +class HuggingFaceMetadataFetch(ModelMetadataFetchBase): + """Fetch model metadata from HuggingFace.""" + + def __init__(self, session: Optional[Session] = None): + """ + Initialize the fetcher with an optional requests.sessions.Session object. + + By providing a configurable Session object, we can support unit tests on + this module without an internet connection. + """ + self._requests = session or requests.Session() + configure_http_backend(backend_factory=lambda: self._requests) + + @classmethod + def from_json(cls, json: str) -> HuggingFaceMetadata: + """Given the JSON representation of the metadata, return the corresponding Pydantic object.""" + metadata = HuggingFaceMetadata.model_validate_json(json) + return metadata + + def from_id(self, id: str, variant: Optional[ModelRepoVariant] = None) -> AnyModelRepoMetadata: + """Return a HuggingFaceMetadata object given the model's repo_id.""" + # Little loop which tries fetching a revision corresponding to the selected variant. + # If not available, then set variant to None and get the default. + # If this too fails, raise exception. + + model_info = None + while not model_info: + try: + model_info = HfApi().model_info(repo_id=id, files_metadata=True, revision=variant) + except RepositoryNotFoundError as excp: + raise UnknownMetadataException(f"'{id}' not found. See trace for details.") from excp + except RevisionNotFoundError: + if variant is None: + raise + else: + variant = None + + files: list[RemoteModelFile] = [] + + _, name = id.split("/") + + for s in model_info.siblings or []: + assert s.rfilename is not None + assert s.size is not None + files.append( + RemoteModelFile( + url=hf_hub_url(id, s.rfilename, revision=variant or "main"), + path=Path(name, s.rfilename), + size=s.size, + sha256=s.lfs.get("sha256") if s.lfs else None, + ) + ) + + # diffusers models have a `model_index.json` or `config.json` file + is_diffusers = any(str(f.url).endswith(("model_index.json", "config.json")) for f in files) + + # These URLs will be exposed to the user - I think these are the only file types we fully support + ckpt_urls = ( + None + if is_diffusers + else [ + f.url + for f in files + if str(f.url).endswith( + ( + ".safetensors", + ".bin", + ".pth", + ".pt", + ".ckpt", + ) + ) + ] + ) + + return HuggingFaceMetadata( + id=model_info.id, + name=name, + files=files, + api_response=json.dumps(model_info.__dict__, default=str), + is_diffusers=is_diffusers, + ckpt_urls=ckpt_urls, + ) + + def from_url(self, url: AnyHttpUrl) -> AnyModelRepoMetadata: + """ + Return a HuggingFaceMetadata object given the model's web page URL. + + In the case of an invalid or missing URL, raises a ModelNotFound exception. + """ + if match := re.match(HF_MODEL_RE, str(url), re.IGNORECASE): + repo_id = match.group(1) + return self.from_id(repo_id) + else: + raise UnknownMetadataException(f"'{url}' does not look like a HuggingFace model page") diff --git a/invokeai/backend/model_manager/metadata/metadata_base.py b/invokeai/backend/model_manager/metadata/metadata_base.py new file mode 100644 index 0000000000000000000000000000000000000000..97fc598380090d847270d8219b20a76d651b1eb9 --- /dev/null +++ b/invokeai/backend/model_manager/metadata/metadata_base.py @@ -0,0 +1,130 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team + +"""This module defines core text-to-image model metadata fields. + +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. + +Note that the "name" and "description" are also present in `config` +records. This is intentional. The config record fields are intended to +be editable by the user as a form of customization. The metadata +versions of these fields are intended to be kept in sync with the +remote repo. +""" + +from pathlib import Path +from typing import List, Literal, Optional, Union + +from huggingface_hub import configure_http_backend, hf_hub_url +from pydantic import BaseModel, Field, TypeAdapter +from pydantic.networks import AnyHttpUrl +from requests.sessions import Session +from typing_extensions import Annotated + +from invokeai.backend.model_manager import ModelRepoVariant +from invokeai.backend.model_manager.util.select_hf_files import filter_files + + +class UnknownMetadataException(Exception): + """Raised when no metadata is available for a model.""" + + +class RemoteModelFile(BaseModel): + """Information about a downloadable file that forms part of a model.""" + + url: AnyHttpUrl = Field(description="The url to download this model file") + path: Path = Field(description="The path to the file, relative to the model root") + size: Optional[int] = Field(description="The size of this file, in bytes", default=0) + sha256: Optional[str] = Field(description="SHA256 hash of this model (not always available)", default=None) + + def __hash__(self) -> int: + return hash(str(self)) + + +class ModelMetadataBase(BaseModel): + """Base class for model metadata information.""" + + name: str = Field(description="model's name") + + +class BaseMetadata(ModelMetadataBase): + """Adds typing data for discriminated union.""" + + type: Literal["basemetadata"] = "basemetadata" + + +class ModelMetadataWithFiles(ModelMetadataBase): + """Base class for metadata that contains a list of downloadable model file(s).""" + + files: List[RemoteModelFile] = Field(description="model files and their sizes", default_factory=list) + + def download_urls( + self, + variant: Optional[ModelRepoVariant] = None, + subfolder: Optional[Path] = None, + session: Optional[Session] = None, + ) -> List[RemoteModelFile]: + """ + Return a list of URLs needed to download the model. + + :param variant: Return files needed to reconstruct the indicated variant (e.g. ModelRepoVariant('fp16')) + :param subfolder: Return files in the designated subfolder only + :param session: A request.Session object for offline testing + + Note that the "variant" and "subfolder" concepts currently only apply to HuggingFace. + However Civitai does have fields for the precision and format of its models, and may + provide variant selection criteria in the future. + """ + return self.files + + +class HuggingFaceMetadata(ModelMetadataWithFiles): + """Extended metadata fields provided by HuggingFace.""" + + type: Literal["huggingface"] = "huggingface" + id: str = Field(description="The HF model id") + api_response: Optional[str] = Field(description="Response from the HF API as stringified JSON", default=None) + is_diffusers: bool = Field(description="Whether the metadata is for a Diffusers format model", default=False) + ckpt_urls: Optional[List[AnyHttpUrl]] = Field( + description="URLs for all checkpoint format models in the metadata", default=None + ) + + def download_urls( + self, + variant: Optional[ModelRepoVariant] = None, + subfolder: Optional[Path] = None, + session: Optional[Session] = None, + ) -> List[RemoteModelFile]: + """ + Return list of downloadable files, filtering by variant and subfolder, if any. + + :param variant: Return model files needed to reconstruct the indicated variant + :param subfolder: Return model files from the designated subfolder only + :param session: A request.Session object used for internet-free testing + + Note that there is special variant-filtering behavior here: + When the fp16 variant is requested and not available, the + full-precision model is returned. + """ + session = session or Session() + configure_http_backend(backend_factory=lambda: session) # used in testing + + paths = filter_files([x.path for x in self.files], variant, subfolder) # all files in the model + prefix = f"{subfolder}/" if subfolder else "" + # the next step reads model_index.json to determine which subdirectories belong + # to the model + if Path(f"{prefix}model_index.json") in paths: + url = hf_hub_url(self.id, filename="model_index.json", subfolder=str(subfolder) if subfolder else None) + resp = session.get(url) + resp.raise_for_status() + submodels = resp.json() + paths = [Path(subfolder or "", x) for x in paths if Path(x).parent.as_posix() in submodels] + paths.insert(0, Path(f"{prefix}model_index.json")) + + return [x for x in self.files if x.path in paths] + + +AnyModelRepoMetadata = Annotated[Union[BaseMetadata, HuggingFaceMetadata], Field(discriminator="type")] +AnyModelRepoMetadataValidator = TypeAdapter(AnyModelRepoMetadata) diff --git a/invokeai/backend/model_manager/probe.py b/invokeai/backend/model_manager/probe.py new file mode 100644 index 0000000000000000000000000000000000000000..a2c762b8c8d3817059c6a6d8bc01c551d4494f3a --- /dev/null +++ b/invokeai/backend/model_manager/probe.py @@ -0,0 +1,1055 @@ +import json +import re +from pathlib import Path +from typing import Any, Callable, Dict, Literal, Optional, Union + +import safetensors.torch +import spandrel +import torch +from picklescan.scanner import scan_file_path + +import invokeai.backend.util.logging as logger +from invokeai.app.util.misc import uuid_string +from invokeai.backend.flux.controlnet.state_dict_utils import ( + is_state_dict_instantx_controlnet, + is_state_dict_xlabs_controlnet, +) +from invokeai.backend.flux.ip_adapter.state_dict_utils import is_state_dict_xlabs_ip_adapter +from invokeai.backend.lora.conversions.flux_diffusers_lora_conversion_utils import ( + is_state_dict_likely_in_flux_diffusers_format, +) +from invokeai.backend.lora.conversions.flux_kohya_lora_conversion_utils import is_state_dict_likely_in_flux_kohya_format +from invokeai.backend.model_hash.model_hash import HASHING_ALGORITHMS, ModelHash +from invokeai.backend.model_manager.config import ( + AnyModelConfig, + AnyVariant, + BaseModelType, + ControlAdapterDefaultSettings, + InvalidModelConfigException, + MainModelDefaultSettings, + ModelConfigFactory, + ModelFormat, + ModelRepoVariant, + ModelSourceType, + ModelType, + ModelVariantType, + SchedulerPredictionType, + SubmodelDefinition, + SubModelType, +) +from invokeai.backend.model_manager.load.model_loaders.generic_diffusers import ConfigLoader +from invokeai.backend.model_manager.util.model_util import ( + get_clip_variant_type, + lora_token_vector_length, + read_checkpoint_meta, +) +from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor +from invokeai.backend.quantization.gguf.loaders import gguf_sd_loader +from invokeai.backend.spandrel_image_to_image_model import SpandrelImageToImageModel +from invokeai.backend.util.silence_warnings import SilenceWarnings + +CkptType = Dict[str | int, Any] + +LEGACY_CONFIGS: Dict[BaseModelType, Dict[ModelVariantType, Union[str, Dict[SchedulerPredictionType, str]]]] = { + BaseModelType.StableDiffusion1: { + ModelVariantType.Normal: { + SchedulerPredictionType.Epsilon: "v1-inference.yaml", + SchedulerPredictionType.VPrediction: "v1-inference-v.yaml", + }, + ModelVariantType.Inpaint: "v1-inpainting-inference.yaml", + }, + BaseModelType.StableDiffusion2: { + ModelVariantType.Normal: { + SchedulerPredictionType.Epsilon: "v2-inference.yaml", + SchedulerPredictionType.VPrediction: "v2-inference-v.yaml", + }, + ModelVariantType.Inpaint: { + SchedulerPredictionType.Epsilon: "v2-inpainting-inference.yaml", + SchedulerPredictionType.VPrediction: "v2-inpainting-inference-v.yaml", + }, + ModelVariantType.Depth: "v2-midas-inference.yaml", + }, + BaseModelType.StableDiffusionXL: { + ModelVariantType.Normal: "sd_xl_base.yaml", + ModelVariantType.Inpaint: "sd_xl_inpaint.yaml", + }, + BaseModelType.StableDiffusionXLRefiner: { + ModelVariantType.Normal: "sd_xl_refiner.yaml", + }, +} + + +class ProbeBase(object): + """Base class for probes.""" + + def __init__(self, model_path: Path): + self.model_path = model_path + + def get_base_type(self) -> BaseModelType: + """Get model base type.""" + raise NotImplementedError + + def get_format(self) -> ModelFormat: + """Get model file format.""" + raise NotImplementedError + + def get_variant_type(self) -> Optional[ModelVariantType]: + """Get model variant type.""" + return None + + def get_scheduler_prediction_type(self) -> Optional[SchedulerPredictionType]: + """Get model scheduler prediction type.""" + return None + + def get_image_encoder_model_id(self) -> Optional[str]: + """Get image encoder (IP adapters only).""" + return None + + +class ModelProbe(object): + PROBES: Dict[str, Dict[ModelType, type[ProbeBase]]] = { + "diffusers": {}, + "checkpoint": {}, + "onnx": {}, + } + + CLASS2TYPE = { + "FluxPipeline": ModelType.Main, + "StableDiffusionPipeline": ModelType.Main, + "StableDiffusionInpaintPipeline": ModelType.Main, + "StableDiffusionXLPipeline": ModelType.Main, + "StableDiffusionXLImg2ImgPipeline": ModelType.Main, + "StableDiffusionXLInpaintPipeline": ModelType.Main, + "StableDiffusion3Pipeline": ModelType.Main, + "LatentConsistencyModelPipeline": ModelType.Main, + "AutoencoderKL": ModelType.VAE, + "AutoencoderTiny": ModelType.VAE, + "ControlNetModel": ModelType.ControlNet, + "CLIPVisionModelWithProjection": ModelType.CLIPVision, + "T2IAdapter": ModelType.T2IAdapter, + "CLIPModel": ModelType.CLIPEmbed, + "CLIPTextModel": ModelType.CLIPEmbed, + "T5EncoderModel": ModelType.T5Encoder, + "FluxControlNetModel": ModelType.ControlNet, + "SD3Transformer2DModel": ModelType.Main, + "CLIPTextModelWithProjection": ModelType.CLIPEmbed, + } + + TYPE2VARIANT: Dict[ModelType, Callable[[str], Optional[AnyVariant]]] = {ModelType.CLIPEmbed: get_clip_variant_type} + + @classmethod + def register_probe( + cls, format: Literal["diffusers", "checkpoint", "onnx"], model_type: ModelType, probe_class: type[ProbeBase] + ) -> None: + cls.PROBES[format][model_type] = probe_class + + @classmethod + def probe( + cls, model_path: Path, fields: Optional[Dict[str, Any]] = None, hash_algo: HASHING_ALGORITHMS = "blake3_single" + ) -> AnyModelConfig: + """ + Probe the model at model_path and return its configuration record. + + :param model_path: Path to the model file (checkpoint) or directory (diffusers). + :param fields: An optional dictionary that can be used to override probed + fields. Typically used for fields that don't probe well, such as prediction_type. + + Returns: The appropriate model configuration derived from ModelConfigBase. + """ + if fields is None: + fields = {} + + model_path = model_path.resolve() + + format_type = ModelFormat.Diffusers if model_path.is_dir() else ModelFormat.Checkpoint + model_info = None + model_type = ModelType(fields["type"]) if "type" in fields and fields["type"] else None + if not model_type: + if format_type is ModelFormat.Diffusers: + model_type = cls.get_model_type_from_folder(model_path) + else: + model_type = cls.get_model_type_from_checkpoint(model_path) + format_type = ModelFormat.ONNX if model_type == ModelType.ONNX else format_type + + probe_class = cls.PROBES[format_type].get(model_type) + if not probe_class: + raise InvalidModelConfigException(f"Unhandled combination of {format_type} and {model_type}") + + probe = probe_class(model_path) + + fields["source_type"] = fields.get("source_type") or ModelSourceType.Path + fields["source"] = fields.get("source") or model_path.as_posix() + fields["key"] = fields.get("key", uuid_string()) + fields["path"] = model_path.as_posix() + fields["type"] = fields.get("type") or model_type + fields["base"] = fields.get("base") or probe.get_base_type() + variant_func = cls.TYPE2VARIANT.get(fields["type"], None) + fields["variant"] = ( + fields.get("variant") or (variant_func and variant_func(model_path.as_posix())) or probe.get_variant_type() + ) + fields["prediction_type"] = fields.get("prediction_type") or probe.get_scheduler_prediction_type() + fields["image_encoder_model_id"] = fields.get("image_encoder_model_id") or probe.get_image_encoder_model_id() + fields["name"] = fields.get("name") or cls.get_model_name(model_path) + fields["description"] = ( + fields.get("description") or f"{fields['base'].value} {model_type.value} model {fields['name']}" + ) + fields["format"] = ModelFormat(fields.get("format")) if "format" in fields else probe.get_format() + fields["hash"] = fields.get("hash") or ModelHash(algorithm=hash_algo).hash(model_path) + + fields["default_settings"] = fields.get("default_settings") + + if not fields["default_settings"]: + if fields["type"] in {ModelType.ControlNet, ModelType.T2IAdapter}: + fields["default_settings"] = get_default_settings_controlnet_t2i_adapter(fields["name"]) + elif fields["type"] is ModelType.Main: + fields["default_settings"] = get_default_settings_main(fields["base"]) + + if format_type == ModelFormat.Diffusers and isinstance(probe, FolderProbeBase): + fields["repo_variant"] = fields.get("repo_variant") or probe.get_repo_variant() + + # additional fields needed for main and controlnet models + if fields["type"] in [ModelType.Main, ModelType.ControlNet, ModelType.VAE] and fields["format"] in [ + ModelFormat.Checkpoint, + ModelFormat.BnbQuantizednf4b, + ModelFormat.GGUFQuantized, + ]: + ckpt_config_path = cls._get_checkpoint_config_path( + model_path, + model_type=fields["type"], + base_type=fields["base"], + variant_type=fields["variant"], + prediction_type=fields["prediction_type"], + ) + fields["config_path"] = str(ckpt_config_path) + + # additional fields needed for main non-checkpoint models + elif fields["type"] == ModelType.Main and fields["format"] in [ + ModelFormat.ONNX, + ModelFormat.Olive, + ModelFormat.Diffusers, + ]: + fields["upcast_attention"] = fields.get("upcast_attention") or ( + fields["base"] == BaseModelType.StableDiffusion2 + and fields["prediction_type"] == SchedulerPredictionType.VPrediction + ) + + get_submodels = getattr(probe, "get_submodels", None) + if fields["base"] == BaseModelType.StableDiffusion3 and callable(get_submodels): + fields["submodels"] = get_submodels() + + model_info = ModelConfigFactory.make_config(fields) # , key=fields.get("key", None)) + return model_info + + @classmethod + def get_model_name(cls, model_path: Path) -> str: + if model_path.suffix in {".safetensors", ".bin", ".pt", ".ckpt"}: + return model_path.stem + else: + return model_path.name + + @classmethod + def get_model_type_from_checkpoint(cls, model_path: Path, checkpoint: Optional[CkptType] = None) -> ModelType: + if model_path.suffix not in (".bin", ".pt", ".ckpt", ".safetensors", ".pth", ".gguf"): + raise InvalidModelConfigException(f"{model_path}: unrecognized suffix") + + if model_path.name == "learned_embeds.bin": + return ModelType.TextualInversion + + ckpt = checkpoint if checkpoint else read_checkpoint_meta(model_path, scan=True) + ckpt = ckpt.get("state_dict", ckpt) + + for key in [str(k) for k in ckpt.keys()]: + if key.startswith( + ( + "cond_stage_model.", + "first_stage_model.", + "model.diffusion_model.", + # Some FLUX checkpoint files contain transformer keys prefixed with "model.diffusion_model". + # This prefix is typically used to distinguish between multiple models bundled in a single file. + "model.diffusion_model.double_blocks.", + ) + ): + # Keys starting with double_blocks are associated with Flux models + return ModelType.Main + # FLUX models in the official BFL format contain keys with the "double_blocks." prefix, but we must be + # careful to avoid false positives on XLabs FLUX IP-Adapter models. + elif key.startswith("double_blocks.") and "ip_adapter" not in key: + return ModelType.Main + elif key.startswith(("encoder.conv_in", "decoder.conv_in")): + return ModelType.VAE + elif key.startswith(("lora_te_", "lora_unet_")): + return ModelType.LoRA + # "lora_A.weight" and "lora_B.weight" are associated with models in PEFT format. We don't support all PEFT + # LoRA models, but as of the time of writing, we support Diffusers FLUX PEFT LoRA models. + elif key.endswith(("to_k_lora.up.weight", "to_q_lora.down.weight", "lora_A.weight", "lora_B.weight")): + return ModelType.LoRA + elif key.startswith( + ( + "controlnet", + "control_model", + "input_blocks", + # XLabs FLUX ControlNet models have keys starting with "controlnet_blocks." + # For example: https://huggingface.co/XLabs-AI/flux-controlnet-collections/blob/86ab1e915a389d5857135c00e0d350e9e38a9048/flux-canny-controlnet_v2.safetensors + # TODO(ryand): This is very fragile. XLabs FLUX ControlNet models also contain keys starting with + # "double_blocks.", which we check for above. But, I'm afraid to modify this logic because it is so + # delicate. + "controlnet_blocks", + ) + ): + return ModelType.ControlNet + elif key.startswith( + ( + "image_proj.", + "ip_adapter.", + # XLabs FLUX IP-Adapter models have keys startinh with "ip_adapter_proj_model.". + "ip_adapter_proj_model.", + ) + ): + return ModelType.IPAdapter + elif key in {"emb_params", "string_to_param"}: + return ModelType.TextualInversion + + # diffusers-ti + if len(ckpt) < 10 and all(isinstance(v, torch.Tensor) for v in ckpt.values()): + return ModelType.TextualInversion + + # Check if the model can be loaded as a SpandrelImageToImageModel. + # This check is intentionally performed last, as it can be expensive (it requires loading the model from disk). + try: + # It would be nice to avoid having to load the Spandrel model from disk here. A couple of options were + # explored to avoid this: + # 1. Call `SpandrelImageToImageModel.load_from_state_dict(ckpt)`, where `ckpt` is a state_dict on the meta + # device. Unfortunately, some Spandrel models perform operations during initialization that are not + # supported on meta tensors. + # 2. Spandrel has internal logic to determine a model's type from its state_dict before loading the model. + # This logic is not exposed in spandrel's public API. We could copy the logic here, but then we have to + # maintain it, and the risk of false positive detections is higher. + SpandrelImageToImageModel.load_from_file(model_path) + return ModelType.SpandrelImageToImage + except spandrel.UnsupportedModelError: + pass + except Exception as e: + logger.warning( + f"Encountered error while probing to determine if {model_path} is a Spandrel model. Ignoring. Error: {e}" + ) + + raise InvalidModelConfigException(f"Unable to determine model type for {model_path}") + + @classmethod + def get_model_type_from_folder(cls, folder_path: Path) -> ModelType: + """Get the model type of a hugging-face style folder.""" + class_name = None + error_hint = None + for suffix in ["bin", "safetensors"]: + if (folder_path / f"learned_embeds.{suffix}").exists(): + return ModelType.TextualInversion + if (folder_path / f"pytorch_lora_weights.{suffix}").exists(): + return ModelType.LoRA + if (folder_path / "unet/model.onnx").exists(): + return ModelType.ONNX + if (folder_path / "image_encoder.txt").exists(): + return ModelType.IPAdapter + + config_path = None + for p in [ + folder_path / "model_index.json", # pipeline + folder_path / "config.json", # most diffusers + folder_path / "text_encoder_2" / "config.json", # T5 text encoder + folder_path / "text_encoder" / "config.json", # T5 CLIP + ]: + if p.exists(): + config_path = p + break + + if config_path: + with open(config_path, "r") as file: + conf = json.load(file) + if "_class_name" in conf: + class_name = conf["_class_name"] + elif "architectures" in conf: + class_name = conf["architectures"][0] + else: + class_name = None + else: + error_hint = f"No model_index.json or config.json found in {folder_path}." + + if class_name and (type := cls.CLASS2TYPE.get(class_name)): + return type + else: + error_hint = f"class {class_name} is not one of the supported classes [{', '.join(cls.CLASS2TYPE.keys())}]" + + # give up + raise InvalidModelConfigException( + f"Unable to determine model type for {folder_path}" + (f"; {error_hint}" if error_hint else "") + ) + + @classmethod + def _get_checkpoint_config_path( + cls, + model_path: Path, + model_type: ModelType, + base_type: BaseModelType, + variant_type: ModelVariantType, + prediction_type: SchedulerPredictionType, + ) -> Path: + # look for a YAML file adjacent to the model file first + possible_conf = model_path.with_suffix(".yaml") + if possible_conf.exists(): + return possible_conf.absolute() + + if model_type is ModelType.Main: + if base_type == BaseModelType.Flux: + # TODO: Decide between dev/schnell + checkpoint = ModelProbe._scan_and_load_checkpoint(model_path) + state_dict = checkpoint.get("state_dict") or checkpoint + if ( + "guidance_in.out_layer.weight" in state_dict + or "model.diffusion_model.guidance_in.out_layer.weight" in state_dict + ): + # For flux, this is a key in invokeai.backend.flux.util.params + # Due to model type and format being the descriminator for model configs this + # is used rather than attempting to support flux with separate model types and format + # If changed in the future, please fix me + config_file = "flux-dev" + else: + # For flux, this is a key in invokeai.backend.flux.util.params + # Due to model type and format being the discriminator for model configs this + # is used rather than attempting to support flux with separate model types and format + # If changed in the future, please fix me + config_file = "flux-schnell" + else: + config_file = LEGACY_CONFIGS[base_type][variant_type] + if isinstance(config_file, dict): # need another tier for sd-2.x models + config_file = config_file[prediction_type] + config_file = f"stable-diffusion/{config_file}" + elif model_type is ModelType.ControlNet: + config_file = ( + "controlnet/cldm_v15.yaml" + if base_type is BaseModelType.StableDiffusion1 + else "controlnet/cldm_v21.yaml" + ) + elif model_type is ModelType.VAE: + config_file = ( + # For flux, this is a key in invokeai.backend.flux.util.ae_params + # Due to model type and format being the descriminator for model configs this + # is used rather than attempting to support flux with separate model types and format + # If changed in the future, please fix me + "flux" + if base_type is BaseModelType.Flux + else "stable-diffusion/v1-inference.yaml" + if base_type is BaseModelType.StableDiffusion1 + else "stable-diffusion/sd_xl_base.yaml" + if base_type is BaseModelType.StableDiffusionXL + else "stable-diffusion/v2-inference.yaml" + ) + else: + raise InvalidModelConfigException( + f"{model_path}: Unrecognized combination of model_type={model_type}, base_type={base_type}" + ) + return Path(config_file) + + @classmethod + def _scan_and_load_checkpoint(cls, model_path: Path) -> CkptType: + with SilenceWarnings(): + if model_path.suffix.endswith((".ckpt", ".pt", ".pth", ".bin")): + cls._scan_model(model_path.name, model_path) + model = torch.load(model_path, map_location="cpu") + assert isinstance(model, dict) + return model + elif model_path.suffix.endswith(".gguf"): + return gguf_sd_loader(model_path, compute_dtype=torch.float32) + else: + return safetensors.torch.load_file(model_path) + + @classmethod + def _scan_model(cls, model_name: str, checkpoint: Path) -> None: + """ + Apply picklescanner to the indicated checkpoint and issue a warning + and option to exit if an infected file is identified. + """ + # scan model + scan_result = scan_file_path(checkpoint) + if scan_result.infected_files != 0: + raise Exception("The model {model_name} is potentially infected by malware. Aborting import.") + + +# Probing utilities +MODEL_NAME_TO_PREPROCESSOR = { + "canny": "canny_image_processor", + "mlsd": "mlsd_image_processor", + "depth": "depth_anything_image_processor", + "bae": "normalbae_image_processor", + "normal": "normalbae_image_processor", + "sketch": "pidi_image_processor", + "scribble": "lineart_image_processor", + "lineart anime": "lineart_anime_image_processor", + "lineart_anime": "lineart_anime_image_processor", + "lineart": "lineart_image_processor", + "softedge": "hed_image_processor", + "hed": "hed_image_processor", + "shuffle": "content_shuffle_image_processor", + "pose": "dw_openpose_image_processor", + "mediapipe": "mediapipe_face_processor", + "pidi": "pidi_image_processor", + "zoe": "zoe_depth_image_processor", + "color": "color_map_image_processor", +} + + +def get_default_settings_controlnet_t2i_adapter(model_name: str) -> Optional[ControlAdapterDefaultSettings]: + for k, v in MODEL_NAME_TO_PREPROCESSOR.items(): + model_name_lower = model_name.lower() + if k in model_name_lower: + return ControlAdapterDefaultSettings(preprocessor=v) + return None + + +def get_default_settings_main(model_base: BaseModelType) -> Optional[MainModelDefaultSettings]: + if model_base is BaseModelType.StableDiffusion1 or model_base is BaseModelType.StableDiffusion2: + return MainModelDefaultSettings(width=512, height=512) + elif model_base is BaseModelType.StableDiffusionXL: + return MainModelDefaultSettings(width=1024, height=1024) + # We don't provide defaults for BaseModelType.StableDiffusionXLRefiner, as they are not standalone models. + return None + + +# ##################################################3 +# Checkpoint probing +# ##################################################3 + + +class CheckpointProbeBase(ProbeBase): + def __init__(self, model_path: Path): + super().__init__(model_path) + self.checkpoint = ModelProbe._scan_and_load_checkpoint(model_path) + + def get_format(self) -> ModelFormat: + state_dict = self.checkpoint.get("state_dict") or self.checkpoint + if ( + "double_blocks.0.img_attn.proj.weight.quant_state.bitsandbytes__nf4" in state_dict + or "model.diffusion_model.double_blocks.0.img_attn.proj.weight.quant_state.bitsandbytes__nf4" in state_dict + ): + return ModelFormat.BnbQuantizednf4b + elif any(isinstance(v, GGMLTensor) for v in state_dict.values()): + return ModelFormat.GGUFQuantized + return ModelFormat("checkpoint") + + def get_variant_type(self) -> ModelVariantType: + model_type = ModelProbe.get_model_type_from_checkpoint(self.model_path, self.checkpoint) + base_type = self.get_base_type() + if model_type != ModelType.Main or base_type == BaseModelType.Flux: + return ModelVariantType.Normal + state_dict = self.checkpoint.get("state_dict") or self.checkpoint + in_channels = state_dict["model.diffusion_model.input_blocks.0.0.weight"].shape[1] + if in_channels == 9: + return ModelVariantType.Inpaint + elif in_channels == 5: + return ModelVariantType.Depth + elif in_channels == 4: + return ModelVariantType.Normal + else: + raise InvalidModelConfigException( + f"Cannot determine variant type (in_channels={in_channels}) at {self.model_path}" + ) + + +class PipelineCheckpointProbe(CheckpointProbeBase): + def get_base_type(self) -> BaseModelType: + checkpoint = self.checkpoint + state_dict = self.checkpoint.get("state_dict") or checkpoint + if ( + "double_blocks.0.img_attn.norm.key_norm.scale" in state_dict + or "model.diffusion_model.double_blocks.0.img_attn.norm.key_norm.scale" in state_dict + ): + return BaseModelType.Flux + key_name = "model.diffusion_model.input_blocks.2.1.transformer_blocks.0.attn2.to_k.weight" + if key_name in state_dict and state_dict[key_name].shape[-1] == 768: + return BaseModelType.StableDiffusion1 + if key_name in state_dict and state_dict[key_name].shape[-1] == 1024: + return BaseModelType.StableDiffusion2 + key_name = "model.diffusion_model.input_blocks.4.1.transformer_blocks.0.attn2.to_k.weight" + if key_name in state_dict and state_dict[key_name].shape[-1] == 2048: + return BaseModelType.StableDiffusionXL + elif key_name in state_dict and state_dict[key_name].shape[-1] == 1280: + return BaseModelType.StableDiffusionXLRefiner + else: + raise InvalidModelConfigException("Cannot determine base type") + + def get_scheduler_prediction_type(self) -> SchedulerPredictionType: + """Return model prediction type.""" + type = self.get_base_type() + if type == BaseModelType.StableDiffusion2: + checkpoint = self.checkpoint + state_dict = self.checkpoint.get("state_dict") or checkpoint + key_name = "model.diffusion_model.input_blocks.2.1.transformer_blocks.0.attn2.to_k.weight" + if key_name in state_dict and state_dict[key_name].shape[-1] == 1024: + if "global_step" in checkpoint: + if checkpoint["global_step"] == 220000: + return SchedulerPredictionType.Epsilon + elif checkpoint["global_step"] == 110000: + return SchedulerPredictionType.VPrediction + return SchedulerPredictionType.VPrediction # a guess for sd2 ckpts + + elif type == BaseModelType.StableDiffusion1: + return SchedulerPredictionType.Epsilon # a reasonable guess for sd1 ckpts + else: + return SchedulerPredictionType.Epsilon + + +class VaeCheckpointProbe(CheckpointProbeBase): + def get_base_type(self) -> BaseModelType: + # VAEs of all base types have the same structure, so we wimp out and + # guess using the name. + for regexp, basetype in [ + (r"xl", BaseModelType.StableDiffusionXL), + (r"sd2", BaseModelType.StableDiffusion2), + (r"vae", BaseModelType.StableDiffusion1), + (r"FLUX.1-schnell_ae", BaseModelType.Flux), + ]: + if re.search(regexp, self.model_path.name, re.IGNORECASE): + return basetype + raise InvalidModelConfigException("Cannot determine base type") + + +class LoRACheckpointProbe(CheckpointProbeBase): + """Class for LoRA checkpoints.""" + + def get_format(self) -> ModelFormat: + if is_state_dict_likely_in_flux_diffusers_format(self.checkpoint): + # TODO(ryand): This is an unusual case. In other places throughout the codebase, we treat + # ModelFormat.Diffusers as meaning that the model is in a directory. In this case, the model is a single + # file, but the weight keys are in the diffusers format. + return ModelFormat.Diffusers + return ModelFormat.LyCORIS + + def get_base_type(self) -> BaseModelType: + if is_state_dict_likely_in_flux_kohya_format(self.checkpoint) or is_state_dict_likely_in_flux_diffusers_format( + self.checkpoint + ): + return BaseModelType.Flux + + # If we've gotten here, we assume that the model is a Stable Diffusion model. + token_vector_length = lora_token_vector_length(self.checkpoint) + if token_vector_length == 768: + return BaseModelType.StableDiffusion1 + elif token_vector_length == 1024: + return BaseModelType.StableDiffusion2 + elif token_vector_length == 1280: + return BaseModelType.StableDiffusionXL # recognizes format at https://civitai.com/models/224641 + elif token_vector_length == 2048: + return BaseModelType.StableDiffusionXL + else: + raise InvalidModelConfigException(f"Unknown LoRA type: {self.model_path}") + + +class TextualInversionCheckpointProbe(CheckpointProbeBase): + """Class for probing embeddings.""" + + def get_format(self) -> ModelFormat: + return ModelFormat.EmbeddingFile + + def get_base_type(self) -> BaseModelType: + checkpoint = self.checkpoint + if "string_to_token" in checkpoint: + token_dim = list(checkpoint["string_to_param"].values())[0].shape[-1] + elif "emb_params" in checkpoint: + token_dim = checkpoint["emb_params"].shape[-1] + elif "clip_g" in checkpoint: + token_dim = checkpoint["clip_g"].shape[-1] + else: + token_dim = list(checkpoint.values())[0].shape[0] + if token_dim == 768: + return BaseModelType.StableDiffusion1 + elif token_dim == 1024: + return BaseModelType.StableDiffusion2 + elif token_dim == 1280: + return BaseModelType.StableDiffusionXL + else: + raise InvalidModelConfigException(f"{self.model_path}: Could not determine base type") + + +class ControlNetCheckpointProbe(CheckpointProbeBase): + """Class for probing controlnets.""" + + def get_base_type(self) -> BaseModelType: + checkpoint = self.checkpoint + if is_state_dict_xlabs_controlnet(checkpoint) or is_state_dict_instantx_controlnet(checkpoint): + # TODO(ryand): Should I distinguish between XLabs, InstantX and other ControlNet models by implementing + # get_format()? + return BaseModelType.Flux + + for key_name in ( + "control_model.input_blocks.2.1.transformer_blocks.0.attn2.to_k.weight", + "controlnet_mid_block.bias", + "input_blocks.2.1.transformer_blocks.0.attn2.to_k.weight", + "down_blocks.1.attentions.0.transformer_blocks.0.attn2.to_k.weight", + ): + if key_name not in checkpoint: + continue + width = checkpoint[key_name].shape[-1] + if width == 768: + return BaseModelType.StableDiffusion1 + elif width == 1024: + return BaseModelType.StableDiffusion2 + elif width == 2048: + return BaseModelType.StableDiffusionXL + elif width == 1280: + return BaseModelType.StableDiffusionXL + raise InvalidModelConfigException(f"{self.model_path}: Unable to determine base type") + + +class IPAdapterCheckpointProbe(CheckpointProbeBase): + """Class for probing IP Adapters""" + + def get_base_type(self) -> BaseModelType: + checkpoint = self.checkpoint + + if is_state_dict_xlabs_ip_adapter(checkpoint): + return BaseModelType.Flux + + for key in checkpoint.keys(): + if not key.startswith(("image_proj.", "ip_adapter.")): + continue + cross_attention_dim = checkpoint["ip_adapter.1.to_k_ip.weight"].shape[-1] + if cross_attention_dim == 768: + return BaseModelType.StableDiffusion1 + elif cross_attention_dim == 1024: + return BaseModelType.StableDiffusion2 + elif cross_attention_dim == 2048: + return BaseModelType.StableDiffusionXL + else: + raise InvalidModelConfigException( + f"IP-Adapter had unexpected cross-attention dimension: {cross_attention_dim}." + ) + raise InvalidModelConfigException(f"{self.model_path}: Unable to determine base type") + + +class CLIPVisionCheckpointProbe(CheckpointProbeBase): + def get_base_type(self) -> BaseModelType: + raise NotImplementedError() + + +class T2IAdapterCheckpointProbe(CheckpointProbeBase): + def get_base_type(self) -> BaseModelType: + raise NotImplementedError() + + +class SpandrelImageToImageCheckpointProbe(CheckpointProbeBase): + def get_base_type(self) -> BaseModelType: + return BaseModelType.Any + + +######################################################## +# classes for probing folders +####################################################### +class FolderProbeBase(ProbeBase): + def get_variant_type(self) -> ModelVariantType: + return ModelVariantType.Normal + + def get_format(self) -> ModelFormat: + return ModelFormat("diffusers") + + def get_repo_variant(self) -> ModelRepoVariant: + # get all files ending in .bin or .safetensors + weight_files = list(self.model_path.glob("**/*.safetensors")) + weight_files.extend(list(self.model_path.glob("**/*.bin"))) + for x in weight_files: + if ".fp16" in x.suffixes: + return ModelRepoVariant.FP16 + if "openvino_model" in x.name: + return ModelRepoVariant.OpenVINO + if "flax_model" in x.name: + return ModelRepoVariant.Flax + if x.suffix == ".onnx": + return ModelRepoVariant.ONNX + return ModelRepoVariant.Default + + +class PipelineFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + # Handle pipelines with a UNet (i.e SD 1.x, SD2, SDXL). + config_path = self.model_path / "unet" / "config.json" + if config_path.exists(): + with open(config_path) as file: + unet_conf = json.load(file) + if unet_conf["cross_attention_dim"] == 768: + return BaseModelType.StableDiffusion1 + elif unet_conf["cross_attention_dim"] == 1024: + return BaseModelType.StableDiffusion2 + elif unet_conf["cross_attention_dim"] == 1280: + return BaseModelType.StableDiffusionXLRefiner + elif unet_conf["cross_attention_dim"] == 2048: + return BaseModelType.StableDiffusionXL + else: + raise InvalidModelConfigException(f"Unknown base model for {self.model_path}") + + # Handle pipelines with a transformer (i.e. SD3). + config_path = self.model_path / "transformer" / "config.json" + if config_path.exists(): + with open(config_path) as file: + transformer_conf = json.load(file) + if transformer_conf["_class_name"] == "SD3Transformer2DModel": + return BaseModelType.StableDiffusion3 + else: + raise InvalidModelConfigException(f"Unknown base model for {self.model_path}") + + raise InvalidModelConfigException(f"Unknown base model for {self.model_path}") + + def get_scheduler_prediction_type(self) -> SchedulerPredictionType: + with open(self.model_path / "scheduler" / "scheduler_config.json", "r") as file: + scheduler_conf = json.load(file) + if scheduler_conf.get("prediction_type", "epsilon") == "v_prediction": + return SchedulerPredictionType.VPrediction + elif scheduler_conf.get("prediction_type", "epsilon") == "epsilon": + return SchedulerPredictionType.Epsilon + else: + raise InvalidModelConfigException("Unknown scheduler prediction type: {scheduler_conf['prediction_type']}") + + def get_submodels(self) -> Dict[SubModelType, SubmodelDefinition]: + config = ConfigLoader.load_config(self.model_path, config_name="model_index.json") + submodels: Dict[SubModelType, SubmodelDefinition] = {} + for key, value in config.items(): + if key.startswith("_") or not (isinstance(value, list) and len(value) == 2): + continue + model_loader = str(value[1]) + if model_type := ModelProbe.CLASS2TYPE.get(model_loader): + variant_func = ModelProbe.TYPE2VARIANT.get(model_type, None) + submodels[SubModelType(key)] = SubmodelDefinition( + path_or_prefix=(self.model_path / key).resolve().as_posix(), + model_type=model_type, + variant=variant_func and variant_func((self.model_path / key).as_posix()), + ) + + return submodels + + def get_variant_type(self) -> ModelVariantType: + # This only works for pipelines! Any kind of + # exception results in our returning the + # "normal" variant type + try: + config_file = self.model_path / "unet" / "config.json" + with open(config_file, "r") as file: + conf = json.load(file) + + in_channels = conf["in_channels"] + if in_channels == 9: + return ModelVariantType.Inpaint + elif in_channels == 5: + return ModelVariantType.Depth + elif in_channels == 4: + return ModelVariantType.Normal + except Exception: + pass + return ModelVariantType.Normal + + +class VaeFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + if self._config_looks_like_sdxl(): + return BaseModelType.StableDiffusionXL + elif self._name_looks_like_sdxl(): + # but SD and SDXL VAE are the same shape (3-channel RGB to 4-channel float scaled down + # by a factor of 8), we can't necessarily tell them apart by config hyperparameters. + return BaseModelType.StableDiffusionXL + else: + return BaseModelType.StableDiffusion1 + + def _config_looks_like_sdxl(self) -> bool: + # config values that distinguish Stability's SD 1.x VAE from their SDXL VAE. + config_file = self.model_path / "config.json" + if not config_file.exists(): + raise InvalidModelConfigException(f"Cannot determine base type for {self.model_path}") + with open(config_file, "r") as file: + config = json.load(file) + return config.get("scaling_factor", 0) == 0.13025 and config.get("sample_size") in [512, 1024] + + def _name_looks_like_sdxl(self) -> bool: + return bool(re.search(r"xl\b", self._guess_name(), re.IGNORECASE)) + + def _guess_name(self) -> str: + name = self.model_path.name + if name == "vae": + name = self.model_path.parent.name + return name + + +class TextualInversionFolderProbe(FolderProbeBase): + def get_format(self) -> ModelFormat: + return ModelFormat.EmbeddingFolder + + def get_base_type(self) -> BaseModelType: + path = self.model_path / "learned_embeds.bin" + if not path.exists(): + raise InvalidModelConfigException( + f"{self.model_path.as_posix()} does not contain expected 'learned_embeds.bin' file" + ) + return TextualInversionCheckpointProbe(path).get_base_type() + + +class T5EncoderFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + return BaseModelType.Any + + def get_format(self) -> ModelFormat: + path = self.model_path / "text_encoder_2" + if (path / "model.safetensors.index.json").exists(): + return ModelFormat.T5Encoder + files = list(path.glob("*.safetensors")) + if len(files) == 0: + raise InvalidModelConfigException(f"{self.model_path.as_posix()}: no .safetensors files found") + + # shortcut: look for the quantization in the name + if any(x for x in files if "llm_int8" in x.as_posix()): + return ModelFormat.BnbQuantizedLlmInt8b + + # more reliable path: probe contents for a 'SCB' key + ckpt = read_checkpoint_meta(files[0], scan=True) + if any("SCB" in x for x in ckpt.keys()): + return ModelFormat.BnbQuantizedLlmInt8b + + raise InvalidModelConfigException(f"{self.model_path.as_posix()}: unknown model format") + + +class ONNXFolderProbe(PipelineFolderProbe): + def get_base_type(self) -> BaseModelType: + # Due to the way the installer is set up, the configuration file for safetensors + # will come along for the ride if both the onnx and safetensors forms + # share the same directory. We take advantage of this here. + if (self.model_path / "unet" / "config.json").exists(): + return super().get_base_type() + else: + logger.warning('Base type probing is not implemented for ONNX models. Assuming "sd-1"') + return BaseModelType.StableDiffusion1 + + def get_format(self) -> ModelFormat: + return ModelFormat("onnx") + + def get_variant_type(self) -> ModelVariantType: + return ModelVariantType.Normal + + +class ControlNetFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + config_file = self.model_path / "config.json" + if not config_file.exists(): + raise InvalidModelConfigException(f"Cannot determine base type for {self.model_path}") + with open(config_file, "r") as file: + config = json.load(file) + + if config.get("_class_name", None) == "FluxControlNetModel": + return BaseModelType.Flux + + # no obvious way to distinguish between sd2-base and sd2-768 + dimension = config["cross_attention_dim"] + if dimension == 768: + return BaseModelType.StableDiffusion1 + if dimension == 1024: + return BaseModelType.StableDiffusion2 + if dimension == 2048: + return BaseModelType.StableDiffusionXL + raise InvalidModelConfigException(f"Unable to determine model base for {self.model_path}") + + +class LoRAFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + model_file = None + for suffix in ["safetensors", "bin"]: + base_file = self.model_path / f"pytorch_lora_weights.{suffix}" + if base_file.exists(): + model_file = base_file + break + if not model_file: + raise InvalidModelConfigException("Unknown LoRA format encountered") + return LoRACheckpointProbe(model_file).get_base_type() + + +class IPAdapterFolderProbe(FolderProbeBase): + def get_format(self) -> ModelFormat: + return ModelFormat.InvokeAI + + def get_base_type(self) -> BaseModelType: + model_file = self.model_path / "ip_adapter.bin" + if not model_file.exists(): + raise InvalidModelConfigException("Unknown IP-Adapter model format.") + + state_dict = torch.load(model_file, map_location="cpu") + cross_attention_dim = state_dict["ip_adapter"]["1.to_k_ip.weight"].shape[-1] + if cross_attention_dim == 768: + return BaseModelType.StableDiffusion1 + elif cross_attention_dim == 1024: + return BaseModelType.StableDiffusion2 + elif cross_attention_dim == 2048: + return BaseModelType.StableDiffusionXL + else: + raise InvalidModelConfigException( + f"IP-Adapter had unexpected cross-attention dimension: {cross_attention_dim}." + ) + + def get_image_encoder_model_id(self) -> Optional[str]: + encoder_id_path = self.model_path / "image_encoder.txt" + if not encoder_id_path.exists(): + return None + with open(encoder_id_path, "r") as f: + image_encoder_model = f.readline().strip() + return image_encoder_model + + +class CLIPVisionFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + return BaseModelType.Any + + +class CLIPEmbedFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + return BaseModelType.Any + + +class SpandrelImageToImageFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + raise NotImplementedError() + + +class T2IAdapterFolderProbe(FolderProbeBase): + def get_base_type(self) -> BaseModelType: + config_file = self.model_path / "config.json" + if not config_file.exists(): + raise InvalidModelConfigException(f"Cannot determine base type for {self.model_path}") + with open(config_file, "r") as file: + config = json.load(file) + + adapter_type = config.get("adapter_type", None) + if adapter_type == "full_adapter_xl": + return BaseModelType.StableDiffusionXL + elif adapter_type == "full_adapter" or "light_adapter": + # I haven't seen any T2I adapter models for SD2, so assume that this is an SD1 adapter. + return BaseModelType.StableDiffusion1 + else: + raise InvalidModelConfigException( + f"Unable to determine base model for '{self.model_path}' (adapter_type = {adapter_type})." + ) + + +# Register probe classes +ModelProbe.register_probe("diffusers", ModelType.Main, PipelineFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.VAE, VaeFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.LoRA, LoRAFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.TextualInversion, TextualInversionFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.T5Encoder, T5EncoderFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.ControlNet, ControlNetFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.IPAdapter, IPAdapterFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.CLIPEmbed, CLIPEmbedFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.CLIPVision, CLIPVisionFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.T2IAdapter, T2IAdapterFolderProbe) +ModelProbe.register_probe("diffusers", ModelType.SpandrelImageToImage, SpandrelImageToImageFolderProbe) + +ModelProbe.register_probe("checkpoint", ModelType.Main, PipelineCheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.VAE, VaeCheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.LoRA, LoRACheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.TextualInversion, TextualInversionCheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.ControlNet, ControlNetCheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.IPAdapter, IPAdapterCheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.CLIPVision, CLIPVisionCheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.T2IAdapter, T2IAdapterCheckpointProbe) +ModelProbe.register_probe("checkpoint", ModelType.SpandrelImageToImage, SpandrelImageToImageCheckpointProbe) + +ModelProbe.register_probe("onnx", ModelType.ONNX, ONNXFolderProbe) diff --git a/invokeai/backend/model_manager/search.py b/invokeai/backend/model_manager/search.py new file mode 100644 index 0000000000000000000000000000000000000000..a056979880480bf45c58f8a17ea458bfcb6219c7 --- /dev/null +++ b/invokeai/backend/model_manager/search.py @@ -0,0 +1,142 @@ +# Copyright 2023, Lincoln D. Stein and the InvokeAI Team +""" +Abstract base class and implementation for recursive directory search for models. + +Example usage: +``` + from invokeai.backend.model_manager import ModelSearch, ModelProbe + + def find_main_models(model: Path) -> bool: + info = ModelProbe.probe(model) + if info.model_type == 'main' and info.base_type == 'sd-1': + return True + else: + return False + + search = ModelSearch(on_model_found=report_it) + found = search.search('/tmp/models') + print(found) # list of matching model paths + print(search.stats) # search stats +``` +""" + +import os +from dataclasses import dataclass +from pathlib import Path +from typing import Callable, Optional + +from invokeai.backend.util.logging import InvokeAILogger + + +@dataclass +class SearchStats: + """Statistics about the search. + + Attributes: + items_scanned: number of items scanned + models_found: number of models found + models_filtered: number of models that passed the filter + """ + + items_scanned = 0 + models_found = 0 + models_filtered = 0 + + +class ModelSearch: + """Searches a directory tree for models, using a callback to filter the results. + + Usage: + search = ModelSearch() + search.on_model_found = lambda path : 'anime' in path.as_posix() + found = search.search(Path('/tmp/models1')) + """ + + def __init__( + self, + on_search_started: Optional[Callable[[Path], None]] = None, + on_model_found: Optional[Callable[[Path], bool]] = None, + on_search_completed: Optional[Callable[[set[Path]], None]] = None, + ) -> None: + """Create a new ModelSearch object. + + Args: + on_search_started: callback to be invoked when the search starts + on_model_found: callback to be invoked when a model is found. The callback should return True if the model + should be included in the results. + on_search_completed: callback to be invoked when the search is completed + """ + self.stats = SearchStats() + self.logger = InvokeAILogger.get_logger() + self.on_search_started = on_search_started + self.on_model_found = on_model_found + self.on_search_completed = on_search_completed + self.models_found: set[Path] = set() + + def search_started(self) -> None: + self.models_found = set() + if self.on_search_started: + self.on_search_started(self._directory) + + def model_found(self, model: Path) -> None: + self.stats.models_found += 1 + if self.on_model_found is None or self.on_model_found(model): + self.stats.models_filtered += 1 + self.models_found.add(model) + + def search_completed(self) -> None: + if self.on_search_completed is not None: + self.on_search_completed(self.models_found) + + def search(self, directory: Path) -> set[Path]: + self._directory = Path(directory) + self._directory = self._directory.resolve() + self.stats = SearchStats() # zero out + self.search_started() # This will initialize _models_found to empty + self._walk_directory(self._directory) + self.search_completed() + return self.models_found + + def _walk_directory(self, path: Path, max_depth: int = 20) -> None: + """Recursively walk the directory tree, looking for models.""" + absolute_path = Path(path) + if ( + len(absolute_path.parts) - len(self._directory.parts) > max_depth + or not absolute_path.exists() + or absolute_path.parent in self.models_found + ): + return + entries = os.scandir(absolute_path.as_posix()) + entries = [entry for entry in entries if not entry.name.startswith(".")] + dirs = [entry for entry in entries if entry.is_dir()] + file_names = [entry.name for entry in entries if entry.is_file()] + if any( + x in file_names + for x in [ + "config.json", + "model_index.json", + "learned_embeds.bin", + "pytorch_lora_weights.bin", + "image_encoder.txt", + ] + ): + try: + self.model_found(absolute_path) + return + except KeyboardInterrupt: + raise + except Exception as e: + self.logger.warning(str(e)) + return + + for n in file_names: + if n.endswith((".ckpt", ".bin", ".pth", ".safetensors", ".pt", ".gguf")): + try: + self.model_found(absolute_path / n) + except KeyboardInterrupt: + raise + except Exception as e: + self.logger.warning(str(e)) + + for d in dirs: + self._walk_directory(absolute_path / d) diff --git a/invokeai/backend/model_manager/starter_models.py b/invokeai/backend/model_manager/starter_models.py new file mode 100644 index 0000000000000000000000000000000000000000..234005050b07a1e415b551f38248abb8d85b098c --- /dev/null +++ b/invokeai/backend/model_manager/starter_models.py @@ -0,0 +1,700 @@ +from typing import Optional + +from pydantic import BaseModel + +from invokeai.backend.model_manager.config import BaseModelType, ModelFormat, ModelType + + +class StarterModelWithoutDependencies(BaseModel): + description: str + source: str + name: str + base: BaseModelType + type: ModelType + format: Optional[ModelFormat] = None + is_installed: bool = False + # allows us to track what models a user has installed across name changes within starter models + # if you update a starter model name, please add the old one to this list for that starter model + previous_names: list[str] = [] + + +class StarterModel(StarterModelWithoutDependencies): + # Optional list of model source dependencies that need to be installed before this model can be used + dependencies: Optional[list[StarterModelWithoutDependencies]] = None + + +class StarterModelBundles(BaseModel): + name: str + models: list[StarterModel] + + +cyberrealistic_negative = StarterModel( + name="CyberRealistic Negative v3", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/cyberdelia/CyberRealistic_Negative/resolve/main/CyberRealistic_Negative_v3.pt", + description="Negative embedding specifically for use with CyberRealistic.", + type=ModelType.TextualInversion, +) + +# region CLIP Image Encoders +ip_adapter_sd_image_encoder = StarterModel( + name="IP Adapter SD1.5 Image Encoder", + base=BaseModelType.StableDiffusion1, + source="InvokeAI/ip_adapter_sd_image_encoder", + description="IP Adapter SD Image Encoder", + type=ModelType.CLIPVision, +) +ip_adapter_sdxl_image_encoder = StarterModel( + name="IP Adapter SDXL Image Encoder", + base=BaseModelType.StableDiffusionXL, + source="InvokeAI/ip_adapter_sdxl_image_encoder", + description="IP Adapter SDXL Image Encoder", + type=ModelType.CLIPVision, +) +# Note: This model is installed from the same source as the CLIPEmbed model below. The model contains both the image +# encoder and the text encoder, but we need separate model entries so that they get loaded correctly. +clip_vit_l_image_encoder = StarterModel( + name="clip-vit-large-patch14", + base=BaseModelType.Any, + source="InvokeAI/clip-vit-large-patch14", + description="CLIP ViT-L Image Encoder", + type=ModelType.CLIPVision, +) +# endregion + +# region TextEncoders +t5_base_encoder = StarterModel( + name="t5_base_encoder", + base=BaseModelType.Any, + source="InvokeAI/t5-v1_1-xxl::bfloat16", + description="T5-XXL text encoder (used in FLUX pipelines). ~8GB", + type=ModelType.T5Encoder, +) + +t5_8b_quantized_encoder = StarterModel( + name="t5_bnb_int8_quantized_encoder", + base=BaseModelType.Any, + source="InvokeAI/t5-v1_1-xxl::bnb_llm_int8", + description="T5-XXL text encoder with bitsandbytes LLM.int8() quantization (used in FLUX pipelines). ~5GB", + type=ModelType.T5Encoder, + format=ModelFormat.BnbQuantizedLlmInt8b, +) + +clip_l_encoder = StarterModel( + name="clip-vit-large-patch14", + base=BaseModelType.Any, + source="InvokeAI/clip-vit-large-patch14-text-encoder::bfloat16", + description="CLIP-L text encoder (used in FLUX pipelines). ~250MB", + type=ModelType.CLIPEmbed, +) +# endregion + +# region VAE +sdxl_fp16_vae_fix = StarterModel( + name="sdxl-vae-fp16-fix", + base=BaseModelType.StableDiffusionXL, + source="madebyollin/sdxl-vae-fp16-fix", + description="SDXL VAE that works with FP16.", + type=ModelType.VAE, +) +flux_vae = StarterModel( + name="FLUX.1-schnell_ae", + base=BaseModelType.Flux, + source="black-forest-labs/FLUX.1-schnell::ae.safetensors", + description="FLUX VAE compatible with both schnell and dev variants.", + type=ModelType.VAE, +) +# endregion + + +# region: Main +flux_schnell_quantized = StarterModel( + name="FLUX Schnell (Quantized)", + base=BaseModelType.Flux, + source="InvokeAI/flux_schnell::transformer/bnb_nf4/flux1-schnell-bnb_nf4.safetensors", + description="FLUX schnell transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB", + type=ModelType.Main, + dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], +) +flux_dev_quantized = StarterModel( + name="FLUX Dev (Quantized)", + base=BaseModelType.Flux, + source="InvokeAI/flux_dev::transformer/bnb_nf4/flux1-dev-bnb_nf4.safetensors", + description="FLUX dev transformer quantized to bitsandbytes NF4 format. Total size with dependencies: ~12GB", + type=ModelType.Main, + dependencies=[t5_8b_quantized_encoder, flux_vae, clip_l_encoder], +) +flux_schnell = StarterModel( + name="FLUX Schnell", + base=BaseModelType.Flux, + source="InvokeAI/flux_schnell::transformer/base/flux1-schnell.safetensors", + description="FLUX schnell transformer in bfloat16. Total size with dependencies: ~33GB", + type=ModelType.Main, + dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], +) +flux_dev = StarterModel( + name="FLUX Dev", + base=BaseModelType.Flux, + source="InvokeAI/flux_dev::transformer/base/flux1-dev.safetensors", + description="FLUX dev transformer in bfloat16. Total size with dependencies: ~33GB", + type=ModelType.Main, + dependencies=[t5_base_encoder, flux_vae, clip_l_encoder], +) +sd35_medium = StarterModel( + name="SD3.5 Medium", + base=BaseModelType.StableDiffusion3, + source="stabilityai/stable-diffusion-3.5-medium", + description="Medium SD3.5 Model: ~15GB", + type=ModelType.Main, + dependencies=[], +) +sd35_large = StarterModel( + name="SD3.5 Large", + base=BaseModelType.StableDiffusion3, + source="stabilityai/stable-diffusion-3.5-large", + description="Large SD3.5 Model: ~19G", + type=ModelType.Main, + dependencies=[], +) +cyberrealistic_sd1 = StarterModel( + name="CyberRealistic v4.1", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/cyberdelia/CyberRealistic/resolve/main/CyberRealistic_V4.1_FP16.safetensors", + description="Photorealistic model. See other variants in HF repo 'cyberdelia/CyberRealistic'.", + type=ModelType.Main, + dependencies=[cyberrealistic_negative], +) +rev_animated_sd1 = StarterModel( + name="ReV Animated", + base=BaseModelType.StableDiffusion1, + source="stablediffusionapi/rev-animated", + description="Fantasy and anime style images.", + type=ModelType.Main, +) +dreamshaper_8_sd1 = StarterModel( + name="Dreamshaper 8", + base=BaseModelType.StableDiffusion1, + source="Lykon/dreamshaper-8", + description="Popular versatile model.", + type=ModelType.Main, +) +dreamshaper_8_inpainting_sd1 = StarterModel( + name="Dreamshaper 8 (inpainting)", + base=BaseModelType.StableDiffusion1, + source="Lykon/dreamshaper-8-inpainting", + description="Inpainting version of Dreamshaper 8.", + type=ModelType.Main, +) +deliberate_sd1 = StarterModel( + name="Deliberate v5", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v5.safetensors", + description="Popular versatile model", + type=ModelType.Main, +) +deliberate_inpainting_sd1 = StarterModel( + name="Deliberate v5 (inpainting)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/XpucT/Deliberate/resolve/main/Deliberate_v5-inpainting.safetensors", + description="Inpainting version of Deliberate v5.", + type=ModelType.Main, +) +juggernaut_sdxl = StarterModel( + name="Juggernaut XL v9", + base=BaseModelType.StableDiffusionXL, + source="RunDiffusion/Juggernaut-XL-v9", + description="Photograph-focused model.", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) +dreamshaper_sdxl = StarterModel( + name="Dreamshaper XL v2 Turbo", + base=BaseModelType.StableDiffusionXL, + source="Lykon/dreamshaper-xl-v2-turbo", + description="For turbo, use CFG Scale 2, 4-8 steps, DPM++ SDE Karras. For non-turbo, use CFG Scale 6, 20-40 steps, DPM++ 2M SDE Karras.", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) + +archvis_sdxl = StarterModel( + name="Architecture (RealVisXL5)", + base=BaseModelType.StableDiffusionXL, + source="SG161222/RealVisXL_V5.0", + description="A photorealistic model, with architecture among its many use cases", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) + +sdxl_refiner = StarterModel( + name="SDXL Refiner", + base=BaseModelType.StableDiffusionXLRefiner, + source="stabilityai/stable-diffusion-xl-refiner-1.0", + description="The OG Stable Diffusion XL refiner model.", + type=ModelType.Main, + dependencies=[sdxl_fp16_vae_fix], +) +# endregion + +# region LoRA +alien_lora_sdxl = StarterModel( + name="Alien Style", + base=BaseModelType.StableDiffusionXL, + source="https://huggingface.co/RalFinger/alien-style-lora-sdxl/resolve/main/alienzkin-sdxl.safetensors", + description="Futuristic, intricate alien styles. Trigger with 'alienzkin'.", + type=ModelType.LoRA, +) +noodle_lora_sdxl = StarterModel( + name="Noodles Style", + base=BaseModelType.StableDiffusionXL, + source="https://huggingface.co/RalFinger/noodles-lora-sdxl/resolve/main/noodlez-sdxl.safetensors", + description="Never-ending, no-holds-barred, noodle nightmare. Trigger with 'noodlez'.", + type=ModelType.LoRA, +) +# endregion +# region TI +easy_neg_sd1 = StarterModel( + name="EasyNegative", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/embed/EasyNegative/resolve/main/EasyNegative.safetensors", + description="A textual inversion to use in the negative prompt to reduce bad anatomy", + type=ModelType.TextualInversion, +) +# endregion +# region IP Adapter +ip_adapter_sd1 = StarterModel( + name="Standard Reference (IP Adapter)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/InvokeAI/ip_adapter_sd15/resolve/main/ip-adapter_sd15.safetensors", + description="References images with a more generalized/looser degree of precision.", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sd_image_encoder], + previous_names=["IP Adapter"], +) +ip_adapter_plus_sd1 = StarterModel( + name="Precise Reference (IP Adapter Plus)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/InvokeAI/ip_adapter_plus_sd15/resolve/main/ip-adapter-plus_sd15.safetensors", + description="References images with a higher degree of precision.", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sd_image_encoder], + previous_names=["IP Adapter Plus"], +) +ip_adapter_plus_face_sd1 = StarterModel( + name="Face Reference (IP Adapter Plus Face)", + base=BaseModelType.StableDiffusion1, + source="https://huggingface.co/InvokeAI/ip_adapter_plus_face_sd15/resolve/main/ip-adapter-plus-face_sd15.safetensors", + description="References images with a higher degree of precision, adapted for faces", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sd_image_encoder], + previous_names=["IP Adapter Plus Face"], +) +ip_adapter_sdxl = StarterModel( + name="Standard Reference (IP Adapter ViT-H)", + base=BaseModelType.StableDiffusionXL, + source="https://huggingface.co/InvokeAI/ip_adapter_sdxl_vit_h/resolve/main/ip-adapter_sdxl_vit-h.safetensors", + description="References images with a higher degree of precision.", + type=ModelType.IPAdapter, + dependencies=[ip_adapter_sdxl_image_encoder], + previous_names=["IP Adapter SDXL"], +) +ip_adapter_flux = StarterModel( + name="Standard Reference (XLabs FLUX IP-Adapter)", + base=BaseModelType.Flux, + source="https://huggingface.co/XLabs-AI/flux-ip-adapter/resolve/main/ip_adapter.safetensors", + description="References images with a more generalized/looser degree of precision.", + type=ModelType.IPAdapter, + dependencies=[clip_vit_l_image_encoder], + previous_names=["XLabs FLUX IP-Adapter"], +) +# endregion +# region ControlNet +qr_code_cnet_sd1 = StarterModel( + name="QRCode Monster v2 (SD1.5)", + base=BaseModelType.StableDiffusion1, + source="monster-labs/control_v1p_sd15_qrcode_monster::v2", + description="ControlNet model that generates scannable creative QR codes", + type=ModelType.ControlNet, +) +qr_code_cnet_sdxl = StarterModel( + name="QRCode Monster (SDXL)", + base=BaseModelType.StableDiffusionXL, + source="monster-labs/control_v1p_sdxl_qrcode_monster", + description="ControlNet model that generates scannable creative QR codes", + type=ModelType.ControlNet, +) +canny_sd1 = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_canny", + description="Uses detected edges in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["canny"], +) +inpaint_cnet_sd1 = StarterModel( + name="Inpainting", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_inpaint", + description="ControlNet weights trained on sd-1.5 with canny conditioning, inpaint version", + type=ModelType.ControlNet, + previous_names=["inpaint"], +) +mlsd_sd1 = StarterModel( + name="Line Drawing (mlsd)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_mlsd", + description="Uses straight line detection for controlling the generation.", + type=ModelType.ControlNet, + previous_names=["mlsd"], +) +depth_sd1 = StarterModel( + name="Depth Map", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11f1p_sd15_depth", + description="Uses depth information in the image to control the depth in the generation.", + type=ModelType.ControlNet, + previous_names=["depth"], +) +normal_bae_sd1 = StarterModel( + name="Lighting Detection (Normals)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_normalbae", + description="Uses detected lighting information to guide the lighting of the composition.", + type=ModelType.ControlNet, + previous_names=["normal_bae"], +) +seg_sd1 = StarterModel( + name="Segmentation Map", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_seg", + description="Uses segmentation maps to guide the structure of the composition.", + type=ModelType.ControlNet, + previous_names=["seg"], +) +lineart_sd1 = StarterModel( + name="Lineart", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_lineart", + description="Uses lineart detection to guide the lighting of the composition.", + type=ModelType.ControlNet, + previous_names=["lineart"], +) +lineart_anime_sd1 = StarterModel( + name="Lineart Anime", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15s2_lineart_anime", + description="Uses anime lineart detection to guide the lighting of the composition.", + type=ModelType.ControlNet, + previous_names=["lineart_anime"], +) +openpose_sd1 = StarterModel( + name="Pose Detection (openpose)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_openpose", + description="Uses pose information to control the pose of human characters in the generation.", + type=ModelType.ControlNet, + previous_names=["openpose"], +) +scribble_sd1 = StarterModel( + name="Contour Detection (scribble)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_scribble", + description="Uses edges, contours, or line art in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["scribble"], +) +softedge_sd1 = StarterModel( + name="Soft Edge Detection (softedge)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11p_sd15_softedge", + description="Uses a soft edge detection map to control composition.", + type=ModelType.ControlNet, + previous_names=["softedge"], +) +shuffle_sd1 = StarterModel( + name="Remix (shuffle)", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11e_sd15_shuffle", + description="ControlNet weights trained on sd-1.5 with shuffle image conditioning", + type=ModelType.ControlNet, + previous_names=["shuffle"], +) +tile_sd1 = StarterModel( + name="Tile", + base=BaseModelType.StableDiffusion1, + source="lllyasviel/control_v11f1e_sd15_tile", + description="Uses image data to replicate exact colors/structure in the resulting generation.", + type=ModelType.ControlNet, + previous_names=["tile"], +) +canny_sdxl = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-canny-sdxl-1.0", + description="Uses detected edges in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["canny-sdxl"], +) +depth_sdxl = StarterModel( + name="Depth Map", + base=BaseModelType.StableDiffusionXL, + source="diffusers/controlNet-depth-sdxl-1.0", + description="Uses depth information in the image to control the depth in the generation.", + type=ModelType.ControlNet, + previous_names=["depth-sdxl"], +) +softedge_sdxl = StarterModel( + name="Soft Edge Detection (softedge)", + base=BaseModelType.StableDiffusionXL, + source="SargeZT/controlNet-sd-xl-1.0-softedge-dexined", + description="Uses a soft edge detection map to control composition.", + type=ModelType.ControlNet, + previous_names=["softedge-dexined-sdxl"], +) +openpose_sdxl = StarterModel( + name="Pose Detection (openpose)", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-openpose-sdxl-1.0", + description="Uses pose information to control the pose of human characters in the generation.", + type=ModelType.ControlNet, + previous_names=["openpose-sdxl", "controlnet-openpose-sdxl"], +) +scribble_sdxl = StarterModel( + name="Contour Detection (scribble)", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-scribble-sdxl-1.0", + description="Uses edges, contours, or line art in the image to control composition.", + type=ModelType.ControlNet, + previous_names=["scribble-sdxl", "controlnet-scribble-sdxl"], +) +tile_sdxl = StarterModel( + name="Tile", + base=BaseModelType.StableDiffusionXL, + source="xinsir/controlNet-tile-sdxl-1.0", + description="Uses image data to replicate exact colors/structure in the resulting generation.", + type=ModelType.ControlNet, + previous_names=["tile-sdxl"], +) +union_cnet_sdxl = StarterModel( + name="Multi-Guidance Detection (Union Pro)", + base=BaseModelType.StableDiffusionXL, + source="InvokeAI/Xinsir-SDXL_Controlnet_Union", + description="A unified ControlNet for SDXL model that supports 10+ control types", + type=ModelType.ControlNet, +) +union_cnet_flux = StarterModel( + name="FLUX.1-dev-Controlnet-Union", + base=BaseModelType.Flux, + source="InstantX/FLUX.1-dev-Controlnet-Union", + description="A unified ControlNet for FLUX.1-dev model that supports 7 control modes, including canny (0), tile (1), depth (2), blur (3), pose (4), gray (5), low quality (6)", + type=ModelType.ControlNet, +) +# endregion +# region T2I Adapter +t2i_canny_sd1 = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusion1, + source="TencentARC/t2iadapter_canny_sd15v2", + description="Uses detected edges in the image to control composition", + type=ModelType.T2IAdapter, + previous_names=["canny-sd15"], +) +t2i_sketch_sd1 = StarterModel( + name="Sketch", + base=BaseModelType.StableDiffusion1, + source="TencentARC/t2iadapter_sketch_sd15v2", + description="Uses a sketch to control composition", + type=ModelType.T2IAdapter, + previous_names=["sketch-sd15"], +) +t2i_depth_sd1 = StarterModel( + name="Depth Map", + base=BaseModelType.StableDiffusion1, + source="TencentARC/t2iadapter_depth_sd15v2", + description="Uses depth information in the image to control the depth in the generation.", + type=ModelType.T2IAdapter, + previous_names=["depth-sd15"], +) +t2i_canny_sdxl = StarterModel( + name="Hard Edge Detection (canny)", + base=BaseModelType.StableDiffusionXL, + source="TencentARC/t2i-adapter-canny-sdxl-1.0", + description="Uses detected edges in the image to control composition", + type=ModelType.T2IAdapter, + previous_names=["canny-sdxl"], +) +t2i_lineart_sdxl = StarterModel( + name="Lineart", + base=BaseModelType.StableDiffusionXL, + source="TencentARC/t2i-adapter-lineart-sdxl-1.0", + description="Uses lineart detection to guide the lighting of the composition.", + type=ModelType.T2IAdapter, + previous_names=["lineart-sdxl"], +) +t2i_sketch_sdxl = StarterModel( + name="Sketch", + base=BaseModelType.StableDiffusionXL, + source="TencentARC/t2i-adapter-sketch-sdxl-1.0", + description="Uses a sketch to control composition", + type=ModelType.T2IAdapter, + previous_names=["sketch-sdxl"], +) +# endregion +# region SpandrelImageToImage +realesrgan_anime = StarterModel( + name="RealESRGAN_x4plus_anime_6B", + base=BaseModelType.Any, + source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.2.4/RealESRGAN_x4plus_anime_6B.pth", + description="A Real-ESRGAN 4x upscaling model (optimized for anime images).", + type=ModelType.SpandrelImageToImage, +) +realesrgan_x4 = StarterModel( + name="RealESRGAN_x4plus", + base=BaseModelType.Any, + source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.0/RealESRGAN_x4plus.pth", + description="A Real-ESRGAN 4x upscaling model (general-purpose).", + type=ModelType.SpandrelImageToImage, +) +esrgan_srx4 = StarterModel( + name="ESRGAN_SRx4_DF2KOST_official", + base=BaseModelType.Any, + source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.1.1/ESRGAN_SRx4_DF2KOST_official-ff704c30.pth", + description="The official ESRGAN 4x upscaling model.", + type=ModelType.SpandrelImageToImage, +) +realesrgan_x2 = StarterModel( + name="RealESRGAN_x2plus", + base=BaseModelType.Any, + source="https://github.com/xinntao/Real-ESRGAN/releases/download/v0.2.1/RealESRGAN_x2plus.pth", + description="A Real-ESRGAN 2x upscaling model (general-purpose).", + type=ModelType.SpandrelImageToImage, +) +swinir = StarterModel( + name="SwinIR - realSR_BSRGAN_DFOWMFC_s64w8_SwinIR-L_x4_GAN", + base=BaseModelType.Any, + source="https://github.com/JingyunLiang/SwinIR/releases/download/v0.0/003_realSR_BSRGAN_DFOWMFC_s64w8_SwinIR-L_x4_GAN-with-dict-keys-params-and-params_ema.pth", + description="A SwinIR 4x upscaling model.", + type=ModelType.SpandrelImageToImage, +) + +# endregion + + +# List of starter models, displayed on the frontend. +# The order/sort of this list is not changed by the frontend - set it how you want it here. +STARTER_MODELS: list[StarterModel] = [ + flux_schnell_quantized, + flux_dev_quantized, + flux_schnell, + flux_dev, + sd35_medium, + sd35_large, + cyberrealistic_sd1, + rev_animated_sd1, + dreamshaper_8_sd1, + dreamshaper_8_inpainting_sd1, + deliberate_sd1, + deliberate_inpainting_sd1, + juggernaut_sdxl, + dreamshaper_sdxl, + archvis_sdxl, + sdxl_refiner, + sdxl_fp16_vae_fix, + flux_vae, + alien_lora_sdxl, + noodle_lora_sdxl, + easy_neg_sd1, + ip_adapter_sd1, + ip_adapter_plus_sd1, + ip_adapter_plus_face_sd1, + ip_adapter_sdxl, + ip_adapter_flux, + qr_code_cnet_sd1, + qr_code_cnet_sdxl, + canny_sd1, + inpaint_cnet_sd1, + mlsd_sd1, + depth_sd1, + normal_bae_sd1, + seg_sd1, + lineart_sd1, + lineart_anime_sd1, + openpose_sd1, + scribble_sd1, + softedge_sd1, + shuffle_sd1, + tile_sd1, + canny_sdxl, + depth_sdxl, + softedge_sdxl, + openpose_sdxl, + scribble_sdxl, + tile_sdxl, + union_cnet_sdxl, + union_cnet_flux, + t2i_canny_sd1, + t2i_sketch_sd1, + t2i_depth_sd1, + t2i_canny_sdxl, + t2i_lineart_sdxl, + t2i_sketch_sdxl, + realesrgan_x4, + realesrgan_anime, + realesrgan_x2, + swinir, + t5_base_encoder, + t5_8b_quantized_encoder, + clip_l_encoder, +] + +sd1_bundle: list[StarterModel] = [ + dreamshaper_8_sd1, + easy_neg_sd1, + ip_adapter_sd1, + ip_adapter_plus_sd1, + ip_adapter_plus_face_sd1, + canny_sd1, + inpaint_cnet_sd1, + mlsd_sd1, + depth_sd1, + normal_bae_sd1, + seg_sd1, + lineart_sd1, + lineart_anime_sd1, + openpose_sd1, + scribble_sd1, + softedge_sd1, + shuffle_sd1, + tile_sd1, + swinir, +] + +sdxl_bundle: list[StarterModel] = [ + juggernaut_sdxl, + sdxl_fp16_vae_fix, + ip_adapter_sdxl, + canny_sdxl, + depth_sdxl, + softedge_sdxl, + openpose_sdxl, + scribble_sdxl, + tile_sdxl, + swinir, +] + +flux_bundle: list[StarterModel] = [ + flux_schnell_quantized, + flux_dev_quantized, + flux_vae, + t5_8b_quantized_encoder, + clip_l_encoder, + union_cnet_flux, + ip_adapter_flux, +] + +STARTER_BUNDLES: dict[str, list[StarterModel]] = { + BaseModelType.StableDiffusion1: sd1_bundle, + BaseModelType.StableDiffusionXL: sdxl_bundle, + BaseModelType.Flux: flux_bundle, +} + +assert len(STARTER_MODELS) == len({m.source for m in STARTER_MODELS}), "Duplicate starter models" diff --git a/invokeai/backend/model_manager/util/libc_util.py b/invokeai/backend/model_manager/util/libc_util.py new file mode 100644 index 0000000000000000000000000000000000000000..ef1ac2f8a4b15ae27674fb0f3b8451a8bc3d518b --- /dev/null +++ b/invokeai/backend/model_manager/util/libc_util.py @@ -0,0 +1,76 @@ +import ctypes + + +class Struct_mallinfo2(ctypes.Structure): + """A ctypes Structure that matches the libc mallinfo2 struct. + + Docs: + - https://man7.org/linux/man-pages/man3/mallinfo.3.html + - https://www.gnu.org/software/libc/manual/html_node/Statistics-of-Malloc.html + + struct mallinfo2 { + size_t arena; /* Non-mmapped space allocated (bytes) */ + size_t ordblks; /* Number of free chunks */ + size_t smblks; /* Number of free fastbin blocks */ + size_t hblks; /* Number of mmapped regions */ + size_t hblkhd; /* Space allocated in mmapped regions (bytes) */ + size_t usmblks; /* See below */ + size_t fsmblks; /* Space in freed fastbin blocks (bytes) */ + size_t uordblks; /* Total allocated space (bytes) */ + size_t fordblks; /* Total free space (bytes) */ + size_t keepcost; /* Top-most, releasable space (bytes) */ + }; + """ + + _fields_ = [ + ("arena", ctypes.c_size_t), + ("ordblks", ctypes.c_size_t), + ("smblks", ctypes.c_size_t), + ("hblks", ctypes.c_size_t), + ("hblkhd", ctypes.c_size_t), + ("usmblks", ctypes.c_size_t), + ("fsmblks", ctypes.c_size_t), + ("uordblks", ctypes.c_size_t), + ("fordblks", ctypes.c_size_t), + ("keepcost", ctypes.c_size_t), + ] + + def __str__(self) -> str: + s = "" + s += f"{'arena': <10}= {(self.arena/2**30):15.5f} # Non-mmapped space allocated (GB) (uordblks + fordblks)\n" + s += f"{'ordblks': <10}= {(self.ordblks): >15} # Number of free chunks\n" + s += f"{'smblks': <10}= {(self.smblks): >15} # Number of free fastbin blocks \n" + s += f"{'hblks': <10}= {(self.hblks): >15} # Number of mmapped regions \n" + s += f"{'hblkhd': <10}= {(self.hblkhd/2**30):15.5f} # Space allocated in mmapped regions (GB)\n" + s += f"{'usmblks': <10}= {(self.usmblks): >15} # Unused\n" + s += f"{'fsmblks': <10}= {(self.fsmblks/2**30):15.5f} # Space in freed fastbin blocks (GB)\n" + s += ( + f"{'uordblks': <10}= {(self.uordblks/2**30):15.5f} # Space used by in-use allocations (non-mmapped)" + " (GB)\n" + ) + s += f"{'fordblks': <10}= {(self.fordblks/2**30):15.5f} # Space in free blocks (non-mmapped) (GB)\n" + s += f"{'keepcost': <10}= {(self.keepcost/2**30):15.5f} # Top-most, releasable space (GB)\n" + return s + + +class LibcUtil: + """A utility class for interacting with the C Standard Library (`libc`) via ctypes. + + Note that this class will raise on __init__() if 'libc.so.6' can't be found. Take care to handle environments where + this shared library is not available. + + TODO: Improve cross-OS compatibility of this class. + """ + + def __init__(self) -> None: + self._libc = ctypes.cdll.LoadLibrary("libc.so.6") + + def mallinfo2(self) -> Struct_mallinfo2: + """Calls `libc` `mallinfo2`. + + Docs: https://man7.org/linux/man-pages/man3/mallinfo.3.html + """ + mallinfo2 = self._libc.mallinfo2 + mallinfo2.restype = Struct_mallinfo2 + result: Struct_mallinfo2 = mallinfo2() + return result diff --git a/invokeai/backend/model_manager/util/model_util.py b/invokeai/backend/model_manager/util/model_util.py new file mode 100644 index 0000000000000000000000000000000000000000..e218124fb8ac03a56c22e6d4c4226fc09faab7f1 --- /dev/null +++ b/invokeai/backend/model_manager/util/model_util.py @@ -0,0 +1,190 @@ +"""Utilities for parsing model files, used mostly by probe.py""" + +import json +from pathlib import Path +from typing import Dict, Optional, Union + +import safetensors +import torch +from picklescan.scanner import scan_file_path + +from invokeai.backend.model_manager.config import ClipVariantType +from invokeai.backend.quantization.gguf.loaders import gguf_sd_loader + + +def _fast_safetensors_reader(path: str) -> Dict[str, torch.Tensor]: + checkpoint = {} + device = torch.device("meta") + with open(path, "rb") as f: + definition_len = int.from_bytes(f.read(8), "little") + definition_json = f.read(definition_len) + definition = json.loads(definition_json) + + if "__metadata__" in definition and definition["__metadata__"].get("format", "pt") not in { + "pt", + "torch", + "pytorch", + }: + raise Exception("Supported only pytorch safetensors files") + definition.pop("__metadata__", None) + + for key, info in definition.items(): + dtype = { + "I8": torch.int8, + "I16": torch.int16, + "I32": torch.int32, + "I64": torch.int64, + "F16": torch.float16, + "F32": torch.float32, + "F64": torch.float64, + }[info["dtype"]] + + checkpoint[key] = torch.empty(info["shape"], dtype=dtype, device=device) + + return checkpoint + + +def read_checkpoint_meta(path: Union[str, Path], scan: bool = False) -> Dict[str, torch.Tensor]: + if str(path).endswith(".safetensors"): + try: + path_str = path.as_posix() if isinstance(path, Path) else path + checkpoint = _fast_safetensors_reader(path_str) + except Exception: + # TODO: create issue for support "meta"? + checkpoint = safetensors.torch.load_file(path, device="cpu") + else: + if scan: + scan_result = scan_file_path(path) + if scan_result.infected_files != 0: + raise Exception(f'The model file "{path}" is potentially infected by malware. Aborting import.') + if str(path).endswith(".gguf"): + # The GGUF reader used here uses numpy memmap, so these tensors are not loaded into memory during this function + checkpoint = gguf_sd_loader(Path(path), compute_dtype=torch.float32) + else: + checkpoint = torch.load(path, map_location=torch.device("meta")) + return checkpoint + + +def lora_token_vector_length(checkpoint: Dict[str, torch.Tensor]) -> Optional[int]: + """ + Given a checkpoint in memory, return the lora token vector length + + :param checkpoint: The checkpoint + """ + + def _get_shape_1(key: str, tensor: torch.Tensor, checkpoint: Dict[str, torch.Tensor]) -> Optional[int]: + lora_token_vector_length = None + + if "." not in key: + return lora_token_vector_length # wrong key format + model_key, lora_key = key.split(".", 1) + + # check lora/locon + if lora_key == "lora_down.weight": + lora_token_vector_length = tensor.shape[1] + + # check loha (don't worry about hada_t1/hada_t2 as it used only in 4d shapes) + elif lora_key in ["hada_w1_b", "hada_w2_b"]: + lora_token_vector_length = tensor.shape[1] + + # check lokr (don't worry about lokr_t2 as it used only in 4d shapes) + elif "lokr_" in lora_key: + if model_key + ".lokr_w1" in checkpoint: + _lokr_w1 = checkpoint[model_key + ".lokr_w1"] + elif model_key + "lokr_w1_b" in checkpoint: + _lokr_w1 = checkpoint[model_key + ".lokr_w1_b"] + else: + return lora_token_vector_length # unknown format + + if model_key + ".lokr_w2" in checkpoint: + _lokr_w2 = checkpoint[model_key + ".lokr_w2"] + elif model_key + "lokr_w2_b" in checkpoint: + _lokr_w2 = checkpoint[model_key + ".lokr_w2_b"] + else: + return lora_token_vector_length # unknown format + + lora_token_vector_length = _lokr_w1.shape[1] * _lokr_w2.shape[1] + + elif lora_key == "diff": + lora_token_vector_length = tensor.shape[1] + + # ia3 can be detected only by shape[0] in text encoder + elif lora_key == "weight" and "lora_unet_" not in model_key: + lora_token_vector_length = tensor.shape[0] + + return lora_token_vector_length + + lora_token_vector_length = None + lora_te1_length = None + lora_te2_length = None + for key, tensor in checkpoint.items(): + if key.startswith("lora_unet_") and ("_attn2_to_k." in key or "_attn2_to_v." in key): + lora_token_vector_length = _get_shape_1(key, tensor, checkpoint) + elif key.startswith("lora_unet_") and ( + "time_emb_proj.lora_down" in key + ): # recognizes format at https://civitai.com/models/224641 + lora_token_vector_length = _get_shape_1(key, tensor, checkpoint) + elif key.startswith("lora_te") and "_self_attn_" in key: + tmp_length = _get_shape_1(key, tensor, checkpoint) + if key.startswith("lora_te_"): + lora_token_vector_length = tmp_length + elif key.startswith("lora_te1_"): + lora_te1_length = tmp_length + elif key.startswith("lora_te2_"): + lora_te2_length = tmp_length + + if lora_te1_length is not None and lora_te2_length is not None: + lora_token_vector_length = lora_te1_length + lora_te2_length + + if lora_token_vector_length is not None: + break + + return lora_token_vector_length + + +def convert_bundle_to_flux_transformer_checkpoint( + transformer_state_dict: dict[str, torch.Tensor], +) -> dict[str, torch.Tensor]: + original_state_dict: dict[str, torch.Tensor] = {} + keys_to_remove: list[str] = [] + + for k, v in transformer_state_dict.items(): + if not k.startswith("model.diffusion_model"): + keys_to_remove.append(k) # This can be removed in the future if we only want to delete transformer keys + continue + if k.endswith("scale"): + # Scale math must be done at bfloat16 due to our current flux model + # support limitations at inference time + v = v.to(dtype=torch.bfloat16) + new_key = k.replace("model.diffusion_model.", "") + original_state_dict[new_key] = v + keys_to_remove.append(k) + + # Remove processed keys from the original dictionary, leaving others in case + # other model state dicts need to be pulled + for k in keys_to_remove: + del transformer_state_dict[k] + + return original_state_dict + + +def get_clip_variant_type(location: str) -> Optional[ClipVariantType]: + try: + path = Path(location) + config_path = path / "config.json" + if not config_path.exists(): + config_path = path / "text_encoder" / "config.json" + if not config_path.exists(): + return ClipVariantType.L + with open(config_path) as file: + clip_conf = json.load(file) + hidden_size = clip_conf.get("hidden_size", -1) + match hidden_size: + case 1280: + return ClipVariantType.G + case 768: + return ClipVariantType.L + case _: + return ClipVariantType.L + except Exception: + return ClipVariantType.L diff --git a/invokeai/backend/model_manager/util/select_hf_files.py b/invokeai/backend/model_manager/util/select_hf_files.py new file mode 100644 index 0000000000000000000000000000000000000000..0f5cc763b20626fa7a8b7698041c0a7dac362adc --- /dev/null +++ b/invokeai/backend/model_manager/util/select_hf_files.py @@ -0,0 +1,202 @@ +# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team +""" +Select the files from a HuggingFace repository needed for a particular model variant. + +Usage: +``` +from invokeai.backend.model_manager.util.select_hf_files import select_hf_model_files +from invokeai.backend.model_manager.metadata.fetch import HuggingFaceMetadataFetch + +metadata = HuggingFaceMetadataFetch().from_url("https://huggingface.co/stabilityai/stable-diffusion-xl-base-1.0") +files_to_download = select_hf_model_files(metadata.files, variant='onnx') +``` +""" + +import re +from dataclasses import dataclass +from pathlib import Path +from typing import Dict, List, Optional, Set + +from invokeai.backend.model_manager.config import ModelRepoVariant + + +def filter_files( + files: List[Path], + variant: Optional[ModelRepoVariant] = None, + subfolder: Optional[Path] = None, +) -> List[Path]: + """ + Take a list of files in a HuggingFace repo root and return paths to files needed to load the model. + + :param files: List of files relative to the repo root. + :param subfolder: Filter by the indicated subfolder. + :param variant: Filter by files belonging to a particular variant, such as fp16. + + The file list can be obtained from the `files` field of HuggingFaceMetadata, + as defined in `invokeai.backend.model_manager.metadata.metadata_base`. + """ + variant = variant or ModelRepoVariant.Default + paths: List[Path] = [] + root = files[0].parts[0] + + # if the subfolder is a single file, then bypass the selection and just return it + if subfolder and subfolder.suffix in [".safetensors", ".bin", ".onnx", ".xml", ".pth", ".pt", ".ckpt", ".msgpack"]: + return [root / subfolder] + + # Start by filtering on model file extensions, discarding images, docs, etc + for file in files: + if file.name.endswith((".json", ".txt")): + paths.append(file) + elif file.name.endswith( + ( + "learned_embeds.bin", + "ip_adapter.bin", + "lora_weights.safetensors", + "weights.pb", + "onnx_data", + "spiece.model", # Added for `black-forest-labs/FLUX.1-schnell`. + ) + ): + paths.append(file) + # BRITTLENESS WARNING!! + # Diffusers models always seem to have "model" in their name, and the regex filter below is applied to avoid + # downloading random checkpoints that might also be in the repo. However there is no guarantee + # that a checkpoint doesn't contain "model" in its name, and no guarantee that future diffusers models + # will adhere to this naming convention, so this is an area to be careful of. + elif re.search(r"model.*\.(safetensors|bin|onnx|xml|pth|pt|ckpt|msgpack)$", file.name): + paths.append(file) + + # limit search to subfolder if requested + if subfolder: + subfolder = root / subfolder + paths = [x for x in paths if Path(subfolder) in x.parents] + + # _filter_by_variant uniquifies the paths and returns a set + return sorted(_filter_by_variant(paths, variant)) + + +@dataclass +class SubfolderCandidate: + path: Path + score: int + + +def _filter_by_variant(files: List[Path], variant: ModelRepoVariant) -> Set[Path]: + """Select the proper variant files from a list of HuggingFace repo_id paths.""" + result: set[Path] = set() + subfolder_weights: dict[Path, list[SubfolderCandidate]] = {} + safetensors_detected = False + for path in files: + if path.suffix in [".onnx", ".pb", ".onnx_data"]: + if variant == ModelRepoVariant.ONNX: + result.add(path) + + elif "openvino_model" in path.name: + if variant == ModelRepoVariant.OpenVINO: + result.add(path) + + elif "flax_model" in path.name: + if variant == ModelRepoVariant.Flax: + result.add(path) + + # Note: '.model' was added to support: + # https://huggingface.co/black-forest-labs/FLUX.1-schnell/blob/768d12a373ed5cc9ef9a9dea7504dc09fcc14842/tokenizer_2/spiece.model + elif path.suffix in [".json", ".txt", ".model"]: + result.add(path) + + elif variant in [ + ModelRepoVariant.FP16, + ModelRepoVariant.FP32, + ModelRepoVariant.Default, + ] and path.suffix in [".bin", ".safetensors", ".pt", ".ckpt"]: + # For weights files, we want to select the best one for each subfolder. For example, we may have multiple + # text encoders: + # + # - text_encoder/model.fp16.safetensors + # - text_encoder/model.safetensors + # - text_encoder/pytorch_model.bin + # - text_encoder/pytorch_model.fp16.bin + # + # We prefer safetensors over other file formats and an exact variant match. We'll score each file based on + # variant and format and select the best one. + + if safetensors_detected and path.suffix == ".bin": + continue + + parent = path.parent + score = 0 + + if path.suffix == ".safetensors": + safetensors_detected = True + if parent in subfolder_weights: + subfolder_weights[parent] = [sfc for sfc in subfolder_weights[parent] if sfc.path.suffix != ".bin"] + score += 1 + + candidate_variant_label = path.suffixes[0] if len(path.suffixes) == 2 else None + + # Some special handling is needed here if there is not an exact match and if we cannot infer the variant + # from the file name. In this case, we only give this file a point if the requested variant is FP32 or DEFAULT. + if ( + variant is not ModelRepoVariant.Default + and candidate_variant_label + and candidate_variant_label.startswith(f".{variant.value}") + ) or (not candidate_variant_label and variant in [ModelRepoVariant.FP32, ModelRepoVariant.Default]): + score += 1 + + if parent not in subfolder_weights: + subfolder_weights[parent] = [] + + subfolder_weights[parent].append(SubfolderCandidate(path=path, score=score)) + + else: + continue + + for candidate_list in subfolder_weights.values(): + # Check if at least one of the files has the explicit fp16 variant. + at_least_one_fp16 = False + for candidate in candidate_list: + if len(candidate.path.suffixes) == 2 and candidate.path.suffixes[0].startswith(".fp16"): + at_least_one_fp16 = True + break + + if not at_least_one_fp16: + # If none of the candidates in this candidate_list have the explicit fp16 variant label, then this + # candidate_list probably doesn't adhere to the variant naming convention that we expected. In this case, + # we'll simply keep all the candidates. An example of a model that hits this case is + # `black-forest-labs/FLUX.1-schnell` (as of commit 012d2fd). + for candidate in candidate_list: + result.add(candidate.path) + + # The candidate_list seems to have the expected variant naming convention. We'll select the highest scoring + # candidate. + highest_score_candidate = max(candidate_list, key=lambda candidate: candidate.score) + if highest_score_candidate: + pattern = r"^(.*?)-\d+-of-\d+(\.\w+)$" + match = re.match(pattern, highest_score_candidate.path.as_posix()) + if match: + for candidate in candidate_list: + if candidate.path.as_posix().startswith(match.group(1)) and candidate.path.as_posix().endswith( + match.group(2) + ): + result.add(candidate.path) + else: + result.add(highest_score_candidate.path) + + # If one of the architecture-related variants was specified and no files matched other than + # config and text files then we return an empty list + if ( + variant + and variant in [ModelRepoVariant.ONNX, ModelRepoVariant.OpenVINO, ModelRepoVariant.Flax] + and not any(variant.value in x.name for x in result) + ): + return set() + + # Prune folders that contain just a `config.json`. This happens when + # the requested variant (e.g. "onnx") is missing + directories: Dict[Path, int] = {} + for x in result: + if not x.parent: + continue + directories[x.parent] = directories.get(x.parent, 0) + 1 + + return {x for x in result if directories[x.parent] > 1 or x.name != "config.json"} diff --git a/invokeai/backend/model_patcher.py b/invokeai/backend/model_patcher.py new file mode 100644 index 0000000000000000000000000000000000000000..705ac6e685de033d6dffcf509e110f9afb2059f4 --- /dev/null +++ b/invokeai/backend/model_patcher.py @@ -0,0 +1,355 @@ +# Copyright (c) 2024 Ryan Dick, Lincoln D. Stein, and the InvokeAI Development Team +"""These classes implement model patching with LoRAs and Textual Inversions.""" + +from __future__ import annotations + +import pickle +from contextlib import contextmanager +from typing import Any, Dict, Iterator, List, Optional, Tuple, Type, Union + +import numpy as np +import torch +from diffusers import UNet2DConditionModel +from transformers import CLIPTextModel, CLIPTextModelWithProjection, CLIPTokenizer + +from invokeai.app.shared.models import FreeUConfig +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.model_manager.load.optimizations import skip_torch_weight_init +from invokeai.backend.onnx.onnx_runtime import IAIOnnxRuntimeModel +from invokeai.backend.textual_inversion import TextualInversionManager, TextualInversionModelRaw + + +class ModelPatcher: + @staticmethod + @contextmanager + def patch_unet_attention_processor(unet: UNet2DConditionModel, processor_cls: Type[Any]): + """A context manager that patches `unet` with the provided attention processor. + + Args: + unet (UNet2DConditionModel): The UNet model to patch. + processor (Type[Any]): Class which will be initialized for each key and passed to set_attn_processor(...). + """ + unet_orig_processors = unet.attn_processors + + # create separate instance for each attention, to be able modify each attention separately + unet_new_processors = {key: processor_cls() for key in unet_orig_processors.keys()} + try: + unet.set_attn_processor(unet_new_processors) + yield None + + finally: + unet.set_attn_processor(unet_orig_processors) + + @classmethod + @contextmanager + def apply_ti( + cls, + tokenizer: CLIPTokenizer, + text_encoder: Union[CLIPTextModel, CLIPTextModelWithProjection], + ti_list: List[Tuple[str, TextualInversionModelRaw]], + ) -> Iterator[Tuple[CLIPTokenizer, TextualInversionManager]]: + init_tokens_count = None + new_tokens_added = None + + # TODO: This is required since Transformers 4.32 see + # https://github.com/huggingface/transformers/pull/25088 + # More information by NVIDIA: + # https://docs.nvidia.com/deeplearning/performance/dl-performance-matrix-multiplication/index.html#requirements-tc + # This value might need to be changed in the future and take the GPUs model into account as there seem + # to be ideal values for different GPUS. This value is temporary! + # For references to the current discussion please see https://github.com/invoke-ai/InvokeAI/pull/4817 + pad_to_multiple_of = 8 + + try: + # HACK: The CLIPTokenizer API does not include a way to remove tokens after calling add_tokens(...). As a + # workaround, we create a full copy of `tokenizer` so that its original behavior can be restored after + # exiting this `apply_ti(...)` context manager. + # + # In a previous implementation, the deep copy was obtained with `ti_tokenizer = copy.deepcopy(tokenizer)`, + # but a pickle roundtrip was found to be much faster (1 sec vs. 0.05 secs). + ti_tokenizer = pickle.loads(pickle.dumps(tokenizer)) + ti_manager = TextualInversionManager(ti_tokenizer) + init_tokens_count = text_encoder.resize_token_embeddings(None, pad_to_multiple_of).num_embeddings + + def _get_trigger(ti_name: str, index: int) -> str: + trigger = ti_name + if index > 0: + trigger += f"-!pad-{i}" + return f"<{trigger}>" + + def _get_ti_embedding(model_embeddings: torch.nn.Module, ti: TextualInversionModelRaw) -> torch.Tensor: + # for SDXL models, select the embedding that matches the text encoder's dimensions + if ti.embedding_2 is not None: + return ( + ti.embedding_2 + if ti.embedding_2.shape[1] == model_embeddings.weight.data[0].shape[0] + else ti.embedding + ) + else: + return ti.embedding + + # modify tokenizer + new_tokens_added = 0 + for ti_name, ti in ti_list: + ti_embedding = _get_ti_embedding(text_encoder.get_input_embeddings(), ti) + + for i in range(ti_embedding.shape[0]): + new_tokens_added += ti_tokenizer.add_tokens(_get_trigger(ti_name, i)) + + # Modify text_encoder. + # resize_token_embeddings(...) constructs a new torch.nn.Embedding internally. Initializing the weights of + # this embedding is slow and unnecessary, so we wrap this step in skip_torch_weight_init() to save some + # time. + with skip_torch_weight_init(): + text_encoder.resize_token_embeddings(init_tokens_count + new_tokens_added, pad_to_multiple_of) + model_embeddings = text_encoder.get_input_embeddings() + + for ti_name, ti in ti_list: + assert isinstance(ti, TextualInversionModelRaw) + ti_embedding = _get_ti_embedding(text_encoder.get_input_embeddings(), ti) + + ti_tokens = [] + for i in range(ti_embedding.shape[0]): + embedding = ti_embedding[i] + trigger = _get_trigger(ti_name, i) + + token_id = ti_tokenizer.convert_tokens_to_ids(trigger) + if token_id == ti_tokenizer.unk_token_id: + raise RuntimeError(f"Unable to find token id for token '{trigger}'") + + if model_embeddings.weight.data[token_id].shape != embedding.shape: + raise ValueError( + f"Cannot load embedding for {trigger}. It was trained on a model with token dimension" + f" {embedding.shape[0]}, but the current model has token dimension" + f" {model_embeddings.weight.data[token_id].shape[0]}." + ) + + model_embeddings.weight.data[token_id] = embedding.to( + device=text_encoder.device, dtype=text_encoder.dtype + ) + ti_tokens.append(token_id) + + if len(ti_tokens) > 1: + ti_manager.pad_tokens[ti_tokens[0]] = ti_tokens[1:] + + yield ti_tokenizer, ti_manager + + finally: + if init_tokens_count and new_tokens_added: + text_encoder.resize_token_embeddings(init_tokens_count, pad_to_multiple_of) + + @classmethod + @contextmanager + def apply_clip_skip( + cls, + text_encoder: Union[CLIPTextModel, CLIPTextModelWithProjection], + clip_skip: int, + ) -> None: + skipped_layers = [] + try: + for _i in range(clip_skip): + skipped_layers.append(text_encoder.text_model.encoder.layers.pop(-1)) + + yield + + finally: + while len(skipped_layers) > 0: + text_encoder.text_model.encoder.layers.append(skipped_layers.pop()) + + @classmethod + @contextmanager + def apply_freeu( + cls, + unet: UNet2DConditionModel, + freeu_config: Optional[FreeUConfig] = None, + ) -> None: + did_apply_freeu = False + try: + assert hasattr(unet, "enable_freeu") # mypy doesn't pick up this attribute? + if freeu_config is not None: + unet.enable_freeu(b1=freeu_config.b1, b2=freeu_config.b2, s1=freeu_config.s1, s2=freeu_config.s2) + did_apply_freeu = True + + yield + + finally: + assert hasattr(unet, "disable_freeu") # mypy doesn't pick up this attribute? + if did_apply_freeu: + unet.disable_freeu() + + +class ONNXModelPatcher: + # based on + # https://github.com/ssube/onnx-web/blob/ca2e436f0623e18b4cfe8a0363fcfcf10508acf7/api/onnx_web/convert/diffusion/lora.py#L323 + @classmethod + @contextmanager + def apply_lora( + cls, + model: IAIOnnxRuntimeModel, + loras: List[Tuple[LoRAModelRaw, float]], + prefix: str, + ) -> None: + from invokeai.backend.models.base import IAIOnnxRuntimeModel + + if not isinstance(model, IAIOnnxRuntimeModel): + raise Exception("Only IAIOnnxRuntimeModel models supported") + + orig_weights = {} + + try: + blended_loras: Dict[str, torch.Tensor] = {} + + for lora, lora_weight in loras: + for layer_key, layer in lora.layers.items(): + if not layer_key.startswith(prefix): + continue + + layer.to(dtype=torch.float32) + layer_key = layer_key.replace(prefix, "") + # TODO: rewrite to pass original tensor weight(required by ia3) + layer_weight = layer.get_weight(None).detach().cpu().numpy() * lora_weight + if layer_key in blended_loras: + blended_loras[layer_key] += layer_weight + else: + blended_loras[layer_key] = layer_weight + + node_names = {} + for node in model.nodes.values(): + node_names[node.name.replace("/", "_").replace(".", "_").lstrip("_")] = node.name + + for layer_key, lora_weight in blended_loras.items(): + conv_key = layer_key + "_Conv" + gemm_key = layer_key + "_Gemm" + matmul_key = layer_key + "_MatMul" + + if conv_key in node_names or gemm_key in node_names: + if conv_key in node_names: + conv_node = model.nodes[node_names[conv_key]] + else: + conv_node = model.nodes[node_names[gemm_key]] + + weight_name = [n for n in conv_node.input if ".weight" in n][0] + orig_weight = model.tensors[weight_name] + + if orig_weight.shape[-2:] == (1, 1): + if lora_weight.shape[-2:] == (1, 1): + new_weight = orig_weight.squeeze((3, 2)) + lora_weight.squeeze((3, 2)) + else: + new_weight = orig_weight.squeeze((3, 2)) + lora_weight + + new_weight = np.expand_dims(new_weight, (2, 3)) + else: + if orig_weight.shape != lora_weight.shape: + new_weight = orig_weight + lora_weight.reshape(orig_weight.shape) + else: + new_weight = orig_weight + lora_weight + + orig_weights[weight_name] = orig_weight + model.tensors[weight_name] = new_weight.astype(orig_weight.dtype) + + elif matmul_key in node_names: + weight_node = model.nodes[node_names[matmul_key]] + matmul_name = [n for n in weight_node.input if "MatMul" in n][0] + + orig_weight = model.tensors[matmul_name] + new_weight = orig_weight + lora_weight.transpose() + + orig_weights[matmul_name] = orig_weight + model.tensors[matmul_name] = new_weight.astype(orig_weight.dtype) + + else: + # warn? err? + pass + + yield + + finally: + # restore original weights + for name, orig_weight in orig_weights.items(): + model.tensors[name] = orig_weight + + @classmethod + @contextmanager + def apply_ti( + cls, + tokenizer: CLIPTokenizer, + text_encoder: IAIOnnxRuntimeModel, + ti_list: List[Tuple[str, Any]], + ) -> Iterator[Tuple[CLIPTokenizer, TextualInversionManager]]: + from invokeai.backend.models.base import IAIOnnxRuntimeModel + + if not isinstance(text_encoder, IAIOnnxRuntimeModel): + raise Exception("Only IAIOnnxRuntimeModel models supported") + + orig_embeddings = None + + try: + # HACK: The CLIPTokenizer API does not include a way to remove tokens after calling add_tokens(...). As a + # workaround, we create a full copy of `tokenizer` so that its original behavior can be restored after + # exiting this `apply_ti(...)` context manager. + # + # In a previous implementation, the deep copy was obtained with `ti_tokenizer = copy.deepcopy(tokenizer)`, + # but a pickle roundtrip was found to be much faster (1 sec vs. 0.05 secs). + ti_tokenizer = pickle.loads(pickle.dumps(tokenizer)) + ti_manager = TextualInversionManager(ti_tokenizer) + + def _get_trigger(ti_name: str, index: int) -> str: + trigger = ti_name + if index > 0: + trigger += f"-!pad-{i}" + return f"<{trigger}>" + + # modify text_encoder + orig_embeddings = text_encoder.tensors["text_model.embeddings.token_embedding.weight"] + + # modify tokenizer + new_tokens_added = 0 + for ti_name, ti in ti_list: + if ti.embedding_2 is not None: + ti_embedding = ( + ti.embedding_2 if ti.embedding_2.shape[1] == orig_embeddings.shape[0] else ti.embedding + ) + else: + ti_embedding = ti.embedding + + for i in range(ti_embedding.shape[0]): + new_tokens_added += ti_tokenizer.add_tokens(_get_trigger(ti_name, i)) + + embeddings = np.concatenate( + (np.copy(orig_embeddings), np.zeros((new_tokens_added, orig_embeddings.shape[1]))), + axis=0, + ) + + for ti_name, _ in ti_list: + ti_tokens = [] + for i in range(ti_embedding.shape[0]): + embedding = ti_embedding[i].detach().numpy() + trigger = _get_trigger(ti_name, i) + + token_id = ti_tokenizer.convert_tokens_to_ids(trigger) + if token_id == ti_tokenizer.unk_token_id: + raise RuntimeError(f"Unable to find token id for token '{trigger}'") + + if embeddings[token_id].shape != embedding.shape: + raise ValueError( + f"Cannot load embedding for {trigger}. It was trained on a model with token dimension" + f" {embedding.shape[0]}, but the current model has token dimension" + f" {embeddings[token_id].shape[0]}." + ) + + embeddings[token_id] = embedding + ti_tokens.append(token_id) + + if len(ti_tokens) > 1: + ti_manager.pad_tokens[ti_tokens[0]] = ti_tokens[1:] + + text_encoder.tensors["text_model.embeddings.token_embedding.weight"] = embeddings.astype( + orig_embeddings.dtype + ) + + yield ti_tokenizer, ti_manager + + finally: + # restore + if orig_embeddings is not None: + text_encoder.tensors["text_model.embeddings.token_embedding.weight"] = orig_embeddings diff --git a/invokeai/backend/onnx/onnx_runtime.py b/invokeai/backend/onnx/onnx_runtime.py new file mode 100644 index 0000000000000000000000000000000000000000..a8132d4b2337d765ecbd506db5d0d8b72694e6cd --- /dev/null +++ b/invokeai/backend/onnx/onnx_runtime.py @@ -0,0 +1,223 @@ +# Copyright (c) 2024 The InvokeAI Development Team +import os +import sys +from pathlib import Path +from typing import Any, List, Optional, Tuple, Union + +import numpy as np +import onnx +import torch +from onnx import numpy_helper +from onnxruntime import InferenceSession, SessionOptions, get_available_providers + +from invokeai.backend.raw_model import RawModel + +ONNX_WEIGHTS_NAME = "model.onnx" + + +# NOTE FROM LS: This was copied from Stalker's original implementation. +# I have not yet gone through and fixed all the type hints +class IAIOnnxRuntimeModel(RawModel): + class _tensor_access: + def __init__(self, model): # type: ignore + self.model = model + self.indexes = {} + for idx, obj in enumerate(self.model.proto.graph.initializer): + self.indexes[obj.name] = idx + + def __getitem__(self, key: str): # type: ignore + value = self.model.proto.graph.initializer[self.indexes[key]] + return numpy_helper.to_array(value) + + def __setitem__(self, key: str, value: np.ndarray): # type: ignore + new_node = numpy_helper.from_array(value) + # set_external_data(new_node, location="in-memory-location") + new_node.name = key + # new_node.ClearField("raw_data") + del self.model.proto.graph.initializer[self.indexes[key]] + self.model.proto.graph.initializer.insert(self.indexes[key], new_node) + # self.model.data[key] = OrtValue.ortvalue_from_numpy(value) + + # __delitem__ + + def __contains__(self, key: str) -> bool: + return self.indexes[key] in self.model.proto.graph.initializer + + def items(self) -> List[Tuple[str, Any]]: # fixme + raise NotImplementedError("tensor.items") + # return [(obj.name, obj) for obj in self.raw_proto] + + def keys(self) -> List[str]: + return list(self.indexes.keys()) + + def values(self) -> List[Any]: # fixme + raise NotImplementedError("tensor.values") + # return [obj for obj in self.raw_proto] + + def size(self) -> int: + bytesSum = 0 + for node in self.model.proto.graph.initializer: + bytesSum += sys.getsizeof(node.raw_data) + return bytesSum + + class _access_helper: + def __init__(self, raw_proto): # type: ignore + self.indexes = {} + self.raw_proto = raw_proto + for idx, obj in enumerate(raw_proto): + self.indexes[obj.name] = idx + + def __getitem__(self, key: str): # type: ignore + return self.raw_proto[self.indexes[key]] + + def __setitem__(self, key: str, value): # type: ignore + index = self.indexes[key] + del self.raw_proto[index] + self.raw_proto.insert(index, value) + + # __delitem__ + + def __contains__(self, key: str) -> bool: + return key in self.indexes + + def items(self) -> List[Tuple[str, Any]]: + return [(obj.name, obj) for obj in self.raw_proto] + + def keys(self) -> List[str]: + return list(self.indexes.keys()) + + def values(self) -> List[Any]: # fixme + return list(self.raw_proto) + + def __init__(self, model_path: str, provider: Optional[str]): + self.path = model_path + self.session = None + self.provider = provider + """ + self.data_path = self.path + "_data" + if not os.path.exists(self.data_path): + print(f"Moving model tensors to separate file: {self.data_path}") + tmp_proto = onnx.load(model_path, load_external_data=True) + onnx.save_model(tmp_proto, self.path, save_as_external_data=True, all_tensors_to_one_file=True, location=os.path.basename(self.data_path), size_threshold=1024, convert_attribute=False) + del tmp_proto + gc.collect() + + self.proto = onnx.load(model_path, load_external_data=False) + """ + + self.proto = onnx.load(model_path, load_external_data=True) + # self.data = dict() + # for tensor in self.proto.graph.initializer: + # name = tensor.name + + # if tensor.HasField("raw_data"): + # npt = numpy_helper.to_array(tensor) + # orv = OrtValue.ortvalue_from_numpy(npt) + # # self.data[name] = orv + # # set_external_data(tensor, location="in-memory-location") + # tensor.name = name + # # tensor.ClearField("raw_data") + + self.nodes = self._access_helper(self.proto.graph.node) # type: ignore + # self.initializers = self._access_helper(self.proto.graph.initializer) + # print(self.proto.graph.input) + # print(self.proto.graph.initializer) + + self.tensors = self._tensor_access(self) # type: ignore + + # TODO: integrate with model manager/cache + def create_session(self, height=None, width=None): + if self.session is None or self.session_width != width or self.session_height != height: + # onnx.save(self.proto, "tmp.onnx") + # onnx.save_model(self.proto, "tmp.onnx", save_as_external_data=True, all_tensors_to_one_file=True, location="tmp.onnx_data", size_threshold=1024, convert_attribute=False) + # TODO: something to be able to get weight when they already moved outside of model proto + # (trimmed_model, external_data) = buffer_external_data_tensors(self.proto) + sess = SessionOptions() + # self._external_data.update(**external_data) + # sess.add_external_initializers(list(self.data.keys()), list(self.data.values())) + # sess.enable_profiling = True + + # sess.intra_op_num_threads = 1 + # sess.inter_op_num_threads = 1 + # sess.execution_mode = ExecutionMode.ORT_SEQUENTIAL + # sess.graph_optimization_level = GraphOptimizationLevel.ORT_ENABLE_ALL + # sess.enable_cpu_mem_arena = True + # sess.enable_mem_pattern = True + # sess.add_session_config_entry("session.intra_op.use_xnnpack_threadpool", "1") ########### It's the key code + self.session_height = height + self.session_width = width + if height and width: + sess.add_free_dimension_override_by_name("unet_sample_batch", 2) + sess.add_free_dimension_override_by_name("unet_sample_channels", 4) + sess.add_free_dimension_override_by_name("unet_hidden_batch", 2) + sess.add_free_dimension_override_by_name("unet_hidden_sequence", 77) + sess.add_free_dimension_override_by_name("unet_sample_height", self.session_height) + sess.add_free_dimension_override_by_name("unet_sample_width", self.session_width) + sess.add_free_dimension_override_by_name("unet_time_batch", 1) + providers = [] + if self.provider: + providers.append(self.provider) + else: + providers = get_available_providers() + if "TensorrtExecutionProvider" in providers: + providers.remove("TensorrtExecutionProvider") + try: + self.session = InferenceSession(self.proto.SerializeToString(), providers=providers, sess_options=sess) + except Exception as e: + raise e + # self.session = InferenceSession("tmp.onnx", providers=[self.provider], sess_options=self.sess_options) + # self.io_binding = self.session.io_binding() + + def release_session(self): + self.session = None + import gc + + gc.collect() + return + + def __call__(self, **kwargs): + if self.session is None: + raise Exception("You should call create_session before running model") + + inputs = {k: np.array(v) for k, v in kwargs.items()} + # output_names = self.session.get_outputs() + # for k in inputs: + # self.io_binding.bind_cpu_input(k, inputs[k]) + # for name in output_names: + # self.io_binding.bind_output(name.name) + # self.session.run_with_iobinding(self.io_binding, None) + # return self.io_binding.copy_outputs_to_cpu() + return self.session.run(None, inputs) + + # compatability with RawModel ABC + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None) -> None: + pass + + # compatability with diffusers load code + @classmethod + def from_pretrained( + cls, + model_id: Union[str, Path], + subfolder: Optional[Union[str, Path]] = None, + file_name: Optional[str] = None, + provider: Optional[str] = None, + sess_options: Optional["SessionOptions"] = None, + **kwargs: Any, + ) -> Any: # fixme + file_name = file_name or ONNX_WEIGHTS_NAME + + if os.path.isdir(model_id): + model_path = model_id + if subfolder is not None: + model_path = os.path.join(model_path, subfolder) + model_path = os.path.join(model_path, file_name) + + else: + model_path = model_id + + # load model from local directory + if not os.path.isfile(model_path): + raise Exception(f"Model not found: {model_path}") + + # TODO: session options + return cls(str(model_path), provider=provider) diff --git a/invokeai/backend/quantization/__init__.py b/invokeai/backend/quantization/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/quantization/bnb_llm_int8.py b/invokeai/backend/quantization/bnb_llm_int8.py new file mode 100644 index 0000000000000000000000000000000000000000..02f94936e96305d3365d1717323879dffb7973f4 --- /dev/null +++ b/invokeai/backend/quantization/bnb_llm_int8.py @@ -0,0 +1,135 @@ +import bitsandbytes as bnb +import torch + +# This file contains utils for working with models that use bitsandbytes LLM.int8() quantization. +# The utils in this file are partially inspired by: +# https://github.com/Lightning-AI/pytorch-lightning/blob/1551a16b94f5234a4a78801098f64d0732ef5cb5/src/lightning/fabric/plugins/precision/bitsandbytes.py + + +# NOTE(ryand): All of the custom state_dict manipulation logic in this file is pretty hacky. This could be made much +# cleaner by re-implementing bnb.nn.Linear8bitLt with proper use of buffers and less magic. But, for now, we try to +# stick close to the bitsandbytes classes to make interoperability easier with other models that might use bitsandbytes. + + +class InvokeInt8Params(bnb.nn.Int8Params): + """We override cuda() to avoid re-quantizing the weights in the following cases: + - We loaded quantized weights from a state_dict on the cpu, and then moved the model to the gpu. + - We are moving the model back-and-forth between the cpu and gpu. + """ + + def cuda(self, device): + if self.has_fp16_weights: + return super().cuda(device) + elif self.CB is not None and self.SCB is not None: + self.data = self.data.cuda() + self.CB = self.data + self.SCB = self.SCB.cuda() + else: + # we store the 8-bit rows-major weight + # we convert this weight to the turning/ampere weight during the first inference pass + B = self.data.contiguous().half().cuda(device) + CB, CBt, SCB, SCBt, coo_tensorB = bnb.functional.double_quant(B) + del CBt + del SCBt + self.data = CB + self.CB = CB + self.SCB = SCB + + return self + + +class InvokeLinear8bitLt(bnb.nn.Linear8bitLt): + def _load_from_state_dict( + self, + state_dict: dict[str, torch.Tensor], + prefix: str, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ): + weight = state_dict.pop(prefix + "weight") + bias = state_dict.pop(prefix + "bias", None) + + # See `bnb.nn.Linear8bitLt._save_to_state_dict()` for the serialization logic of SCB and weight_format. + scb = state_dict.pop(prefix + "SCB", None) + + # Currently, we only support weight_format=0. + weight_format = state_dict.pop(prefix + "weight_format", None) + assert weight_format == 0 + + # TODO(ryand): Technically, we should be using `strict`, `missing_keys`, `unexpected_keys`, and `error_msgs` + # rather than raising an exception to correctly implement this API. + assert len(state_dict) == 0 + + if scb is not None: + # We are loading a pre-quantized state dict. + self.weight = InvokeInt8Params( + data=weight, + requires_grad=self.weight.requires_grad, + has_fp16_weights=False, + # Note: After quantization, CB is the same as weight. + CB=weight, + SCB=scb, + ) + self.bias = bias if bias is None else torch.nn.Parameter(bias) + else: + # We are loading a non-quantized state dict. + + # We could simply call the `super()._load_from_state_dict()` method here, but then we wouldn't be able to + # load from a state_dict into a model on the "meta" device. Attempting to load into a model on the "meta" + # device requires setting `assign=True`, doing this with the default `super()._load_from_state_dict()` + # implementation causes `Params4Bit` to be replaced by a `torch.nn.Parameter`. By initializing a new + # `Params4bit` object, we work around this issue. It's a bit hacky, but it gets the job done. + self.weight = InvokeInt8Params( + data=weight, + requires_grad=self.weight.requires_grad, + has_fp16_weights=False, + CB=None, + SCB=None, + ) + self.bias = bias if bias is None else torch.nn.Parameter(bias) + + # Reset the state. The persisted fields are based on the initialization behaviour in + # `bnb.nn.Linear8bitLt.__init__()`. + new_state = bnb.MatmulLtState() + new_state.threshold = self.state.threshold + new_state.has_fp16_weights = False + new_state.use_pool = self.state.use_pool + self.state = new_state + + +def _convert_linear_layers_to_llm_8bit( + module: torch.nn.Module, ignore_modules: set[str], outlier_threshold: float, prefix: str = "" +) -> None: + """Convert all linear layers in the module to bnb.nn.Linear8bitLt layers.""" + for name, child in module.named_children(): + fullname = f"{prefix}.{name}" if prefix else name + if isinstance(child, torch.nn.Linear) and not any(fullname.startswith(s) for s in ignore_modules): + has_bias = child.bias is not None + replacement = InvokeLinear8bitLt( + child.in_features, + child.out_features, + bias=has_bias, + has_fp16_weights=False, + threshold=outlier_threshold, + ) + replacement.weight.data = child.weight.data + if has_bias: + replacement.bias.data = child.bias.data + replacement.requires_grad_(False) + module.__setattr__(name, replacement) + else: + _convert_linear_layers_to_llm_8bit( + child, ignore_modules, outlier_threshold=outlier_threshold, prefix=fullname + ) + + +def quantize_model_llm_int8(model: torch.nn.Module, modules_to_not_convert: set[str], outlier_threshold: float = 6.0): + """Apply bitsandbytes LLM.8bit() quantization to the model.""" + _convert_linear_layers_to_llm_8bit( + module=model, ignore_modules=modules_to_not_convert, outlier_threshold=outlier_threshold + ) + + return model diff --git a/invokeai/backend/quantization/bnb_nf4.py b/invokeai/backend/quantization/bnb_nf4.py new file mode 100644 index 0000000000000000000000000000000000000000..105bf1474c18823a0b5ac8945a8cfdf43b374672 --- /dev/null +++ b/invokeai/backend/quantization/bnb_nf4.py @@ -0,0 +1,156 @@ +import bitsandbytes as bnb +import torch + +# This file contains utils for working with models that use bitsandbytes NF4 quantization. +# The utils in this file are partially inspired by: +# https://github.com/Lightning-AI/pytorch-lightning/blob/1551a16b94f5234a4a78801098f64d0732ef5cb5/src/lightning/fabric/plugins/precision/bitsandbytes.py + +# NOTE(ryand): All of the custom state_dict manipulation logic in this file is pretty hacky. This could be made much +# cleaner by re-implementing bnb.nn.LinearNF4 with proper use of buffers and less magic. But, for now, we try to stick +# close to the bitsandbytes classes to make interoperability easier with other models that might use bitsandbytes. + + +class InvokeLinearNF4(bnb.nn.LinearNF4): + """A class that extends `bnb.nn.LinearNF4` to add the following functionality: + - Ability to load Linear NF4 layers from a pre-quantized state_dict. + - Ability to load Linear NF4 layers from a state_dict when the model is on the "meta" device. + """ + + def _load_from_state_dict( + self, + state_dict: dict[str, torch.Tensor], + prefix: str, + local_metadata, + strict, + missing_keys, + unexpected_keys, + error_msgs, + ): + """This method is based on the logic in the bitsandbytes serialization unit tests for `Linear4bit`: + https://github.com/bitsandbytes-foundation/bitsandbytes/blob/6d714a5cce3db5bd7f577bc447becc7a92d5ccc7/tests/test_linear4bit.py#L52-L71 + """ + weight = state_dict.pop(prefix + "weight") + bias = state_dict.pop(prefix + "bias", None) + # We expect the remaining keys to be quant_state keys. + quant_state_sd = state_dict + + # During serialization, the quant_state is stored as subkeys of "weight." (See + # `bnb.nn.LinearNF4._save_to_state_dict()`). We validate that they at least have the correct prefix. + # TODO(ryand): Technically, we should be using `strict`, `missing_keys`, `unexpected_keys`, and `error_msgs` + # rather than raising an exception to correctly implement this API. + assert all(k.startswith(prefix + "weight.") for k in quant_state_sd.keys()) + + if len(quant_state_sd) > 0: + # We are loading a pre-quantized state dict. + self.weight = bnb.nn.Params4bit.from_prequantized( + data=weight, quantized_stats=quant_state_sd, device=weight.device + ) + self.bias = bias if bias is None else torch.nn.Parameter(bias, requires_grad=False) + else: + # We are loading a non-quantized state dict. + + # We could simply call the `super()._load_from_state_dict()` method here, but then we wouldn't be able to + # load from a state_dict into a model on the "meta" device. Attempting to load into a model on the "meta" + # device requires setting `assign=True`, doing this with the default `super()._load_from_state_dict()` + # implementation causes `Params4Bit` to be replaced by a `torch.nn.Parameter`. By initializing a new + # `Params4bit` object, we work around this issue. It's a bit hacky, but it gets the job done. + self.weight = bnb.nn.Params4bit( + data=weight, + requires_grad=self.weight.requires_grad, + compress_statistics=self.weight.compress_statistics, + quant_type=self.weight.quant_type, + quant_storage=self.weight.quant_storage, + module=self, + ) + self.bias = bias if bias is None else torch.nn.Parameter(bias) + + +def _replace_param( + param: torch.nn.Parameter | bnb.nn.Params4bit, + data: torch.Tensor, +) -> torch.nn.Parameter: + """A helper function to replace the data of a model parameter with new data in a way that allows replacing params on + the "meta" device. + + Supports both `torch.nn.Parameter` and `bnb.nn.Params4bit` parameters. + """ + if param.device.type == "meta": + # Doing `param.data = data` raises a RuntimeError if param.data was on the "meta" device, so we need to + # re-create the param instead of overwriting the data. + if isinstance(param, bnb.nn.Params4bit): + return bnb.nn.Params4bit( + data, + requires_grad=data.requires_grad, + quant_state=param.quant_state, + compress_statistics=param.compress_statistics, + quant_type=param.quant_type, + ) + return torch.nn.Parameter(data, requires_grad=data.requires_grad) + + param.data = data + return param + + +def _convert_linear_layers_to_nf4( + module: torch.nn.Module, + ignore_modules: set[str], + compute_dtype: torch.dtype, + compress_statistics: bool = False, + prefix: str = "", +) -> None: + """Convert all linear layers in the model to NF4 quantized linear layers. + + Args: + module: All linear layers in this module will be converted. + ignore_modules: A set of module prefixes to ignore when converting linear layers. + compute_dtype: The dtype to use for computation in the quantized linear layers. + compress_statistics: Whether to enable nested quantization (aka double quantization) where the quantization + constants from the first quantization are quantized again. + prefix: The prefix of the current module in the model. Used to call this function recursively. + """ + for name, child in module.named_children(): + fullname = f"{prefix}.{name}" if prefix else name + if isinstance(child, torch.nn.Linear) and not any(fullname.startswith(s) for s in ignore_modules): + has_bias = child.bias is not None + replacement = InvokeLinearNF4( + child.in_features, + child.out_features, + bias=has_bias, + compute_dtype=compute_dtype, + compress_statistics=compress_statistics, + ) + if has_bias: + replacement.bias = _replace_param(replacement.bias, child.bias.data) + replacement.weight = _replace_param(replacement.weight, child.weight.data) + replacement.requires_grad_(False) + module.__setattr__(name, replacement) + else: + _convert_linear_layers_to_nf4(child, ignore_modules, compute_dtype=compute_dtype, prefix=fullname) + + +def quantize_model_nf4(model: torch.nn.Module, modules_to_not_convert: set[str], compute_dtype: torch.dtype): + """Apply bitsandbytes nf4 quantization to the model. + + You likely want to call this function inside a `accelerate.init_empty_weights()` context. + + Example usage: + ``` + # Initialize the model from a config on the meta device. + with accelerate.init_empty_weights(): + model = ModelClass.from_config(...) + + # Add NF4 quantization linear layers to the model - still on the meta device. + with accelerate.init_empty_weights(): + model = quantize_model_nf4(model, modules_to_not_convert=set(), compute_dtype=torch.float16) + + # Load a state_dict into the model. (Could be either a prequantized or non-quantized state_dict.) + model.load_state_dict(state_dict, strict=True, assign=True) + + # Move the model to the "cuda" device. If the model was non-quantized, this is where the weight quantization takes + # place. + model.to("cuda") + ``` + """ + _convert_linear_layers_to_nf4(module=model, ignore_modules=modules_to_not_convert, compute_dtype=compute_dtype) + + return model diff --git a/invokeai/backend/quantization/gguf/ggml_tensor.py b/invokeai/backend/quantization/gguf/ggml_tensor.py new file mode 100644 index 0000000000000000000000000000000000000000..fdf7aac067f879c444590804b4957ebbae0c742c --- /dev/null +++ b/invokeai/backend/quantization/gguf/ggml_tensor.py @@ -0,0 +1,157 @@ +from typing import overload + +import gguf +import torch + +from invokeai.backend.quantization.gguf.utils import ( + DEQUANTIZE_FUNCTIONS, + TORCH_COMPATIBLE_QTYPES, + dequantize, +) + + +def dequantize_and_run(func, args, kwargs): + """A helper function for running math ops on GGMLTensor inputs. + + Dequantizes the inputs, and runs the function. + """ + dequantized_args = [a.get_dequantized_tensor() if hasattr(a, "get_dequantized_tensor") else a for a in args] + dequantized_kwargs = { + k: v.get_dequantized_tensor() if hasattr(v, "get_dequantized_tensor") else v for k, v in kwargs.items() + } + return func(*dequantized_args, **dequantized_kwargs) + + +def apply_to_quantized_tensor(func, args, kwargs): + """A helper function to apply a function to a quantized GGML tensor, and re-wrap the result in a GGMLTensor. + + Assumes that the first argument is a GGMLTensor. + """ + # We expect the first argument to be a GGMLTensor, and all other arguments to be non-GGMLTensors. + ggml_tensor = args[0] + assert isinstance(ggml_tensor, GGMLTensor) + assert all(not isinstance(a, GGMLTensor) for a in args[1:]) + assert all(not isinstance(v, GGMLTensor) for v in kwargs.values()) + + new_data = func(ggml_tensor.quantized_data, *args[1:], **kwargs) + + if new_data.dtype != ggml_tensor.quantized_data.dtype: + # This is intended to catch calls such as `.to(dtype-torch.float32)`, which are not supported on GGMLTensors. + raise ValueError("Operation changed the dtype of GGMLTensor unexpectedly.") + + return GGMLTensor( + new_data, ggml_tensor._ggml_quantization_type, ggml_tensor.tensor_shape, ggml_tensor.compute_dtype + ) + + +GGML_TENSOR_OP_TABLE = { + # Ops to run on the quantized tensor. + torch.ops.aten.detach.default: apply_to_quantized_tensor, # pyright: ignore + torch.ops.aten._to_copy.default: apply_to_quantized_tensor, # pyright: ignore + # Ops to run on dequantized tensors. + torch.ops.aten.t.default: dequantize_and_run, # pyright: ignore + torch.ops.aten.addmm.default: dequantize_and_run, # pyright: ignore + torch.ops.aten.mul.Tensor: dequantize_and_run, # pyright: ignore +} + +if torch.backends.mps.is_available(): + GGML_TENSOR_OP_TABLE.update( + {torch.ops.aten.linear.default: dequantize_and_run} # pyright: ignore + ) + + +class GGMLTensor(torch.Tensor): + """A torch.Tensor sub-class holding a quantized GGML tensor. + + The underlying tensor is quantized, but the GGMLTensor class provides a dequantized view of the tensor on-the-fly + when it is used in operations. + """ + + @staticmethod + def __new__( + cls, + data: torch.Tensor, + ggml_quantization_type: gguf.GGMLQuantizationType, + tensor_shape: torch.Size, + compute_dtype: torch.dtype, + ): + # Type hinting is not supported for torch.Tensor._make_wrapper_subclass, so we ignore the errors. + return torch.Tensor._make_wrapper_subclass( # pyright: ignore + cls, + data.shape, + dtype=data.dtype, + layout=data.layout, + device=data.device, + strides=data.stride(), + storage_offset=data.storage_offset(), + ) + + def __init__( + self, + data: torch.Tensor, + ggml_quantization_type: gguf.GGMLQuantizationType, + tensor_shape: torch.Size, + compute_dtype: torch.dtype, + ): + self.quantized_data = data + self._ggml_quantization_type = ggml_quantization_type + # The dequantized shape of the tensor. + self.tensor_shape = tensor_shape + self.compute_dtype = compute_dtype + + def __repr__(self, *, tensor_contents=None): + return f"GGMLTensor(type={self._ggml_quantization_type.name}, dequantized_shape=({self.tensor_shape})" + + @overload + def size(self, dim: None = None) -> torch.Size: ... + + @overload + def size(self, dim: int) -> int: ... + + def size(self, dim: int | None = None): + """Return the size of the tensor after dequantization. I.e. the shape that will be used in any math ops.""" + if dim is not None: + return self.tensor_shape[dim] + return self.tensor_shape + + @property + def shape(self) -> torch.Size: # pyright: ignore[reportIncompatibleVariableOverride] pyright doesn't understand this for some reason. + """The shape of the tensor after dequantization. I.e. the shape that will be used in any math ops.""" + return self.size() + + @property + def quantized_shape(self) -> torch.Size: + """The shape of the quantized tensor.""" + return self.quantized_data.shape + + def requires_grad_(self, mode: bool = True) -> torch.Tensor: + """The GGMLTensor class is currently only designed for inference (not training). Setting requires_grad to True + is not supported. This method is a no-op. + """ + return self + + def get_dequantized_tensor(self): + """Return the dequantized tensor. + + Args: + dtype: The dtype of the dequantized tensor. + """ + if self._ggml_quantization_type in TORCH_COMPATIBLE_QTYPES: + return self.quantized_data.to(self.compute_dtype) + elif self._ggml_quantization_type in DEQUANTIZE_FUNCTIONS: + # TODO(ryand): Look into how the dtype param is intended to be used. + return dequantize( + data=self.quantized_data, qtype=self._ggml_quantization_type, oshape=self.tensor_shape, dtype=None + ).to(self.compute_dtype) + else: + # There is no GPU implementation for this quantization type, so fallback to the numpy implementation. + new = gguf.quants.dequantize(self.quantized_data.cpu().numpy(), self._ggml_quantization_type) + return torch.from_numpy(new).to(self.quantized_data.device, dtype=self.compute_dtype) + + @classmethod + def __torch_dispatch__(cls, func, types, args, kwargs): + # We will likely hit cases here in the future where a new op is encountered that is not yet supported. + # The new op simply needs to be added to the GGML_TENSOR_OP_TABLE. + if func in GGML_TENSOR_OP_TABLE: + return GGML_TENSOR_OP_TABLE[func](func, args, kwargs) + return NotImplemented diff --git a/invokeai/backend/quantization/gguf/loaders.py b/invokeai/backend/quantization/gguf/loaders.py new file mode 100644 index 0000000000000000000000000000000000000000..178c0508466c2cc5c39b99dadc7b108fcee4ac53 --- /dev/null +++ b/invokeai/backend/quantization/gguf/loaders.py @@ -0,0 +1,22 @@ +from pathlib import Path + +import gguf +import torch + +from invokeai.backend.quantization.gguf.ggml_tensor import GGMLTensor +from invokeai.backend.quantization.gguf.utils import TORCH_COMPATIBLE_QTYPES + + +def gguf_sd_loader(path: Path, compute_dtype: torch.dtype) -> dict[str, GGMLTensor]: + reader = gguf.GGUFReader(path) + + sd: dict[str, GGMLTensor] = {} + for tensor in reader.tensors: + torch_tensor = torch.from_numpy(tensor.data) + shape = torch.Size(tuple(int(v) for v in reversed(tensor.shape))) + if tensor.tensor_type in TORCH_COMPATIBLE_QTYPES: + torch_tensor = torch_tensor.view(*shape) + sd[tensor.name] = GGMLTensor( + torch_tensor, ggml_quantization_type=tensor.tensor_type, tensor_shape=shape, compute_dtype=compute_dtype + ) + return sd diff --git a/invokeai/backend/quantization/gguf/utils.py b/invokeai/backend/quantization/gguf/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..fa8c267c41b935944576015fa1edbfa9fb34dcba --- /dev/null +++ b/invokeai/backend/quantization/gguf/utils.py @@ -0,0 +1,308 @@ +# Largely based on https://github.com/city96/ComfyUI-GGUF + +from typing import Callable, Optional, Union + +import gguf +import torch + +TORCH_COMPATIBLE_QTYPES = {None, gguf.GGMLQuantizationType.F32, gguf.GGMLQuantizationType.F16} + +# K Quants # +QK_K = 256 +K_SCALE_SIZE = 12 + + +def get_scale_min(scales: torch.Tensor): + n_blocks = scales.shape[0] + scales = scales.view(torch.uint8) + scales = scales.reshape((n_blocks, 3, 4)) + + d, m, m_d = torch.split(scales, scales.shape[-2] // 3, dim=-2) + + sc = torch.cat([d & 0x3F, (m_d & 0x0F) | ((d >> 2) & 0x30)], dim=-1) + min = torch.cat([m & 0x3F, (m_d >> 4) | ((m >> 2) & 0x30)], dim=-1) + + return (sc.reshape((n_blocks, 8)), min.reshape((n_blocks, 8))) + + +# Legacy Quants # +def dequantize_blocks_Q8_0( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + d, x = split_block_dims(blocks, 2) + d = d.view(torch.float16).to(dtype) + x = x.view(torch.int8) + return d * x + + +def dequantize_blocks_Q5_1( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + d, m, qh, qs = split_block_dims(blocks, 2, 2, 4) + d = d.view(torch.float16).to(dtype) + m = m.view(torch.float16).to(dtype) + qh = to_uint32(qh) + + qh = qh.reshape((n_blocks, 1)) >> torch.arange(32, device=d.device, dtype=torch.int32).reshape(1, 32) + ql = qs.reshape((n_blocks, -1, 1, block_size // 2)) >> torch.tensor( + [0, 4], device=d.device, dtype=torch.uint8 + ).reshape(1, 1, 2, 1) + qh = (qh & 1).to(torch.uint8) + ql = (ql & 0x0F).reshape((n_blocks, -1)) + + qs = ql | (qh << 4) + return (d * qs) + m + + +def dequantize_blocks_Q5_0( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + d, qh, qs = split_block_dims(blocks, 2, 4) + d = d.view(torch.float16).to(dtype) + qh = to_uint32(qh) + + qh = qh.reshape(n_blocks, 1) >> torch.arange(32, device=d.device, dtype=torch.int32).reshape(1, 32) + ql = qs.reshape(n_blocks, -1, 1, block_size // 2) >> torch.tensor( + [0, 4], device=d.device, dtype=torch.uint8 + ).reshape(1, 1, 2, 1) + + qh = (qh & 1).to(torch.uint8) + ql = (ql & 0x0F).reshape(n_blocks, -1) + + qs = (ql | (qh << 4)).to(torch.int8) - 16 + return d * qs + + +def dequantize_blocks_Q4_1( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + d, m, qs = split_block_dims(blocks, 2, 2) + d = d.view(torch.float16).to(dtype) + m = m.view(torch.float16).to(dtype) + + qs = qs.reshape((n_blocks, -1, 1, block_size // 2)) >> torch.tensor( + [0, 4], device=d.device, dtype=torch.uint8 + ).reshape(1, 1, 2, 1) + qs = (qs & 0x0F).reshape(n_blocks, -1) + + return (d * qs) + m + + +def dequantize_blocks_Q4_0( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + d, qs = split_block_dims(blocks, 2) + d = d.view(torch.float16).to(dtype) + + qs = qs.reshape((n_blocks, -1, 1, block_size // 2)) >> torch.tensor( + [0, 4], device=d.device, dtype=torch.uint8 + ).reshape((1, 1, 2, 1)) + qs = (qs & 0x0F).reshape((n_blocks, -1)).to(torch.int8) - 8 + return d * qs + + +def dequantize_blocks_BF16( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + return (blocks.view(torch.int16).to(torch.int32) << 16).view(torch.float32) + + +def dequantize_blocks_Q6_K( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + ( + ql, + qh, + scales, + d, + ) = split_block_dims(blocks, QK_K // 2, QK_K // 4, QK_K // 16) + + scales = scales.view(torch.int8).to(dtype) + d = d.view(torch.float16).to(dtype) + d = (d * scales).reshape((n_blocks, QK_K // 16, 1)) + + ql = ql.reshape((n_blocks, -1, 1, 64)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape( + (1, 1, 2, 1) + ) + ql = (ql & 0x0F).reshape((n_blocks, -1, 32)) + qh = qh.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 2, 4, 6], device=d.device, dtype=torch.uint8).reshape( + (1, 1, 4, 1) + ) + qh = (qh & 0x03).reshape((n_blocks, -1, 32)) + q = (ql | (qh << 4)).to(torch.int8) - 32 + q = q.reshape((n_blocks, QK_K // 16, -1)) + + return (d * q).reshape((n_blocks, QK_K)) + + +def dequantize_blocks_Q5_K( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + d, dmin, scales, qh, qs = split_block_dims(blocks, 2, 2, K_SCALE_SIZE, QK_K // 8) + + d = d.view(torch.float16).to(dtype) + dmin = dmin.view(torch.float16).to(dtype) + + sc, m = get_scale_min(scales) + + d = (d * sc).reshape((n_blocks, -1, 1)) + dm = (dmin * m).reshape((n_blocks, -1, 1)) + + ql = qs.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape( + (1, 1, 2, 1) + ) + qh = qh.reshape((n_blocks, -1, 1, 32)) >> torch.tensor(list(range(8)), device=d.device, dtype=torch.uint8).reshape( + (1, 1, 8, 1) + ) + ql = (ql & 0x0F).reshape((n_blocks, -1, 32)) + qh = (qh & 0x01).reshape((n_blocks, -1, 32)) + q = ql | (qh << 4) + + return (d * q - dm).reshape((n_blocks, QK_K)) + + +def dequantize_blocks_Q4_K( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + d, dmin, scales, qs = split_block_dims(blocks, 2, 2, K_SCALE_SIZE) + d = d.view(torch.float16).to(dtype) + dmin = dmin.view(torch.float16).to(dtype) + + sc, m = get_scale_min(scales) + + d = (d * sc).reshape((n_blocks, -1, 1)) + dm = (dmin * m).reshape((n_blocks, -1, 1)) + + qs = qs.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape( + (1, 1, 2, 1) + ) + qs = (qs & 0x0F).reshape((n_blocks, -1, 32)) + + return (d * qs - dm).reshape((n_blocks, QK_K)) + + +def dequantize_blocks_Q3_K( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + hmask, qs, scales, d = split_block_dims(blocks, QK_K // 8, QK_K // 4, 12) + d = d.view(torch.float16).to(dtype) + + lscales, hscales = scales[:, :8], scales[:, 8:] + lscales = lscales.reshape((n_blocks, 1, 8)) >> torch.tensor([0, 4], device=d.device, dtype=torch.uint8).reshape( + (1, 2, 1) + ) + lscales = lscales.reshape((n_blocks, 16)) + hscales = hscales.reshape((n_blocks, 1, 4)) >> torch.tensor( + [0, 2, 4, 6], device=d.device, dtype=torch.uint8 + ).reshape((1, 4, 1)) + hscales = hscales.reshape((n_blocks, 16)) + scales = (lscales & 0x0F) | ((hscales & 0x03) << 4) + scales = scales.to(torch.int8) - 32 + + dl = (d * scales).reshape((n_blocks, 16, 1)) + + ql = qs.reshape((n_blocks, -1, 1, 32)) >> torch.tensor([0, 2, 4, 6], device=d.device, dtype=torch.uint8).reshape( + (1, 1, 4, 1) + ) + qh = hmask.reshape(n_blocks, -1, 1, 32) >> torch.tensor(list(range(8)), device=d.device, dtype=torch.uint8).reshape( + (1, 1, 8, 1) + ) + ql = ql.reshape((n_blocks, 16, QK_K // 16)) & 3 + qh = (qh.reshape((n_blocks, 16, QK_K // 16)) & 1) ^ 1 + q = ql.to(torch.int8) - (qh << 2).to(torch.int8) + + return (dl * q).reshape((n_blocks, QK_K)) + + +def dequantize_blocks_Q2_K( + blocks: torch.Tensor, block_size: int, type_size: int, dtype: Optional[torch.dtype] = None +) -> torch.Tensor: + n_blocks = blocks.shape[0] + + scales, qs, d, dmin = split_block_dims(blocks, QK_K // 16, QK_K // 4, 2) + d = d.view(torch.float16).to(dtype) + dmin = dmin.view(torch.float16).to(dtype) + + # (n_blocks, 16, 1) + dl = (d * (scales & 0xF)).reshape((n_blocks, QK_K // 16, 1)) + ml = (dmin * (scales >> 4)).reshape((n_blocks, QK_K // 16, 1)) + + shift = torch.tensor([0, 2, 4, 6], device=d.device, dtype=torch.uint8).reshape((1, 1, 4, 1)) + + qs = (qs.reshape((n_blocks, -1, 1, 32)) >> shift) & 3 + qs = qs.reshape((n_blocks, QK_K // 16, 16)) + qs = dl * qs - ml + + return qs.reshape((n_blocks, -1)) + + +DEQUANTIZE_FUNCTIONS: dict[ + gguf.GGMLQuantizationType, Callable[[torch.Tensor, int, int, Optional[torch.dtype]], torch.Tensor] +] = { + gguf.GGMLQuantizationType.BF16: dequantize_blocks_BF16, + gguf.GGMLQuantizationType.Q8_0: dequantize_blocks_Q8_0, + gguf.GGMLQuantizationType.Q5_1: dequantize_blocks_Q5_1, + gguf.GGMLQuantizationType.Q5_0: dequantize_blocks_Q5_0, + gguf.GGMLQuantizationType.Q4_1: dequantize_blocks_Q4_1, + gguf.GGMLQuantizationType.Q4_0: dequantize_blocks_Q4_0, + gguf.GGMLQuantizationType.Q6_K: dequantize_blocks_Q6_K, + gguf.GGMLQuantizationType.Q5_K: dequantize_blocks_Q5_K, + gguf.GGMLQuantizationType.Q4_K: dequantize_blocks_Q4_K, + gguf.GGMLQuantizationType.Q3_K: dequantize_blocks_Q3_K, + gguf.GGMLQuantizationType.Q2_K: dequantize_blocks_Q2_K, +} + + +def is_torch_compatible(tensor: Optional[torch.Tensor]): + return getattr(tensor, "tensor_type", None) in TORCH_COMPATIBLE_QTYPES + + +def is_quantized(tensor: torch.Tensor): + return not is_torch_compatible(tensor) + + +def dequantize( + data: torch.Tensor, qtype: gguf.GGMLQuantizationType, oshape: torch.Size, dtype: Optional[torch.dtype] = None +): + """ + Dequantize tensor back to usable shape/dtype + """ + block_size, type_size = gguf.GGML_QUANT_SIZES[qtype] + dequantize_blocks = DEQUANTIZE_FUNCTIONS[qtype] + + rows = data.reshape((-1, data.shape[-1])).view(torch.uint8) + + n_blocks = rows.numel() // type_size + blocks = rows.reshape((n_blocks, type_size)) + blocks = dequantize_blocks(blocks, block_size, type_size, dtype) + return blocks.reshape(oshape) + + +def to_uint32(x: torch.Tensor) -> torch.Tensor: + x = x.view(torch.uint8).to(torch.int32) + return (x[:, 0] | x[:, 1] << 8 | x[:, 2] << 16 | x[:, 3] << 24).unsqueeze(1) + + +def split_block_dims(blocks: torch.Tensor, *args): + n_max = blocks.shape[1] + dims = list(args) + [n_max - sum(args)] + return torch.split(blocks, dims, dim=1) + + +PATCH_TYPES = Union[torch.Tensor, list[torch.Tensor], tuple[torch.Tensor]] diff --git a/invokeai/backend/quantization/scripts/load_flux_model_bnb_llm_int8.py b/invokeai/backend/quantization/scripts/load_flux_model_bnb_llm_int8.py new file mode 100644 index 0000000000000000000000000000000000000000..804336e0007f7c9d1142807aecf88534d4c451d1 --- /dev/null +++ b/invokeai/backend/quantization/scripts/load_flux_model_bnb_llm_int8.py @@ -0,0 +1,79 @@ +from pathlib import Path + +import accelerate +from safetensors.torch import load_file, save_file + +from invokeai.backend.flux.model import Flux +from invokeai.backend.flux.util import params +from invokeai.backend.quantization.bnb_llm_int8 import quantize_model_llm_int8 +from invokeai.backend.quantization.scripts.load_flux_model_bnb_nf4 import log_time + + +def main(): + """A script for quantizing a FLUX transformer model using the bitsandbytes LLM.int8() quantization method. + + This script is primarily intended for reference. The script params (e.g. the model_path, modules_to_not_convert, + etc.) are hardcoded and would need to be modified for other use cases. + """ + # Load the FLUX transformer model onto the meta device. + model_path = Path( + "/data/invokeai/models/.download_cache/https__huggingface.co_black-forest-labs_flux.1-schnell_resolve_main_flux1-schnell.safetensors/flux1-schnell.safetensors" + ) + + with log_time("Intialize FLUX transformer on meta device"): + # TODO(ryand): Determine if this is a schnell model or a dev model and load the appropriate config. + p = params["flux-schnell"] + + # Initialize the model on the "meta" device. + with accelerate.init_empty_weights(): + model = Flux(p) + + # TODO(ryand): We may want to add some modules to not quantize here (e.g. the proj_out layer). See the accelerate + # `get_keys_to_not_convert(...)` function for a heuristic to determine which modules to not quantize. + modules_to_not_convert: set[str] = set() + + model_int8_path = model_path.parent / "bnb_llm_int8.safetensors" + if model_int8_path.exists(): + # The quantized model already exists, load it and return it. + print(f"A pre-quantized model already exists at '{model_int8_path}'. Attempting to load it...") + + # Replace the linear layers with LLM.int8() quantized linear layers (still on the meta device). + with log_time("Replace linear layers with LLM.int8() layers"), accelerate.init_empty_weights(): + model = quantize_model_llm_int8(model, modules_to_not_convert=modules_to_not_convert) + + with log_time("Load state dict into model"): + sd = load_file(model_int8_path) + model.load_state_dict(sd, strict=True, assign=True) + + with log_time("Move model to cuda"): + model = model.to("cuda") + + print(f"Successfully loaded pre-quantized model from '{model_int8_path}'.") + + else: + # The quantized model does not exist, quantize the model and save it. + print(f"No pre-quantized model found at '{model_int8_path}'. Quantizing the model...") + + with log_time("Replace linear layers with LLM.int8() layers"), accelerate.init_empty_weights(): + model = quantize_model_llm_int8(model, modules_to_not_convert=modules_to_not_convert) + + with log_time("Load state dict into model"): + state_dict = load_file(model_path) + # TODO(ryand): Cast the state_dict to the appropriate dtype? + model.load_state_dict(state_dict, strict=True, assign=True) + + with log_time("Move model to cuda and quantize"): + model = model.to("cuda") + + with log_time("Save quantized model"): + model_int8_path.parent.mkdir(parents=True, exist_ok=True) + save_file(model.state_dict(), model_int8_path) + + print(f"Successfully quantized and saved model to '{model_int8_path}'.") + + assert isinstance(model, Flux) + return model + + +if __name__ == "__main__": + main() diff --git a/invokeai/backend/quantization/scripts/load_flux_model_bnb_nf4.py b/invokeai/backend/quantization/scripts/load_flux_model_bnb_nf4.py new file mode 100644 index 0000000000000000000000000000000000000000..f1621dbc6ddbca548f97603ed068e8493db7932f --- /dev/null +++ b/invokeai/backend/quantization/scripts/load_flux_model_bnb_nf4.py @@ -0,0 +1,96 @@ +import time +from contextlib import contextmanager +from pathlib import Path + +import accelerate +import torch +from safetensors.torch import load_file, save_file + +from invokeai.backend.flux.model import Flux +from invokeai.backend.flux.util import params +from invokeai.backend.quantization.bnb_nf4 import quantize_model_nf4 + + +@contextmanager +def log_time(name: str): + """Helper context manager to log the time taken by a block of code.""" + start = time.time() + try: + yield None + finally: + end = time.time() + print(f"'{name}' took {end - start:.4f} secs") + + +def main(): + """A script for quantizing a FLUX transformer model using the bitsandbytes NF4 quantization method. + + This script is primarily intended for reference. The script params (e.g. the model_path, modules_to_not_convert, + etc.) are hardcoded and would need to be modified for other use cases. + """ + model_path = Path( + "/data/invokeai/models/.download_cache/https__huggingface.co_black-forest-labs_flux.1-schnell_resolve_main_flux1-schnell.safetensors/flux1-schnell.safetensors" + ) + + # inference_dtype = torch.bfloat16 + with log_time("Intialize FLUX transformer on meta device"): + # TODO(ryand): Determine if this is a schnell model or a dev model and load the appropriate config. + p = params["flux-schnell"] + + # Initialize the model on the "meta" device. + with accelerate.init_empty_weights(): + model = Flux(p) + + # TODO(ryand): We may want to add some modules to not quantize here (e.g. the proj_out layer). See the accelerate + # `get_keys_to_not_convert(...)` function for a heuristic to determine which modules to not quantize. + modules_to_not_convert: set[str] = set() + + model_nf4_path = model_path.parent / "bnb_nf4.safetensors" + if model_nf4_path.exists(): + # The quantized model already exists, load it and return it. + print(f"A pre-quantized model already exists at '{model_nf4_path}'. Attempting to load it...") + + # Replace the linear layers with NF4 quantized linear layers (still on the meta device). + with log_time("Replace linear layers with NF4 layers"), accelerate.init_empty_weights(): + model = quantize_model_nf4( + model, modules_to_not_convert=modules_to_not_convert, compute_dtype=torch.bfloat16 + ) + + with log_time("Load state dict into model"): + state_dict = load_file(model_nf4_path) + model.load_state_dict(state_dict, strict=True, assign=True) + + with log_time("Move model to cuda"): + model = model.to("cuda") + + print(f"Successfully loaded pre-quantized model from '{model_nf4_path}'.") + + else: + # The quantized model does not exist, quantize the model and save it. + print(f"No pre-quantized model found at '{model_nf4_path}'. Quantizing the model...") + + with log_time("Replace linear layers with NF4 layers"), accelerate.init_empty_weights(): + model = quantize_model_nf4( + model, modules_to_not_convert=modules_to_not_convert, compute_dtype=torch.bfloat16 + ) + + with log_time("Load state dict into model"): + state_dict = load_file(model_path) + # TODO(ryand): Cast the state_dict to the appropriate dtype? + model.load_state_dict(state_dict, strict=True, assign=True) + + with log_time("Move model to cuda and quantize"): + model = model.to("cuda") + + with log_time("Save quantized model"): + model_nf4_path.parent.mkdir(parents=True, exist_ok=True) + save_file(model.state_dict(), model_nf4_path) + + print(f"Successfully quantized and saved model to '{model_nf4_path}'.") + + assert isinstance(model, Flux) + return model + + +if __name__ == "__main__": + main() diff --git a/invokeai/backend/quantization/scripts/quantize_t5_xxl_bnb_llm_int8.py b/invokeai/backend/quantization/scripts/quantize_t5_xxl_bnb_llm_int8.py new file mode 100644 index 0000000000000000000000000000000000000000..fc681e8fc57f5a839a7962aee53ee20f9a09d737 --- /dev/null +++ b/invokeai/backend/quantization/scripts/quantize_t5_xxl_bnb_llm_int8.py @@ -0,0 +1,92 @@ +from pathlib import Path + +import accelerate +from safetensors.torch import load_file, save_file +from transformers import AutoConfig, AutoModelForTextEncoding, T5EncoderModel + +from invokeai.backend.quantization.bnb_llm_int8 import quantize_model_llm_int8 +from invokeai.backend.quantization.scripts.load_flux_model_bnb_nf4 import log_time + + +def load_state_dict_into_t5(model: T5EncoderModel, state_dict: dict): + # There is a shared reference to a single weight tensor in the model. + # Both "encoder.embed_tokens.weight" and "shared.weight" refer to the same tensor, so only the latter should + # be present in the state_dict. + missing_keys, unexpected_keys = model.load_state_dict(state_dict, strict=False, assign=True) + assert len(unexpected_keys) == 0 + assert set(missing_keys) == {"encoder.embed_tokens.weight"} + # Assert that the layers we expect to be shared are actually shared. + assert model.encoder.embed_tokens.weight is model.shared.weight + + +def main(): + """A script for quantizing a T5 text encoder model using the bitsandbytes LLM.int8() quantization method. + + This script is primarily intended for reference. The script params (e.g. the model_path, modules_to_not_convert, + etc.) are hardcoded and would need to be modified for other use cases. + """ + model_path = Path("/data/misc/text_encoder_2") + + with log_time("Intialize T5 on meta device"): + model_config = AutoConfig.from_pretrained(model_path) + with accelerate.init_empty_weights(): + model = AutoModelForTextEncoding.from_config(model_config) + + # TODO(ryand): We may want to add some modules to not quantize here (e.g. the proj_out layer). See the accelerate + # `get_keys_to_not_convert(...)` function for a heuristic to determine which modules to not quantize. + modules_to_not_convert: set[str] = set() + + model_int8_path = model_path / "bnb_llm_int8.safetensors" + if model_int8_path.exists(): + # The quantized model already exists, load it and return it. + print(f"A pre-quantized model already exists at '{model_int8_path}'. Attempting to load it...") + + # Replace the linear layers with LLM.int8() quantized linear layers (still on the meta device). + with log_time("Replace linear layers with LLM.int8() layers"), accelerate.init_empty_weights(): + model = quantize_model_llm_int8(model, modules_to_not_convert=modules_to_not_convert) + + with log_time("Load state dict into model"): + sd = load_file(model_int8_path) + load_state_dict_into_t5(model, sd) + + with log_time("Move model to cuda"): + model = model.to("cuda") + + print(f"Successfully loaded pre-quantized model from '{model_int8_path}'.") + + else: + # The quantized model does not exist, quantize the model and save it. + print(f"No pre-quantized model found at '{model_int8_path}'. Quantizing the model...") + + with log_time("Replace linear layers with LLM.int8() layers"), accelerate.init_empty_weights(): + model = quantize_model_llm_int8(model, modules_to_not_convert=modules_to_not_convert) + + with log_time("Load state dict into model"): + # Load sharded state dict. + files = list(model_path.glob("*.safetensors")) + state_dict = {} + for file in files: + sd = load_file(file) + state_dict.update(sd) + load_state_dict_into_t5(model, state_dict) + + with log_time("Move model to cuda and quantize"): + model = model.to("cuda") + + with log_time("Save quantized model"): + model_int8_path.parent.mkdir(parents=True, exist_ok=True) + state_dict = model.state_dict() + state_dict.pop("encoder.embed_tokens.weight") + save_file(state_dict, model_int8_path) + # This handling of shared weights could also be achieved with save_model(...), but then we'd lose control + # over which keys are kept. And, the corresponding load_model(...) function does not support assign=True. + # save_model(model, model_int8_path) + + print(f"Successfully quantized and saved model to '{model_int8_path}'.") + + assert isinstance(model, T5EncoderModel) + return model + + +if __name__ == "__main__": + main() diff --git a/invokeai/backend/raw_model.py b/invokeai/backend/raw_model.py new file mode 100644 index 0000000000000000000000000000000000000000..23502b20cb608d70e68f90ea2998f9ee240875f7 --- /dev/null +++ b/invokeai/backend/raw_model.py @@ -0,0 +1,22 @@ +from abc import ABC, abstractmethod +from typing import Optional + +import torch + + +class RawModel(ABC): + """Base class for 'Raw' models. + + The RawModel class is the base class of LoRAModelRaw, TextualInversionModelRaw, etc. + and is used for type checking of calls to the model patcher. Its main purpose + is to avoid a circular import issues when lora.py tries to import BaseModelType + from invokeai.backend.model_manager.config, and the latter tries to import LoRAModelRaw + from lora.py. + + The term 'raw' was introduced to describe a wrapper around a torch.nn.Module + that adds additional methods and attributes. + """ + + @abstractmethod + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None) -> None: + pass diff --git a/invokeai/backend/sd3/__init__.py b/invokeai/backend/sd3/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/sd3/extensions/__init__.py b/invokeai/backend/sd3/extensions/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/sd3/extensions/inpaint_extension.py b/invokeai/backend/sd3/extensions/inpaint_extension.py new file mode 100644 index 0000000000000000000000000000000000000000..a6a0c01876c65a5aa6b9ecc2bbb13ff24485e3eb --- /dev/null +++ b/invokeai/backend/sd3/extensions/inpaint_extension.py @@ -0,0 +1,58 @@ +import torch + + +class InpaintExtension: + """A class for managing inpainting with SD3.""" + + def __init__(self, init_latents: torch.Tensor, inpaint_mask: torch.Tensor, noise: torch.Tensor): + """Initialize InpaintExtension. + + Args: + init_latents (torch.Tensor): The initial latents (i.e. un-noised at timestep 0). + inpaint_mask (torch.Tensor): A mask specifying which elements to inpaint. Range [0, 1]. Values of 1 will be + re-generated. Values of 0 will remain unchanged. Values between 0 and 1 can be used to blend the + inpainted region with the background. + noise (torch.Tensor): The noise tensor used to noise the init_latents. + """ + assert init_latents.dim() == inpaint_mask.dim() == noise.dim() == 4 + assert init_latents.shape[-2:] == inpaint_mask.shape[-2:] == noise.shape[-2:] + + self._init_latents = init_latents + self._inpaint_mask = inpaint_mask + self._noise = noise + + def _apply_mask_gradient_adjustment(self, t_prev: float) -> torch.Tensor: + """Applies inpaint mask gradient adjustment and returns the inpaint mask to be used at the current timestep.""" + # As we progress through the denoising process, we promote gradient regions of the mask to have a full weight of + # 1.0. This helps to produce more coherent seams around the inpainted region. We experimented with a (small) + # number of promotion strategies (e.g. gradual promotion based on timestep), but found that a simple cutoff + # threshold worked well. + # We use a small epsilon to avoid any potential issues with floating point precision. + eps = 1e-4 + mask_gradient_t_cutoff = 0.5 + if t_prev > mask_gradient_t_cutoff: + # Early in the denoising process, use the inpaint mask as-is. + return self._inpaint_mask + else: + # After the cut-off, promote all non-zero mask values to 1.0. + mask = self._inpaint_mask.where(self._inpaint_mask <= (0.0 + eps), 1.0) + + return mask + + def merge_intermediate_latents_with_init_latents( + self, intermediate_latents: torch.Tensor, t_prev: float + ) -> torch.Tensor: + """Merge the intermediate latents with the initial latents for the current timestep using the inpaint mask. I.e. + update the intermediate latents to keep the regions that are not being inpainted on the correct noise + trajectory. + + This function should be called after each denoising step. + """ + + mask = self._apply_mask_gradient_adjustment(t_prev) + + # Noise the init latents for the current timestep. + noised_init_latents = self._noise * t_prev + (1.0 - t_prev) * self._init_latents + + # Merge the intermediate latents with the noised_init_latents using the inpaint_mask. + return intermediate_latents * mask + noised_init_latents * (1.0 - mask) diff --git a/invokeai/backend/spandrel_image_to_image_model.py b/invokeai/backend/spandrel_image_to_image_model.py new file mode 100644 index 0000000000000000000000000000000000000000..ccf02c57ac08e2baf178c17f934433985b5f5825 --- /dev/null +++ b/invokeai/backend/spandrel_image_to_image_model.py @@ -0,0 +1,139 @@ +from pathlib import Path +from typing import Any, Optional + +import numpy as np +import torch +from PIL import Image +from spandrel import ImageModelDescriptor, ModelLoader + +from invokeai.backend.raw_model import RawModel + + +class SpandrelImageToImageModel(RawModel): + """A wrapper for a Spandrel Image-to-Image model. + + The main reason for having a wrapper class is to integrate with the type handling of RawModel. + """ + + def __init__(self, spandrel_model: ImageModelDescriptor[Any]): + self._spandrel_model = spandrel_model + + @staticmethod + def pil_to_tensor(image: Image.Image) -> torch.Tensor: + """Convert PIL Image to the torch.Tensor format expected by SpandrelImageToImageModel.run(). + + Args: + image (Image.Image): A PIL Image with shape (H, W, C) and values in the range [0, 255]. + + Returns: + torch.Tensor: A torch.Tensor with shape (N, C, H, W) and values in the range [0, 1]. + """ + image_np = np.array(image) + # (H, W, C) -> (C, H, W) + image_np = np.transpose(image_np, (2, 0, 1)) + image_np = image_np / 255 + image_tensor = torch.from_numpy(image_np).float() + # (C, H, W) -> (N, C, H, W) + image_tensor = image_tensor.unsqueeze(0) + return image_tensor + + @staticmethod + def tensor_to_pil(tensor: torch.Tensor) -> Image.Image: + """Convert a torch.Tensor produced by SpandrelImageToImageModel.run() to a PIL Image. + + Args: + tensor (torch.Tensor): A torch.Tensor with shape (N, C, H, W) and values in the range [0, 1]. + + Returns: + Image.Image: A PIL Image with shape (H, W, C) and values in the range [0, 255]. + """ + # (N, C, H, W) -> (C, H, W) + tensor = tensor.squeeze(0) + # (C, H, W) -> (H, W, C) + tensor = tensor.permute(1, 2, 0) + tensor = tensor.clamp(0, 1) + tensor = (tensor * 255).cpu().detach().numpy().astype(np.uint8) + image = Image.fromarray(tensor) + return image + + def run(self, image_tensor: torch.Tensor) -> torch.Tensor: + """Run the image-to-image model. + + Args: + image_tensor (torch.Tensor): A torch.Tensor with shape (N, C, H, W) and values in the range [0, 1]. + """ + return self._spandrel_model(image_tensor) + + @classmethod + def load_from_file(cls, file_path: str | Path): + model = ModelLoader().load_from_file(file_path) + if not isinstance(model, ImageModelDescriptor): + raise ValueError( + f"Loaded a spandrel model of type '{type(model)}'. Only image-to-image models are supported " + "('ImageModelDescriptor')." + ) + + return cls(spandrel_model=model) + + @classmethod + def load_from_state_dict(cls, state_dict: dict[str, torch.Tensor]): + model = ModelLoader().load_from_state_dict(state_dict) + if not isinstance(model, ImageModelDescriptor): + raise ValueError( + f"Loaded a spandrel model of type '{type(model)}'. Only image-to-image models are supported " + "('ImageModelDescriptor')." + ) + + return cls(spandrel_model=model) + + def supports_dtype(self, dtype: torch.dtype) -> bool: + """Check if the model supports the given dtype.""" + if dtype == torch.float16: + return self._spandrel_model.supports_half + elif dtype == torch.bfloat16: + return self._spandrel_model.supports_bfloat16 + elif dtype == torch.float32: + # All models support float32. + return True + else: + raise ValueError(f"Unexpected dtype '{dtype}'.") + + def get_model_type_name(self) -> str: + """The model type name. Intended for logging / debugging purposes. Do not rely on this field remaining + consistent over time. + """ + return str(type(self._spandrel_model.model)) + + def to( + self, + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None, + non_blocking: bool = False, + ) -> None: + """Note: Some models have limited dtype support. Call supports_dtype(...) to check if the dtype is supported. + Note: The non_blocking parameter is currently ignored.""" + # TODO(ryand): spandrel.ImageModelDescriptor.to(...) does not support non_blocking. We will have to access the + # model directly if we want to apply this optimization. + self._spandrel_model.to(device=device, dtype=dtype) + + @property + def device(self) -> torch.device: + """The device of the underlying model.""" + return self._spandrel_model.device + + @property + def dtype(self) -> torch.dtype: + """The dtype of the underlying model.""" + return self._spandrel_model.dtype + + @property + def scale(self) -> int: + """The scale of the model (e.g. 1x, 2x, 4x, etc.).""" + return self._spandrel_model.scale + + def calc_size(self) -> int: + """Get size of the model in memory in bytes.""" + # HACK(ryand): Fix this issue with circular imports. + from invokeai.backend.model_manager.load.model_util import calc_module_size + + return calc_module_size(self._spandrel_model.model) diff --git a/invokeai/backend/stable_diffusion/__init__.py b/invokeai/backend/stable_diffusion/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6a6f2ebc49ce391e68ac07243bcd2ff6a77178ef --- /dev/null +++ b/invokeai/backend/stable_diffusion/__init__.py @@ -0,0 +1,15 @@ +""" +Initialization file for the invokeai.backend.stable_diffusion package +""" + +from invokeai.backend.stable_diffusion.diffusers_pipeline import ( # noqa: F401 + PipelineIntermediateState, + StableDiffusionGeneratorPipeline, +) +from invokeai.backend.stable_diffusion.diffusion import InvokeAIDiffuserComponent # noqa: F401 + +__all__ = [ + "PipelineIntermediateState", + "StableDiffusionGeneratorPipeline", + "InvokeAIDiffuserComponent", +] diff --git a/invokeai/backend/stable_diffusion/denoise_context.py b/invokeai/backend/stable_diffusion/denoise_context.py new file mode 100644 index 0000000000000000000000000000000000000000..9060d549776137e3861050a8d60b1749258a3005 --- /dev/null +++ b/invokeai/backend/stable_diffusion/denoise_context.py @@ -0,0 +1,131 @@ +from __future__ import annotations + +from dataclasses import dataclass, field +from typing import TYPE_CHECKING, Any, Dict, Optional, Tuple, Type, Union + +import torch +from diffusers import UNet2DConditionModel +from diffusers.schedulers.scheduling_utils import SchedulerMixin, SchedulerOutput + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningMode, TextConditioningData + + +@dataclass +class UNetKwargs: + sample: torch.Tensor + timestep: Union[torch.Tensor, float, int] + encoder_hidden_states: torch.Tensor + + class_labels: Optional[torch.Tensor] = None + timestep_cond: Optional[torch.Tensor] = None + attention_mask: Optional[torch.Tensor] = None + cross_attention_kwargs: Optional[Dict[str, Any]] = None + added_cond_kwargs: Optional[Dict[str, torch.Tensor]] = None + down_block_additional_residuals: Optional[Tuple[torch.Tensor]] = None + mid_block_additional_residual: Optional[torch.Tensor] = None + down_intrablock_additional_residuals: Optional[Tuple[torch.Tensor]] = None + encoder_attention_mask: Optional[torch.Tensor] = None + # return_dict: bool = True + + +@dataclass +class DenoiseInputs: + """Initial variables passed to denoise. Supposed to be unchanged.""" + + # The latent-space image to denoise. + # Shape: [batch, channels, latent_height, latent_width] + # - If we are inpainting, this is the initial latent image before noise has been added. + # - If we are generating a new image, this should be initialized to zeros. + # - In some cases, this may be a partially-noised latent image (e.g. when running the SDXL refiner). + orig_latents: torch.Tensor + + # kwargs forwarded to the scheduler.step() method. + scheduler_step_kwargs: dict[str, Any] + + # Text conditionging data. + conditioning_data: TextConditioningData + + # Noise used for two purposes: + # 1. Used by the scheduler to noise the initial `latents` before denoising. + # 2. Used to noise the `masked_latents` when inpainting. + # `noise` should be None if the `latents` tensor has already been noised. + # Shape: [1 or batch, channels, latent_height, latent_width] + noise: Optional[torch.Tensor] + + # The seed used to generate the noise for the denoising process. + # HACK(ryand): seed is only used in a particular case when `noise` is None, but we need to re-generate the + # same noise used earlier in the pipeline. This should really be handled in a clearer way. + seed: int + + # The timestep schedule for the denoising process. + timesteps: torch.Tensor + + # The first timestep in the schedule. This is used to determine the initial noise level, so + # should be populated if you want noise applied *even* if timesteps is empty. + init_timestep: torch.Tensor + + # Class of attention processor that is used. + attention_processor_cls: Type[Any] + + +@dataclass +class DenoiseContext: + """Context with all variables in denoise""" + + # Initial variables passed to denoise. Supposed to be unchanged. + inputs: DenoiseInputs + + # Scheduler which used to apply noise predictions. + scheduler: SchedulerMixin + + # UNet model. + unet: Optional[UNet2DConditionModel] = None + + # Current state of latent-space image in denoising process. + # None until `PRE_DENOISE_LOOP` callback. + # Shape: [batch, channels, latent_height, latent_width] + latents: Optional[torch.Tensor] = None + + # Current denoising step index. + # None until `PRE_STEP` callback. + step_index: Optional[int] = None + + # Current denoising step timestep. + # None until `PRE_STEP` callback. + timestep: Optional[torch.Tensor] = None + + # Arguments which will be passed to UNet model. + # Available in `PRE_UNET`/`POST_UNET` callbacks, otherwise will be None. + unet_kwargs: Optional[UNetKwargs] = None + + # SchedulerOutput class returned from step function(normally, generated by scheduler). + # Supposed to be used only in `POST_STEP` callback, otherwise can be None. + step_output: Optional[SchedulerOutput] = None + + # Scaled version of `latents`, which will be passed to unet_kwargs initialization. + # Available in events inside step(between `PRE_STEP` and `POST_STEP`). + # Shape: [batch, channels, latent_height, latent_width] + latent_model_input: Optional[torch.Tensor] = None + + # [TMP] Defines on which conditionings current unet call will be runned. + # Available in `PRE_UNET`/`POST_UNET` callbacks, otherwise will be None. + conditioning_mode: Optional[ConditioningMode] = None + + # [TMP] Noise predictions from negative conditioning. + # Available in `POST_COMBINE_NOISE_PREDS` callback, otherwise will be None. + # Shape: [batch, channels, latent_height, latent_width] + negative_noise_pred: Optional[torch.Tensor] = None + + # [TMP] Noise predictions from positive conditioning. + # Available in `POST_COMBINE_NOISE_PREDS` callback, otherwise will be None. + # Shape: [batch, channels, latent_height, latent_width] + positive_noise_pred: Optional[torch.Tensor] = None + + # Combined noise prediction from passed conditionings. + # Available in `POST_COMBINE_NOISE_PREDS` callback, otherwise will be None. + # Shape: [batch, channels, latent_height, latent_width] + noise_pred: Optional[torch.Tensor] = None + + # Dictionary for extensions to pass extra info about denoise process to other extensions. + extra: dict = field(default_factory=dict) diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..e5d4393faf64ceb1561651ef31331b701221e3c9 --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -0,0 +1,614 @@ +from __future__ import annotations + +import math +from contextlib import nullcontext +from dataclasses import dataclass +from typing import Any, Callable, List, Optional, Union + +import einops +import PIL.Image +import psutil +import torch +import torchvision.transforms as T +from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL +from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel +from diffusers.pipelines.stable_diffusion.pipeline_stable_diffusion import StableDiffusionPipeline +from diffusers.pipelines.stable_diffusion.safety_checker import StableDiffusionSafetyChecker +from diffusers.schedulers.scheduling_utils import KarrasDiffusionSchedulers, SchedulerMixin +from diffusers.utils.import_utils import is_xformers_available +from pydantic import Field +from transformers import CLIPFeatureExtractor, CLIPTextModel, CLIPTokenizer + +from invokeai.app.services.config.config_default import get_config +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import IPAdapterData, TextConditioningData +from invokeai.backend.stable_diffusion.diffusion.shared_invokeai_diffusion import InvokeAIDiffuserComponent +from invokeai.backend.stable_diffusion.diffusion.unet_attention_patcher import UNetAttentionPatcher, UNetIPAdapterData +from invokeai.backend.stable_diffusion.extensions.preview import PipelineIntermediateState +from invokeai.backend.util.attention import auto_detect_slice_size +from invokeai.backend.util.devices import TorchDevice +from invokeai.backend.util.hotfixes import ControlNetModel + + +@dataclass +class AddsMaskGuidance: + mask: torch.Tensor + mask_latents: torch.Tensor + scheduler: SchedulerMixin + noise: torch.Tensor + is_gradient_mask: bool + + def __call__(self, latents: torch.Tensor, t: torch.Tensor) -> torch.Tensor: + return self.apply_mask(latents, t) + + def apply_mask(self, latents: torch.Tensor, t: torch.Tensor) -> torch.Tensor: + batch_size = latents.size(0) + mask = einops.repeat(self.mask, "b c h w -> (repeat b) c h w", repeat=batch_size) + if t.dim() == 0: + # some schedulers expect t to be one-dimensional. + # TODO: file diffusers bug about inconsistency? + t = einops.repeat(t, "-> batch", batch=batch_size) + # Noise shouldn't be re-randomized between steps here. The multistep schedulers + # get very confused about what is happening from step to step when we do that. + mask_latents = self.scheduler.add_noise(self.mask_latents, self.noise, t) + # TODO: Do we need to also apply scheduler.scale_model_input? Or is add_noise appropriately scaled already? + # mask_latents = self.scheduler.scale_model_input(mask_latents, t) + mask_latents = einops.repeat(mask_latents, "b c h w -> (repeat b) c h w", repeat=batch_size) + if self.is_gradient_mask: + threshhold = (t.item()) / self.scheduler.config.num_train_timesteps + mask_bool = mask > threshhold # I don't know when mask got inverted, but it did + masked_input = torch.where(mask_bool, latents, mask_latents) + else: + masked_input = torch.lerp(mask_latents.to(dtype=latents.dtype), latents, mask.to(dtype=latents.dtype)) + return masked_input + + +def trim_to_multiple_of(*args, multiple_of=8): + return tuple((x - x % multiple_of) for x in args) + + +def image_resized_to_grid_as_tensor(image: PIL.Image.Image, normalize: bool = True, multiple_of=8) -> torch.FloatTensor: + """ + + :param image: input image + :param normalize: scale the range to [-1, 1] instead of [0, 1] + :param multiple_of: resize the input so both dimensions are a multiple of this + """ + w, h = trim_to_multiple_of(*image.size, multiple_of=multiple_of) + transformation = T.Compose( + [ + T.Resize((h, w), T.InterpolationMode.LANCZOS, antialias=True), + T.ToTensor(), + ] + ) + tensor = transformation(image) + if normalize: + tensor = tensor * 2.0 - 1.0 + return tensor + + +def is_inpainting_model(unet: UNet2DConditionModel): + return unet.conv_in.in_channels == 9 + + +@dataclass +class ControlNetData: + model: ControlNetModel = Field(default=None) + image_tensor: torch.Tensor = Field(default=None) + weight: Union[float, List[float]] = Field(default=1.0) + begin_step_percent: float = Field(default=0.0) + end_step_percent: float = Field(default=1.0) + control_mode: str = Field(default="balanced") + resize_mode: str = Field(default="just_resize") + + +@dataclass +class T2IAdapterData: + """A structure containing the information required to apply conditioning from a single T2I-Adapter model.""" + + adapter_state: dict[torch.Tensor] = Field() + weight: Union[float, list[float]] = Field(default=1.0) + begin_step_percent: float = Field(default=0.0) + end_step_percent: float = Field(default=1.0) + + +class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): + r""" + Pipeline for text-to-image generation using Stable Diffusion. + + This model inherits from [`DiffusionPipeline`]. Check the superclass documentation for the generic methods the + library implements for all the pipelines (such as downloading or saving, running on a particular device, etc.) + + Implementation note: This class started as a refactored copy of diffusers.StableDiffusionPipeline. + Hopefully future versions of diffusers provide access to more of these functions so that we don't + need to duplicate them here: https://github.com/huggingface/diffusers/issues/551#issuecomment-1281508384 + + Args: + vae ([`AutoencoderKL`]): + Variational Auto-Encoder (VAE) Model to encode and decode images to and from latent representations. + text_encoder ([`CLIPTextModel`]): + Frozen text-encoder. Stable Diffusion uses the text portion of + [CLIP](https://huggingface.co/docs/transformers/model_doc/clip#transformers.CLIPTextModel), specifically + the [clip-vit-large-patch14](https://huggingface.co/openai/clip-vit-large-patch14) variant. + tokenizer (`CLIPTokenizer`): + Tokenizer of class + [CLIPTokenizer](https://huggingface.co/docs/transformers/v4.21.0/en/model_doc/clip#transformers.CLIPTokenizer). + unet ([`UNet2DConditionModel`]): Conditional U-Net architecture to denoise the encoded image latents. + scheduler ([`SchedulerMixin`]): + A scheduler to be used in combination with `unet` to denoise the encoded image latents. Can be one of + [`DDIMScheduler`], [`LMSDiscreteScheduler`], or [`PNDMScheduler`]. + safety_checker ([`StableDiffusionSafetyChecker`]): + Classification module that estimates whether generated images could be considered offensive or harmful. + Please, refer to the [model card](https://huggingface.co/CompVis/stable-diffusion-v1-4) for details. + feature_extractor ([`CLIPFeatureExtractor`]): + Model that extracts features from generated images to be used as inputs for the `safety_checker`. + """ + + def __init__( + self, + vae: AutoencoderKL, + text_encoder: CLIPTextModel, + tokenizer: CLIPTokenizer, + unet: UNet2DConditionModel, + scheduler: KarrasDiffusionSchedulers, + safety_checker: Optional[StableDiffusionSafetyChecker], + feature_extractor: Optional[CLIPFeatureExtractor], + requires_safety_checker: bool = False, + ): + super().__init__( + vae=vae, + text_encoder=text_encoder, + tokenizer=tokenizer, + unet=unet, + scheduler=scheduler, + safety_checker=safety_checker, + feature_extractor=feature_extractor, + requires_safety_checker=requires_safety_checker, + ) + + self.invokeai_diffuser = InvokeAIDiffuserComponent(self.unet, self._unet_forward) + + def _adjust_memory_efficient_attention(self, latents: torch.Tensor): + """ + if xformers is available, use it, otherwise use sliced attention. + """ + + # On 30xx and 40xx series GPUs, `torch-sdp` is faster than `xformers`. This corresponds to a CUDA major + # version of 8 or higher. So, for major version 7 or below, we prefer `xformers`. + # See: + # - https://developer.nvidia.com/cuda-gpus + # - https://docs.nvidia.com/cuda/cuda-c-programming-guide/index.html#compute-capabilities + try: + prefer_xformers = torch.cuda.is_available() and torch.cuda.get_device_properties("cuda").major <= 7 # type: ignore # Type of "get_device_properties" is partially unknown + except Exception: + prefer_xformers = False + + config = get_config() + if config.attention_type == "xformers" and is_xformers_available() and prefer_xformers: + self.enable_xformers_memory_efficient_attention() + return + elif config.attention_type == "sliced": + slice_size = config.attention_slice_size + if slice_size == "auto": + slice_size = auto_detect_slice_size(latents) + elif slice_size == "balanced": + slice_size = "auto" + self.enable_attention_slicing(slice_size=slice_size) + return + elif config.attention_type == "normal": + self.disable_attention_slicing() + return + elif config.attention_type == "torch-sdp": + # torch-sdp is the default in diffusers. + return + + # See https://github.com/invoke-ai/InvokeAI/issues/7049 for context. + # Bumping torch from 2.2.2 to 2.4.1 caused the sliced attention implementation to produce incorrect results. + # For now, if a user is on an MPS device and has not explicitly set the attention_type, then we select the + # non-sliced torch-sdp implementation. This keeps things working on MPS at the cost of increased peak memory + # utilization. + if torch.backends.mps.is_available(): + return + + # The remainder if this code is called when attention_type=='auto'. + if self.unet.device.type == "cuda": + if is_xformers_available() and prefer_xformers: + self.enable_xformers_memory_efficient_attention() + return + # torch-sdp is the default in diffusers. + return + + if self.unet.device.type == "cpu" or self.unet.device.type == "mps": + mem_free = psutil.virtual_memory().free + elif self.unet.device.type == "cuda": + mem_free, _ = torch.cuda.mem_get_info(TorchDevice.normalize(self.unet.device)) + else: + raise ValueError(f"unrecognized device {self.unet.device}") + # input tensor of [1, 4, h/8, w/8] + # output tensor of [16, (h/8 * w/8), (h/8 * w/8)] + bytes_per_element_needed_for_baddbmm_duplication = latents.element_size() + 4 + max_size_required_for_baddbmm = ( + 16 + * latents.size(dim=2) + * latents.size(dim=3) + * latents.size(dim=2) + * latents.size(dim=3) + * bytes_per_element_needed_for_baddbmm_duplication + ) + if max_size_required_for_baddbmm > (mem_free * 3.0 / 4.0): # 3.3 / 4.0 is from old Invoke code + self.enable_attention_slicing(slice_size="max") + elif torch.backends.mps.is_available(): + # diffusers recommends always enabling for mps + self.enable_attention_slicing(slice_size="max") + else: + self.disable_attention_slicing() + + def to(self, torch_device: Optional[Union[str, torch.device]] = None, silence_dtype_warnings=False): + raise Exception("Should not be called") + + def add_inpainting_channels_to_latents( + self, latents: torch.Tensor, masked_ref_image_latents: torch.Tensor, inpainting_mask: torch.Tensor + ): + """Given a `latents` tensor, adds the mask and image latents channels required for inpainting. + + Standard (non-inpainting) SD UNet models expect an input with shape (N, 4, H, W). Inpainting models expect an + input of shape (N, 9, H, W). The 9 channels are defined as follows: + - Channel 0-3: The latents being denoised. + - Channel 4: The mask indicating which parts of the image are being inpainted. + - Channel 5-8: The latent representation of the masked reference image being inpainted. + + This function assumes that the same mask and base image should apply to all items in the batch. + """ + # Validate assumptions about input tensor shapes. + batch_size, latent_channels, latent_height, latent_width = latents.shape + assert latent_channels == 4 + assert list(masked_ref_image_latents.shape) == [1, 4, latent_height, latent_width] + assert list(inpainting_mask.shape) == [1, 1, latent_height, latent_width] + + # Repeat original_image_latents and inpainting_mask to match the latents batch size. + original_image_latents = masked_ref_image_latents.expand(batch_size, -1, -1, -1) + inpainting_mask = inpainting_mask.expand(batch_size, -1, -1, -1) + + # Concatenate along the channel dimension. + return torch.cat([latents, inpainting_mask, original_image_latents], dim=1) + + def latents_from_embeddings( + self, + latents: torch.Tensor, + scheduler_step_kwargs: dict[str, Any], + conditioning_data: TextConditioningData, + noise: Optional[torch.Tensor], + seed: int, + timesteps: torch.Tensor, + init_timestep: torch.Tensor, + callback: Callable[[PipelineIntermediateState], None], + control_data: list[ControlNetData] | None = None, + ip_adapter_data: Optional[list[IPAdapterData]] = None, + t2i_adapter_data: Optional[list[T2IAdapterData]] = None, + mask: Optional[torch.Tensor] = None, + masked_latents: Optional[torch.Tensor] = None, + is_gradient_mask: bool = False, + ) -> torch.Tensor: + """Denoise the latents. + + Args: + latents: The latent-space image to denoise. + - If we are inpainting, this is the initial latent image before noise has been added. + - If we are generating a new image, this should be initialized to zeros. + - In some cases, this may be a partially-noised latent image (e.g. when running the SDXL refiner). + scheduler_step_kwargs: kwargs forwarded to the scheduler.step() method. + conditioning_data: Text conditionging data. + noise: Noise used for two purposes: + 1. Used by the scheduler to noise the initial `latents` before denoising. + 2. Used to noise the `masked_latents` when inpainting. + `noise` should be None if the `latents` tensor has already been noised. + seed: The seed used to generate the noise for the denoising process. + HACK(ryand): seed is only used in a particular case when `noise` is None, but we need to re-generate the + same noise used earlier in the pipeline. This should really be handled in a clearer way. + timesteps: The timestep schedule for the denoising process. + init_timestep: The first timestep in the schedule. This is used to determine the initial noise level, so + should be populated if you want noise applied *even* if timesteps is empty. + callback: A callback function that is called to report progress during the denoising process. + control_data: ControlNet data. + ip_adapter_data: IP-Adapter data. + t2i_adapter_data: T2I-Adapter data. + mask: A mask indicating which parts of the image are being inpainted. The presence of mask is used to + determine whether we are inpainting or not. `mask` should have the same spatial dimensions as the + `latents` tensor. + TODO(ryand): Check and document the expected dtype, range, and values used to represent + foreground/background. + masked_latents: A latent-space representation of a masked inpainting reference image. This tensor is only + used if an *inpainting* model is being used i.e. this tensor is not used when inpainting with a standard + SD UNet model. + is_gradient_mask: A flag indicating whether `mask` is a gradient mask or not. + """ + if init_timestep.shape[0] == 0: + return latents + + orig_latents = latents.clone() + + batch_size = latents.shape[0] + batched_init_timestep = init_timestep.expand(batch_size) + + # noise can be None if the latents have already been noised (e.g. when running the SDXL refiner). + if noise is not None: + # TODO(ryand): I'm pretty sure we should be applying init_noise_sigma in cases where we are starting with + # full noise. Investigate the history of why this got commented out. + # latents = noise * self.scheduler.init_noise_sigma # it's like in t2l according to diffusers + latents = self.scheduler.add_noise(latents, noise, batched_init_timestep) + + self._adjust_memory_efficient_attention(latents) + + # Handle mask guidance (a.k.a. inpainting). + mask_guidance: AddsMaskGuidance | None = None + if mask is not None and not is_inpainting_model(self.unet): + # We are doing inpainting, since a mask is provided, but we are not using an inpainting model, so we will + # apply mask guidance to the latents. + + # 'noise' might be None if the latents have already been noised (e.g. when running the SDXL refiner). + # We still need noise for inpainting, so we generate it from the seed here. + if noise is None: + noise = torch.randn( + orig_latents.shape, + dtype=torch.float32, + device="cpu", + generator=torch.Generator(device="cpu").manual_seed(seed), + ).to(device=orig_latents.device, dtype=orig_latents.dtype) + + mask_guidance = AddsMaskGuidance( + mask=mask, + mask_latents=orig_latents, + scheduler=self.scheduler, + noise=noise, + is_gradient_mask=is_gradient_mask, + ) + + use_ip_adapter = ip_adapter_data is not None + use_regional_prompting = ( + conditioning_data.cond_regions is not None or conditioning_data.uncond_regions is not None + ) + unet_attention_patcher = None + attn_ctx = nullcontext() + + if use_ip_adapter or use_regional_prompting: + ip_adapters: Optional[List[UNetIPAdapterData]] = ( + [{"ip_adapter": ipa.ip_adapter_model, "target_blocks": ipa.target_blocks} for ipa in ip_adapter_data] + if use_ip_adapter + else None + ) + unet_attention_patcher = UNetAttentionPatcher(ip_adapters) + attn_ctx = unet_attention_patcher.apply_ip_adapter_attention(self.invokeai_diffuser.model) + + with attn_ctx: + callback( + PipelineIntermediateState( + step=0, # initial latents + order=self.scheduler.order, + total_steps=len(timesteps), + timestep=self.scheduler.config.num_train_timesteps, + latents=latents, + ) + ) + + for i, t in enumerate(self.progress_bar(timesteps)): + batched_t = t.expand(batch_size) + step_output = self.step( + t=batched_t, + latents=latents, + conditioning_data=conditioning_data, + step_index=i, + total_step_count=len(timesteps), + scheduler_step_kwargs=scheduler_step_kwargs, + mask_guidance=mask_guidance, + mask=mask, + masked_latents=masked_latents, + control_data=control_data, + ip_adapter_data=ip_adapter_data, + t2i_adapter_data=t2i_adapter_data, + ) + latents = step_output.prev_sample + predicted_original = getattr(step_output, "pred_original_sample", None) + + callback( + PipelineIntermediateState( + step=i + 1, # final latents + order=self.scheduler.order, + total_steps=len(timesteps), + timestep=int(t), + latents=latents, + predicted_original=predicted_original, + ) + ) + + # restore unmasked part after the last step is completed + # in-process masking happens before each step + if mask is not None: + if is_gradient_mask: + latents = torch.where(mask > 0, latents, orig_latents) + else: + latents = torch.lerp( + orig_latents, latents.to(dtype=orig_latents.dtype), mask.to(dtype=orig_latents.dtype) + ) + + return latents + + @torch.inference_mode() + def step( + self, + t: torch.Tensor, + latents: torch.Tensor, + conditioning_data: TextConditioningData, + step_index: int, + total_step_count: int, + scheduler_step_kwargs: dict[str, Any], + mask_guidance: AddsMaskGuidance | None, + mask: torch.Tensor | None, + masked_latents: torch.Tensor | None, + control_data: list[ControlNetData] | None = None, + ip_adapter_data: Optional[list[IPAdapterData]] = None, + t2i_adapter_data: Optional[list[T2IAdapterData]] = None, + ): + # invokeai_diffuser has batched timesteps, but diffusers schedulers expect a single value + timestep = t[0] + + # Handle masked image-to-image (a.k.a inpainting). + if mask_guidance is not None: + # NOTE: This is intentionally done *before* self.scheduler.scale_model_input(...). + latents = mask_guidance(latents, timestep) + + # TODO: should this scaling happen here or inside self._unet_forward? + # i.e. before or after passing it to InvokeAIDiffuserComponent + latent_model_input = self.scheduler.scale_model_input(latents, timestep) + + # Handle ControlNet(s) + down_block_additional_residuals = None + mid_block_additional_residual = None + if control_data is not None: + down_block_additional_residuals, mid_block_additional_residual = self.invokeai_diffuser.do_controlnet_step( + control_data=control_data, + sample=latent_model_input, + timestep=timestep, + step_index=step_index, + total_step_count=total_step_count, + conditioning_data=conditioning_data, + ) + + # Handle T2I-Adapter(s) + down_intrablock_additional_residuals = None + if t2i_adapter_data is not None: + accum_adapter_state = None + for single_t2i_adapter_data in t2i_adapter_data: + # Determine the T2I-Adapter weights for the current denoising step. + first_t2i_adapter_step = math.floor(single_t2i_adapter_data.begin_step_percent * total_step_count) + last_t2i_adapter_step = math.ceil(single_t2i_adapter_data.end_step_percent * total_step_count) + t2i_adapter_weight = ( + single_t2i_adapter_data.weight[step_index] + if isinstance(single_t2i_adapter_data.weight, list) + else single_t2i_adapter_data.weight + ) + if step_index < first_t2i_adapter_step or step_index > last_t2i_adapter_step: + # If the current step is outside of the T2I-Adapter's begin/end step range, then set its weight to 0 + # so it has no effect. + t2i_adapter_weight = 0.0 + + # Apply the t2i_adapter_weight, and accumulate. + if accum_adapter_state is None: + # Handle the first T2I-Adapter. + accum_adapter_state = [val * t2i_adapter_weight for val in single_t2i_adapter_data.adapter_state] + else: + # Add to the previous adapter states. + for idx, value in enumerate(single_t2i_adapter_data.adapter_state): + accum_adapter_state[idx] += value * t2i_adapter_weight + + # Hack: force compatibility with irregular resolutions by padding the feature map with zeros + for idx, tensor in enumerate(accum_adapter_state): + # The tensor size is supposed to be some integer downscale factor of the latents size. + # Internally, the unet will pad the latents before downscaling between levels when it is no longer divisible by its downscale factor. + # If the latent size does not scale down evenly, we need to pad the tensor so that it matches the the downscaled padded latents later on. + scale_factor = latents.size()[-1] // tensor.size()[-1] + required_padding_width = math.ceil(latents.size()[-1] / scale_factor) - tensor.size()[-1] + required_padding_height = math.ceil(latents.size()[-2] / scale_factor) - tensor.size()[-2] + tensor = torch.nn.functional.pad( + tensor, + (0, required_padding_width, 0, required_padding_height, 0, 0, 0, 0), + mode="constant", + value=0, + ) + accum_adapter_state[idx] = tensor + + down_intrablock_additional_residuals = accum_adapter_state + + # Handle inpainting models. + if is_inpainting_model(self.unet): + # NOTE: These calls to add_inpainting_channels_to_latents(...) are intentionally done *after* + # self.scheduler.scale_model_input(...) so that the scaling is not applied to the mask or reference image + # latents. + if mask is not None: + if masked_latents is None: + raise ValueError("Source image required for inpaint mask when inpaint model used!") + latent_model_input = self.add_inpainting_channels_to_latents( + latents=latent_model_input, masked_ref_image_latents=masked_latents, inpainting_mask=mask + ) + else: + # We are using an inpainting model, but no mask was provided, so we are not really "inpainting". + # We generate a global mask and empty original image so that we can still generate in this + # configuration. + # TODO(ryand): Should we just raise an exception here instead? I can't think of a use case for wanting + # to do this. + # TODO(ryand): If we decide that there is a good reason to keep this, then we should generate the 'fake' + # mask and original image once rather than on every denoising step. + latent_model_input = self.add_inpainting_channels_to_latents( + latents=latent_model_input, + masked_ref_image_latents=torch.zeros_like(latent_model_input[:1]), + inpainting_mask=torch.ones_like(latent_model_input[:1, :1]), + ) + + uc_noise_pred, c_noise_pred = self.invokeai_diffuser.do_unet_step( + sample=latent_model_input, + timestep=t, # TODO: debug how handled batched and non batched timesteps + step_index=step_index, + total_step_count=total_step_count, + conditioning_data=conditioning_data, + ip_adapter_data=ip_adapter_data, + down_block_additional_residuals=down_block_additional_residuals, # for ControlNet + mid_block_additional_residual=mid_block_additional_residual, # for ControlNet + down_intrablock_additional_residuals=down_intrablock_additional_residuals, # for T2I-Adapter + ) + + guidance_scale = conditioning_data.guidance_scale + if isinstance(guidance_scale, list): + guidance_scale = guidance_scale[step_index] + + noise_pred = self.invokeai_diffuser._combine(uc_noise_pred, c_noise_pred, guidance_scale) + guidance_rescale_multiplier = conditioning_data.guidance_rescale_multiplier + if guidance_rescale_multiplier > 0: + noise_pred = self._rescale_cfg( + noise_pred, + c_noise_pred, + guidance_rescale_multiplier, + ) + + # compute the previous noisy sample x_t -> x_t-1 + step_output = self.scheduler.step(noise_pred, timestep, latents, **scheduler_step_kwargs) + + # TODO: discuss injection point options. For now this is a patch to get progress images working with inpainting + # again. + if mask_guidance is not None: + # Apply the mask to any "denoised" or "pred_original_sample" fields. + if hasattr(step_output, "denoised"): + step_output.pred_original_sample = mask_guidance(step_output.denoised, self.scheduler.timesteps[-1]) + elif hasattr(step_output, "pred_original_sample"): + step_output.pred_original_sample = mask_guidance( + step_output.pred_original_sample, self.scheduler.timesteps[-1] + ) + else: + step_output.pred_original_sample = mask_guidance(latents, self.scheduler.timesteps[-1]) + + return step_output + + @staticmethod + def _rescale_cfg(total_noise_pred, pos_noise_pred, multiplier=0.7): + """Implementation of Algorithm 2 from https://arxiv.org/pdf/2305.08891.pdf.""" + ro_pos = torch.std(pos_noise_pred, dim=(1, 2, 3), keepdim=True) + ro_cfg = torch.std(total_noise_pred, dim=(1, 2, 3), keepdim=True) + + x_rescaled = total_noise_pred * (ro_pos / ro_cfg) + x_final = multiplier * x_rescaled + (1.0 - multiplier) * total_noise_pred + return x_final + + def _unet_forward( + self, + latents, + t, + text_embeddings, + cross_attention_kwargs: Optional[dict[str, Any]] = None, + **kwargs, + ): + """predict the noise residual""" + # First three args should be positional, not keywords, so torch hooks can see them. + return self.unet( + latents, + t, + text_embeddings, + cross_attention_kwargs=cross_attention_kwargs, + **kwargs, + ).sample diff --git a/invokeai/backend/stable_diffusion/diffusion/__init__.py b/invokeai/backend/stable_diffusion/diffusion/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..712542f79cf7bdecfaa8ea6cbf62ccf5bc9aed2e --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion/__init__.py @@ -0,0 +1,7 @@ +""" +Initialization file for invokeai.models.diffusion +""" + +from invokeai.backend.stable_diffusion.diffusion.shared_invokeai_diffusion import ( + InvokeAIDiffuserComponent, # noqa: F401 +) diff --git a/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py b/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py new file mode 100644 index 0000000000000000000000000000000000000000..184cdb9b025e4e9f72e91a8f07bf39fd0154ef3c --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion/conditioning_data.py @@ -0,0 +1,282 @@ +from __future__ import annotations + +import math +from dataclasses import dataclass +from enum import Enum +from typing import TYPE_CHECKING, List, Optional, Tuple, Union + +import torch + +from invokeai.backend.stable_diffusion.diffusion.regional_prompt_data import RegionalPromptData + +if TYPE_CHECKING: + from invokeai.backend.ip_adapter.ip_adapter import IPAdapter + from invokeai.backend.stable_diffusion.denoise_context import UNetKwargs + + +@dataclass +class BasicConditioningInfo: + """SD 1/2 text conditioning information produced by Compel.""" + + embeds: torch.Tensor + + def to(self, device, dtype=None): + self.embeds = self.embeds.to(device=device, dtype=dtype) + return self + + +@dataclass +class SDXLConditioningInfo(BasicConditioningInfo): + """SDXL text conditioning information produced by Compel.""" + + pooled_embeds: torch.Tensor + add_time_ids: torch.Tensor + + def to(self, device, dtype=None): + self.pooled_embeds = self.pooled_embeds.to(device=device, dtype=dtype) + self.add_time_ids = self.add_time_ids.to(device=device, dtype=dtype) + return super().to(device=device, dtype=dtype) + + +@dataclass +class FLUXConditioningInfo: + clip_embeds: torch.Tensor + t5_embeds: torch.Tensor + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + self.clip_embeds = self.clip_embeds.to(device=device, dtype=dtype) + self.t5_embeds = self.t5_embeds.to(device=device, dtype=dtype) + return self + + +@dataclass +class SD3ConditioningInfo: + clip_l_pooled_embeds: torch.Tensor + clip_l_embeds: torch.Tensor + clip_g_pooled_embeds: torch.Tensor + clip_g_embeds: torch.Tensor + t5_embeds: torch.Tensor | None + + def to(self, device: torch.device | None = None, dtype: torch.dtype | None = None): + self.clip_l_pooled_embeds = self.clip_l_pooled_embeds.to(device=device, dtype=dtype) + self.clip_l_embeds = self.clip_l_embeds.to(device=device, dtype=dtype) + self.clip_g_pooled_embeds = self.clip_g_pooled_embeds.to(device=device, dtype=dtype) + self.clip_g_embeds = self.clip_g_embeds.to(device=device, dtype=dtype) + if self.t5_embeds is not None: + self.t5_embeds = self.t5_embeds.to(device=device, dtype=dtype) + return self + + +@dataclass +class ConditioningFieldData: + conditionings: ( + List[BasicConditioningInfo] + | List[SDXLConditioningInfo] + | List[FLUXConditioningInfo] + | List[SD3ConditioningInfo] + ) + + +@dataclass +class IPAdapterConditioningInfo: + cond_image_prompt_embeds: torch.Tensor + """IP-Adapter image encoder conditioning embeddings. + Shape: (num_images, num_tokens, encoding_dim). + """ + uncond_image_prompt_embeds: torch.Tensor + """IP-Adapter image encoding embeddings to use for unconditional generation. + Shape: (num_images, num_tokens, encoding_dim). + """ + + +@dataclass +class IPAdapterData: + ip_adapter_model: IPAdapter + ip_adapter_conditioning: IPAdapterConditioningInfo + mask: torch.Tensor + target_blocks: List[str] + + # Either a single weight applied to all steps, or a list of weights for each step. + weight: Union[float, List[float]] = 1.0 + begin_step_percent: float = 0.0 + end_step_percent: float = 1.0 + + def scale_for_step(self, step_index: int, total_steps: int) -> float: + first_adapter_step = math.floor(self.begin_step_percent * total_steps) + last_adapter_step = math.ceil(self.end_step_percent * total_steps) + weight = self.weight[step_index] if isinstance(self.weight, List) else self.weight + if step_index >= first_adapter_step and step_index <= last_adapter_step: + # Only apply this IP-Adapter if the current step is within the IP-Adapter's begin/end step range. + return weight + # Otherwise, set the IP-Adapter's scale to 0, so it has no effect. + return 0.0 + + +@dataclass +class Range: + start: int + end: int + + +class TextConditioningRegions: + def __init__( + self, + masks: torch.Tensor, + ranges: list[Range], + ): + # A binary mask indicating the regions of the image that the prompt should be applied to. + # Shape: (1, num_prompts, height, width) + # Dtype: torch.bool + self.masks = masks + + # A list of ranges indicating the start and end indices of the embeddings that corresponding mask applies to. + # ranges[i] contains the embedding range for the i'th prompt / mask. + self.ranges = ranges + + assert self.masks.shape[1] == len(self.ranges) + + +class ConditioningMode(Enum): + Both = "both" + Negative = "negative" + Positive = "positive" + + +class TextConditioningData: + def __init__( + self, + uncond_text: Union[BasicConditioningInfo, SDXLConditioningInfo], + cond_text: Union[BasicConditioningInfo, SDXLConditioningInfo], + uncond_regions: Optional[TextConditioningRegions], + cond_regions: Optional[TextConditioningRegions], + guidance_scale: Union[float, List[float]], + guidance_rescale_multiplier: float = 0, # TODO: old backend, remove + ): + self.uncond_text = uncond_text + self.cond_text = cond_text + self.uncond_regions = uncond_regions + self.cond_regions = cond_regions + # Guidance scale as defined in [Classifier-Free Diffusion Guidance](https://arxiv.org/abs/2207.12598). + # `guidance_scale` is defined as `w` of equation 2. of [Imagen Paper](https://arxiv.org/pdf/2205.11487.pdf). + # Guidance scale is enabled by setting `guidance_scale > 1`. Higher guidance scale encourages to generate + # images that are closely linked to the text `prompt`, usually at the expense of lower image quality. + self.guidance_scale = guidance_scale + # TODO: old backend, remove + # For models trained using zero-terminal SNR ("ztsnr"), it's suggested to use guidance_rescale_multiplier of 0.7. + # See [Common Diffusion Noise Schedules and Sample Steps are Flawed](https://arxiv.org/pdf/2305.08891.pdf). + self.guidance_rescale_multiplier = guidance_rescale_multiplier + + def is_sdxl(self): + assert isinstance(self.uncond_text, SDXLConditioningInfo) == isinstance(self.cond_text, SDXLConditioningInfo) + return isinstance(self.cond_text, SDXLConditioningInfo) + + def to_unet_kwargs(self, unet_kwargs: UNetKwargs, conditioning_mode: ConditioningMode): + """Fills unet arguments with data from provided conditionings. + + Args: + unet_kwargs (UNetKwargs): Object which stores UNet model arguments. + conditioning_mode (ConditioningMode): Describes which conditionings should be used. + """ + _, _, h, w = unet_kwargs.sample.shape + device = unet_kwargs.sample.device + dtype = unet_kwargs.sample.dtype + + # TODO: combine regions with conditionings + if conditioning_mode == ConditioningMode.Both: + conditionings = [self.uncond_text, self.cond_text] + c_regions = [self.uncond_regions, self.cond_regions] + elif conditioning_mode == ConditioningMode.Positive: + conditionings = [self.cond_text] + c_regions = [self.cond_regions] + elif conditioning_mode == ConditioningMode.Negative: + conditionings = [self.uncond_text] + c_regions = [self.uncond_regions] + else: + raise ValueError(f"Unexpected conditioning mode: {conditioning_mode}") + + encoder_hidden_states, encoder_attention_mask = self._concat_conditionings_for_batch( + [c.embeds for c in conditionings] + ) + + unet_kwargs.encoder_hidden_states = encoder_hidden_states + unet_kwargs.encoder_attention_mask = encoder_attention_mask + + if self.is_sdxl(): + added_cond_kwargs = dict( # noqa: C408 + text_embeds=torch.cat([c.pooled_embeds for c in conditionings]), + time_ids=torch.cat([c.add_time_ids for c in conditionings]), + ) + + unet_kwargs.added_cond_kwargs = added_cond_kwargs + + if any(r is not None for r in c_regions): + tmp_regions = [] + for c, r in zip(conditionings, c_regions, strict=True): + if r is None: + r = TextConditioningRegions( + masks=torch.ones((1, 1, h, w), dtype=dtype), + ranges=[Range(start=0, end=c.embeds.shape[1])], + ) + tmp_regions.append(r) + + if unet_kwargs.cross_attention_kwargs is None: + unet_kwargs.cross_attention_kwargs = {} + + unet_kwargs.cross_attention_kwargs.update( + regional_prompt_data=RegionalPromptData(regions=tmp_regions, device=device, dtype=dtype), + ) + + @staticmethod + def _pad_zeros(t: torch.Tensor, pad_shape: tuple, dim: int) -> torch.Tensor: + return torch.cat([t, torch.zeros(pad_shape, device=t.device, dtype=t.dtype)], dim=dim) + + @classmethod + def _pad_conditioning( + cls, + cond: torch.Tensor, + target_len: int, + ) -> Tuple[torch.Tensor, torch.Tensor]: + """Pad provided conditioning tensor to target_len by zeros and returns mask of unpadded bytes. + + Args: + cond (torch.Tensor): Conditioning tensor which to pads by zeros. + target_len (int): To which length(tokens count) pad tensor. + """ + conditioning_attention_mask = torch.ones((cond.shape[0], cond.shape[1]), device=cond.device, dtype=cond.dtype) + + if cond.shape[1] < target_len: + conditioning_attention_mask = cls._pad_zeros( + conditioning_attention_mask, + pad_shape=(cond.shape[0], target_len - cond.shape[1]), + dim=1, + ) + + cond = cls._pad_zeros( + cond, + pad_shape=(cond.shape[0], target_len - cond.shape[1], cond.shape[2]), + dim=1, + ) + + return cond, conditioning_attention_mask + + @classmethod + def _concat_conditionings_for_batch( + cls, + conditionings: List[torch.Tensor], + ) -> Tuple[torch.Tensor, Optional[torch.Tensor]]: + """Concatenate provided conditioning tensors to one batched tensor. + If tensors have different sizes then pad them by zeros and creates + encoder_attention_mask to exclude padding from attention. + + Args: + conditionings (List[torch.Tensor]): List of conditioning tensors to concatenate. + """ + encoder_attention_mask = None + max_len = max([c.shape[1] for c in conditionings]) + if any(c.shape[1] != max_len for c in conditionings): + encoder_attention_masks = [None] * len(conditionings) + for i in range(len(conditionings)): + conditionings[i], encoder_attention_masks[i] = cls._pad_conditioning(conditionings[i], max_len) + encoder_attention_mask = torch.cat(encoder_attention_masks) + + return torch.cat(conditionings), encoder_attention_mask diff --git a/invokeai/backend/stable_diffusion/diffusion/custom_atttention.py b/invokeai/backend/stable_diffusion/diffusion/custom_atttention.py new file mode 100644 index 0000000000000000000000000000000000000000..1334313fe6e8d2b070f6eba931a314b654d59f08 --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion/custom_atttention.py @@ -0,0 +1,214 @@ +from dataclasses import dataclass +from typing import List, Optional, cast + +import torch +import torch.nn.functional as F +from diffusers.models.attention_processor import Attention, AttnProcessor2_0 + +from invokeai.backend.ip_adapter.ip_attention_weights import IPAttentionProcessorWeights +from invokeai.backend.stable_diffusion.diffusion.regional_ip_data import RegionalIPData +from invokeai.backend.stable_diffusion.diffusion.regional_prompt_data import RegionalPromptData + + +@dataclass +class IPAdapterAttentionWeights: + ip_adapter_weights: IPAttentionProcessorWeights + skip: bool + + +class CustomAttnProcessor2_0(AttnProcessor2_0): + """A custom implementation of AttnProcessor2_0 that supports additional Invoke features. + This implementation is based on + https://github.com/huggingface/diffusers/blame/fcfa270fbd1dc294e2f3a505bae6bcb791d721c3/src/diffusers/models/attention_processor.py#L1204 + Supported custom features: + - IP-Adapter + - Regional prompt attention + """ + + def __init__( + self, + ip_adapter_attention_weights: Optional[List[IPAdapterAttentionWeights]] = None, + ): + """Initialize a CustomAttnProcessor2_0. + Note: Arguments that are the same for all attention layers are passed to __call__(). Arguments that are + layer-specific are passed to __init__(). + Args: + ip_adapter_weights: The IP-Adapter attention weights. ip_adapter_weights[i] contains the attention weights + for the i'th IP-Adapter. + """ + super().__init__() + self._ip_adapter_attention_weights = ip_adapter_attention_weights + + def __call__( + self, + attn: Attention, + hidden_states: torch.Tensor, + encoder_hidden_states: Optional[torch.Tensor] = None, + attention_mask: Optional[torch.Tensor] = None, + temb: Optional[torch.Tensor] = None, + # For Regional Prompting: + regional_prompt_data: Optional[RegionalPromptData] = None, + percent_through: Optional[torch.Tensor] = None, + # For IP-Adapter: + regional_ip_data: Optional[RegionalIPData] = None, + *args, + **kwargs, + ) -> torch.FloatTensor: + """Apply attention. + Args: + regional_prompt_data: The regional prompt data for the current batch. If not None, this will be used to + apply regional prompt masking. + regional_ip_data: The IP-Adapter data for the current batch. + """ + # If true, we are doing cross-attention, if false we are doing self-attention. + is_cross_attention = encoder_hidden_states is not None + + # Start unmodified block from AttnProcessor2_0. + # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv + residual = hidden_states + if attn.spatial_norm is not None: + hidden_states = attn.spatial_norm(hidden_states, temb) + + input_ndim = hidden_states.ndim + + if input_ndim == 4: + batch_size, channel, height, width = hidden_states.shape + hidden_states = hidden_states.view(batch_size, channel, height * width).transpose(1, 2) + + batch_size, sequence_length, _ = ( + hidden_states.shape if encoder_hidden_states is None else encoder_hidden_states.shape + ) + # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + # End unmodified block from AttnProcessor2_0. + + _, query_seq_len, _ = hidden_states.shape + # Handle regional prompt attention masks. + if regional_prompt_data is not None and is_cross_attention: + assert percent_through is not None + prompt_region_attention_mask = regional_prompt_data.get_cross_attn_mask( + query_seq_len=query_seq_len, key_seq_len=sequence_length + ) + + if attention_mask is None: + attention_mask = prompt_region_attention_mask + else: + attention_mask = prompt_region_attention_mask + attention_mask + + # Start unmodified block from AttnProcessor2_0. + # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv + if attention_mask is not None: + attention_mask = attn.prepare_attention_mask(attention_mask, sequence_length, batch_size) + # scaled_dot_product_attention expects attention_mask shape to be + # (batch, heads, source_length, target_length) + attention_mask = attention_mask.view(batch_size, attn.heads, -1, attention_mask.shape[-1]) + + if attn.group_norm is not None: + hidden_states = attn.group_norm(hidden_states.transpose(1, 2)).transpose(1, 2) + + query = attn.to_q(hidden_states) + + if encoder_hidden_states is None: + encoder_hidden_states = hidden_states + elif attn.norm_cross: + encoder_hidden_states = attn.norm_encoder_hidden_states(encoder_hidden_states) + + key = attn.to_k(encoder_hidden_states) + value = attn.to_v(encoder_hidden_states) + + inner_dim = key.shape[-1] + head_dim = inner_dim // attn.heads + + query = query.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) + + key = key.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) + value = value.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) + + # the output of sdp = (batch, num_heads, seq_len, head_dim) + # TODO: add support for attn.scale when we move to Torch 2.1 + hidden_states = F.scaled_dot_product_attention( + query, key, value, attn_mask=attention_mask, dropout_p=0.0, is_causal=False + ) + + hidden_states = hidden_states.transpose(1, 2).reshape(batch_size, -1, attn.heads * head_dim) + hidden_states = hidden_states.to(query.dtype) + # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + # End unmodified block from AttnProcessor2_0. + + # Apply IP-Adapter conditioning. + if is_cross_attention: + if self._ip_adapter_attention_weights: + assert regional_ip_data is not None + ip_masks = regional_ip_data.get_masks(query_seq_len=query_seq_len) + + assert ( + len(regional_ip_data.image_prompt_embeds) + == len(self._ip_adapter_attention_weights) + == len(regional_ip_data.scales) + == ip_masks.shape[1] + ) + + for ipa_index, ipa_embed in enumerate(regional_ip_data.image_prompt_embeds): + ipa_weights = self._ip_adapter_attention_weights[ipa_index].ip_adapter_weights + ipa_scale = regional_ip_data.scales[ipa_index] + ip_mask = ip_masks[0, ipa_index, ...] + + # The batch dimensions should match. + assert ipa_embed.shape[0] == encoder_hidden_states.shape[0] + # The token_len dimensions should match. + assert ipa_embed.shape[-1] == encoder_hidden_states.shape[-1] + + ip_hidden_states = ipa_embed + + # Expected ip_hidden_state shape: (batch_size, num_ip_images, ip_seq_len, ip_image_embedding) + + if not self._ip_adapter_attention_weights[ipa_index].skip: + ip_key = ipa_weights.to_k_ip(ip_hidden_states) + ip_value = ipa_weights.to_v_ip(ip_hidden_states) + + # Expected ip_key and ip_value shape: + # (batch_size, num_ip_images, ip_seq_len, head_dim * num_heads) + + ip_key = ip_key.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) + ip_value = ip_value.view(batch_size, -1, attn.heads, head_dim).transpose(1, 2) + + # Expected ip_key and ip_value shape: + # (batch_size, num_heads, num_ip_images * ip_seq_len, head_dim) + + # TODO: add support for attn.scale when we move to Torch 2.1 + ip_hidden_states = F.scaled_dot_product_attention( + query, ip_key, ip_value, attn_mask=None, dropout_p=0.0, is_causal=False + ) + + # Expected ip_hidden_states shape: (batch_size, num_heads, query_seq_len, head_dim) + ip_hidden_states = ip_hidden_states.transpose(1, 2).reshape( + batch_size, -1, attn.heads * head_dim + ) + + ip_hidden_states = ip_hidden_states.to(query.dtype) + + # Expected ip_hidden_states shape: (batch_size, query_seq_len, num_heads * head_dim) + hidden_states = hidden_states + ipa_scale * ip_hidden_states * ip_mask + else: + # If IP-Adapter is not enabled, then regional_ip_data should not be passed in. + assert regional_ip_data is None + + # Start unmodified block from AttnProcessor2_0. + # vvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvvv + # linear proj + hidden_states = attn.to_out[0](hidden_states) + # dropout + hidden_states = attn.to_out[1](hidden_states) + + if input_ndim == 4: + batch_size, channel, height, width = hidden_states.shape + hidden_states = hidden_states.transpose(-1, -2).reshape(batch_size, channel, height, width) + + if attn.residual_connection: + hidden_states = hidden_states + residual + + hidden_states = hidden_states / attn.rescale_output_factor + # ^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^^ + # End of unmodified block from AttnProcessor2_0 + + # casting torch.Tensor to torch.FloatTensor to avoid type issues + return cast(torch.FloatTensor, hidden_states) diff --git a/invokeai/backend/stable_diffusion/diffusion/regional_ip_data.py b/invokeai/backend/stable_diffusion/diffusion/regional_ip_data.py new file mode 100644 index 0000000000000000000000000000000000000000..792c97114da767bea3dfb067078827876c59ddfd --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion/regional_ip_data.py @@ -0,0 +1,72 @@ +import torch + + +class RegionalIPData: + """A class to manage the data for regional IP-Adapter conditioning.""" + + def __init__( + self, + image_prompt_embeds: list[torch.Tensor], + scales: list[float], + masks: list[torch.Tensor], + dtype: torch.dtype, + device: torch.device, + max_downscale_factor: int = 8, + ): + """Initialize a `IPAdapterConditioningData` object.""" + assert len(image_prompt_embeds) == len(scales) == len(masks) + + # The image prompt embeddings. + # regional_ip_data[i] contains the image prompt embeddings for the i'th IP-Adapter. Each tensor + # has shape (batch_size, num_ip_images, seq_len, ip_embedding_len). + self.image_prompt_embeds = image_prompt_embeds + + # The scales for the IP-Adapter attention. + # scales[i] contains the attention scale for the i'th IP-Adapter. + self.scales = scales + + # The IP-Adapter masks. + # self._masks_by_seq_len[s] contains the spatial masks for the downsampling level with query sequence length of + # s. It has shape (batch_size, num_ip_images, query_seq_len, 1). The masks have values of 1.0 for included + # regions and 0.0 for excluded regions. + self._masks_by_seq_len = self._prepare_masks(masks, max_downscale_factor, device, dtype) + + def _prepare_masks( + self, masks: list[torch.Tensor], max_downscale_factor: int, device: torch.device, dtype: torch.dtype + ) -> dict[int, torch.Tensor]: + """Prepare the masks for the IP-Adapter attention.""" + # Concatenate the masks so that they can be processed more efficiently. + mask_tensor = torch.cat(masks, dim=1) + + mask_tensor = mask_tensor.to(device=device, dtype=dtype) + + masks_by_seq_len: dict[int, torch.Tensor] = {} + + # Downsample the spatial dimensions by factors of 2 until max_downscale_factor is reached. + downscale_factor = 1 + while downscale_factor <= max_downscale_factor: + b, num_ip_adapters, h, w = mask_tensor.shape + # Assert that the batch size is 1, because I haven't thought through batch handling for this feature yet. + assert b == 1 + + # The IP-Adapters are applied in the cross-attention layers, where the query sequence length is the h * w of + # the spatial features. + query_seq_len = h * w + + masks_by_seq_len[query_seq_len] = mask_tensor.view((b, num_ip_adapters, -1, 1)) + + downscale_factor *= 2 + if downscale_factor <= max_downscale_factor: + # We use max pooling because we downscale to a pretty low resolution, so we don't want small mask + # regions to be lost entirely. + # + # ceil_mode=True is set to mirror the downsampling behavior of SD and SDXL. + # + # TODO(ryand): In the future, we may want to experiment with other downsampling methods. + mask_tensor = torch.nn.functional.max_pool2d(mask_tensor, kernel_size=2, stride=2, ceil_mode=True) + + return masks_by_seq_len + + def get_masks(self, query_seq_len: int) -> torch.Tensor: + """Get the mask for the given query sequence length.""" + return self._masks_by_seq_len[query_seq_len] diff --git a/invokeai/backend/stable_diffusion/diffusion/regional_prompt_data.py b/invokeai/backend/stable_diffusion/diffusion/regional_prompt_data.py new file mode 100644 index 0000000000000000000000000000000000000000..eddd31f0c4230ad43f2d168d47e58792674d25f1 --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion/regional_prompt_data.py @@ -0,0 +1,110 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch +import torch.nn.functional as F + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( + TextConditioningRegions, + ) + + +class RegionalPromptData: + """A class to manage the prompt data for regional conditioning.""" + + def __init__( + self, + regions: list[TextConditioningRegions], + device: torch.device, + dtype: torch.dtype, + max_downscale_factor: int = 8, + ): + """Initialize a `RegionalPromptData` object. + Args: + regions (list[TextConditioningRegions]): regions[i] contains the prompt regions for the i'th sample in the + batch. + device (torch.device): The device to use for the attention masks. + dtype (torch.dtype): The data type to use for the attention masks. + max_downscale_factor: Spatial masks will be prepared for downscale factors from 1 to max_downscale_factor + in steps of 2x. + """ + self._regions = regions + self._device = device + self._dtype = dtype + # self._spatial_masks_by_seq_len[b][s] contains the spatial masks for the b'th batch sample with a query + # sequence length of s. + self._spatial_masks_by_seq_len: list[dict[int, torch.Tensor]] = self._prepare_spatial_masks( + regions, max_downscale_factor + ) + self._negative_cross_attn_mask_score = -10000.0 + + def _prepare_spatial_masks( + self, regions: list[TextConditioningRegions], max_downscale_factor: int = 8 + ) -> list[dict[int, torch.Tensor]]: + """Prepare the spatial masks for all downscaling factors.""" + # batch_masks_by_seq_len[b][s] contains the spatial masks for the b'th batch sample with a query sequence length + # of s. + batch_sample_masks_by_seq_len: list[dict[int, torch.Tensor]] = [] + + for batch_sample_regions in regions: + batch_sample_masks_by_seq_len.append({}) + + batch_sample_masks = batch_sample_regions.masks.to(device=self._device, dtype=self._dtype) + + # Downsample the spatial dimensions by factors of 2 until max_downscale_factor is reached. + downscale_factor = 1 + while downscale_factor <= max_downscale_factor: + b, _num_prompts, h, w = batch_sample_masks.shape + assert b == 1 + query_seq_len = h * w + + batch_sample_masks_by_seq_len[-1][query_seq_len] = batch_sample_masks + + downscale_factor *= 2 + if downscale_factor <= max_downscale_factor: + # We use max pooling because we downscale to a pretty low resolution, so we don't want small prompt + # regions to be lost entirely. + # + # ceil_mode=True is set to mirror the downsampling behavior of SD and SDXL. + # + # TODO(ryand): In the future, we may want to experiment with other downsampling methods (e.g. + # nearest interpolation), and could potentially use a weighted mask rather than a binary mask. + batch_sample_masks = F.max_pool2d(batch_sample_masks, kernel_size=2, stride=2, ceil_mode=True) + + return batch_sample_masks_by_seq_len + + def get_cross_attn_mask(self, query_seq_len: int, key_seq_len: int) -> torch.Tensor: + """Get the cross-attention mask for the given query sequence length. + Args: + query_seq_len: The length of the flattened spatial features at the current downscaling level. + key_seq_len (int): The sequence length of the prompt embeddings (which act as the key in the cross-attention + layers). This is most likely equal to the max embedding range end, but we pass it explicitly to be sure. + Returns: + torch.Tensor: The cross-attention score mask. + shape: (batch_size, query_seq_len, key_seq_len). + dtype: float + """ + batch_size = len(self._spatial_masks_by_seq_len) + batch_spatial_masks = [self._spatial_masks_by_seq_len[b][query_seq_len] for b in range(batch_size)] + + # Create an empty attention mask with the correct shape. + attn_mask = torch.zeros((batch_size, query_seq_len, key_seq_len), dtype=self._dtype, device=self._device) + + for batch_idx in range(batch_size): + batch_sample_spatial_masks = batch_spatial_masks[batch_idx] + batch_sample_regions = self._regions[batch_idx] + + # Flatten the spatial dimensions of the mask by reshaping to (1, num_prompts, query_seq_len, 1). + _, num_prompts, _, _ = batch_sample_spatial_masks.shape + batch_sample_query_masks = batch_sample_spatial_masks.view((1, num_prompts, query_seq_len, 1)) + + for prompt_idx, embedding_range in enumerate(batch_sample_regions.ranges): + batch_sample_query_scores = batch_sample_query_masks[0, prompt_idx, :, :].clone() + batch_sample_query_mask = batch_sample_query_scores > 0.5 + batch_sample_query_scores[batch_sample_query_mask] = 0.0 + batch_sample_query_scores[~batch_sample_query_mask] = self._negative_cross_attn_mask_score + attn_mask[batch_idx, :, embedding_range.start : embedding_range.end] = batch_sample_query_scores + + return attn_mask diff --git a/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py new file mode 100644 index 0000000000000000000000000000000000000000..f418133e49fb58bd4b25e943898a31c4a7d15769 --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion/shared_invokeai_diffusion.py @@ -0,0 +1,496 @@ +from __future__ import annotations + +import math +from typing import Any, Callable, Optional, Union + +import torch +from typing_extensions import TypeAlias + +from invokeai.app.services.config.config_default import get_config +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ( + IPAdapterData, + Range, + TextConditioningData, + TextConditioningRegions, +) +from invokeai.backend.stable_diffusion.diffusion.regional_ip_data import RegionalIPData +from invokeai.backend.stable_diffusion.diffusion.regional_prompt_data import RegionalPromptData + +ModelForwardCallback: TypeAlias = Union[ + # x, t, conditioning, Optional[cross-attention kwargs] + Callable[ + [torch.Tensor, torch.Tensor, torch.Tensor, Optional[dict[str, Any]]], + torch.Tensor, + ], + Callable[[torch.Tensor, torch.Tensor, torch.Tensor], torch.Tensor], +] + + +class InvokeAIDiffuserComponent: + """ + The aim of this component is to provide a single place for code that can be applied identically to + all InvokeAI diffusion procedures. + + At the moment it includes the following features: + * Cross attention control ("prompt2prompt") + * Hybrid conditioning (used for inpainting) + """ + + debug_thresholding = False + sequential_guidance = False + + def __init__( + self, + model, + model_forward_callback: ModelForwardCallback, + ): + """ + :param model: the unet model to pass through to cross attention control + :param model_forward_callback: a lambda with arguments (x, sigma, conditioning_to_apply). will be called repeatedly. most likely, this should simply call model.forward(x, sigma, conditioning) + """ + config = get_config() + self.conditioning = None + self.model = model + self.model_forward_callback = model_forward_callback + self.sequential_guidance = config.sequential_guidance + + def do_controlnet_step( + self, + control_data, + sample: torch.Tensor, + timestep: torch.Tensor, + step_index: int, + total_step_count: int, + conditioning_data: TextConditioningData, + ): + down_block_res_samples, mid_block_res_sample = None, None + + # control_data should be type List[ControlNetData] + # this loop covers both ControlNet (one ControlNetData in list) + # and MultiControlNet (multiple ControlNetData in list) + for _i, control_datum in enumerate(control_data): + control_mode = control_datum.control_mode + # soft_injection and cfg_injection are the two ControlNet control_mode booleans + # that are combined at higher level to make control_mode enum + # soft_injection determines whether to do per-layer re-weighting adjustment (if True) + # or default weighting (if False) + soft_injection = control_mode == "more_prompt" or control_mode == "more_control" + # cfg_injection = determines whether to apply ControlNet to only the conditional (if True) + # or the default both conditional and unconditional (if False) + cfg_injection = control_mode == "more_control" or control_mode == "unbalanced" + + first_control_step = math.floor(control_datum.begin_step_percent * total_step_count) + last_control_step = math.ceil(control_datum.end_step_percent * total_step_count) + # only apply controlnet if current step is within the controlnet's begin/end step range + if step_index >= first_control_step and step_index <= last_control_step: + if cfg_injection: + sample_model_input = sample + else: + # expand the latents input to control model if doing classifier free guidance + # (which I think for now is always true, there is conditional elsewhere that stops execution if + # classifier_free_guidance is <= 1.0 ?) + sample_model_input = torch.cat([sample] * 2) + + added_cond_kwargs = None + + if cfg_injection: # only applying ControlNet to conditional instead of in unconditioned + if conditioning_data.is_sdxl(): + added_cond_kwargs = { + "text_embeds": conditioning_data.cond_text.pooled_embeds, + "time_ids": conditioning_data.cond_text.add_time_ids, + } + encoder_hidden_states = conditioning_data.cond_text.embeds + encoder_attention_mask = None + else: + if conditioning_data.is_sdxl(): + added_cond_kwargs = { + "text_embeds": torch.cat( + [ + # TODO: how to pad? just by zeros? or even truncate? + conditioning_data.uncond_text.pooled_embeds, + conditioning_data.cond_text.pooled_embeds, + ], + dim=0, + ), + "time_ids": torch.cat( + [ + conditioning_data.uncond_text.add_time_ids, + conditioning_data.cond_text.add_time_ids, + ], + dim=0, + ), + } + ( + encoder_hidden_states, + encoder_attention_mask, + ) = self._concat_conditionings_for_batch( + conditioning_data.uncond_text.embeds, + conditioning_data.cond_text.embeds, + ) + if isinstance(control_datum.weight, list): + # if controlnet has multiple weights, use the weight for the current step + controlnet_weight = control_datum.weight[step_index] + else: + # if controlnet has a single weight, use it for all steps + controlnet_weight = control_datum.weight + + # controlnet(s) inference + down_samples, mid_sample = control_datum.model( + sample=sample_model_input, + timestep=timestep, + encoder_hidden_states=encoder_hidden_states, + controlnet_cond=control_datum.image_tensor, + conditioning_scale=controlnet_weight, # controlnet specific, NOT the guidance scale + encoder_attention_mask=encoder_attention_mask, + added_cond_kwargs=added_cond_kwargs, + guess_mode=soft_injection, # this is still called guess_mode in diffusers ControlNetModel + return_dict=False, + ) + if cfg_injection: + # Inferred ControlNet only for the conditional batch. + # To apply the output of ControlNet to both the unconditional and conditional batches, + # prepend zeros for unconditional batch + down_samples = [torch.cat([torch.zeros_like(d), d]) for d in down_samples] + mid_sample = torch.cat([torch.zeros_like(mid_sample), mid_sample]) + + if down_block_res_samples is None and mid_block_res_sample is None: + down_block_res_samples, mid_block_res_sample = down_samples, mid_sample + else: + # add controlnet outputs together if have multiple controlnets + down_block_res_samples = [ + samples_prev + samples_curr + for samples_prev, samples_curr in zip(down_block_res_samples, down_samples, strict=True) + ] + mid_block_res_sample += mid_sample + + return down_block_res_samples, mid_block_res_sample + + def do_unet_step( + self, + sample: torch.Tensor, + timestep: torch.Tensor, + conditioning_data: TextConditioningData, + ip_adapter_data: Optional[list[IPAdapterData]], + step_index: int, + total_step_count: int, + down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet + mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet + down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter + ): + if self.sequential_guidance: + ( + unconditioned_next_x, + conditioned_next_x, + ) = self._apply_standard_conditioning_sequentially( + x=sample, + sigma=timestep, + conditioning_data=conditioning_data, + ip_adapter_data=ip_adapter_data, + step_index=step_index, + total_step_count=total_step_count, + down_block_additional_residuals=down_block_additional_residuals, + mid_block_additional_residual=mid_block_additional_residual, + down_intrablock_additional_residuals=down_intrablock_additional_residuals, + ) + else: + ( + unconditioned_next_x, + conditioned_next_x, + ) = self._apply_standard_conditioning( + x=sample, + sigma=timestep, + conditioning_data=conditioning_data, + ip_adapter_data=ip_adapter_data, + step_index=step_index, + total_step_count=total_step_count, + down_block_additional_residuals=down_block_additional_residuals, + mid_block_additional_residual=mid_block_additional_residual, + down_intrablock_additional_residuals=down_intrablock_additional_residuals, + ) + + return unconditioned_next_x, conditioned_next_x + + def _concat_conditionings_for_batch(self, unconditioning, conditioning): + def _pad_conditioning(cond, target_len, encoder_attention_mask): + conditioning_attention_mask = torch.ones( + (cond.shape[0], cond.shape[1]), device=cond.device, dtype=cond.dtype + ) + + if cond.shape[1] < max_len: + conditioning_attention_mask = torch.cat( + [ + conditioning_attention_mask, + torch.zeros((cond.shape[0], max_len - cond.shape[1]), device=cond.device, dtype=cond.dtype), + ], + dim=1, + ) + + cond = torch.cat( + [ + cond, + torch.zeros( + (cond.shape[0], max_len - cond.shape[1], cond.shape[2]), + device=cond.device, + dtype=cond.dtype, + ), + ], + dim=1, + ) + + if encoder_attention_mask is None: + encoder_attention_mask = conditioning_attention_mask + else: + encoder_attention_mask = torch.cat( + [ + encoder_attention_mask, + conditioning_attention_mask, + ] + ) + + return cond, encoder_attention_mask + + encoder_attention_mask = None + if unconditioning.shape[1] != conditioning.shape[1]: + max_len = max(unconditioning.shape[1], conditioning.shape[1]) + unconditioning, encoder_attention_mask = _pad_conditioning(unconditioning, max_len, encoder_attention_mask) + conditioning, encoder_attention_mask = _pad_conditioning(conditioning, max_len, encoder_attention_mask) + + return torch.cat([unconditioning, conditioning]), encoder_attention_mask + + # methods below are called from do_diffusion_step and should be considered private to this class. + + def _apply_standard_conditioning( + self, + x: torch.Tensor, + sigma: torch.Tensor, + conditioning_data: TextConditioningData, + ip_adapter_data: Optional[list[IPAdapterData]], + step_index: int, + total_step_count: int, + down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet + mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet + down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter + ) -> tuple[torch.Tensor, torch.Tensor]: + """Runs the conditioned and unconditioned UNet forward passes in a single batch for faster inference speed at + the cost of higher memory usage. + """ + x_twice = torch.cat([x] * 2) + sigma_twice = torch.cat([sigma] * 2) + + cross_attention_kwargs = {} + if ip_adapter_data is not None: + ip_adapter_conditioning = [ipa.ip_adapter_conditioning for ipa in ip_adapter_data] + # Note that we 'stack' to produce tensors of shape (batch_size, num_ip_images, seq_len, token_len). + image_prompt_embeds = [ + torch.stack([ipa_conditioning.uncond_image_prompt_embeds, ipa_conditioning.cond_image_prompt_embeds]) + for ipa_conditioning in ip_adapter_conditioning + ] + scales = [ipa.scale_for_step(step_index, total_step_count) for ipa in ip_adapter_data] + ip_masks = [ipa.mask for ipa in ip_adapter_data] + regional_ip_data = RegionalIPData( + image_prompt_embeds=image_prompt_embeds, scales=scales, masks=ip_masks, dtype=x.dtype, device=x.device + ) + cross_attention_kwargs["regional_ip_data"] = regional_ip_data + + added_cond_kwargs = None + if conditioning_data.is_sdxl(): + added_cond_kwargs = { + "text_embeds": torch.cat( + [ + # TODO: how to pad? just by zeros? or even truncate? + conditioning_data.uncond_text.pooled_embeds, + conditioning_data.cond_text.pooled_embeds, + ], + dim=0, + ), + "time_ids": torch.cat( + [ + conditioning_data.uncond_text.add_time_ids, + conditioning_data.cond_text.add_time_ids, + ], + dim=0, + ), + } + + if conditioning_data.cond_regions is not None or conditioning_data.uncond_regions is not None: + # TODO(ryand): We currently initialize RegionalPromptData for every denoising step. The text conditionings + # and masks are not changing from step-to-step, so this really only needs to be done once. While this seems + # painfully inefficient, the time spent is typically negligible compared to the forward inference pass of + # the UNet. The main reason that this hasn't been moved up to eliminate redundancy is that it is slightly + # awkward to handle both standard conditioning and sequential conditioning further up the stack. + regions = [] + for c, r in [ + (conditioning_data.uncond_text, conditioning_data.uncond_regions), + (conditioning_data.cond_text, conditioning_data.cond_regions), + ]: + if r is None: + # Create a dummy mask and range for text conditioning that doesn't have region masks. + _, _, h, w = x.shape + r = TextConditioningRegions( + masks=torch.ones((1, 1, h, w), dtype=x.dtype), + ranges=[Range(start=0, end=c.embeds.shape[1])], + ) + regions.append(r) + + cross_attention_kwargs["regional_prompt_data"] = RegionalPromptData( + regions=regions, device=x.device, dtype=x.dtype + ) + cross_attention_kwargs["percent_through"] = step_index / total_step_count + + both_conditionings, encoder_attention_mask = self._concat_conditionings_for_batch( + conditioning_data.uncond_text.embeds, conditioning_data.cond_text.embeds + ) + both_results = self.model_forward_callback( + x_twice, + sigma_twice, + both_conditionings, + cross_attention_kwargs=cross_attention_kwargs, + encoder_attention_mask=encoder_attention_mask, + down_block_additional_residuals=down_block_additional_residuals, + mid_block_additional_residual=mid_block_additional_residual, + down_intrablock_additional_residuals=down_intrablock_additional_residuals, + added_cond_kwargs=added_cond_kwargs, + ) + unconditioned_next_x, conditioned_next_x = both_results.chunk(2) + return unconditioned_next_x, conditioned_next_x + + def _apply_standard_conditioning_sequentially( + self, + x: torch.Tensor, + sigma, + conditioning_data: TextConditioningData, + ip_adapter_data: Optional[list[IPAdapterData]], + step_index: int, + total_step_count: int, + down_block_additional_residuals: Optional[torch.Tensor] = None, # for ControlNet + mid_block_additional_residual: Optional[torch.Tensor] = None, # for ControlNet + down_intrablock_additional_residuals: Optional[torch.Tensor] = None, # for T2I-Adapter + ): + """Runs the conditioned and unconditioned UNet forward passes sequentially for lower memory usage at the cost of + slower execution speed. + """ + # Since we are running the conditioned and unconditioned passes sequentially, we need to split the ControlNet + # and T2I-Adapter residuals into two chunks. + uncond_down_block, cond_down_block = None, None + if down_block_additional_residuals is not None: + uncond_down_block, cond_down_block = [], [] + for down_block in down_block_additional_residuals: + _uncond_down, _cond_down = down_block.chunk(2) + uncond_down_block.append(_uncond_down) + cond_down_block.append(_cond_down) + + uncond_down_intrablock, cond_down_intrablock = None, None + if down_intrablock_additional_residuals is not None: + uncond_down_intrablock, cond_down_intrablock = [], [] + for down_intrablock in down_intrablock_additional_residuals: + _uncond_down, _cond_down = down_intrablock.chunk(2) + uncond_down_intrablock.append(_uncond_down) + cond_down_intrablock.append(_cond_down) + + uncond_mid_block, cond_mid_block = None, None + if mid_block_additional_residual is not None: + uncond_mid_block, cond_mid_block = mid_block_additional_residual.chunk(2) + + ##################### + # Unconditioned pass + ##################### + + cross_attention_kwargs = {} + + # Prepare IP-Adapter cross-attention kwargs for the unconditioned pass. + if ip_adapter_data is not None: + ip_adapter_conditioning = [ipa.ip_adapter_conditioning for ipa in ip_adapter_data] + # Note that we 'unsqueeze' to produce tensors of shape (batch_size=1, num_ip_images, seq_len, token_len). + image_prompt_embeds = [ + torch.unsqueeze(ipa_conditioning.uncond_image_prompt_embeds, dim=0) + for ipa_conditioning in ip_adapter_conditioning + ] + + scales = [ipa.scale_for_step(step_index, total_step_count) for ipa in ip_adapter_data] + ip_masks = [ipa.mask for ipa in ip_adapter_data] + regional_ip_data = RegionalIPData( + image_prompt_embeds=image_prompt_embeds, scales=scales, masks=ip_masks, dtype=x.dtype, device=x.device + ) + cross_attention_kwargs["regional_ip_data"] = regional_ip_data + + # Prepare SDXL conditioning kwargs for the unconditioned pass. + added_cond_kwargs = None + if conditioning_data.is_sdxl(): + added_cond_kwargs = { + "text_embeds": conditioning_data.uncond_text.pooled_embeds, + "time_ids": conditioning_data.uncond_text.add_time_ids, + } + + # Prepare prompt regions for the unconditioned pass. + if conditioning_data.uncond_regions is not None: + cross_attention_kwargs["regional_prompt_data"] = RegionalPromptData( + regions=[conditioning_data.uncond_regions], device=x.device, dtype=x.dtype + ) + cross_attention_kwargs["percent_through"] = step_index / total_step_count + + # Run unconditioned UNet denoising (i.e. negative prompt). + unconditioned_next_x = self.model_forward_callback( + x, + sigma, + conditioning_data.uncond_text.embeds, + cross_attention_kwargs=cross_attention_kwargs, + down_block_additional_residuals=uncond_down_block, + mid_block_additional_residual=uncond_mid_block, + down_intrablock_additional_residuals=uncond_down_intrablock, + added_cond_kwargs=added_cond_kwargs, + ) + + ################### + # Conditioned pass + ################### + + cross_attention_kwargs = {} + + if ip_adapter_data is not None: + ip_adapter_conditioning = [ipa.ip_adapter_conditioning for ipa in ip_adapter_data] + # Note that we 'unsqueeze' to produce tensors of shape (batch_size=1, num_ip_images, seq_len, token_len). + image_prompt_embeds = [ + torch.unsqueeze(ipa_conditioning.cond_image_prompt_embeds, dim=0) + for ipa_conditioning in ip_adapter_conditioning + ] + + scales = [ipa.scale_for_step(step_index, total_step_count) for ipa in ip_adapter_data] + ip_masks = [ipa.mask for ipa in ip_adapter_data] + regional_ip_data = RegionalIPData( + image_prompt_embeds=image_prompt_embeds, scales=scales, masks=ip_masks, dtype=x.dtype, device=x.device + ) + cross_attention_kwargs["regional_ip_data"] = regional_ip_data + + # Prepare SDXL conditioning kwargs for the conditioned pass. + added_cond_kwargs = None + if conditioning_data.is_sdxl(): + added_cond_kwargs = { + "text_embeds": conditioning_data.cond_text.pooled_embeds, + "time_ids": conditioning_data.cond_text.add_time_ids, + } + + # Prepare prompt regions for the conditioned pass. + if conditioning_data.cond_regions is not None: + cross_attention_kwargs["regional_prompt_data"] = RegionalPromptData( + regions=[conditioning_data.cond_regions], device=x.device, dtype=x.dtype + ) + cross_attention_kwargs["percent_through"] = step_index / total_step_count + + # Run conditioned UNet denoising (i.e. positive prompt). + conditioned_next_x = self.model_forward_callback( + x, + sigma, + conditioning_data.cond_text.embeds, + cross_attention_kwargs=cross_attention_kwargs, + down_block_additional_residuals=cond_down_block, + mid_block_additional_residual=cond_mid_block, + down_intrablock_additional_residuals=cond_down_intrablock, + added_cond_kwargs=added_cond_kwargs, + ) + return unconditioned_next_x, conditioned_next_x + + def _combine(self, unconditioned_next_x, conditioned_next_x, guidance_scale): + # to scale how much effect conditioning has, calculate the changes it does and then scale that + scaled_delta = (conditioned_next_x - unconditioned_next_x) * guidance_scale + combined_next_x = unconditioned_next_x + scaled_delta + return combined_next_x diff --git a/invokeai/backend/stable_diffusion/diffusion/unet_attention_patcher.py b/invokeai/backend/stable_diffusion/diffusion/unet_attention_patcher.py new file mode 100644 index 0000000000000000000000000000000000000000..ac00a8e06ea0121b23fce146e20e187d795c042c --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion/unet_attention_patcher.py @@ -0,0 +1,68 @@ +from contextlib import contextmanager +from typing import List, Optional, TypedDict + +from diffusers.models import UNet2DConditionModel + +from invokeai.backend.ip_adapter.ip_adapter import IPAdapter +from invokeai.backend.stable_diffusion.diffusion.custom_atttention import ( + CustomAttnProcessor2_0, + IPAdapterAttentionWeights, +) + + +class UNetIPAdapterData(TypedDict): + ip_adapter: IPAdapter + target_blocks: List[str] + + +class UNetAttentionPatcher: + """A class for patching a UNet with CustomAttnProcessor2_0 attention layers.""" + + def __init__(self, ip_adapter_data: Optional[List[UNetIPAdapterData]]): + self._ip_adapters = ip_adapter_data + + def _prepare_attention_processors(self, unet: UNet2DConditionModel): + """Prepare a dict of attention processors that can be injected into a unet, and load the IP-Adapter attention + weights into them (if IP-Adapters are being applied). + Note that the `unet` param is only used to determine attention block dimensions and naming. + """ + # Construct a dict of attention processors based on the UNet's architecture. + attn_procs = {} + for idx, name in enumerate(unet.attn_processors.keys()): + if name.endswith("attn1.processor") or self._ip_adapters is None: + # "attn1" processors do not use IP-Adapters. + attn_procs[name] = CustomAttnProcessor2_0() + else: + # Collect the weights from each IP Adapter for the idx'th attention processor. + ip_adapter_attention_weights_collection: list[IPAdapterAttentionWeights] = [] + + for ip_adapter in self._ip_adapters: + ip_adapter_weights = ip_adapter["ip_adapter"].attn_weights.get_attention_processor_weights(idx) + skip = True + for block in ip_adapter["target_blocks"]: + if block in name: + skip = False + break + ip_adapter_attention_weights: IPAdapterAttentionWeights = IPAdapterAttentionWeights( + ip_adapter_weights=ip_adapter_weights, skip=skip + ) + ip_adapter_attention_weights_collection.append(ip_adapter_attention_weights) + + attn_procs[name] = CustomAttnProcessor2_0(ip_adapter_attention_weights_collection) + + return attn_procs + + @contextmanager + def apply_ip_adapter_attention(self, unet: UNet2DConditionModel): + """A context manager that patches `unet` with CustomAttnProcessor2_0 attention layers.""" + attn_procs = self._prepare_attention_processors(unet) + orig_attn_processors = unet.attn_processors + + try: + # Note to future devs: set_attn_processor(...) does something slightly unexpected - it pops elements from + # the passed dict. So, if you wanted to keep the dict for future use, you'd have to make a + # moderately-shallow copy of it. E.g. `attn_procs_copy = {k: v for k, v in attn_procs.items()}`. + unet.set_attn_processor(attn_procs) + yield None + finally: + unet.set_attn_processor(orig_attn_processors) diff --git a/invokeai/backend/stable_diffusion/diffusion_backend.py b/invokeai/backend/stable_diffusion/diffusion_backend.py new file mode 100644 index 0000000000000000000000000000000000000000..4191db734f9208b6c0bfe22e48709c8bac3e4a48 --- /dev/null +++ b/invokeai/backend/stable_diffusion/diffusion_backend.py @@ -0,0 +1,142 @@ +from __future__ import annotations + +import torch +from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel +from diffusers.schedulers.scheduling_utils import SchedulerMixin, SchedulerOutput +from tqdm.auto import tqdm + +from invokeai.app.services.config.config_default import get_config +from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext, UNetKwargs +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningMode +from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType +from invokeai.backend.stable_diffusion.extensions_manager import ExtensionsManager + + +class StableDiffusionBackend: + def __init__( + self, + unet: UNet2DConditionModel, + scheduler: SchedulerMixin, + ): + self.unet = unet + self.scheduler = scheduler + config = get_config() + self._sequential_guidance = config.sequential_guidance + + def latents_from_embeddings(self, ctx: DenoiseContext, ext_manager: ExtensionsManager): + if ctx.inputs.init_timestep.shape[0] == 0: + return ctx.inputs.orig_latents + + ctx.latents = ctx.inputs.orig_latents.clone() + + if ctx.inputs.noise is not None: + batch_size = ctx.latents.shape[0] + # latents = noise * self.scheduler.init_noise_sigma # it's like in t2l according to diffusers + ctx.latents = ctx.scheduler.add_noise( + ctx.latents, ctx.inputs.noise, ctx.inputs.init_timestep.expand(batch_size) + ) + + # if no work to do, return latents + if ctx.inputs.timesteps.shape[0] == 0: + return ctx.latents + + # ext: inpaint[pre_denoise_loop, priority=normal] (maybe init, but not sure if it needed) + # ext: preview[pre_denoise_loop, priority=low] + ext_manager.run_callback(ExtensionCallbackType.PRE_DENOISE_LOOP, ctx) + + for ctx.step_index, ctx.timestep in enumerate(tqdm(ctx.inputs.timesteps)): # noqa: B020 + # ext: inpaint (apply mask to latents on non-inpaint models) + ext_manager.run_callback(ExtensionCallbackType.PRE_STEP, ctx) + + # ext: tiles? [override: step] + ctx.step_output = self.step(ctx, ext_manager) + + # ext: inpaint[post_step, priority=high] (apply mask to preview on non-inpaint models) + # ext: preview[post_step, priority=low] + ext_manager.run_callback(ExtensionCallbackType.POST_STEP, ctx) + + ctx.latents = ctx.step_output.prev_sample + + # ext: inpaint[post_denoise_loop] (restore unmasked part) + ext_manager.run_callback(ExtensionCallbackType.POST_DENOISE_LOOP, ctx) + return ctx.latents + + @torch.inference_mode() + def step(self, ctx: DenoiseContext, ext_manager: ExtensionsManager) -> SchedulerOutput: + ctx.latent_model_input = ctx.scheduler.scale_model_input(ctx.latents, ctx.timestep) + + # TODO: conditionings as list(conditioning_data.to_unet_kwargs - ready) + # Note: The current handling of conditioning doesn't feel very future-proof. + # This might change in the future as new requirements come up, but for now, + # this is the rough plan. + if self._sequential_guidance: + ctx.negative_noise_pred = self.run_unet(ctx, ext_manager, ConditioningMode.Negative) + ctx.positive_noise_pred = self.run_unet(ctx, ext_manager, ConditioningMode.Positive) + else: + both_noise_pred = self.run_unet(ctx, ext_manager, ConditioningMode.Both) + ctx.negative_noise_pred, ctx.positive_noise_pred = both_noise_pred.chunk(2) + + # ext: override combine_noise_preds + ctx.noise_pred = self.combine_noise_preds(ctx) + + # ext: cfg_rescale [modify_noise_prediction] + # TODO: rename + ext_manager.run_callback(ExtensionCallbackType.POST_COMBINE_NOISE_PREDS, ctx) + + # compute the previous noisy sample x_t -> x_t-1 + step_output = ctx.scheduler.step(ctx.noise_pred, ctx.timestep, ctx.latents, **ctx.inputs.scheduler_step_kwargs) + + # clean up locals + ctx.latent_model_input = None + ctx.negative_noise_pred = None + ctx.positive_noise_pred = None + ctx.noise_pred = None + + return step_output + + @staticmethod + def combine_noise_preds(ctx: DenoiseContext) -> torch.Tensor: + guidance_scale = ctx.inputs.conditioning_data.guidance_scale + if isinstance(guidance_scale, list): + guidance_scale = guidance_scale[ctx.step_index] + + # Note: Although this `torch.lerp(...)` line is logically equivalent to the current CFG line, it seems to result + # in slightly different outputs. It is suspected that this is caused by small precision differences. + # return torch.lerp(ctx.negative_noise_pred, ctx.positive_noise_pred, guidance_scale) + return ctx.negative_noise_pred + guidance_scale * (ctx.positive_noise_pred - ctx.negative_noise_pred) + + def run_unet(self, ctx: DenoiseContext, ext_manager: ExtensionsManager, conditioning_mode: ConditioningMode): + sample = ctx.latent_model_input + if conditioning_mode == ConditioningMode.Both: + sample = torch.cat([sample] * 2) + + ctx.unet_kwargs = UNetKwargs( + sample=sample, + timestep=ctx.timestep, + encoder_hidden_states=None, # set later by conditoning + cross_attention_kwargs=dict( # noqa: C408 + percent_through=ctx.step_index / len(ctx.inputs.timesteps), + ), + ) + + ctx.conditioning_mode = conditioning_mode + ctx.inputs.conditioning_data.to_unet_kwargs(ctx.unet_kwargs, ctx.conditioning_mode) + + # ext: controlnet/ip/t2i [pre_unet] + ext_manager.run_callback(ExtensionCallbackType.PRE_UNET, ctx) + + # ext: inpaint [pre_unet, priority=low] + # or + # ext: inpaint [override: unet_forward] + noise_pred = self._unet_forward(**vars(ctx.unet_kwargs)) + + ext_manager.run_callback(ExtensionCallbackType.POST_UNET, ctx) + + # clean up locals + ctx.unet_kwargs = None + ctx.conditioning_mode = None + + return noise_pred + + def _unet_forward(self, **kwargs) -> torch.Tensor: + return self.unet(**kwargs).sample diff --git a/invokeai/backend/stable_diffusion/extension_callback_type.py b/invokeai/backend/stable_diffusion/extension_callback_type.py new file mode 100644 index 0000000000000000000000000000000000000000..e4c365007bae0fd6bae0537f010f4ab5f6b70bd4 --- /dev/null +++ b/invokeai/backend/stable_diffusion/extension_callback_type.py @@ -0,0 +1,12 @@ +from enum import Enum + + +class ExtensionCallbackType(Enum): + SETUP = "setup" + PRE_DENOISE_LOOP = "pre_denoise_loop" + POST_DENOISE_LOOP = "post_denoise_loop" + PRE_STEP = "pre_step" + POST_STEP = "post_step" + PRE_UNET = "pre_unet" + POST_UNET = "post_unet" + POST_COMBINE_NOISE_PREDS = "post_combine_noise_preds" diff --git a/invokeai/backend/stable_diffusion/extensions/base.py b/invokeai/backend/stable_diffusion/extensions/base.py new file mode 100644 index 0000000000000000000000000000000000000000..a3d27464a0c3c40bcae46396073e518362a478dc --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/base.py @@ -0,0 +1,72 @@ +from __future__ import annotations + +from contextlib import contextmanager +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Dict, List + +from diffusers import UNet2DConditionModel + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType + from invokeai.backend.util.original_weights_storage import OriginalWeightsStorage + + +@dataclass +class CallbackMetadata: + callback_type: ExtensionCallbackType + order: int + + +@dataclass +class CallbackFunctionWithMetadata: + metadata: CallbackMetadata + function: Callable[[DenoiseContext], None] + + +def callback(callback_type: ExtensionCallbackType, order: int = 0): + def _decorator(function): + function._ext_metadata = CallbackMetadata( + callback_type=callback_type, + order=order, + ) + return function + + return _decorator + + +class ExtensionBase: + def __init__(self): + self._callbacks: Dict[ExtensionCallbackType, List[CallbackFunctionWithMetadata]] = {} + + # Register all of the callback methods for this instance. + for func_name in dir(self): + func = getattr(self, func_name) + metadata = getattr(func, "_ext_metadata", None) + if metadata is not None and isinstance(metadata, CallbackMetadata): + if metadata.callback_type not in self._callbacks: + self._callbacks[metadata.callback_type] = [] + self._callbacks[metadata.callback_type].append(CallbackFunctionWithMetadata(metadata, func)) + + def get_callbacks(self): + return self._callbacks + + @contextmanager + def patch_extension(self, ctx: DenoiseContext): + yield None + + @contextmanager + def patch_unet(self, unet: UNet2DConditionModel, original_weights: OriginalWeightsStorage): + """A context manager for applying patches to the UNet model. The context manager's lifetime spans the entire + diffusion process. Weight unpatching is handled upstream, and is achieved by saving unchanged weights by + `original_weights.save` function. Note that this enables some performance optimization by avoiding redundant + operations. All other patches (e.g. changes to tensor shapes, function monkey-patches, etc.) should be unpatched + by this context manager. + + Args: + unet (UNet2DConditionModel): The UNet model on execution device to patch. + original_weights (OriginalWeightsStorage): A storage with copy of the model's original weights in CPU, for + unpatching purposes. Extension should save tensor which being modified in this storage, also extensions + can access original weights values. + """ + yield diff --git a/invokeai/backend/stable_diffusion/extensions/controlnet.py b/invokeai/backend/stable_diffusion/extensions/controlnet.py new file mode 100644 index 0000000000000000000000000000000000000000..a48a681af3ff1884e3c1ff531fc004b9ba800623 --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/controlnet.py @@ -0,0 +1,158 @@ +from __future__ import annotations + +import math +from contextlib import contextmanager +from typing import TYPE_CHECKING, List, Optional, Union + +import torch +from PIL.Image import Image + +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.app.util.controlnet_utils import CONTROLNET_MODE_VALUES, CONTROLNET_RESIZE_VALUES, prepare_control_image +from invokeai.backend.stable_diffusion.denoise_context import UNetKwargs +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningMode +from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase, callback + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + from invokeai.backend.util.hotfixes import ControlNetModel + + +class ControlNetExt(ExtensionBase): + def __init__( + self, + model: ControlNetModel, + image: Image, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + control_mode: CONTROLNET_MODE_VALUES, + resize_mode: CONTROLNET_RESIZE_VALUES, + ): + super().__init__() + self._model = model + self._image = image + self._weight = weight + self._begin_step_percent = begin_step_percent + self._end_step_percent = end_step_percent + self._control_mode = control_mode + self._resize_mode = resize_mode + + self._image_tensor: Optional[torch.Tensor] = None + + @contextmanager + def patch_extension(self, ctx: DenoiseContext): + original_processors = self._model.attn_processors + try: + self._model.set_attn_processor(ctx.inputs.attention_processor_cls()) + + yield None + finally: + self._model.set_attn_processor(original_processors) + + @callback(ExtensionCallbackType.PRE_DENOISE_LOOP) + def resize_image(self, ctx: DenoiseContext): + _, _, latent_height, latent_width = ctx.latents.shape + image_height = latent_height * LATENT_SCALE_FACTOR + image_width = latent_width * LATENT_SCALE_FACTOR + + self._image_tensor = prepare_control_image( + image=self._image, + do_classifier_free_guidance=False, + width=image_width, + height=image_height, + device=ctx.latents.device, + dtype=ctx.latents.dtype, + control_mode=self._control_mode, + resize_mode=self._resize_mode, + ) + + @callback(ExtensionCallbackType.PRE_UNET) + def pre_unet_step(self, ctx: DenoiseContext): + # skip if model not active in current step + total_steps = len(ctx.inputs.timesteps) + first_step = math.floor(self._begin_step_percent * total_steps) + last_step = math.ceil(self._end_step_percent * total_steps) + if ctx.step_index < first_step or ctx.step_index > last_step: + return + + # convert mode to internal flags + soft_injection = self._control_mode in ["more_prompt", "more_control"] + cfg_injection = self._control_mode in ["more_control", "unbalanced"] + + # no negative conditioning in cfg_injection mode + if cfg_injection: + if ctx.conditioning_mode == ConditioningMode.Negative: + return + down_samples, mid_sample = self._run(ctx, soft_injection, ConditioningMode.Positive) + + if ctx.conditioning_mode == ConditioningMode.Both: + # add zeros as samples for negative conditioning + down_samples = [torch.cat([torch.zeros_like(d), d]) for d in down_samples] + mid_sample = torch.cat([torch.zeros_like(mid_sample), mid_sample]) + + else: + down_samples, mid_sample = self._run(ctx, soft_injection, ctx.conditioning_mode) + + if ( + ctx.unet_kwargs.down_block_additional_residuals is None + and ctx.unet_kwargs.mid_block_additional_residual is None + ): + ctx.unet_kwargs.down_block_additional_residuals = down_samples + ctx.unet_kwargs.mid_block_additional_residual = mid_sample + else: + # add controlnet outputs together if have multiple controlnets + ctx.unet_kwargs.down_block_additional_residuals = [ + samples_prev + samples_curr + for samples_prev, samples_curr in zip( + ctx.unet_kwargs.down_block_additional_residuals, down_samples, strict=True + ) + ] + ctx.unet_kwargs.mid_block_additional_residual += mid_sample + + def _run(self, ctx: DenoiseContext, soft_injection: bool, conditioning_mode: ConditioningMode): + total_steps = len(ctx.inputs.timesteps) + + model_input = ctx.latent_model_input + image_tensor = self._image_tensor + if conditioning_mode == ConditioningMode.Both: + model_input = torch.cat([model_input] * 2) + image_tensor = torch.cat([image_tensor] * 2) + + cn_unet_kwargs = UNetKwargs( + sample=model_input, + timestep=ctx.timestep, + encoder_hidden_states=None, # set later by conditioning + cross_attention_kwargs=dict( # noqa: C408 + percent_through=ctx.step_index / total_steps, + ), + ) + + ctx.inputs.conditioning_data.to_unet_kwargs(cn_unet_kwargs, conditioning_mode=conditioning_mode) + + # get static weight, or weight corresponding to current step + weight = self._weight + if isinstance(weight, list): + weight = weight[ctx.step_index] + + tmp_kwargs = vars(cn_unet_kwargs) + + # Remove kwargs not related to ControlNet unet + # ControlNet guidance fields + del tmp_kwargs["down_block_additional_residuals"] + del tmp_kwargs["mid_block_additional_residual"] + + # T2i Adapter guidance fields + del tmp_kwargs["down_intrablock_additional_residuals"] + + # controlnet(s) inference + down_samples, mid_sample = self._model( + controlnet_cond=image_tensor, + conditioning_scale=weight, # controlnet specific, NOT the guidance scale + guess_mode=soft_injection, # this is still called guess_mode in diffusers ControlNetModel + return_dict=False, + **vars(cn_unet_kwargs), + ) + + return down_samples, mid_sample diff --git a/invokeai/backend/stable_diffusion/extensions/freeu.py b/invokeai/backend/stable_diffusion/extensions/freeu.py new file mode 100644 index 0000000000000000000000000000000000000000..ff54e1a52f60174210c939482fe8bfe3a29f66de --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/freeu.py @@ -0,0 +1,35 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from diffusers import UNet2DConditionModel + +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase + +if TYPE_CHECKING: + from invokeai.app.shared.models import FreeUConfig + from invokeai.backend.util.original_weights_storage import OriginalWeightsStorage + + +class FreeUExt(ExtensionBase): + def __init__( + self, + freeu_config: FreeUConfig, + ): + super().__init__() + self._freeu_config = freeu_config + + @contextmanager + def patch_unet(self, unet: UNet2DConditionModel, original_weights: OriginalWeightsStorage): + unet.enable_freeu( + b1=self._freeu_config.b1, + b2=self._freeu_config.b2, + s1=self._freeu_config.s1, + s2=self._freeu_config.s2, + ) + + try: + yield + finally: + unet.disable_freeu() diff --git a/invokeai/backend/stable_diffusion/extensions/inpaint.py b/invokeai/backend/stable_diffusion/extensions/inpaint.py new file mode 100644 index 0000000000000000000000000000000000000000..0079359155883e8c826a65430010a30b1a3c8e4c --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/inpaint.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +import einops +import torch +from diffusers import UNet2DConditionModel + +from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase, callback + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + + +class InpaintExt(ExtensionBase): + """An extension for inpainting with non-inpainting models. See `InpaintModelExt` for inpainting with inpainting + models. + """ + + def __init__( + self, + mask: torch.Tensor, + is_gradient_mask: bool, + ): + """Initialize InpaintExt. + Args: + mask (torch.Tensor): The inpainting mask. Shape: (1, 1, latent_height, latent_width). Values are + expected to be in the range [0, 1]. A value of 1 means that the corresponding 'pixel' should not be + inpainted. + is_gradient_mask (bool): If True, mask is interpreted as a gradient mask meaning that the mask values range + from 0 to 1. If False, mask is interpreted as binary mask meaning that the mask values are either 0 or + 1. + """ + super().__init__() + self._mask = mask + self._is_gradient_mask = is_gradient_mask + + # Noise, which used to noisify unmasked part of image + # if noise provided to context, then it will be used + # if no noise provided, then noise will be generated based on seed + self._noise: Optional[torch.Tensor] = None + + @staticmethod + def _is_normal_model(unet: UNet2DConditionModel): + """Checks if the provided UNet belongs to a regular model. + The `in_channels` of a UNet vary depending on model type: + - normal - 4 + - depth - 5 + - inpaint - 9 + """ + return unet.conv_in.in_channels == 4 + + def _apply_mask(self, ctx: DenoiseContext, latents: torch.Tensor, t: torch.Tensor) -> torch.Tensor: + batch_size = latents.size(0) + mask = einops.repeat(self._mask, "b c h w -> (repeat b) c h w", repeat=batch_size) + if t.dim() == 0: + # some schedulers expect t to be one-dimensional. + # TODO: file diffusers bug about inconsistency? + t = einops.repeat(t, "-> batch", batch=batch_size) + # Noise shouldn't be re-randomized between steps here. The multistep schedulers + # get very confused about what is happening from step to step when we do that. + mask_latents = ctx.scheduler.add_noise(ctx.inputs.orig_latents, self._noise, t) + # TODO: Do we need to also apply scheduler.scale_model_input? Or is add_noise appropriately scaled already? + # mask_latents = self.scheduler.scale_model_input(mask_latents, t) + mask_latents = einops.repeat(mask_latents, "b c h w -> (repeat b) c h w", repeat=batch_size) + if self._is_gradient_mask: + threshold = (t.item()) / ctx.scheduler.config.num_train_timesteps + mask_bool = mask < 1 - threshold + masked_input = torch.where(mask_bool, latents, mask_latents) + else: + masked_input = torch.lerp(latents, mask_latents.to(dtype=latents.dtype), mask.to(dtype=latents.dtype)) + return masked_input + + @callback(ExtensionCallbackType.PRE_DENOISE_LOOP) + def init_tensors(self, ctx: DenoiseContext): + if not self._is_normal_model(ctx.unet): + raise ValueError( + "InpaintExt should be used only on normal (non-inpainting) models. This could be caused by an " + "inpainting model that was incorrectly marked as a non-inpainting model. In some cases, this can be " + "fixed by removing and re-adding the model (so that it gets re-probed)." + ) + + self._mask = self._mask.to(device=ctx.latents.device, dtype=ctx.latents.dtype) + + self._noise = ctx.inputs.noise + # 'noise' might be None if the latents have already been noised (e.g. when running the SDXL refiner). + # We still need noise for inpainting, so we generate it from the seed here. + if self._noise is None: + self._noise = torch.randn( + ctx.latents.shape, + dtype=torch.float32, + device="cpu", + generator=torch.Generator(device="cpu").manual_seed(ctx.seed), + ).to(device=ctx.latents.device, dtype=ctx.latents.dtype) + + # Use negative order to make extensions with default order work with patched latents + @callback(ExtensionCallbackType.PRE_STEP, order=-100) + def apply_mask_to_initial_latents(self, ctx: DenoiseContext): + ctx.latents = self._apply_mask(ctx, ctx.latents, ctx.timestep) + + # TODO: redo this with preview events rewrite + # Use negative order to make extensions with default order work with patched latents + @callback(ExtensionCallbackType.POST_STEP, order=-100) + def apply_mask_to_step_output(self, ctx: DenoiseContext): + timestep = ctx.scheduler.timesteps[-1] + if hasattr(ctx.step_output, "denoised"): + ctx.step_output.denoised = self._apply_mask(ctx, ctx.step_output.denoised, timestep) + elif hasattr(ctx.step_output, "pred_original_sample"): + ctx.step_output.pred_original_sample = self._apply_mask(ctx, ctx.step_output.pred_original_sample, timestep) + else: + ctx.step_output.pred_original_sample = self._apply_mask(ctx, ctx.step_output.prev_sample, timestep) + + # Restore unmasked part after the last step is completed + @callback(ExtensionCallbackType.POST_DENOISE_LOOP) + def restore_unmasked(self, ctx: DenoiseContext): + if self._is_gradient_mask: + ctx.latents = torch.where(self._mask < 1, ctx.latents, ctx.inputs.orig_latents) + else: + ctx.latents = torch.lerp(ctx.latents, ctx.inputs.orig_latents, self._mask) diff --git a/invokeai/backend/stable_diffusion/extensions/inpaint_model.py b/invokeai/backend/stable_diffusion/extensions/inpaint_model.py new file mode 100644 index 0000000000000000000000000000000000000000..6ee8ef6311ca8e0e12cbf2172eb608a2fa57303a --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/inpaint_model.py @@ -0,0 +1,88 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING, Optional + +import torch +from diffusers import UNet2DConditionModel + +from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase, callback + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + + +class InpaintModelExt(ExtensionBase): + """An extension for inpainting with inpainting models. See `InpaintExt` for inpainting with non-inpainting + models. + """ + + def __init__( + self, + mask: Optional[torch.Tensor], + masked_latents: Optional[torch.Tensor], + is_gradient_mask: bool, + ): + """Initialize InpaintModelExt. + Args: + mask (Optional[torch.Tensor]): The inpainting mask. Shape: (1, 1, latent_height, latent_width). Values are + expected to be in the range [0, 1]. A value of 1 means that the corresponding 'pixel' should not be + inpainted. + masked_latents (Optional[torch.Tensor]): Latents of initial image, with masked out by black color inpainted area. + If mask provided, then too should be provided. Shape: (1, 1, latent_height, latent_width) + is_gradient_mask (bool): If True, mask is interpreted as a gradient mask meaning that the mask values range + from 0 to 1. If False, mask is interpreted as binary mask meaning that the mask values are either 0 or + 1. + """ + super().__init__() + if mask is not None and masked_latents is None: + raise ValueError("Source image required for inpaint mask when inpaint model used!") + + # Inverse mask, because inpaint models treat mask as: 0 - remain same, 1 - inpaint + self._mask = None + if mask is not None: + self._mask = 1 - mask + self._masked_latents = masked_latents + self._is_gradient_mask = is_gradient_mask + + @staticmethod + def _is_inpaint_model(unet: UNet2DConditionModel): + """Checks if the provided UNet belongs to a regular model. + The `in_channels` of a UNet vary depending on model type: + - normal - 4 + - depth - 5 + - inpaint - 9 + """ + return unet.conv_in.in_channels == 9 + + @callback(ExtensionCallbackType.PRE_DENOISE_LOOP) + def init_tensors(self, ctx: DenoiseContext): + if not self._is_inpaint_model(ctx.unet): + raise ValueError("InpaintModelExt should be used only on inpaint models!") + + if self._mask is None: + self._mask = torch.ones_like(ctx.latents[:1, :1]) + self._mask = self._mask.to(device=ctx.latents.device, dtype=ctx.latents.dtype) + + if self._masked_latents is None: + self._masked_latents = torch.zeros_like(ctx.latents[:1]) + self._masked_latents = self._masked_latents.to(device=ctx.latents.device, dtype=ctx.latents.dtype) + + # Do last so that other extensions works with normal latents + @callback(ExtensionCallbackType.PRE_UNET, order=1000) + def append_inpaint_layers(self, ctx: DenoiseContext): + batch_size = ctx.unet_kwargs.sample.shape[0] + b_mask = torch.cat([self._mask] * batch_size) + b_masked_latents = torch.cat([self._masked_latents] * batch_size) + ctx.unet_kwargs.sample = torch.cat( + [ctx.unet_kwargs.sample, b_mask, b_masked_latents], + dim=1, + ) + + # Restore unmasked part as inpaint model can change unmasked part slightly + @callback(ExtensionCallbackType.POST_DENOISE_LOOP) + def restore_unmasked(self, ctx: DenoiseContext): + if self._is_gradient_mask: + ctx.latents = torch.where(self._mask > 0, ctx.latents, ctx.inputs.orig_latents) + else: + ctx.latents = torch.lerp(ctx.inputs.orig_latents, ctx.latents, self._mask) diff --git a/invokeai/backend/stable_diffusion/extensions/lora.py b/invokeai/backend/stable_diffusion/extensions/lora.py new file mode 100644 index 0000000000000000000000000000000000000000..bbc394a5cbef8cf8a9f1417117308febd0edd519 --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/lora.py @@ -0,0 +1,43 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import TYPE_CHECKING + +from diffusers import UNet2DConditionModel + +from invokeai.backend.lora.lora_model_raw import LoRAModelRaw +from invokeai.backend.lora.lora_patcher import LoRAPatcher +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase + +if TYPE_CHECKING: + from invokeai.app.invocations.model import ModelIdentifierField + from invokeai.app.services.shared.invocation_context import InvocationContext + from invokeai.backend.util.original_weights_storage import OriginalWeightsStorage + + +class LoRAExt(ExtensionBase): + def __init__( + self, + node_context: InvocationContext, + model_id: ModelIdentifierField, + weight: float, + ): + super().__init__() + self._node_context = node_context + self._model_id = model_id + self._weight = weight + + @contextmanager + def patch_unet(self, unet: UNet2DConditionModel, original_weights: OriginalWeightsStorage): + lora_model = self._node_context.models.load(self._model_id).model + assert isinstance(lora_model, LoRAModelRaw) + LoRAPatcher.apply_lora_patch( + model=unet, + prefix="lora_unet_", + patch=lora_model, + patch_weight=self._weight, + original_weights=original_weights, + ) + del lora_model + + yield diff --git a/invokeai/backend/stable_diffusion/extensions/preview.py b/invokeai/backend/stable_diffusion/extensions/preview.py new file mode 100644 index 0000000000000000000000000000000000000000..6256f475945fcd7e3f86ca7ae2cf9a29839694c7 --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/preview.py @@ -0,0 +1,63 @@ +from __future__ import annotations + +from dataclasses import dataclass +from typing import TYPE_CHECKING, Callable, Optional + +import torch + +from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase, callback + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + + +# TODO: change event to accept image instead of latents +@dataclass +class PipelineIntermediateState: + step: int + order: int + total_steps: int + timestep: int + latents: torch.Tensor + predicted_original: Optional[torch.Tensor] = None + + +class PreviewExt(ExtensionBase): + def __init__(self, callback: Callable[[PipelineIntermediateState], None]): + super().__init__() + self.callback = callback + + # do last so that all other changes shown + @callback(ExtensionCallbackType.PRE_DENOISE_LOOP, order=1000) + def initial_preview(self, ctx: DenoiseContext): + self.callback( + PipelineIntermediateState( + step=0, + order=ctx.scheduler.order, + total_steps=len(ctx.inputs.timesteps), + timestep=int(ctx.scheduler.config.num_train_timesteps), # TODO: is there any code which uses it? + latents=ctx.latents, + ) + ) + + # do last so that all other changes shown + @callback(ExtensionCallbackType.POST_STEP, order=1000) + def step_preview(self, ctx: DenoiseContext): + if hasattr(ctx.step_output, "denoised"): + predicted_original = ctx.step_output.denoised + elif hasattr(ctx.step_output, "pred_original_sample"): + predicted_original = ctx.step_output.pred_original_sample + else: + predicted_original = ctx.step_output.prev_sample + + self.callback( + PipelineIntermediateState( + step=ctx.step_index, + order=ctx.scheduler.order, + total_steps=len(ctx.inputs.timesteps), + timestep=int(ctx.timestep), # TODO: is there any code which uses it? + latents=ctx.step_output.prev_sample, + predicted_original=predicted_original, # TODO: is there any reason for additional field? + ) + ) diff --git a/invokeai/backend/stable_diffusion/extensions/rescale_cfg.py b/invokeai/backend/stable_diffusion/extensions/rescale_cfg.py new file mode 100644 index 0000000000000000000000000000000000000000..7cccbb8a2bc088a33f9e0889f53cf48041b8be0d --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/rescale_cfg.py @@ -0,0 +1,36 @@ +from __future__ import annotations + +from typing import TYPE_CHECKING + +import torch + +from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase, callback + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + + +class RescaleCFGExt(ExtensionBase): + def __init__(self, rescale_multiplier: float): + super().__init__() + self._rescale_multiplier = rescale_multiplier + + @staticmethod + def _rescale_cfg(total_noise_pred: torch.Tensor, pos_noise_pred: torch.Tensor, multiplier: float = 0.7): + """Implementation of Algorithm 2 from https://arxiv.org/pdf/2305.08891.pdf.""" + ro_pos = torch.std(pos_noise_pred, dim=(1, 2, 3), keepdim=True) + ro_cfg = torch.std(total_noise_pred, dim=(1, 2, 3), keepdim=True) + + x_rescaled = total_noise_pred * (ro_pos / ro_cfg) + x_final = multiplier * x_rescaled + (1.0 - multiplier) * total_noise_pred + return x_final + + @callback(ExtensionCallbackType.POST_COMBINE_NOISE_PREDS) + def rescale_noise_pred(self, ctx: DenoiseContext): + if self._rescale_multiplier > 0: + ctx.noise_pred = self._rescale_cfg( + ctx.noise_pred, + ctx.positive_noise_pred, + self._rescale_multiplier, + ) diff --git a/invokeai/backend/stable_diffusion/extensions/seamless.py b/invokeai/backend/stable_diffusion/extensions/seamless.py new file mode 100644 index 0000000000000000000000000000000000000000..a96ea6e4d2e5f289a96404ee85d3859eec0d11c9 --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/seamless.py @@ -0,0 +1,71 @@ +from __future__ import annotations + +from contextlib import contextmanager +from typing import Callable, Dict, List, Optional, Tuple + +import torch +import torch.nn as nn +from diffusers import UNet2DConditionModel +from diffusers.models.lora import LoRACompatibleConv + +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase + + +class SeamlessExt(ExtensionBase): + def __init__( + self, + seamless_axes: List[str], + ): + super().__init__() + self._seamless_axes = seamless_axes + + @contextmanager + def patch_unet(self, unet: UNet2DConditionModel, cached_weights: Optional[Dict[str, torch.Tensor]] = None): + with self.static_patch_model( + model=unet, + seamless_axes=self._seamless_axes, + ): + yield + + @staticmethod + @contextmanager + def static_patch_model( + model: torch.nn.Module, + seamless_axes: List[str], + ): + if not seamless_axes: + yield + return + + x_mode = "circular" if "x" in seamless_axes else "constant" + y_mode = "circular" if "y" in seamless_axes else "constant" + + # override conv_forward + # https://github.com/huggingface/diffusers/issues/556#issuecomment-1993287019 + def _conv_forward_asymmetric( + self, input: torch.Tensor, weight: torch.Tensor, bias: Optional[torch.Tensor] = None + ): + self.paddingX = (self._reversed_padding_repeated_twice[0], self._reversed_padding_repeated_twice[1], 0, 0) + self.paddingY = (0, 0, self._reversed_padding_repeated_twice[2], self._reversed_padding_repeated_twice[3]) + working = torch.nn.functional.pad(input, self.paddingX, mode=x_mode) + working = torch.nn.functional.pad(working, self.paddingY, mode=y_mode) + return torch.nn.functional.conv2d( + working, weight, bias, self.stride, torch.nn.modules.utils._pair(0), self.dilation, self.groups + ) + + original_layers: List[Tuple[nn.Conv2d, Callable]] = [] + try: + for layer in model.modules(): + if not isinstance(layer, torch.nn.Conv2d): + continue + + if isinstance(layer, LoRACompatibleConv) and layer.lora_layer is None: + layer.lora_layer = lambda *x: 0 + original_layers.append((layer, layer._conv_forward)) + layer._conv_forward = _conv_forward_asymmetric.__get__(layer, torch.nn.Conv2d) + + yield + + finally: + for layer, orig_conv_forward in original_layers: + layer._conv_forward = orig_conv_forward diff --git a/invokeai/backend/stable_diffusion/extensions/t2i_adapter.py b/invokeai/backend/stable_diffusion/extensions/t2i_adapter.py new file mode 100644 index 0000000000000000000000000000000000000000..5c290ea4e7916d3a89be4aa5c2ee281c2111e7b5 --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions/t2i_adapter.py @@ -0,0 +1,120 @@ +from __future__ import annotations + +import math +from typing import TYPE_CHECKING, List, Optional, Union + +import torch +from diffusers import T2IAdapter +from PIL.Image import Image + +from invokeai.app.util.controlnet_utils import prepare_control_image +from invokeai.backend.model_manager import BaseModelType +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningMode +from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType +from invokeai.backend.stable_diffusion.extensions.base import ExtensionBase, callback + +if TYPE_CHECKING: + from invokeai.app.invocations.model import ModelIdentifierField + from invokeai.app.services.shared.invocation_context import InvocationContext + from invokeai.app.util.controlnet_utils import CONTROLNET_RESIZE_VALUES + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + + +class T2IAdapterExt(ExtensionBase): + def __init__( + self, + node_context: InvocationContext, + model_id: ModelIdentifierField, + image: Image, + weight: Union[float, List[float]], + begin_step_percent: float, + end_step_percent: float, + resize_mode: CONTROLNET_RESIZE_VALUES, + ): + super().__init__() + self._node_context = node_context + self._model_id = model_id + self._image = image + self._weight = weight + self._resize_mode = resize_mode + self._begin_step_percent = begin_step_percent + self._end_step_percent = end_step_percent + + self._adapter_state: Optional[List[torch.Tensor]] = None + + # The max_unet_downscale is the maximum amount that the UNet model downscales the latent image internally. + model_config = self._node_context.models.get_config(self._model_id.key) + if model_config.base == BaseModelType.StableDiffusion1: + self._max_unet_downscale = 8 + elif model_config.base == BaseModelType.StableDiffusionXL: + self._max_unet_downscale = 4 + else: + raise ValueError(f"Unexpected T2I-Adapter base model type: '{model_config.base}'.") + + @callback(ExtensionCallbackType.SETUP) + def setup(self, ctx: DenoiseContext): + t2i_model: T2IAdapter + with self._node_context.models.load(self._model_id) as t2i_model: + _, _, latents_height, latents_width = ctx.inputs.orig_latents.shape + + self._adapter_state = self._run_model( + model=t2i_model, + image=self._image, + latents_height=latents_height, + latents_width=latents_width, + ) + + def _run_model( + self, + model: T2IAdapter, + image: Image, + latents_height: int, + latents_width: int, + ): + # 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. + input_height = latents_height // self._max_unet_downscale * model.total_downscale_factor + input_width = latents_width // self._max_unet_downscale * model.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). + t2i_image = prepare_control_image( + image=image, + do_classifier_free_guidance=False, + width=input_width, + height=input_height, + num_channels=model.config["in_channels"], + device=model.device, + dtype=model.dtype, + resize_mode=self._resize_mode, + ) + + return model(t2i_image) + + @callback(ExtensionCallbackType.PRE_UNET) + def pre_unet_step(self, ctx: DenoiseContext): + # skip if model not active in current step + total_steps = len(ctx.inputs.timesteps) + first_step = math.floor(self._begin_step_percent * total_steps) + last_step = math.ceil(self._end_step_percent * total_steps) + if ctx.step_index < first_step or ctx.step_index > last_step: + return + + weight = self._weight + if isinstance(weight, list): + weight = weight[ctx.step_index] + + adapter_state = self._adapter_state + if ctx.conditioning_mode == ConditioningMode.Both: + adapter_state = [torch.cat([v] * 2) for v in adapter_state] + + if ctx.unet_kwargs.down_intrablock_additional_residuals is None: + ctx.unet_kwargs.down_intrablock_additional_residuals = [v * weight for v in adapter_state] + else: + for i, value in enumerate(adapter_state): + ctx.unet_kwargs.down_intrablock_additional_residuals[i] += value * weight diff --git a/invokeai/backend/stable_diffusion/extensions_manager.py b/invokeai/backend/stable_diffusion/extensions_manager.py new file mode 100644 index 0000000000000000000000000000000000000000..3783bb422e55294cea47e98d639e8e3378479c39 --- /dev/null +++ b/invokeai/backend/stable_diffusion/extensions_manager.py @@ -0,0 +1,82 @@ +from __future__ import annotations + +from contextlib import ExitStack, contextmanager +from typing import TYPE_CHECKING, Callable, Dict, List, Optional + +import torch +from diffusers import UNet2DConditionModel + +from invokeai.app.services.session_processor.session_processor_common import CanceledException +from invokeai.backend.util.original_weights_storage import OriginalWeightsStorage + +if TYPE_CHECKING: + from invokeai.backend.stable_diffusion.denoise_context import DenoiseContext + from invokeai.backend.stable_diffusion.extension_callback_type import ExtensionCallbackType + from invokeai.backend.stable_diffusion.extensions.base import CallbackFunctionWithMetadata, ExtensionBase + + +class ExtensionsManager: + def __init__(self, is_canceled: Optional[Callable[[], bool]] = None): + self._is_canceled = is_canceled + + # A list of extensions in the order that they were added to the ExtensionsManager. + self._extensions: List[ExtensionBase] = [] + self._ordered_callbacks: Dict[ExtensionCallbackType, List[CallbackFunctionWithMetadata]] = {} + + def add_extension(self, extension: ExtensionBase): + self._extensions.append(extension) + self._regenerate_ordered_callbacks() + + def _regenerate_ordered_callbacks(self): + """Regenerates self._ordered_callbacks. Intended to be called each time a new extension is added.""" + self._ordered_callbacks = {} + + # Fill the ordered callbacks dictionary. + for extension in self._extensions: + for callback_type, callbacks in extension.get_callbacks().items(): + if callback_type not in self._ordered_callbacks: + self._ordered_callbacks[callback_type] = [] + self._ordered_callbacks[callback_type].extend(callbacks) + + # Sort each callback list. + for callback_type, callbacks in self._ordered_callbacks.items(): + # Note that sorted() is stable, so if two callbacks have the same order, the order that they extensions were + # added will be preserved. + self._ordered_callbacks[callback_type] = sorted(callbacks, key=lambda x: x.metadata.order) + + def run_callback(self, callback_type: ExtensionCallbackType, ctx: DenoiseContext): + if self._is_canceled and self._is_canceled(): + raise CanceledException + + callbacks = self._ordered_callbacks.get(callback_type, []) + for cb in callbacks: + cb.function(ctx) + + @contextmanager + def patch_extensions(self, ctx: DenoiseContext): + if self._is_canceled and self._is_canceled(): + raise CanceledException + + with ExitStack() as exit_stack: + for ext in self._extensions: + exit_stack.enter_context(ext.patch_extension(ctx)) + + yield None + + @contextmanager + def patch_unet(self, unet: UNet2DConditionModel, cached_weights: Optional[Dict[str, torch.Tensor]] = None): + if self._is_canceled and self._is_canceled(): + raise CanceledException + + original_weights = OriginalWeightsStorage(cached_weights) + try: + with ExitStack() as exit_stack: + for ext in self._extensions: + exit_stack.enter_context(ext.patch_unet(unet, original_weights)) + + yield None + + finally: + with torch.no_grad(): + for param_key, weight in original_weights.get_changed_weights(): + unet.get_parameter(param_key).copy_(weight) diff --git a/invokeai/backend/stable_diffusion/multi_diffusion_pipeline.py b/invokeai/backend/stable_diffusion/multi_diffusion_pipeline.py new file mode 100644 index 0000000000000000000000000000000000000000..63e74de5044457205fb607f057738ffb4cc85b0f --- /dev/null +++ b/invokeai/backend/stable_diffusion/multi_diffusion_pipeline.py @@ -0,0 +1,194 @@ +from __future__ import annotations + +import copy +from dataclasses import dataclass +from typing import Any, Callable, Optional + +import torch +from diffusers.schedulers.scheduling_utils import SchedulerMixin + +from invokeai.backend.stable_diffusion.diffusers_pipeline import ( + ControlNetData, + PipelineIntermediateState, + StableDiffusionGeneratorPipeline, +) +from invokeai.backend.stable_diffusion.diffusion.conditioning_data import TextConditioningData +from invokeai.backend.tiles.utils import Tile + + +@dataclass +class MultiDiffusionRegionConditioning: + # Region coords in latent space. + region: Tile + text_conditioning_data: TextConditioningData + control_data: list[ControlNetData] + + +class MultiDiffusionPipeline(StableDiffusionGeneratorPipeline): + """A Stable Diffusion pipeline that uses Multi-Diffusion (https://arxiv.org/pdf/2302.08113) for denoising.""" + + def _check_regional_prompting(self, multi_diffusion_conditioning: list[MultiDiffusionRegionConditioning]): + """Validate that regional conditioning is not used.""" + for region_conditioning in multi_diffusion_conditioning: + if ( + region_conditioning.text_conditioning_data.cond_regions is not None + or region_conditioning.text_conditioning_data.uncond_regions is not None + ): + raise NotImplementedError("Regional prompting is not yet supported in Multi-Diffusion.") + + def multi_diffusion_denoise( + self, + multi_diffusion_conditioning: list[MultiDiffusionRegionConditioning], + target_overlap: int, + latents: torch.Tensor, + scheduler_step_kwargs: dict[str, Any], + noise: Optional[torch.Tensor], + timesteps: torch.Tensor, + init_timestep: torch.Tensor, + callback: Callable[[PipelineIntermediateState], None], + ) -> torch.Tensor: + self._check_regional_prompting(multi_diffusion_conditioning) + + if init_timestep.shape[0] == 0: + return latents + + batch_size, _, latent_height, latent_width = latents.shape + batched_init_timestep = init_timestep.expand(batch_size) + + # noise can be None if the latents have already been noised (e.g. when running the SDXL refiner). + if noise is not None: + # TODO(ryand): I'm pretty sure we should be applying init_noise_sigma in cases where we are starting with + # full noise. Investigate the history of why this got commented out. + # latents = noise * self.scheduler.init_noise_sigma # it's like in t2l according to diffusers + latents = self.scheduler.add_noise(latents, noise, batched_init_timestep) + assert isinstance(latents, torch.Tensor) # For static type checking. + + # TODO(ryand): Look into the implications of passing in latents here that are larger than they will be after + # cropping into regions. + self._adjust_memory_efficient_attention(latents) + + # Many of the diffusers schedulers are stateful (i.e. they update internal state in each call to step()). Since + # we are calling step() multiple times at the same timestep (once for each region batch), we must maintain a + # separate scheduler state for each region batch. + # TODO(ryand): This solution allows all schedulers to **run**, but does not fully solve the issue of scheduler + # statefulness. Some schedulers store previous model outputs in their state, but these values become incorrect + # as Multi-Diffusion blending is applied (e.g. the PNDMScheduler). This can result in a blurring effect when + # multiple MultiDiffusion regions overlap. Solving this properly would require a case-by-case review of each + # scheduler to determine how it's state needs to be updated for compatibilty with Multi-Diffusion. + region_batch_schedulers: list[SchedulerMixin] = [ + copy.deepcopy(self.scheduler) for _ in multi_diffusion_conditioning + ] + + callback( + PipelineIntermediateState( + step=0, + order=self.scheduler.order, + total_steps=len(timesteps), + timestep=self.scheduler.config.num_train_timesteps, + latents=latents, + ) + ) + + for i, t in enumerate(self.progress_bar(timesteps)): + batched_t = t.expand(batch_size) + + merged_latents = torch.zeros_like(latents) + merged_latents_weights = torch.zeros( + (1, 1, latent_height, latent_width), device=latents.device, dtype=latents.dtype + ) + merged_pred_original: torch.Tensor | None = None + for region_idx, region_conditioning in enumerate(multi_diffusion_conditioning): + # Switch to the scheduler for the region batch. + self.scheduler = region_batch_schedulers[region_idx] + + # Crop the inputs to the region. + region_latents = latents[ + :, + :, + region_conditioning.region.coords.top : region_conditioning.region.coords.bottom, + region_conditioning.region.coords.left : region_conditioning.region.coords.right, + ] + + # Run the denoising step on the region. + step_output = self.step( + t=batched_t, + latents=region_latents, + conditioning_data=region_conditioning.text_conditioning_data, + step_index=i, + total_step_count=len(timesteps), + scheduler_step_kwargs=scheduler_step_kwargs, + mask_guidance=None, + mask=None, + masked_latents=None, + control_data=region_conditioning.control_data, + ) + + # Build a region_weight matrix that applies gradient blending to the edges of the region. + region = region_conditioning.region + _, _, region_height, region_width = step_output.prev_sample.shape + region_weight = torch.ones( + (1, 1, region_height, region_width), + dtype=latents.dtype, + device=latents.device, + ) + if region.overlap.left > 0: + left_grad = torch.linspace( + 0, 1, region.overlap.left, device=latents.device, dtype=latents.dtype + ).view((1, 1, 1, -1)) + region_weight[:, :, :, : region.overlap.left] *= left_grad + if region.overlap.top > 0: + top_grad = torch.linspace( + 0, 1, region.overlap.top, device=latents.device, dtype=latents.dtype + ).view((1, 1, -1, 1)) + region_weight[:, :, : region.overlap.top, :] *= top_grad + if region.overlap.right > 0: + right_grad = torch.linspace( + 1, 0, region.overlap.right, device=latents.device, dtype=latents.dtype + ).view((1, 1, 1, -1)) + region_weight[:, :, :, -region.overlap.right :] *= right_grad + if region.overlap.bottom > 0: + bottom_grad = torch.linspace( + 1, 0, region.overlap.bottom, device=latents.device, dtype=latents.dtype + ).view((1, 1, -1, 1)) + region_weight[:, :, -region.overlap.bottom :, :] *= bottom_grad + + # Update the merged results with the region results. + merged_latents[ + :, :, region.coords.top : region.coords.bottom, region.coords.left : region.coords.right + ] += step_output.prev_sample * region_weight + merged_latents_weights[ + :, :, region.coords.top : region.coords.bottom, region.coords.left : region.coords.right + ] += region_weight + + pred_orig_sample = getattr(step_output, "pred_original_sample", None) + if pred_orig_sample is not None: + # If one region has pred_original_sample, then we can assume that all regions will have it, because + # they all use the same scheduler. + if merged_pred_original is None: + merged_pred_original = torch.zeros_like(latents) + merged_pred_original[ + :, :, region.coords.top : region.coords.bottom, region.coords.left : region.coords.right + ] += pred_orig_sample + + # Normalize the merged results. + latents = torch.where(merged_latents_weights > 0, merged_latents / merged_latents_weights, merged_latents) + # For debugging, uncomment this line to visualize the region seams: + # latents = torch.where(merged_latents_weights > 1, 0.0, latents) + predicted_original = None + if merged_pred_original is not None: + predicted_original = torch.where( + merged_latents_weights > 0, merged_pred_original / merged_latents_weights, merged_pred_original + ) + + callback( + PipelineIntermediateState( + step=i + 1, + order=self.scheduler.order, + total_steps=len(timesteps), + timestep=int(t), + latents=latents, + predicted_original=predicted_original, + ) + ) + + return latents diff --git a/invokeai/backend/stable_diffusion/schedulers/__init__.py b/invokeai/backend/stable_diffusion/schedulers/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..6c02acda512d33b91ab49c64f7e239a886e2f3ea --- /dev/null +++ b/invokeai/backend/stable_diffusion/schedulers/__init__.py @@ -0,0 +1,3 @@ +from invokeai.backend.stable_diffusion.schedulers.schedulers import SCHEDULER_MAP # noqa: F401 + +__all__ = ["SCHEDULER_MAP"] diff --git a/invokeai/backend/stable_diffusion/schedulers/schedulers.py b/invokeai/backend/stable_diffusion/schedulers/schedulers.py new file mode 100644 index 0000000000000000000000000000000000000000..c8836b316abdc48e56c18428e834c3f6a3f2e1d2 --- /dev/null +++ b/invokeai/backend/stable_diffusion/schedulers/schedulers.py @@ -0,0 +1,96 @@ +from typing import Any, Literal, Type + +from diffusers import ( + DDIMScheduler, + DDPMScheduler, + DEISMultistepScheduler, + DPMSolverMultistepScheduler, + DPMSolverSDEScheduler, + DPMSolverSinglestepScheduler, + EulerAncestralDiscreteScheduler, + EulerDiscreteScheduler, + HeunDiscreteScheduler, + KDPM2AncestralDiscreteScheduler, + KDPM2DiscreteScheduler, + LCMScheduler, + LMSDiscreteScheduler, + PNDMScheduler, + TCDScheduler, + UniPCMultistepScheduler, +) +from diffusers.schedulers.scheduling_utils import SchedulerMixin + +# TODO: add dpmpp_3s/dpmpp_3s_k when fix released +# https://github.com/huggingface/diffusers/issues/9007 + +SCHEDULER_NAME_VALUES = Literal[ + "ddim", + "ddpm", + "deis", + "deis_k", + "lms", + "lms_k", + "pndm", + "heun", + "heun_k", + "euler", + "euler_k", + "euler_a", + "kdpm_2", + "kdpm_2_k", + "kdpm_2_a", + "kdpm_2_a_k", + "dpmpp_2s", + "dpmpp_2s_k", + "dpmpp_2m", + "dpmpp_2m_k", + "dpmpp_2m_sde", + "dpmpp_2m_sde_k", + "dpmpp_3m", + "dpmpp_3m_k", + "dpmpp_sde", + "dpmpp_sde_k", + "unipc", + "unipc_k", + "lcm", + "tcd", +] + +SCHEDULER_MAP: dict[SCHEDULER_NAME_VALUES, tuple[Type[SchedulerMixin], dict[str, Any]]] = { + "ddim": (DDIMScheduler, {}), + "ddpm": (DDPMScheduler, {}), + "deis": (DEISMultistepScheduler, {"use_karras_sigmas": False}), + "deis_k": (DEISMultistepScheduler, {"use_karras_sigmas": True}), + "lms": (LMSDiscreteScheduler, {"use_karras_sigmas": False}), + "lms_k": (LMSDiscreteScheduler, {"use_karras_sigmas": True}), + "pndm": (PNDMScheduler, {}), + "heun": (HeunDiscreteScheduler, {"use_karras_sigmas": False}), + "heun_k": (HeunDiscreteScheduler, {"use_karras_sigmas": True}), + "euler": (EulerDiscreteScheduler, {"use_karras_sigmas": False}), + "euler_k": (EulerDiscreteScheduler, {"use_karras_sigmas": True}), + "euler_a": (EulerAncestralDiscreteScheduler, {}), + "kdpm_2": (KDPM2DiscreteScheduler, {"use_karras_sigmas": False}), + "kdpm_2_k": (KDPM2DiscreteScheduler, {"use_karras_sigmas": True}), + "kdpm_2_a": (KDPM2AncestralDiscreteScheduler, {"use_karras_sigmas": False}), + "kdpm_2_a_k": (KDPM2AncestralDiscreteScheduler, {"use_karras_sigmas": True}), + "dpmpp_2s": (DPMSolverSinglestepScheduler, {"use_karras_sigmas": False, "solver_order": 2}), + "dpmpp_2s_k": (DPMSolverSinglestepScheduler, {"use_karras_sigmas": True, "solver_order": 2}), + "dpmpp_2m": (DPMSolverMultistepScheduler, {"use_karras_sigmas": False, "solver_order": 2}), + "dpmpp_2m_k": (DPMSolverMultistepScheduler, {"use_karras_sigmas": True, "solver_order": 2}), + "dpmpp_2m_sde": ( + DPMSolverMultistepScheduler, + {"use_karras_sigmas": False, "solver_order": 2, "algorithm_type": "sde-dpmsolver++"}, + ), + "dpmpp_2m_sde_k": ( + DPMSolverMultistepScheduler, + {"use_karras_sigmas": True, "solver_order": 2, "algorithm_type": "sde-dpmsolver++"}, + ), + "dpmpp_3m": (DPMSolverMultistepScheduler, {"use_karras_sigmas": False, "solver_order": 3}), + "dpmpp_3m_k": (DPMSolverMultistepScheduler, {"use_karras_sigmas": True, "solver_order": 3}), + "dpmpp_sde": (DPMSolverSDEScheduler, {"use_karras_sigmas": False, "noise_sampler_seed": 0}), + "dpmpp_sde_k": (DPMSolverSDEScheduler, {"use_karras_sigmas": True, "noise_sampler_seed": 0}), + "unipc": (UniPCMultistepScheduler, {"use_karras_sigmas": False, "cpu_only": True}), + "unipc_k": (UniPCMultistepScheduler, {"use_karras_sigmas": True, "cpu_only": True}), + "lcm": (LCMScheduler, {}), + "tcd": (TCDScheduler, {}), +} diff --git a/invokeai/backend/stable_diffusion/vae_tiling.py b/invokeai/backend/stable_diffusion/vae_tiling.py new file mode 100644 index 0000000000000000000000000000000000000000..d31cb331f438d466d561e3847e7c9d347dc75c88 --- /dev/null +++ b/invokeai/backend/stable_diffusion/vae_tiling.py @@ -0,0 +1,35 @@ +from contextlib import contextmanager + +from diffusers.models.autoencoders.autoencoder_kl import AutoencoderKL +from diffusers.models.autoencoders.autoencoder_tiny import AutoencoderTiny + + +@contextmanager +def patch_vae_tiling_params( + vae: AutoencoderKL | AutoencoderTiny, + tile_sample_min_size: int, + tile_latent_min_size: int, + tile_overlap_factor: float, +): + """Patch the parameters that control the VAE tiling tile size and overlap. + + These parameters are not explicitly exposed in the VAE's API, but they have a significant impact on the quality of + the outputs. As a general rule, bigger tiles produce better results, but this comes at the cost of higher memory + usage. + """ + # Record initial config. + orig_tile_sample_min_size = vae.tile_sample_min_size + orig_tile_latent_min_size = vae.tile_latent_min_size + orig_tile_overlap_factor = vae.tile_overlap_factor + + try: + # Apply target config. + vae.tile_sample_min_size = tile_sample_min_size + vae.tile_latent_min_size = tile_latent_min_size + vae.tile_overlap_factor = tile_overlap_factor + yield + finally: + # Restore initial config. + vae.tile_sample_min_size = orig_tile_sample_min_size + vae.tile_latent_min_size = orig_tile_latent_min_size + vae.tile_overlap_factor = orig_tile_overlap_factor diff --git a/invokeai/backend/textual_inversion.py b/invokeai/backend/textual_inversion.py new file mode 100644 index 0000000000000000000000000000000000000000..b83d769a8d16099baa504eef61fd1f7b0f955838 --- /dev/null +++ b/invokeai/backend/textual_inversion.py @@ -0,0 +1,129 @@ +"""Textual Inversion wrapper class.""" + +from pathlib import Path +from typing import Optional, Union + +import torch +from compel.embeddings_provider import BaseTextualInversionManager +from safetensors.torch import load_file +from transformers import CLIPTokenizer +from typing_extensions import Self + +from invokeai.backend.raw_model import RawModel +from invokeai.backend.util.calc_tensor_size import calc_tensors_size + + +class TextualInversionModelRaw(RawModel): + embedding: torch.Tensor # [n, 768]|[n, 1280] + embedding_2: Optional[torch.Tensor] = None # [n, 768]|[n, 1280] - for SDXL models + + @classmethod + def from_checkpoint( + cls, + file_path: Union[str, Path], + device: Optional[torch.device] = None, + dtype: Optional[torch.dtype] = None, + ) -> Self: + if not isinstance(file_path, Path): + file_path = Path(file_path) + + result = cls() # TODO: + + if file_path.suffix == ".safetensors": + state_dict = load_file(file_path.absolute().as_posix(), device="cpu") + else: + state_dict = torch.load(file_path, map_location="cpu") + + # both v1 and v2 format embeddings + # difference mostly in metadata + if "string_to_param" in state_dict: + if len(state_dict["string_to_param"]) > 1: + print( + f'Warn: Embedding "{file_path.name}" contains multiple tokens, which is not supported. The first', + " token will be used.", + ) + + result.embedding = next(iter(state_dict["string_to_param"].values())) + + # v3 (easynegative) + elif "emb_params" in state_dict: + result.embedding = state_dict["emb_params"] + + # v5(sdxl safetensors file) + elif "clip_g" in state_dict and "clip_l" in state_dict: + result.embedding = state_dict["clip_g"] + result.embedding_2 = state_dict["clip_l"] + + # v4(diffusers bin files) + else: + result.embedding = next(iter(state_dict.values())) + + if len(result.embedding.shape) == 1: + result.embedding = result.embedding.unsqueeze(0) + + if not isinstance(result.embedding, torch.Tensor): + raise ValueError(f"Invalid embeddings file: {file_path.name}") + + return result + + def to(self, device: Optional[torch.device] = None, dtype: Optional[torch.dtype] = None) -> None: + if not torch.cuda.is_available(): + return + for emb in [self.embedding, self.embedding_2]: + if emb is not None: + emb.to(device=device, dtype=dtype) + + def calc_size(self) -> int: + """Get the size of this model in bytes.""" + return calc_tensors_size([self.embedding, self.embedding_2]) + + +class TextualInversionManager(BaseTextualInversionManager): + """TextualInversionManager implements the BaseTextualInversionManager ABC from the compel library.""" + + def __init__(self, tokenizer: CLIPTokenizer): + self.pad_tokens: dict[int, list[int]] = {} + self.tokenizer = tokenizer + + def expand_textual_inversion_token_ids_if_necessary(self, token_ids: list[int]) -> list[int]: + """Given a list of tokens ids, expand any TI tokens to their corresponding pad tokens. + + For example, suppose we have a `` TI with 4 vectors that was added to the tokenizer with the following + mapping of tokens to token_ids: + ``` + : 49408 + : 49409 + : 49410 + : 49411 + ``` + `self.pad_tokens` would be set to `{49408: [49408, 49409, 49410, 49411]}`. + This function is responsible for expanding `49408` in the token_ids list to `[49408, 49409, 49410, 49411]`. + """ + # Short circuit if there are no pad tokens to save a little time. + if len(self.pad_tokens) == 0: + return token_ids + + # This function assumes that compel has not included the BOS and EOS tokens in the token_ids list. We verify + # this assumption here. + if token_ids[0] == self.tokenizer.bos_token_id: + raise ValueError("token_ids must not start with bos_token_id") + if token_ids[-1] == self.tokenizer.eos_token_id: + raise ValueError("token_ids must not end with eos_token_id") + + # Expand any TI tokens to their corresponding pad tokens. + new_token_ids: list[int] = [] + for token_id in token_ids: + new_token_ids.append(token_id) + if token_id in self.pad_tokens: + new_token_ids.extend(self.pad_tokens[token_id]) + + # Do not exceed the max model input size. The -2 here is compensating for + # compel.embeddings_provider.get_token_ids(), which first removes and then adds back the start and end tokens. + max_length = self.tokenizer.model_max_length - 2 + if len(new_token_ids) > max_length: + # HACK: If TI token expansion causes us to exceed the max text encoder input length, we silently discard + # tokens. Token expansion should happen in a way that is compatible with compel's default handling of long + # prompts. + new_token_ids = new_token_ids[0:max_length] + + return new_token_ids diff --git a/invokeai/backend/tiles/__init__.py b/invokeai/backend/tiles/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/backend/tiles/tiles.py b/invokeai/backend/tiles/tiles.py new file mode 100644 index 0000000000000000000000000000000000000000..2757dadba20d19f4b2abbbc8fa3ddca02bd37b26 --- /dev/null +++ b/invokeai/backend/tiles/tiles.py @@ -0,0 +1,426 @@ +import math +from typing import Union + +import numpy as np + +from invokeai.app.invocations.constants import LATENT_SCALE_FACTOR +from invokeai.backend.tiles.utils import TBLR, Tile, paste, seam_blend + + +def calc_overlap(tiles: list[Tile], num_tiles_x: int, num_tiles_y: int) -> list[Tile]: + """Calculate and update the overlap of a list of tiles. + + Args: + tiles (list[Tile]): The list of tiles describing the locations of the respective `tile_images`. + num_tiles_x: the number of tiles on the x axis. + num_tiles_y: the number of tiles on the y axis. + """ + + def get_tile_or_none(idx_y: int, idx_x: int) -> Union[Tile, None]: + if idx_y < 0 or idx_y > num_tiles_y or idx_x < 0 or idx_x > num_tiles_x: + return None + return tiles[idx_y * num_tiles_x + idx_x] + + for tile_idx_y in range(num_tiles_y): + for tile_idx_x in range(num_tiles_x): + cur_tile = get_tile_or_none(tile_idx_y, tile_idx_x) + top_neighbor_tile = get_tile_or_none(tile_idx_y - 1, tile_idx_x) + left_neighbor_tile = get_tile_or_none(tile_idx_y, tile_idx_x - 1) + + assert cur_tile is not None + + # Update cur_tile top-overlap and corresponding top-neighbor bottom-overlap. + if top_neighbor_tile is not None: + cur_tile.overlap.top = max(0, top_neighbor_tile.coords.bottom - cur_tile.coords.top) + top_neighbor_tile.overlap.bottom = cur_tile.overlap.top + + # Update cur_tile left-overlap and corresponding left-neighbor right-overlap. + if left_neighbor_tile is not None: + cur_tile.overlap.left = max(0, left_neighbor_tile.coords.right - cur_tile.coords.left) + left_neighbor_tile.overlap.right = cur_tile.overlap.left + return tiles + + +def calc_tiles_with_overlap( + image_height: int, image_width: int, tile_height: int, tile_width: int, overlap: int = 0 +) -> list[Tile]: + """Calculate the tile coordinates for a given image shape under a simple tiling scheme with overlaps. + + Args: + image_height (int): The image height in px. + image_width (int): The image width in px. + tile_height (int): The tile height in px. All tiles will have this height. + tile_width (int): The tile width in px. All tiles will have this width. + overlap (int, optional): The target overlap between adjacent tiles. If the tiles do not evenly cover the image + shape, then the last row/column of tiles will overlap more than this. Defaults to 0. + + Returns: + list[Tile]: A list of tiles that cover the image shape. Ordered from left-to-right, top-to-bottom. + """ + assert image_height >= tile_height + assert image_width >= tile_width + assert overlap < tile_height + assert overlap < tile_width + + non_overlap_per_tile_height = tile_height - overlap + non_overlap_per_tile_width = tile_width - overlap + + num_tiles_y = math.ceil((image_height - overlap) / non_overlap_per_tile_height) + num_tiles_x = math.ceil((image_width - overlap) / non_overlap_per_tile_width) + + # tiles[y * num_tiles_x + x] is the tile for the y'th row, x'th column. + tiles: list[Tile] = [] + + # Calculate tile coordinates. (Ignore overlap values for now.) + for tile_idx_y in range(num_tiles_y): + for tile_idx_x in range(num_tiles_x): + tile = Tile( + coords=TBLR( + top=tile_idx_y * non_overlap_per_tile_height, + bottom=tile_idx_y * non_overlap_per_tile_height + tile_height, + left=tile_idx_x * non_overlap_per_tile_width, + right=tile_idx_x * non_overlap_per_tile_width + tile_width, + ), + overlap=TBLR(top=0, bottom=0, left=0, right=0), + ) + + if tile.coords.bottom > image_height: + # If this tile would go off the bottom of the image, shift it so that it is aligned with the bottom + # of the image. + tile.coords.bottom = image_height + tile.coords.top = image_height - tile_height + + if tile.coords.right > image_width: + # If this tile would go off the right edge of the image, shift it so that it is aligned with the + # right edge of the image. + tile.coords.right = image_width + tile.coords.left = image_width - tile_width + + tiles.append(tile) + + return calc_overlap(tiles, num_tiles_x, num_tiles_y) + + +def calc_tiles_even_split( + image_height: int, image_width: int, num_tiles_x: int, num_tiles_y: int, overlap: int = 0 +) -> list[Tile]: + """Calculate the tile coordinates for a given image shape with the number of tiles requested. + + Args: + image_height (int): The image height in px. + image_width (int): The image width in px. + num_x_tiles (int): The number of tile to split the image into on the X-axis. + num_y_tiles (int): The number of tile to split the image into on the Y-axis. + overlap (int, optional): The overlap between adjacent tiles in pixels. Defaults to 0. + + Returns: + list[Tile]: A list of tiles that cover the image shape. Ordered from left-to-right, top-to-bottom. + """ + # Ensure the image is divisible by LATENT_SCALE_FACTOR + if image_width % LATENT_SCALE_FACTOR != 0 or image_height % LATENT_SCALE_FACTOR != 0: + raise ValueError(f"image size (({image_width}, {image_height})) must be divisible by {LATENT_SCALE_FACTOR}") + + # Calculate the tile size based on the number of tiles and overlap, and ensure it's divisible by 8 (rounding down) + if num_tiles_x > 1: + # ensure the overlap is not more than the maximum overlap if we only have 1 tile then we dont care about overlap + assert overlap <= image_width - (LATENT_SCALE_FACTOR * (num_tiles_x - 1)) + tile_size_x = LATENT_SCALE_FACTOR * math.floor( + ((image_width + overlap * (num_tiles_x - 1)) // num_tiles_x) / LATENT_SCALE_FACTOR + ) + assert overlap < tile_size_x + else: + tile_size_x = image_width + + if num_tiles_y > 1: + # ensure the overlap is not more than the maximum overlap if we only have 1 tile then we dont care about overlap + assert overlap <= image_height - (LATENT_SCALE_FACTOR * (num_tiles_y - 1)) + tile_size_y = LATENT_SCALE_FACTOR * math.floor( + ((image_height + overlap * (num_tiles_y - 1)) // num_tiles_y) / LATENT_SCALE_FACTOR + ) + assert overlap < tile_size_y + else: + tile_size_y = image_height + + # tiles[y * num_tiles_x + x] is the tile for the y'th row, x'th column. + tiles: list[Tile] = [] + + # Calculate tile coordinates. (Ignore overlap values for now.) + for tile_idx_y in range(num_tiles_y): + # Calculate the top and bottom of the row + top = tile_idx_y * (tile_size_y - overlap) + bottom = min(top + tile_size_y, image_height) + # For the last row adjust bottom to be the height of the image + if tile_idx_y == num_tiles_y - 1: + bottom = image_height + + for tile_idx_x in range(num_tiles_x): + # Calculate the left & right coordinate of each tile + left = tile_idx_x * (tile_size_x - overlap) + right = min(left + tile_size_x, image_width) + # For the last tile in the row adjust right to be the width of the image + if tile_idx_x == num_tiles_x - 1: + right = image_width + + tile = Tile( + coords=TBLR(top=top, bottom=bottom, left=left, right=right), + overlap=TBLR(top=0, bottom=0, left=0, right=0), + ) + + tiles.append(tile) + + return calc_overlap(tiles, num_tiles_x, num_tiles_y) + + +def calc_tiles_min_overlap( + image_height: int, + image_width: int, + tile_height: int, + tile_width: int, + min_overlap: int = 0, +) -> list[Tile]: + """Calculate the tile coordinates for a given image shape under a simple tiling scheme with overlaps. + + Args: + image_height (int): The image height in px. + image_width (int): The image width in px. + tile_height (int): The tile height in px. All tiles will have this height. + tile_width (int): The tile width in px. All tiles will have this width. + min_overlap (int): The target minimum overlap between adjacent tiles. If the tiles do not evenly cover the image + shape, then the overlap will be spread between the tiles. + + Returns: + list[Tile]: A list of tiles that cover the image shape. Ordered from left-to-right, top-to-bottom. + """ + + assert min_overlap < tile_height + assert min_overlap < tile_width + + # catches the cases when the tile size is larger than the images size and adjusts the tile size + if image_width < tile_width: + tile_width = image_width + + if image_height < tile_height: + tile_height = image_height + + num_tiles_x = math.ceil((image_width - min_overlap) / (tile_width - min_overlap)) + num_tiles_y = math.ceil((image_height - min_overlap) / (tile_height - min_overlap)) + + # tiles[y * num_tiles_x + x] is the tile for the y'th row, x'th column. + tiles: list[Tile] = [] + + # Calculate tile coordinates. (Ignore overlap values for now.) + for tile_idx_y in range(num_tiles_y): + top = (tile_idx_y * (image_height - tile_height)) // (num_tiles_y - 1) if num_tiles_y > 1 else 0 + bottom = top + tile_height + + for tile_idx_x in range(num_tiles_x): + left = (tile_idx_x * (image_width - tile_width)) // (num_tiles_x - 1) if num_tiles_x > 1 else 0 + right = left + tile_width + + tile = Tile( + coords=TBLR(top=top, bottom=bottom, left=left, right=right), + overlap=TBLR(top=0, bottom=0, left=0, right=0), + ) + + tiles.append(tile) + + return calc_overlap(tiles, num_tiles_x, num_tiles_y) + + +def merge_tiles_with_linear_blending( + dst_image: np.ndarray, tiles: list[Tile], tile_images: list[np.ndarray], blend_amount: int +): + """Merge a set of image tiles into `dst_image` with linear blending between the tiles. + + We expect every tile edge to either: + 1) have an overlap of 0, because it is aligned with the image edge, or + 2) have an overlap >= blend_amount. + If neither of these conditions are satisfied, we raise an exception. + + The linear blending is centered at the halfway point of the overlap between adjacent tiles. + + Args: + dst_image (np.ndarray): The destination image. Shape: (H, W, C). + tiles (list[Tile]): The list of tiles describing the locations of the respective `tile_images`. + tile_images (list[np.ndarray]): The tile images to merge into `dst_image`. + blend_amount (int): The amount of blending (in px) between adjacent overlapping tiles. + """ + # Sort tiles and images 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_and_images = list(zip(tiles, tile_images, strict=True)) + tiles_and_images = sorted(tiles_and_images, key=lambda x: x[0].coords.left) + tiles_and_images = sorted(tiles_and_images, key=lambda x: x[0].coords.top) + + # Organize tiles into rows. + tile_and_image_rows: list[list[tuple[Tile, np.ndarray]]] = [] + cur_tile_and_image_row: list[tuple[Tile, np.ndarray]] = [] + first_tile_in_cur_row, _ = tiles_and_images[0] + for tile_and_image in tiles_and_images: + tile, _ = tile_and_image + if not ( + tile.coords.top == first_tile_in_cur_row.coords.top + and tile.coords.bottom == first_tile_in_cur_row.coords.bottom + ): + # Store the previous row, and start a new one. + tile_and_image_rows.append(cur_tile_and_image_row) + cur_tile_and_image_row = [] + first_tile_in_cur_row, _ = tile_and_image + + cur_tile_and_image_row.append(tile_and_image) + tile_and_image_rows.append(cur_tile_and_image_row) + + # Prepare 1D linear gradients for blending. + gradient_left_x = np.linspace(start=0.0, stop=1.0, num=blend_amount) + gradient_top_y = np.linspace(start=0.0, stop=1.0, num=blend_amount) + # Convert shape: (blend_amount, ) -> (blend_amount, 1). The extra dimension enables the gradient to be applied + # to a 2D image via broadcasting. Note that no additional dimension is needed on gradient_left_x for + # broadcasting to work correctly. + gradient_top_y = np.expand_dims(gradient_top_y, axis=1) + + for tile_and_image_row in tile_and_image_rows: + first_tile_in_row, _ = tile_and_image_row[0] + row_height = first_tile_in_row.coords.bottom - first_tile_in_row.coords.top + row_image = np.zeros((row_height, dst_image.shape[1], dst_image.shape[2]), dtype=dst_image.dtype) + + # Blend the tiles in the row horizontally. + for tile, tile_image in tile_and_image_row: + # We expect the tiles to be ordered left-to-right. For each tile, we construct a mask that applies linear + # blending to the left of the current tile. The inverse linear blending is automatically applied to the + # right of the tiles that have already been pasted by the paste(...) operation. + tile_height, tile_width, _ = tile_image.shape + mask = np.ones(shape=(tile_height, tile_width), dtype=np.float64) + + # Left blending: + if tile.overlap.left > 0: + assert tile.overlap.left >= blend_amount + # Center the blending gradient in the middle of the overlap. + blend_start_left = tile.overlap.left // 2 - blend_amount // 2 + # The region left of the blending region is masked completely. + mask[:, :blend_start_left] = 0.0 + # Apply the blend gradient to the mask. + mask[:, blend_start_left : blend_start_left + blend_amount] = gradient_left_x + # For visual debugging: + # tile_image[:, blend_start_left : blend_start_left + blend_amount] = 0 + + paste( + dst_image=row_image, + src_image=tile_image, + box=TBLR( + top=0, bottom=tile.coords.bottom - tile.coords.top, left=tile.coords.left, right=tile.coords.right + ), + mask=mask, + ) + + # Blend the row into the dst_image vertically. + # We construct a mask that applies linear blending to the top of the current row. The inverse linear blending is + # automatically applied to the bottom of the tiles that have already been pasted by the paste(...) operation. + mask = np.ones(shape=(row_image.shape[0], row_image.shape[1]), dtype=np.float64) + # Top blending: + # (See comments under 'Left blending' for an explanation of the logic.) + # We assume that the entire row has the same vertical overlaps as the first_tile_in_row. + if first_tile_in_row.overlap.top > 0: + assert first_tile_in_row.overlap.top >= blend_amount + blend_start_top = first_tile_in_row.overlap.top // 2 - blend_amount // 2 + mask[:blend_start_top, :] = 0.0 + mask[blend_start_top : blend_start_top + blend_amount, :] = gradient_top_y + # For visual debugging: + # row_image[blend_start_top : blend_start_top + blend_amount, :] = 0 + paste( + dst_image=dst_image, + src_image=row_image, + box=TBLR( + top=first_tile_in_row.coords.top, + bottom=first_tile_in_row.coords.bottom, + left=0, + right=row_image.shape[1], + ), + mask=mask, + ) + + +def merge_tiles_with_seam_blending( + dst_image: np.ndarray, tiles: list[Tile], tile_images: list[np.ndarray], blend_amount: int +): + """Merge a set of image tiles into `dst_image` with seam blending between the tiles. + + We expect every tile edge to either: + 1) have an overlap of 0, because it is aligned with the image edge, or + 2) have an overlap >= blend_amount. + If neither of these conditions are satisfied, we raise an exception. + + The seam blending is centered on a seam of least energy of the overlap between adjacent tiles. + + Args: + dst_image (np.ndarray): The destination image. Shape: (H, W, C). + tiles (list[Tile]): The list of tiles describing the locations of the respective `tile_images`. + tile_images (list[np.ndarray]): The tile images to merge into `dst_image`. + blend_amount (int): The amount of blending (in px) between adjacent overlapping tiles. + """ + # Sort tiles and images 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_and_images = list(zip(tiles, tile_images, strict=True)) + tiles_and_images = sorted(tiles_and_images, key=lambda x: x[0].coords.left) + tiles_and_images = sorted(tiles_and_images, key=lambda x: x[0].coords.top) + + # Organize tiles into rows. + tile_and_image_rows: list[list[tuple[Tile, np.ndarray]]] = [] + cur_tile_and_image_row: list[tuple[Tile, np.ndarray]] = [] + first_tile_in_cur_row, _ = tiles_and_images[0] + for tile_and_image in tiles_and_images: + tile, _ = tile_and_image + if not ( + tile.coords.top == first_tile_in_cur_row.coords.top + and tile.coords.bottom == first_tile_in_cur_row.coords.bottom + ): + # Store the previous row, and start a new one. + tile_and_image_rows.append(cur_tile_and_image_row) + cur_tile_and_image_row = [] + first_tile_in_cur_row, _ = tile_and_image + + cur_tile_and_image_row.append(tile_and_image) + tile_and_image_rows.append(cur_tile_and_image_row) + + for tile_and_image_row in tile_and_image_rows: + first_tile_in_row, _ = tile_and_image_row[0] + row_height = first_tile_in_row.coords.bottom - first_tile_in_row.coords.top + row_image = np.zeros((row_height, dst_image.shape[1], dst_image.shape[2]), dtype=dst_image.dtype) + + # Blend the tiles in the row horizontally. + for tile, tile_image in tile_and_image_row: + # We expect the tiles to be ordered left-to-right. + # For each tile: + # - extract the overlap regions and pass to seam_blend() + # - apply blended region to the row_image + # - apply the un-blended region to the row_image + tile_height, tile_width, _ = tile_image.shape + overlap_size = tile.overlap.left + # Left blending: + if overlap_size > 0: + assert overlap_size >= blend_amount + + overlap_coord_right = tile.coords.left + overlap_size + src_overlap = row_image[:, tile.coords.left : overlap_coord_right] + dst_overlap = tile_image[:, :overlap_size] + blended_overlap = seam_blend(src_overlap, dst_overlap, blend_amount, x_seam=False) + row_image[:, tile.coords.left : overlap_coord_right] = blended_overlap + row_image[:, overlap_coord_right : tile.coords.right] = tile_image[:, overlap_size:] + else: + # no overlap just paste the tile + row_image[:, tile.coords.left : tile.coords.right] = tile_image + + # Blend the row into the dst_image + # We assume that the entire row has the same vertical overlaps as the first_tile_in_row. + # Rows are processed in the same way as tiles (extract overlap, blend, apply) + row_overlap_size = first_tile_in_row.overlap.top + if row_overlap_size > 0: + assert row_overlap_size >= blend_amount + + overlap_coords_bottom = first_tile_in_row.coords.top + row_overlap_size + src_overlap = dst_image[first_tile_in_row.coords.top : overlap_coords_bottom, :] + dst_overlap = row_image[:row_overlap_size, :] + blended_overlap = seam_blend(src_overlap, dst_overlap, blend_amount, x_seam=True) + dst_image[first_tile_in_row.coords.top : overlap_coords_bottom, :] = blended_overlap + dst_image[overlap_coords_bottom : first_tile_in_row.coords.bottom, :] = row_image[row_overlap_size:, :] + else: + # no overlap just paste the row + dst_image[first_tile_in_row.coords.top : first_tile_in_row.coords.bottom, :] = row_image diff --git a/invokeai/backend/tiles/utils.py b/invokeai/backend/tiles/utils.py new file mode 100644 index 0000000000000000000000000000000000000000..dc6d914170e26c6c4614618bf45b4c035bf217d3 --- /dev/null +++ b/invokeai/backend/tiles/utils.py @@ -0,0 +1,152 @@ +import math +from typing import Optional + +import cv2 +import numpy as np +from pydantic import BaseModel, Field + + +class TBLR(BaseModel): + top: int + bottom: int + left: int + right: int + + def __eq__(self, other): + return ( + self.top == other.top + and self.bottom == other.bottom + and self.left == other.left + and self.right == other.right + ) + + +class Tile(BaseModel): + coords: TBLR = Field(description="The coordinates of this tile relative to its parent image.") + overlap: TBLR = Field(description="The amount of overlap with adjacent tiles on each side of this tile.") + + def __eq__(self, other): + return self.coords == other.coords and self.overlap == other.overlap + + +def paste(dst_image: np.ndarray, src_image: np.ndarray, box: TBLR, mask: Optional[np.ndarray] = None): + """Paste a source image into a destination image. + + Args: + dst_image (np.array): The destination image to paste into. Shape: (H, W, C). + src_image (np.array): The source image to paste. Shape: (H, W, C). H and W must be compatible with 'box'. + box (TBLR): Box defining the region in the 'dst_image' where 'src_image' will be pasted. + mask (Optional[np.array]): A mask that defines the blending between 'src_image' and 'dst_image'. + Range: [0.0, 1.0], Shape: (H, W). The output is calculate per-pixel according to + `src * mask + dst * (1 - mask)`. + """ + + if mask is None: + dst_image[box.top : box.bottom, box.left : box.right] = src_image + else: + mask = np.expand_dims(mask, -1) + dst_image_box = dst_image[box.top : box.bottom, box.left : box.right] + dst_image[box.top : box.bottom, box.left : box.right] = src_image * mask + dst_image_box * (1.0 - mask) + + +def seam_blend(ia1: np.ndarray, ia2: np.ndarray, blend_amount: int, x_seam: bool) -> np.ndarray: + """Blend two overlapping tile sections using a seams to find a path. + + It is assumed that input images will be RGB np arrays and are the same size. + + Args: + ia1 (np.array): Image array 1 Shape: (H, W, C). + ia2 (np.array): Image array 2 Shape: (H, W, C). + x_seam (bool): If the images should be blended on the x axis or not. + blend_amount (int): The size of the blur to use on the seam. Half of this value will be used to avoid the edges of the image. + """ + assert ia1.shape == ia2.shape + assert ia2.size == ia2.size + + def shift(arr, num, fill_value=255.0): + result = np.full_like(arr, fill_value) + if num > 0: + result[num:] = arr[:-num] + elif num < 0: + result[:num] = arr[-num:] + else: + result[:] = arr + return result + + # Assume RGB and convert to grey + # Could offer other options for the luminance conversion + # BT.709 [0.2126, 0.7152, 0.0722], BT.2020 [0.2627, 0.6780, 0.0593]) + # it might not have a huge impact due to the blur that is applied over the seam + iag1 = np.dot(ia1, [0.2989, 0.5870, 0.1140]) # BT.601 perceived brightness + iag2 = np.dot(ia2, [0.2989, 0.5870, 0.1140]) + + # Calc Difference between the images + ia = iag2 - iag1 + + # If the seam is on the X-axis rotate the array so we can treat it like a vertical seam + if x_seam: + ia = np.rot90(ia, 1) + + # Calc max and min X & Y limits + # gutter is used to avoid the blur hitting the edge of the image + gutter = math.ceil(blend_amount / 2) if blend_amount > 0 else 0 + max_y, max_x = ia.shape + max_x -= gutter + min_x = gutter + + # Calc the energy in the difference + # Could offer different energy calculations e.g. Sobel or Scharr + energy = np.abs(np.gradient(ia, axis=0)) + np.abs(np.gradient(ia, axis=1)) + + # Find the starting position of the seam + res = np.copy(energy) + for y in range(1, max_y): + row = res[y, :] + rowl = shift(row, -1) + rowr = shift(row, 1) + res[y, :] = res[y - 1, :] + np.min([row, rowl, rowr], axis=0) + + # create an array max_y long + lowest_energy_line = np.empty([max_y], dtype="uint16") + lowest_energy_line[max_y - 1] = np.argmin(res[max_y - 1, min_x : max_x - 1]) + + # Calc the path of the seam + # could offer options for larger search than just 1 pixel by adjusting lpos and rpos + for ypos in range(max_y - 2, -1, -1): + lowest_pos = lowest_energy_line[ypos + 1] + lpos = lowest_pos - 1 + rpos = lowest_pos + 1 + lpos = np.clip(lpos, min_x, max_x - 1) + rpos = np.clip(rpos, min_x, max_x - 1) + lowest_energy_line[ypos] = np.argmin(energy[ypos, lpos : rpos + 1]) + lpos + + # Draw the mask + mask = np.zeros_like(ia) + for ypos in range(0, max_y): + to_fill = lowest_energy_line[ypos] + mask[ypos, :to_fill] = 1 + + # If the seam is on the X-axis rotate the array back + if x_seam: + mask = np.rot90(mask, 3) + + # blur the seam mask if required + if blend_amount > 0: + mask = cv2.blur(mask, (blend_amount, blend_amount)) + + # for visual debugging + # from PIL import Image + # m_image = Image.fromarray((mask * 255.0).astype("uint8")) + + # copy ia2 over ia1 while applying the seam mask + mask = np.expand_dims(mask, -1) + blended_image = ia1 * mask + ia2 * (1.0 - mask) + + # for visual debugging + # i1 = Image.fromarray(ia1.astype("uint8")) + # i2 = Image.fromarray(ia2.astype("uint8")) + # b_image = Image.fromarray(blended_image.astype("uint8")) + # print(f"{ia1.shape}, {ia2.shape}, {mask.shape}, {blended_image.shape}") + # print(f"{i1.size}, {i2.size}, {m_image.size}, {b_image.size}") + + return blended_image diff --git a/invokeai/backend/util/__init__.py b/invokeai/backend/util/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..f24b6db3e1294f5274a404cbfd1f15ed0f3d0d50 --- /dev/null +++ b/invokeai/backend/util/__init__.py @@ -0,0 +1,12 @@ +""" +Initialization file for invokeai.backend.util +""" + +from invokeai.backend.util.logging import InvokeAILogger +from invokeai.backend.util.util import Chdir, directory_size + +__all__ = [ + "directory_size", + "Chdir", + "InvokeAILogger", +] diff --git a/invokeai/backend/util/attention.py b/invokeai/backend/util/attention.py new file mode 100644 index 0000000000000000000000000000000000000000..88dc6e5cec90c6982ee9d02d087f2a7bc0f40880 --- /dev/null +++ b/invokeai/backend/util/attention.py @@ -0,0 +1,33 @@ +# Copyright (c) 2023 Lincoln Stein and the InvokeAI Team +""" +Utility routine used for autodetection of optimal slice size +for attention mechanism. +""" + +import psutil +import torch + + +def auto_detect_slice_size(latents: torch.Tensor) -> str: + bytes_per_element_needed_for_baddbmm_duplication = latents.element_size() + 4 + max_size_required_for_baddbmm = ( + 16 + * latents.size(dim=2) + * latents.size(dim=3) + * latents.size(dim=2) + * latents.size(dim=3) + * bytes_per_element_needed_for_baddbmm_duplication + ) + if latents.device.type in {"cpu", "mps"}: + mem_free = psutil.virtual_memory().free + elif latents.device.type == "cuda": + mem_free, _ = torch.cuda.mem_get_info(latents.device) + else: + raise ValueError(f"unrecognized device {latents.device}") + + if max_size_required_for_baddbmm > (mem_free * 3.0 / 4.0): + return "max" + elif torch.backends.mps.is_available(): + return "max" + else: + return "balanced" diff --git a/invokeai/backend/util/build_line.py b/invokeai/backend/util/build_line.py new file mode 100644 index 0000000000000000000000000000000000000000..77cf98d8df67d28b17259ed9becd0ed676d90dca --- /dev/null +++ b/invokeai/backend/util/build_line.py @@ -0,0 +1,6 @@ +from typing import Callable + + +def build_line(x1: float, y1: float, x2: float, y2: float) -> Callable[[float], float]: + """Build a linear function given two points on the line (x1, y1) and (x2, y2).""" + return lambda x: (y2 - y1) / (x2 - x1) * (x - x1) + y1 diff --git a/invokeai/backend/util/calc_tensor_size.py b/invokeai/backend/util/calc_tensor_size.py new file mode 100644 index 0000000000000000000000000000000000000000..70b99cd8849295f016351f356c21105083f49054 --- /dev/null +++ b/invokeai/backend/util/calc_tensor_size.py @@ -0,0 +1,11 @@ +import torch + + +def calc_tensor_size(t: torch.Tensor) -> int: + """Calculate the size of a tensor in bytes.""" + return t.nelement() * t.element_size() + + +def calc_tensors_size(tensors: list[torch.Tensor | None]) -> int: + """Calculate the size of a list of tensors in bytes.""" + return sum(calc_tensor_size(t) for t in tensors if t is not None) diff --git a/invokeai/backend/util/catch_sigint.py b/invokeai/backend/util/catch_sigint.py new file mode 100644 index 0000000000000000000000000000000000000000..b9735d94f96bcc1e413502a8bc8e53ba8d4dad63 --- /dev/null +++ b/invokeai/backend/util/catch_sigint.py @@ -0,0 +1,29 @@ +""" +This module defines a context manager `catch_sigint()` which temporarily replaces +the sigINT handler defined by the ASGI in order to allow the user to ^C the application +and shut it down immediately. This was implemented in order to allow the user to interrupt +slow model hashing during startup. + +Use like this: + + from invokeai.backend.util.catch_sigint import catch_sigint + with catch_sigint(): + run_some_hard_to_interrupt_process() +""" + +import signal +from contextlib import contextmanager +from typing import Generator + + +def sigint_handler(signum, frame): # type: ignore + signal.signal(signal.SIGINT, signal.SIG_DFL) + signal.raise_signal(signal.SIGINT) + + +@contextmanager +def catch_sigint() -> Generator[None, None, None]: + original_handler = signal.getsignal(signal.SIGINT) + signal.signal(signal.SIGINT, sigint_handler) + yield + signal.signal(signal.SIGINT, original_handler) diff --git a/invokeai/backend/util/db_maintenance.py b/invokeai/backend/util/db_maintenance.py new file mode 100644 index 0000000000000000000000000000000000000000..e7d3432121f9d20d01600930f02de9727f805bbd --- /dev/null +++ b/invokeai/backend/util/db_maintenance.py @@ -0,0 +1,577 @@ +# pylint: disable=line-too-long +# pylint: disable=broad-exception-caught +# pylint: disable=missing-function-docstring +"""Script to peform db maintenance and outputs directory management.""" + +import argparse +import datetime +import enum +import glob +import locale +import os +import shutil +import sqlite3 +from pathlib import Path + +import PIL +import PIL.ImageOps +import PIL.PngImagePlugin +import yaml + + +class ConfigMapper: + """Configuration loader.""" + + def __init__(self): # noqa D107 + pass + + TIMESTAMP_STRING = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + + INVOKE_DIRNAME = "invokeai" + YAML_FILENAME = "invokeai.yaml" + DATABASE_FILENAME = "invokeai.db" + + DEFAULT_OUTDIR = "outputs" + DEFAULT_DB_DIR = "databases" + + database_path = None + database_backup_dir = None + outputs_path = None + archive_path = None + thumbnails_path = None + thumbnails_archive_path = None + + def load(self): + """Read paths from yaml config and validate.""" + root = "." + + if not self.__load_from_root_config(os.path.abspath(root)): + return False + + return True + + def __load_from_root_config(self, invoke_root): + """Validate a yaml path exists, confirm the user wants to use it and load config.""" + yaml_path = os.path.join(invoke_root, self.YAML_FILENAME) + if not os.path.exists(yaml_path): + print(f"Unable to find invokeai.yaml at {yaml_path}!") + return False + if os.path.exists(yaml_path): + db_dir, outdir = self.__load_paths_from_yaml_file(yaml_path) + + if db_dir is None: + db_dir = self.DEFAULT_DB_DIR + print(f"The invokeai.yaml file was found but is missing the db_dir setting! Defaulting to {db_dir}") + if outdir is None: + outdir = self.DEFAULT_OUTDIR + print(f"The invokeai.yaml file was found but is missing the outdir setting! Defaulting to {outdir}") + + if os.path.isabs(db_dir): + self.database_path = os.path.join(db_dir, self.DATABASE_FILENAME) + else: + self.database_path = os.path.join(invoke_root, db_dir, self.DATABASE_FILENAME) + + self.database_backup_dir = os.path.join(os.path.dirname(self.database_path), "backup") + + if os.path.isabs(outdir): + self.outputs_path = os.path.join(outdir, "images") + self.archive_path = os.path.join(outdir, "images-archive") + else: + self.outputs_path = os.path.join(invoke_root, outdir, "images") + self.archive_path = os.path.join(invoke_root, outdir, "images-archive") + + self.thumbnails_path = os.path.join(self.outputs_path, "thumbnails") + self.thumbnails_archive_path = os.path.join(self.archive_path, "thumbnails") + + db_exists = os.path.exists(self.database_path) + outdir_exists = os.path.exists(self.outputs_path) + + text = f"Found {self.YAML_FILENAME} file at {yaml_path}:" + text += f"\n Database : {self.database_path} - {'Exists!' if db_exists else 'Not Found!'}" + text += f"\n Outputs : {self.outputs_path}- {'Exists!' if outdir_exists else 'Not Found!'}" + print(text) + + if db_exists and outdir_exists: + return True + else: + print( + "\nOne or more paths specified in invoke.yaml do not exist. Please inspect/correct the configuration and ensure the script is run in the developer console mode (option 8) from an Invoke AI root directory." + ) + return False + else: + print( + f"Auto-discovery of configuration failed! Could not find ({yaml_path})!\n\nPlease ensure the script is run in the developer console mode (option 8) from an Invoke AI root directory." + ) + return False + + def __load_paths_from_yaml_file(self, yaml_path): + """Load an Invoke AI yaml file and get the database and outputs paths.""" + try: + with open(yaml_path, "rt", encoding=locale.getpreferredencoding()) as file: + yamlinfo = yaml.safe_load(file) + db_dir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("db_dir", None) + outdir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("outdir", None) + return db_dir, outdir + except Exception: + print(f"Failed to load paths from yaml file! {yaml_path}!") + return None, None + + +class MaintenanceStats: + """DTO for tracking work progress.""" + + def __init__(self): # noqa D107 + pass + + time_start = datetime.datetime.utcnow() + count_orphaned_db_entries_cleaned = 0 + count_orphaned_disk_files_cleaned = 0 + count_orphaned_thumbnails_cleaned = 0 + count_thumbnails_regenerated = 0 + count_errors = 0 + + @staticmethod + def get_elapsed_time_string(): + """Get a friendly time string for the time elapsed since processing start.""" + time_now = datetime.datetime.utcnow() + total_seconds = (time_now - MaintenanceStats.time_start).total_seconds() + hours = int((total_seconds) / 3600) + minutes = int(((total_seconds) % 3600) / 60) + seconds = total_seconds % 60 + out_str = f"{hours} hour(s) -" if hours > 0 else "" + out_str += f"{minutes} minute(s) -" if minutes > 0 else "" + out_str += f"{seconds:.2f} second(s)" + return out_str + + +class DatabaseMapper: + """Class to abstract database functionality.""" + + def __init__(self, database_path, database_backup_dir): # noqa D107 + self.database_path = database_path + self.database_backup_dir = database_backup_dir + self.connection = None + self.cursor = None + + def backup(self, timestamp_string): + """Take a backup of the database.""" + if not os.path.exists(self.database_backup_dir): + print(f"Database backup directory {self.database_backup_dir} does not exist -> creating...", end="") + os.makedirs(self.database_backup_dir) + print("Done!") + database_backup_path = os.path.join(self.database_backup_dir, f"backup-{timestamp_string}-invokeai.db") + print(f"Making DB Backup at {database_backup_path}...", end="") + shutil.copy2(self.database_path, database_backup_path) + print("Done!") + + def connect(self): + """Open connection to the database.""" + self.connection = sqlite3.connect(self.database_path) + self.cursor = self.connection.cursor() + + def get_all_image_files(self): + """Get the full list of image file names from the database.""" + sql_get_image_by_name = "SELECT image_name FROM images" + self.cursor.execute(sql_get_image_by_name) + rows = self.cursor.fetchall() + db_files = [] + for row in rows: + db_files.append(row[0]) + return db_files + + def remove_image_file_record(self, filename: str): + """Remove an image file reference from the database by filename.""" + sanitized_filename = str.replace(filename, "'", "''") # prevent injection + sql_command = f"DELETE FROM images WHERE image_name='{sanitized_filename}'" + self.cursor.execute(sql_command) + self.connection.commit() + + def does_image_exist(self, image_filename): + """Check database if a image name already exists and return a boolean.""" + sanitized_filename = str.replace(image_filename, "'", "''") # prevent injection + sql_get_image_by_name = f"SELECT image_name FROM images WHERE image_name='{sanitized_filename}'" + self.cursor.execute(sql_get_image_by_name) + rows = self.cursor.fetchall() + return True if len(rows) > 0 else False + + def disconnect(self): + """Disconnect from the db, cleaning up connections and cursors.""" + if self.cursor is not None: + self.cursor.close() + if self.connection is not None: + self.connection.close() + + +class PhysicalFileMapper: + """Containing class for script functionality.""" + + def __init__(self, outputs_path, thumbnails_path, archive_path, thumbnails_archive_path): # noqa D107 + self.outputs_path = outputs_path + self.archive_path = archive_path + self.thumbnails_path = thumbnails_path + self.thumbnails_archive_path = thumbnails_archive_path + + def create_archive_directories(self): + """Create the directory for archiving orphaned image files.""" + if not os.path.exists(self.archive_path): + print(f"Image archive directory ({self.archive_path}) does not exist -> creating...", end="") + os.makedirs(self.archive_path) + print("Created!") + if not os.path.exists(self.thumbnails_archive_path): + print( + f"Image thumbnails archive directory ({self.thumbnails_archive_path}) does not exist -> creating...", + end="", + ) + os.makedirs(self.thumbnails_archive_path) + print("Created!") + + def get_image_path_for_image_name(self, image_filename): # noqa D102 + return os.path.join(self.outputs_path, image_filename) + + def image_file_exists(self, image_filename): # noqa D102 + return os.path.exists(self.get_image_path_for_image_name(image_filename)) + + def get_thumbnail_path_for_image(self, image_filename): # noqa D102 + return os.path.join(self.thumbnails_path, os.path.splitext(image_filename)[0]) + ".webp" + + def get_image_name_from_thumbnail_path(self, thumbnail_path): # noqa D102 + return os.path.splitext(os.path.basename(thumbnail_path))[0] + ".png" + + def thumbnail_exists_for_filename(self, image_filename): # noqa D102 + return os.path.exists(self.get_thumbnail_path_for_image(image_filename)) + + def archive_image(self, image_filename): # noqa D102 + if self.image_file_exists(image_filename): + image_path = self.get_image_path_for_image_name(image_filename) + shutil.move(image_path, self.archive_path) + + def archive_thumbnail_by_image_filename(self, image_filename): # noqa D102 + if self.thumbnail_exists_for_filename(image_filename): + thumbnail_path = self.get_thumbnail_path_for_image(image_filename) + shutil.move(thumbnail_path, self.thumbnails_archive_path) + + def get_all_png_filenames_in_directory(self, directory_path): # noqa D102 + filepaths = glob.glob(directory_path + "/*.png", recursive=False) + filenames = [] + for filepath in filepaths: + filenames.append(os.path.basename(filepath)) + return filenames + + def get_all_thumbnails_with_full_path(self, thumbnails_directory): # noqa D102 + return glob.glob(thumbnails_directory + "/*.webp", recursive=False) + + def generate_thumbnail_for_image_name(self, image_filename): # noqa D102 + # create thumbnail + file_path = self.get_image_path_for_image_name(image_filename) + thumb_path = self.get_thumbnail_path_for_image(image_filename) + thumb_size = 256, 256 + with PIL.Image.open(file_path) as source_image: + source_image.thumbnail(thumb_size) + source_image.save(thumb_path, "webp") + + +class MaintenanceOperation(str, enum.Enum): + """Enum class for operations.""" + + Ask = "ask" + CleanOrphanedDbEntries = "clean" + CleanOrphanedDiskFiles = "archive" + ReGenerateThumbnails = "thumbnails" + All = "all" + + +class InvokeAIDatabaseMaintenanceApp: + """Main processor class for the application.""" + + _operation: MaintenanceOperation + _headless: bool = False + __stats: MaintenanceStats = MaintenanceStats() + + def __init__(self, operation: MaintenanceOperation = MaintenanceOperation.Ask): + """Initialize maintenance app.""" + self._operation = MaintenanceOperation(operation) + self._headless = operation != MaintenanceOperation.Ask + + def ask_for_operation(self) -> MaintenanceOperation: + """Ask user to choose the operation to perform.""" + while True: + print() + print("It is recommennded to run these operations as ordered below to avoid additional") + print("work being performed that will be discarded in a subsequent step.") + print() + print("Select maintenance operation:") + print() + print("1) Clean Orphaned Database Image Entries") + print(" Cleans entries in the database where the matching file was removed from") + print(" the outputs directory.") + print("2) Archive Orphaned Image Files") + print(" Files found in the outputs directory without an entry in the database are") + print(" moved to an archive directory.") + print("3) Re-Generate Missing Thumbnail Files") + print(" For files found in the outputs directory, re-generate a thumbnail if it") + print(" not found in the thumbnails directory.") + print() + print("(CTRL-C to quit)") + + try: + input_option = int(input("Specify desired operation number (1-3): ")) + + operations = [ + MaintenanceOperation.CleanOrphanedDbEntries, + MaintenanceOperation.CleanOrphanedDiskFiles, + MaintenanceOperation.ReGenerateThumbnails, + ] + return operations[input_option - 1] + except (IndexError, ValueError): + print("\nInvalid selection!") + + def ask_to_continue(self) -> bool: + """Ask user whether they want to continue with the operation.""" + while True: + input_choice = input("Do you wish to continue? (Y or N)? ") + if str.lower(input_choice) == "y": + return True + if str.lower(input_choice) == "n": + return False + + def clean_orphaned_db_entries( + self, config: ConfigMapper, file_mapper: PhysicalFileMapper, db_mapper: DatabaseMapper + ): + """Clean dangling database entries that no longer point to a file in outputs.""" + if self._headless: + print(f"Removing database references to images that no longer exist in {config.outputs_path}...") + else: + print() + print("===============================================================================") + print("= Clean Orphaned Database Entries") + print() + print("Perform this operation if you have removed files from the outputs/images") + print("directory but the database was never updated. You may see this as empty imaages") + print("in the app gallery, or images that only show an enlarged version of the") + print("thumbnail.") + print() + print(f"Database File Path : {config.database_path}") + print(f"Database backup will be taken at : {config.database_backup_dir}") + print(f"Outputs/Images Directory : {config.outputs_path}") + print(f"Outputs/Images Archive Directory : {config.archive_path}") + + print("\nNotes about this operation:") + print("- This operation will find database image file entries that do not exist in the") + print(" outputs/images dir and remove those entries from the database.") + print("- This operation will target all image types including intermediate files.") + print("- If a thumbnail still exists in outputs/images/thumbnails matching the") + print(" orphaned entry, it will be moved to the archive directory.") + print() + + if not self.ask_to_continue(): + raise KeyboardInterrupt + + file_mapper.create_archive_directories() + db_mapper.backup(config.TIMESTAMP_STRING) + db_mapper.connect() + db_files = db_mapper.get_all_image_files() + for db_file in db_files: + try: + if not file_mapper.image_file_exists(db_file): + print(f"Found orphaned image db entry {db_file}. Cleaning ...", end="") + db_mapper.remove_image_file_record(db_file) + print("Cleaned!") + if file_mapper.thumbnail_exists_for_filename(db_file): + print("A thumbnail was found, archiving ...", end="") + file_mapper.archive_thumbnail_by_image_filename(db_file) + print("Archived!") + self.__stats.count_orphaned_db_entries_cleaned += 1 + except Exception as ex: + print("An error occurred cleaning db entry, error was:") + print(ex) + self.__stats.count_errors += 1 + + def clean_orphaned_disk_files( + self, config: ConfigMapper, file_mapper: PhysicalFileMapper, db_mapper: DatabaseMapper + ): + """Archive image files that no longer have entries in the database.""" + if self._headless: + print(f"Archiving orphaned image files to {config.archive_path}...") + else: + print() + print("===============================================================================") + print("= Clean Orphaned Disk Files") + print() + print("Perform this operation if you have files that were copied into the outputs") + print("directory which are not referenced by the database. This can happen if you") + print("upgraded to a version with a fresh database, but re-used the outputs directory") + print("and now new images are mixed with the files not in the db. The script will") + print("archive these files so you can choose to delete them or re-import using the") + print("official import script.") + print() + print(f"Database File Path : {config.database_path}") + print(f"Database backup will be taken at : {config.database_backup_dir}") + print(f"Outputs/Images Directory : {config.outputs_path}") + print(f"Outputs/Images Archive Directory : {config.archive_path}") + + print("\nNotes about this operation:") + print("- This operation will find image files not referenced by the database and move to an") + print(" archive directory.") + print("- This operation will target all image types including intermediate references.") + print("- The matching thumbnail will also be archived.") + print("- Any remaining orphaned thumbnails will also be archived.") + + if not self.ask_to_continue(): + raise KeyboardInterrupt + + print() + + file_mapper.create_archive_directories() + db_mapper.backup(config.TIMESTAMP_STRING) + db_mapper.connect() + phys_files = file_mapper.get_all_png_filenames_in_directory(config.outputs_path) + for phys_file in phys_files: + try: + if not db_mapper.does_image_exist(phys_file): + print(f"Found orphaned file {phys_file}, archiving...", end="") + file_mapper.archive_image(phys_file) + print("Archived!") + if file_mapper.thumbnail_exists_for_filename(phys_file): + print("Related thumbnail exists, archiving...", end="") + file_mapper.archive_thumbnail_by_image_filename(phys_file) + print("Archived!") + else: + print("No matching thumbnail existed to be cleaned.") + self.__stats.count_orphaned_disk_files_cleaned += 1 + except Exception as ex: + print("Error found trying to archive file or thumbnail, error was:") + print(ex) + self.__stats.count_errors += 1 + + thumb_filepaths = file_mapper.get_all_thumbnails_with_full_path(config.thumbnails_path) + # archive any remaining orphaned thumbnails + for thumb_filepath in thumb_filepaths: + try: + thumb_src_image_name = file_mapper.get_image_name_from_thumbnail_path(thumb_filepath) + if not file_mapper.image_file_exists(thumb_src_image_name): + print(f"Found orphaned thumbnail {thumb_filepath}, archiving...", end="") + file_mapper.archive_thumbnail_by_image_filename(thumb_src_image_name) + print("Archived!") + self.__stats.count_orphaned_thumbnails_cleaned += 1 + except Exception as ex: + print("Error found trying to archive thumbnail, error was:") + print(ex) + self.__stats.count_errors += 1 + + def regenerate_thumbnails(self, config: ConfigMapper, file_mapper: PhysicalFileMapper, *args): + """Create missing thumbnails for any valid general images both in the db and on disk.""" + if self._headless: + print("Regenerating missing image thumbnails...") + else: + print() + print("===============================================================================") + print("= Regenerate Thumbnails") + print() + print("This operation will find files that have no matching thumbnail on disk") + print("and regenerate those thumbnail files.") + print("NOTE: It is STRONGLY recommended that the user first clean/archive orphaned") + print(" disk files from the previous menu to avoid wasting time regenerating") + print(" thumbnails for orphaned files.") + + print() + print(f"Outputs/Images Directory : {config.outputs_path}") + print(f"Outputs/Images Directory : {config.thumbnails_path}") + + print("\nNotes about this operation:") + print("- This operation will find image files both referenced in the db and on disk") + print(" that do not have a matching thumbnail on disk and re-generate the thumbnail") + print(" file.") + + if not self.ask_to_continue(): + raise KeyboardInterrupt + + print() + + phys_files = file_mapper.get_all_png_filenames_in_directory(config.outputs_path) + for phys_file in phys_files: + try: + if not file_mapper.thumbnail_exists_for_filename(phys_file): + print(f"Found file without thumbnail {phys_file}...Regenerating Thumbnail...", end="") + file_mapper.generate_thumbnail_for_image_name(phys_file) + print("Done!") + self.__stats.count_thumbnails_regenerated += 1 + except Exception as ex: + print("Error found trying to regenerate thumbnail, error was:") + print(ex) + self.__stats.count_errors += 1 + + def main(self): # noqa D107 + print("\n===============================================================================") + print("Database and outputs Maintenance for Invoke AI 3.0.0 +") + print("===============================================================================\n") + + config_mapper = ConfigMapper() + if not config_mapper.load(): + print("\nInvalid configuration...exiting.\n") + return + + file_mapper = PhysicalFileMapper( + config_mapper.outputs_path, + config_mapper.thumbnails_path, + config_mapper.archive_path, + config_mapper.thumbnails_archive_path, + ) + db_mapper = DatabaseMapper(config_mapper.database_path, config_mapper.database_backup_dir) + + op = self._operation + operations_to_perform = [] + + if op == MaintenanceOperation.Ask: + op = self.ask_for_operation() + + if op in [MaintenanceOperation.CleanOrphanedDbEntries, MaintenanceOperation.All]: + operations_to_perform.append(self.clean_orphaned_db_entries) + if op in [MaintenanceOperation.CleanOrphanedDiskFiles, MaintenanceOperation.All]: + operations_to_perform.append(self.clean_orphaned_disk_files) + if op in [MaintenanceOperation.ReGenerateThumbnails, MaintenanceOperation.All]: + operations_to_perform.append(self.regenerate_thumbnails) + + for operation in operations_to_perform: + operation(config_mapper, file_mapper, db_mapper) + + print("\n===============================================================================") + print(f"= Maintenance Complete - Elapsed Time: {MaintenanceStats.get_elapsed_time_string()}") + print() + print(f"Orphaned db entries cleaned : {self.__stats.count_orphaned_db_entries_cleaned}") + print(f"Orphaned disk files archived : {self.__stats.count_orphaned_disk_files_cleaned}") + print(f"Orphaned thumbnail files archived : {self.__stats.count_orphaned_thumbnails_cleaned}") + print(f"Thumbnails regenerated : {self.__stats.count_thumbnails_regenerated}") + print(f"Errors during operation : {self.__stats.count_errors}") + + print() + + +def main(): # noqa D107 + parser = argparse.ArgumentParser( + description="InvokeAI image database maintenance utility", + formatter_class=argparse.RawDescriptionHelpFormatter, + epilog="""Operations: + ask Choose operation from a menu [default] + all Run all maintenance operations + clean Clean database of dangling entries + archive Archive orphaned image files + thumbnails Regenerate missing image thumbnails +""", + ) + parser.add_argument("--root", default=".", type=Path, help="InvokeAI root directory") + parser.add_argument( + "--operation", default="ask", choices=[x.value for x in MaintenanceOperation], help="Operation to perform." + ) + args = parser.parse_args() + try: + os.chdir(args.root) + app = InvokeAIDatabaseMaintenanceApp(args.operation) + app.main() + except KeyboardInterrupt: + print("\n\nUser cancelled execution.") + except FileNotFoundError: + print(f"Invalid root directory '{args.root}'.") + + +if __name__ == "__main__": + main() diff --git a/invokeai/backend/util/devices.py b/invokeai/backend/util/devices.py new file mode 100644 index 0000000000000000000000000000000000000000..83ce055024ffbddcd9ce6a8843b4edda27ac7928 --- /dev/null +++ b/invokeai/backend/util/devices.py @@ -0,0 +1,114 @@ +from typing import Dict, Literal, Optional, Union + +import torch +from deprecated import deprecated + +from invokeai.app.services.config.config_default import get_config + +# legacy APIs +TorchPrecisionNames = Literal["float32", "float16", "bfloat16"] +CPU_DEVICE = torch.device("cpu") +CUDA_DEVICE = torch.device("cuda") +MPS_DEVICE = torch.device("mps") + + +@deprecated("Use TorchDevice.choose_torch_dtype() instead.") # type: ignore +def choose_precision(device: torch.device) -> TorchPrecisionNames: + """Return the string representation of the recommended torch device.""" + torch_dtype = TorchDevice.choose_torch_dtype(device) + return PRECISION_TO_NAME[torch_dtype] + + +@deprecated("Use TorchDevice.choose_torch_device() instead.") # type: ignore +def choose_torch_device() -> torch.device: + """Return the torch.device to use for accelerated inference.""" + return TorchDevice.choose_torch_device() + + +@deprecated("Use TorchDevice.choose_torch_dtype() instead.") # type: ignore +def torch_dtype(device: torch.device) -> torch.dtype: + """Return the torch precision for the recommended torch device.""" + return TorchDevice.choose_torch_dtype(device) + + +NAME_TO_PRECISION: Dict[TorchPrecisionNames, torch.dtype] = { + "float32": torch.float32, + "float16": torch.float16, + "bfloat16": torch.bfloat16, +} +PRECISION_TO_NAME: Dict[torch.dtype, TorchPrecisionNames] = {v: k for k, v in NAME_TO_PRECISION.items()} + + +class TorchDevice: + """Abstraction layer for torch devices.""" + + CPU_DEVICE = torch.device("cpu") + CUDA_DEVICE = torch.device("cuda") + MPS_DEVICE = torch.device("mps") + + @classmethod + def choose_torch_device(cls) -> torch.device: + """Return the torch.device to use for accelerated inference.""" + app_config = get_config() + if app_config.device != "auto": + device = torch.device(app_config.device) + elif torch.cuda.is_available(): + device = CUDA_DEVICE + elif torch.backends.mps.is_available(): + device = MPS_DEVICE + else: + device = CPU_DEVICE + return cls.normalize(device) + + @classmethod + def choose_torch_dtype(cls, device: Optional[torch.device] = None) -> torch.dtype: + """Return the precision to use for accelerated inference.""" + device = device or cls.choose_torch_device() + config = get_config() + if device.type == "cuda" and torch.cuda.is_available(): + device_name = torch.cuda.get_device_name(device) + if "GeForce GTX 1660" in device_name or "GeForce GTX 1650" in device_name: + # These GPUs have limited support for float16 + return cls._to_dtype("float32") + elif config.precision == "auto": + # Default to float16 for CUDA devices + return cls._to_dtype("float16") + else: + # Use the user-defined precision + return cls._to_dtype(config.precision) + + elif device.type == "mps" and torch.backends.mps.is_available(): + if config.precision == "auto": + # Default to float16 for MPS devices + return cls._to_dtype("float16") + else: + # Use the user-defined precision + return cls._to_dtype(config.precision) + # CPU / safe fallback + return cls._to_dtype("float32") + + @classmethod + def get_torch_device_name(cls) -> str: + """Return the device name for the current torch device.""" + device = cls.choose_torch_device() + return torch.cuda.get_device_name(device) if device.type == "cuda" else device.type.upper() + + @classmethod + def normalize(cls, device: Union[str, torch.device]) -> torch.device: + """Add the device index to CUDA devices.""" + device = torch.device(device) + if device.index is None and device.type == "cuda" and torch.cuda.is_available(): + device = torch.device(device.type, torch.cuda.current_device()) + return device + + @classmethod + def empty_cache(cls) -> None: + """Clear the GPU device cache.""" + if torch.backends.mps.is_available(): + torch.mps.empty_cache() + if torch.cuda.is_available(): + torch.cuda.empty_cache() + + @classmethod + def _to_dtype(cls, precision_name: TorchPrecisionNames) -> torch.dtype: + return NAME_TO_PRECISION[precision_name] diff --git a/invokeai/backend/util/hotfixes.py b/invokeai/backend/util/hotfixes.py new file mode 100644 index 0000000000000000000000000000000000000000..95f2c904ad892281910a78686f80bee6e6ee0f70 --- /dev/null +++ b/invokeai/backend/util/hotfixes.py @@ -0,0 +1,841 @@ +from typing import Any, Dict, List, Optional, Tuple, Union + +import diffusers +import torch +from diffusers.configuration_utils import ConfigMixin, register_to_config +from diffusers.loaders.single_file_model import FromOriginalModelMixin +from diffusers.models.attention_processor import AttentionProcessor, AttnProcessor +from diffusers.models.controlnet import ControlNetConditioningEmbedding, ControlNetOutput, zero_module +from diffusers.models.embeddings import ( + TextImageProjection, + TextImageTimeEmbedding, + TextTimeEmbedding, + TimestepEmbedding, + Timesteps, +) +from diffusers.models.modeling_utils import ModelMixin +from diffusers.models.unets.unet_2d_blocks import ( + CrossAttnDownBlock2D, + DownBlock2D, + UNetMidBlock2DCrossAttn, + get_down_block, +) +from diffusers.models.unets.unet_2d_condition import UNet2DConditionModel +from torch import nn + +from invokeai.backend.util.logging import InvokeAILogger + +# TODO: create PR to diffusers +# Modified ControlNetModel with encoder_attention_mask argument added + + +logger = InvokeAILogger.get_logger(__name__) + + +# NOTE(ryand): I'm not the origina author of this code, but for future reference, it appears that this class was copied +# from diffusers in order to add support for the encoder_attention_mask argument. +class ControlNetModel(ModelMixin, ConfigMixin, FromOriginalModelMixin): + """ + A ControlNet model. + + Args: + in_channels (`int`, defaults to 4): + The number of channels in the input sample. + flip_sin_to_cos (`bool`, defaults to `True`): + Whether to flip the sin to cos in the time embedding. + freq_shift (`int`, defaults to 0): + The frequency shift to apply to the time embedding. + down_block_types (`tuple[str]`, defaults to `("CrossAttnDownBlock2D", "CrossAttnDownBlock2D", \ + "CrossAttnDownBlock2D", "DownBlock2D")`): + The tuple of downsample blocks to use. + only_cross_attention (`Union[bool, Tuple[bool]]`, defaults to `False`): + block_out_channels (`tuple[int]`, defaults to `(320, 640, 1280, 1280)`): + The tuple of output channels for each block. + layers_per_block (`int`, defaults to 2): + The number of layers per block. + downsample_padding (`int`, defaults to 1): + The padding to use for the downsampling convolution. + mid_block_scale_factor (`float`, defaults to 1): + The scale factor to use for the mid block. + act_fn (`str`, defaults to "silu"): + The activation function to use. + norm_num_groups (`int`, *optional*, defaults to 32): + The number of groups to use for the normalization. If None, normalization and activation layers is skipped + in post-processing. + norm_eps (`float`, defaults to 1e-5): + The epsilon to use for the normalization. + cross_attention_dim (`int`, defaults to 1280): + The dimension of the cross attention features. + transformer_layers_per_block (`int` or `Tuple[int]`, *optional*, defaults to 1): + The number of transformer blocks of type [`~models.attention.BasicTransformerBlock`]. Only relevant for + [`~models.unet_2d_blocks.CrossAttnDownBlock2D`], [`~models.unet_2d_blocks.CrossAttnUpBlock2D`], + [`~models.unet_2d_blocks.UNetMidBlock2DCrossAttn`]. + encoder_hid_dim (`int`, *optional*, defaults to None): + If `encoder_hid_dim_type` is defined, `encoder_hidden_states` will be projected from `encoder_hid_dim` + dimension to `cross_attention_dim`. + encoder_hid_dim_type (`str`, *optional*, defaults to `None`): + If given, the `encoder_hidden_states` and potentially other embeddings are down-projected to text + embeddings of dimension `cross_attention` according to `encoder_hid_dim_type`. + attention_head_dim (`Union[int, Tuple[int]]`, defaults to 8): + The dimension of the attention heads. + use_linear_projection (`bool`, defaults to `False`): + class_embed_type (`str`, *optional*, defaults to `None`): + The type of class embedding to use which is ultimately summed with the time embeddings. Choose from None, + `"timestep"`, `"identity"`, `"projection"`, or `"simple_projection"`. + addition_embed_type (`str`, *optional*, defaults to `None`): + Configures an optional embedding which will be summed with the time embeddings. Choose from `None` or + "text". "text" will use the `TextTimeEmbedding` layer. + num_class_embeds (`int`, *optional*, defaults to 0): + Input dimension of the learnable embedding matrix to be projected to `time_embed_dim`, when performing + class conditioning with `class_embed_type` equal to `None`. + upcast_attention (`bool`, defaults to `False`): + resnet_time_scale_shift (`str`, defaults to `"default"`): + Time scale shift config for ResNet blocks (see `ResnetBlock2D`). Choose from `default` or `scale_shift`. + projection_class_embeddings_input_dim (`int`, *optional*, defaults to `None`): + The dimension of the `class_labels` input when `class_embed_type="projection"`. Required when + `class_embed_type="projection"`. + controlnet_conditioning_channel_order (`str`, defaults to `"rgb"`): + The channel order of conditional image. Will convert to `rgb` if it's `bgr`. + conditioning_embedding_out_channels (`tuple[int]`, *optional*, defaults to `(16, 32, 96, 256)`): + The tuple of output channel for each block in the `conditioning_embedding` layer. + global_pool_conditions (`bool`, defaults to `False`): + """ + + _supports_gradient_checkpointing = True + + @register_to_config + def __init__( + self, + in_channels: int = 4, + conditioning_channels: int = 3, + flip_sin_to_cos: bool = True, + freq_shift: int = 0, + down_block_types: Tuple[str] = ( + "CrossAttnDownBlock2D", + "CrossAttnDownBlock2D", + "CrossAttnDownBlock2D", + "DownBlock2D", + ), + only_cross_attention: Union[bool, Tuple[bool]] = False, + block_out_channels: Tuple[int, ...] = (320, 640, 1280, 1280), + layers_per_block: int = 2, + downsample_padding: int = 1, + mid_block_scale_factor: float = 1, + act_fn: str = "silu", + norm_num_groups: Optional[int] = 32, + norm_eps: float = 1e-5, + cross_attention_dim: int = 1280, + transformer_layers_per_block: Union[int, Tuple[int]] = 1, + encoder_hid_dim: Optional[int] = None, + encoder_hid_dim_type: Optional[str] = None, + attention_head_dim: Union[int, Tuple[int]] = 8, + num_attention_heads: Optional[Union[int, Tuple[int]]] = None, + use_linear_projection: bool = False, + class_embed_type: Optional[str] = None, + addition_embed_type: Optional[str] = None, + addition_time_embed_dim: Optional[int] = None, + num_class_embeds: Optional[int] = None, + upcast_attention: bool = False, + resnet_time_scale_shift: str = "default", + projection_class_embeddings_input_dim: Optional[int] = None, + controlnet_conditioning_channel_order: str = "rgb", + conditioning_embedding_out_channels: Optional[Tuple[int]] = (16, 32, 96, 256), + global_pool_conditions: bool = False, + addition_embed_type_num_heads=64, + ): + super().__init__() + + # If `num_attention_heads` is not defined (which is the case for most models) + # it will default to `attention_head_dim`. This looks weird upon first reading it and it is. + # The reason for this behavior is to correct for incorrectly named variables that were introduced + # when this library was created... + # The incorrect naming was only discovered much ... + # later in https://github.com/huggingface/diffusers/issues/2011#issuecomment-1547958131 + # Changing `attention_head_dim` to `num_attention_heads` for 40,000+ configurations is too backwards breaking + # which is why we correct for the naming here. + num_attention_heads = num_attention_heads or attention_head_dim + + # Check inputs + if len(block_out_channels) != len(down_block_types): + raise ValueError( + f"Must provide the same number of `block_out_channels` as `down_block_types`. \ + `block_out_channels`: {block_out_channels}. `down_block_types`: {down_block_types}." + ) + + if not isinstance(only_cross_attention, bool) and len(only_cross_attention) != len(down_block_types): + raise ValueError( + f"Must provide the same number of `only_cross_attention` as `down_block_types`. \ + `only_cross_attention`: {only_cross_attention}. `down_block_types`: {down_block_types}." + ) + + if not isinstance(num_attention_heads, int) and len(num_attention_heads) != len(down_block_types): + raise ValueError( + f"Must provide the same number of `num_attention_heads` as `down_block_types`. \ + `num_attention_heads`: {num_attention_heads}. `down_block_types`: {down_block_types}." + ) + + if isinstance(transformer_layers_per_block, int): + transformer_layers_per_block = [transformer_layers_per_block] * len(down_block_types) + + # input + conv_in_kernel = 3 + conv_in_padding = (conv_in_kernel - 1) // 2 + self.conv_in = nn.Conv2d( + in_channels, block_out_channels[0], kernel_size=conv_in_kernel, padding=conv_in_padding + ) + + # time + time_embed_dim = block_out_channels[0] * 4 + self.time_proj = Timesteps(block_out_channels[0], flip_sin_to_cos, freq_shift) + timestep_input_dim = block_out_channels[0] + self.time_embedding = TimestepEmbedding( + timestep_input_dim, + time_embed_dim, + act_fn=act_fn, + ) + + if encoder_hid_dim_type is None and encoder_hid_dim is not None: + encoder_hid_dim_type = "text_proj" + self.register_to_config(encoder_hid_dim_type=encoder_hid_dim_type) + logger.info("encoder_hid_dim_type defaults to 'text_proj' as `encoder_hid_dim` is defined.") + + if encoder_hid_dim is None and encoder_hid_dim_type is not None: + raise ValueError( + f"`encoder_hid_dim` has to be defined when `encoder_hid_dim_type` is set to {encoder_hid_dim_type}." + ) + + if encoder_hid_dim_type == "text_proj": + self.encoder_hid_proj = nn.Linear(encoder_hid_dim, cross_attention_dim) + elif encoder_hid_dim_type == "text_image_proj": + # image_embed_dim DOESN'T have to be `cross_attention_dim`. To not clutter the __init__ too much + # they are set to `cross_attention_dim` here as this is exactly the required dimension ... + # for the currently only use + # case when `addition_embed_type == "text_image_proj"` (Kadinsky 2.1)` + self.encoder_hid_proj = TextImageProjection( + text_embed_dim=encoder_hid_dim, + image_embed_dim=cross_attention_dim, + cross_attention_dim=cross_attention_dim, + ) + + elif encoder_hid_dim_type is not None: + raise ValueError( + f"encoder_hid_dim_type: {encoder_hid_dim_type} must be None, 'text_proj' or 'text_image_proj'." + ) + else: + self.encoder_hid_proj = None + + # class embedding + if class_embed_type is None and num_class_embeds is not None: + self.class_embedding = nn.Embedding(num_class_embeds, time_embed_dim) + elif class_embed_type == "timestep": + self.class_embedding = TimestepEmbedding(timestep_input_dim, time_embed_dim) + elif class_embed_type == "identity": + self.class_embedding = nn.Identity(time_embed_dim, time_embed_dim) + elif class_embed_type == "projection": + if projection_class_embeddings_input_dim is None: + raise ValueError( + "`class_embed_type`: 'projection' requires `projection_class_embeddings_input_dim` be set" + ) + # The projection `class_embed_type` is the same as the timestep `class_embed_type` except + # 1. the `class_labels` inputs are not first converted to sinusoidal embeddings + # 2. it projects from an arbitrary input dimension. + # + # Note that `TimestepEmbedding` is quite general, being mainly linear layers and activations. + # When used for embedding actual timesteps, the timesteps are first converted to sinusoidal embeddings. + # As a result, `TimestepEmbedding` can be passed arbitrary vectors. + self.class_embedding = TimestepEmbedding(projection_class_embeddings_input_dim, time_embed_dim) + else: + self.class_embedding = None + + if addition_embed_type == "text": + if encoder_hid_dim is not None: + text_time_embedding_from_dim = encoder_hid_dim + else: + text_time_embedding_from_dim = cross_attention_dim + + self.add_embedding = TextTimeEmbedding( + text_time_embedding_from_dim, time_embed_dim, num_heads=addition_embed_type_num_heads + ) + elif addition_embed_type == "text_image": + # text_embed_dim and image_embed_dim DON'T have to be `cross_attention_dim`. + # To not clutter the __init__ too much + # they are set to `cross_attention_dim` here as this is exactly the required dimension... + # for the currently only use + # case when `addition_embed_type == "text_image"` (Kadinsky 2.1)` + self.add_embedding = TextImageTimeEmbedding( + text_embed_dim=cross_attention_dim, image_embed_dim=cross_attention_dim, time_embed_dim=time_embed_dim + ) + elif addition_embed_type == "text_time": + self.add_time_proj = Timesteps(addition_time_embed_dim, flip_sin_to_cos, freq_shift) + self.add_embedding = TimestepEmbedding(projection_class_embeddings_input_dim, time_embed_dim) + + elif addition_embed_type is not None: + raise ValueError(f"addition_embed_type: {addition_embed_type} must be None, 'text' or 'text_image'.") + + # control net conditioning embedding + self.controlnet_cond_embedding = ControlNetConditioningEmbedding( + conditioning_embedding_channels=block_out_channels[0], + block_out_channels=conditioning_embedding_out_channels, + conditioning_channels=conditioning_channels, + ) + + self.down_blocks = nn.ModuleList([]) + self.controlnet_down_blocks = nn.ModuleList([]) + + if isinstance(only_cross_attention, bool): + only_cross_attention = [only_cross_attention] * len(down_block_types) + + if isinstance(attention_head_dim, int): + attention_head_dim = (attention_head_dim,) * len(down_block_types) + + if isinstance(num_attention_heads, int): + num_attention_heads = (num_attention_heads,) * len(down_block_types) + + # down + output_channel = block_out_channels[0] + + controlnet_block = nn.Conv2d(output_channel, output_channel, kernel_size=1) + controlnet_block = zero_module(controlnet_block) + self.controlnet_down_blocks.append(controlnet_block) + + for i, down_block_type in enumerate(down_block_types): + input_channel = output_channel + output_channel = block_out_channels[i] + is_final_block = i == len(block_out_channels) - 1 + + down_block = get_down_block( + down_block_type, + num_layers=layers_per_block, + transformer_layers_per_block=transformer_layers_per_block[i], + in_channels=input_channel, + out_channels=output_channel, + temb_channels=time_embed_dim, + add_downsample=not is_final_block, + resnet_eps=norm_eps, + resnet_act_fn=act_fn, + resnet_groups=norm_num_groups, + cross_attention_dim=cross_attention_dim, + num_attention_heads=num_attention_heads[i], + attention_head_dim=attention_head_dim[i] if attention_head_dim[i] is not None else output_channel, + downsample_padding=downsample_padding, + use_linear_projection=use_linear_projection, + only_cross_attention=only_cross_attention[i], + upcast_attention=upcast_attention, + resnet_time_scale_shift=resnet_time_scale_shift, + ) + self.down_blocks.append(down_block) + + for _ in range(layers_per_block): + controlnet_block = nn.Conv2d(output_channel, output_channel, kernel_size=1) + controlnet_block = zero_module(controlnet_block) + self.controlnet_down_blocks.append(controlnet_block) + + if not is_final_block: + controlnet_block = nn.Conv2d(output_channel, output_channel, kernel_size=1) + controlnet_block = zero_module(controlnet_block) + self.controlnet_down_blocks.append(controlnet_block) + + # mid + mid_block_channel = block_out_channels[-1] + + controlnet_block = nn.Conv2d(mid_block_channel, mid_block_channel, kernel_size=1) + controlnet_block = zero_module(controlnet_block) + self.controlnet_mid_block = controlnet_block + + self.mid_block = UNetMidBlock2DCrossAttn( + transformer_layers_per_block=transformer_layers_per_block[-1], + in_channels=mid_block_channel, + temb_channels=time_embed_dim, + resnet_eps=norm_eps, + resnet_act_fn=act_fn, + output_scale_factor=mid_block_scale_factor, + resnet_time_scale_shift=resnet_time_scale_shift, + cross_attention_dim=cross_attention_dim, + num_attention_heads=num_attention_heads[-1], + resnet_groups=norm_num_groups, + use_linear_projection=use_linear_projection, + upcast_attention=upcast_attention, + ) + + @classmethod + def from_unet( + cls, + unet: UNet2DConditionModel, + controlnet_conditioning_channel_order: str = "rgb", + conditioning_embedding_out_channels: Optional[Tuple[int]] = (16, 32, 96, 256), + load_weights_from_unet: bool = True, + ): + r""" + Instantiate a [`ControlNetModel`] from [`UNet2DConditionModel`]. + + Parameters: + unet (`UNet2DConditionModel`): + The UNet model weights to copy to the [`ControlNetModel`]. All configuration options are also copied + where applicable. + """ + transformer_layers_per_block = ( + unet.config.transformer_layers_per_block if "transformer_layers_per_block" in unet.config else 1 + ) + encoder_hid_dim = unet.config.encoder_hid_dim if "encoder_hid_dim" in unet.config else None + encoder_hid_dim_type = unet.config.encoder_hid_dim_type if "encoder_hid_dim_type" in unet.config else None + addition_embed_type = unet.config.addition_embed_type if "addition_embed_type" in unet.config else None + addition_time_embed_dim = ( + unet.config.addition_time_embed_dim if "addition_time_embed_dim" in unet.config else None + ) + + controlnet = cls( + encoder_hid_dim=encoder_hid_dim, + encoder_hid_dim_type=encoder_hid_dim_type, + addition_embed_type=addition_embed_type, + addition_time_embed_dim=addition_time_embed_dim, + transformer_layers_per_block=transformer_layers_per_block, + in_channels=unet.config.in_channels, + flip_sin_to_cos=unet.config.flip_sin_to_cos, + freq_shift=unet.config.freq_shift, + down_block_types=unet.config.down_block_types, + only_cross_attention=unet.config.only_cross_attention, + block_out_channels=unet.config.block_out_channels, + layers_per_block=unet.config.layers_per_block, + downsample_padding=unet.config.downsample_padding, + mid_block_scale_factor=unet.config.mid_block_scale_factor, + act_fn=unet.config.act_fn, + norm_num_groups=unet.config.norm_num_groups, + norm_eps=unet.config.norm_eps, + cross_attention_dim=unet.config.cross_attention_dim, + attention_head_dim=unet.config.attention_head_dim, + num_attention_heads=unet.config.num_attention_heads, + use_linear_projection=unet.config.use_linear_projection, + class_embed_type=unet.config.class_embed_type, + num_class_embeds=unet.config.num_class_embeds, + upcast_attention=unet.config.upcast_attention, + resnet_time_scale_shift=unet.config.resnet_time_scale_shift, + projection_class_embeddings_input_dim=unet.config.projection_class_embeddings_input_dim, + controlnet_conditioning_channel_order=controlnet_conditioning_channel_order, + conditioning_embedding_out_channels=conditioning_embedding_out_channels, + ) + + if load_weights_from_unet: + controlnet.conv_in.load_state_dict(unet.conv_in.state_dict()) + controlnet.time_proj.load_state_dict(unet.time_proj.state_dict()) + controlnet.time_embedding.load_state_dict(unet.time_embedding.state_dict()) + + if controlnet.class_embedding: + controlnet.class_embedding.load_state_dict(unet.class_embedding.state_dict()) + + controlnet.down_blocks.load_state_dict(unet.down_blocks.state_dict()) + controlnet.mid_block.load_state_dict(unet.mid_block.state_dict()) + + return controlnet + + @property + # Copied from diffusers.models.unet_2d_condition.UNet2DConditionModel.attn_processors + def attn_processors(self) -> Dict[str, AttentionProcessor]: + r""" + Returns: + `dict` of attention processors: A dictionary containing all attention processors used in the model with + indexed by its weight name. + """ + # set recursively + processors = {} + + def fn_recursive_add_processors(name: str, module: torch.nn.Module, processors: Dict[str, AttentionProcessor]): + if hasattr(module, "set_processor"): + processors[f"{name}.processor"] = module.processor + + for sub_name, child in module.named_children(): + fn_recursive_add_processors(f"{name}.{sub_name}", child, processors) + + return processors + + for name, module in self.named_children(): + fn_recursive_add_processors(name, module, processors) + + return processors + + # Copied from diffusers.models.unet_2d_condition.UNet2DConditionModel.set_attn_processor + def set_attn_processor(self, processor: Union[AttentionProcessor, Dict[str, AttentionProcessor]]): + r""" + Sets the attention processor to use to compute attention. + + Parameters: + processor (`dict` of `AttentionProcessor` or only `AttentionProcessor`): + The instantiated processor class or a dictionary of processor classes that will be set as the processor + for **all** `Attention` layers. + + If `processor` is a dict, the key needs to define the path to the corresponding cross attention + processor. This is strongly recommended when setting trainable attention processors. + + """ + count = len(self.attn_processors.keys()) + + if isinstance(processor, dict) and len(processor) != count: + raise ValueError( + f"A dict of processors was passed, but the number of processors {len(processor)} does not match the" + f" number of attention layers: {count}. Please make sure to pass {count} processor classes." + ) + + def fn_recursive_attn_processor(name: str, module: torch.nn.Module, processor): + if hasattr(module, "set_processor"): + if not isinstance(processor, dict): + module.set_processor(processor) + else: + module.set_processor(processor.pop(f"{name}.processor")) + + for sub_name, child in module.named_children(): + fn_recursive_attn_processor(f"{name}.{sub_name}", child, processor) + + for name, module in self.named_children(): + fn_recursive_attn_processor(name, module, processor) + + # Copied from diffusers.models.unet_2d_condition.UNet2DConditionModel.set_default_attn_processor + def set_default_attn_processor(self): + """ + Disables custom attention processors and sets the default attention implementation. + """ + self.set_attn_processor(AttnProcessor()) + + # Copied from diffusers.models.unet_2d_condition.UNet2DConditionModel.set_attention_slice + def set_attention_slice(self, slice_size): + r""" + Enable sliced attention computation. + + When this option is enabled, the attention module splits the input tensor in slices to compute attention in + several steps. This is useful for saving some memory in exchange for a small decrease in speed. + + Args: + slice_size (`str` or `int` or `list(int)`, *optional*, defaults to `"auto"`): + When `"auto"`, input to the attention heads is halved, so attention is computed in two steps. If + `"max"`, maximum amount of memory is saved by running only one slice at a time. If a number is + provided, uses as many slices as `attention_head_dim // slice_size`. In this case, `attention_head_dim` + must be a multiple of `slice_size`. + """ + sliceable_head_dims = [] + + def fn_recursive_retrieve_sliceable_dims(module: torch.nn.Module): + if hasattr(module, "set_attention_slice"): + sliceable_head_dims.append(module.sliceable_head_dim) + + for child in module.children(): + fn_recursive_retrieve_sliceable_dims(child) + + # retrieve number of attention layers + for module in self.children(): + fn_recursive_retrieve_sliceable_dims(module) + + num_sliceable_layers = len(sliceable_head_dims) + + if slice_size == "auto": + # half the attention head size is usually a good trade-off between + # speed and memory + slice_size = [dim // 2 for dim in sliceable_head_dims] + elif slice_size == "max": + # make smallest slice possible + slice_size = num_sliceable_layers * [1] + + slice_size = num_sliceable_layers * [slice_size] if not isinstance(slice_size, list) else slice_size + + if len(slice_size) != len(sliceable_head_dims): + raise ValueError( + f"You have provided {len(slice_size)}, but {self.config} has {len(sliceable_head_dims)} different" + f" attention layers. Make sure to match `len(slice_size)` to be {len(sliceable_head_dims)}." + ) + + for i in range(len(slice_size)): + size = slice_size[i] + dim = sliceable_head_dims[i] + if size is not None and size > dim: + raise ValueError(f"size {size} has to be smaller or equal to {dim}.") + + # Recursively walk through all the children. + # Any children which exposes the set_attention_slice method + # gets the message + def fn_recursive_set_attention_slice(module: torch.nn.Module, slice_size: List[int]): + if hasattr(module, "set_attention_slice"): + module.set_attention_slice(slice_size.pop()) + + for child in module.children(): + fn_recursive_set_attention_slice(child, slice_size) + + reversed_slice_size = list(reversed(slice_size)) + for module in self.children(): + fn_recursive_set_attention_slice(module, reversed_slice_size) + + def _set_gradient_checkpointing(self, module, value=False): + if isinstance(module, (CrossAttnDownBlock2D, DownBlock2D)): + module.gradient_checkpointing = value + + def forward( + self, + sample: torch.FloatTensor, + timestep: Union[torch.Tensor, float, int], + encoder_hidden_states: torch.Tensor, + controlnet_cond: torch.FloatTensor, + conditioning_scale: float = 1.0, + class_labels: Optional[torch.Tensor] = None, + timestep_cond: Optional[torch.Tensor] = None, + attention_mask: Optional[torch.Tensor] = None, + added_cond_kwargs: Optional[Dict[str, torch.Tensor]] = None, + cross_attention_kwargs: Optional[Dict[str, Any]] = None, + encoder_attention_mask: Optional[torch.Tensor] = None, + guess_mode: bool = False, + return_dict: bool = True, + ) -> Union[ControlNetOutput, Tuple]: + """ + The [`ControlNetModel`] forward method. + + Args: + sample (`torch.FloatTensor`): + The noisy input tensor. + timestep (`Union[torch.Tensor, float, int]`): + The number of timesteps to denoise an input. + encoder_hidden_states (`torch.Tensor`): + The encoder hidden states. + controlnet_cond (`torch.FloatTensor`): + The conditional input tensor of shape `(batch_size, sequence_length, hidden_size)`. + conditioning_scale (`float`, defaults to `1.0`): + The scale factor for ControlNet outputs. + class_labels (`torch.Tensor`, *optional*, defaults to `None`): + Optional class labels for conditioning. Their embeddings will be summed with the timestep embeddings. + timestep_cond (`torch.Tensor`, *optional*, defaults to `None`): + attention_mask (`torch.Tensor`, *optional*, defaults to `None`): + added_cond_kwargs (`dict`): + Additional conditions for the Stable Diffusion XL UNet. + cross_attention_kwargs (`dict[str]`, *optional*, defaults to `None`): + A kwargs dictionary that if specified is passed along to the `AttnProcessor`. + encoder_attention_mask (`torch.Tensor`): + A cross-attention mask of shape `(batch, sequence_length)` is applied to `encoder_hidden_states`. If + `True` the mask is kept, otherwise if `False` it is discarded. Mask will be converted into a bias, + which adds large negative values to the attention scores corresponding to "discard" tokens. + guess_mode (`bool`, defaults to `False`): + In this mode, the ControlNet encoder tries its best to recognize the input content of the input even if + you remove all prompts. A `guidance_scale` between 3.0 and 5.0 is recommended. + return_dict (`bool`, defaults to `True`): + Whether or not to return a [`~models.controlnet.ControlNetOutput`] instead of a plain tuple. + + Returns: + [`~models.controlnet.ControlNetOutput`] **or** `tuple`: + If `return_dict` is `True`, a [`~models.controlnet.ControlNetOutput`] is returned, otherwise a tuple is + returned where the first element is the sample tensor. + """ + # check channel order + channel_order = self.config.controlnet_conditioning_channel_order + + if channel_order == "rgb": + # in rgb order by default + ... + elif channel_order == "bgr": + controlnet_cond = torch.flip(controlnet_cond, dims=[1]) + else: + raise ValueError(f"unknown `controlnet_conditioning_channel_order`: {channel_order}") + + # prepare attention_mask + if attention_mask is not None: + attention_mask = (1 - attention_mask.to(sample.dtype)) * -10000.0 + attention_mask = attention_mask.unsqueeze(1) + + # convert encoder_attention_mask to a bias the same way we do for attention_mask + if encoder_attention_mask is not None: + encoder_attention_mask = (1 - encoder_attention_mask.to(sample.dtype)) * -10000.0 + encoder_attention_mask = encoder_attention_mask.unsqueeze(1) + + # 1. time + timesteps = timestep + if not torch.is_tensor(timesteps): + # TODO: this requires sync between CPU and GPU. So try to pass timesteps as tensors if you can + # This would be a good case for the `match` statement (Python 3.10+) + is_mps = sample.device.type == "mps" + if isinstance(timestep, float): + dtype = torch.float32 if is_mps else torch.float64 + else: + dtype = torch.int32 if is_mps else torch.int64 + timesteps = torch.tensor([timesteps], dtype=dtype, device=sample.device) + elif len(timesteps.shape) == 0: + timesteps = timesteps[None].to(sample.device) + + # broadcast to batch dimension in a way that's compatible with ONNX/Core ML + timesteps = timesteps.expand(sample.shape[0]) + + t_emb = self.time_proj(timesteps) + + # timesteps does not contain any weights and will always return f32 tensors + # but time_embedding might actually be running in fp16. so we need to cast here. + # there might be better ways to encapsulate this. + t_emb = t_emb.to(dtype=sample.dtype) + + emb = self.time_embedding(t_emb, timestep_cond) + aug_emb = None + + if self.class_embedding is not None: + if class_labels is None: + raise ValueError("class_labels should be provided when num_class_embeds > 0") + + if self.config.class_embed_type == "timestep": + class_labels = self.time_proj(class_labels) + + class_emb = self.class_embedding(class_labels).to(dtype=self.dtype) + emb = emb + class_emb + + if "addition_embed_type" in self.config: + if self.config.addition_embed_type == "text": + aug_emb = self.add_embedding(encoder_hidden_states) + + elif self.config.addition_embed_type == "text_time": + if "text_embeds" not in added_cond_kwargs: + raise ValueError( + f"{self.__class__} has the config param `addition_embed_type` set to 'text_time' which \ + requires the keyword argument `text_embeds` to be passed in `added_cond_kwargs`" + ) + text_embeds = added_cond_kwargs.get("text_embeds") + if "time_ids" not in added_cond_kwargs: + raise ValueError( + f"{self.__class__} has the config param `addition_embed_type` set to 'text_time' which \ + requires the keyword argument `time_ids` to be passed in `added_cond_kwargs`" + ) + time_ids = added_cond_kwargs.get("time_ids") + time_embeds = self.add_time_proj(time_ids.flatten()) + time_embeds = time_embeds.reshape((text_embeds.shape[0], -1)) + + add_embeds = torch.concat([text_embeds, time_embeds], dim=-1) + add_embeds = add_embeds.to(emb.dtype) + aug_emb = self.add_embedding(add_embeds) + + emb = emb + aug_emb if aug_emb is not None else emb + + # 2. pre-process + sample = self.conv_in(sample) + + controlnet_cond = self.controlnet_cond_embedding(controlnet_cond) + sample = sample + controlnet_cond + + # 3. down + down_block_res_samples = (sample,) + for downsample_block in self.down_blocks: + if hasattr(downsample_block, "has_cross_attention") and downsample_block.has_cross_attention: + sample, res_samples = downsample_block( + hidden_states=sample, + temb=emb, + encoder_hidden_states=encoder_hidden_states, + attention_mask=attention_mask, + cross_attention_kwargs=cross_attention_kwargs, + encoder_attention_mask=encoder_attention_mask, + ) + else: + sample, res_samples = downsample_block(hidden_states=sample, temb=emb) + + down_block_res_samples += res_samples + + # 4. mid + if self.mid_block is not None: + sample = self.mid_block( + sample, + emb, + encoder_hidden_states=encoder_hidden_states, + attention_mask=attention_mask, + cross_attention_kwargs=cross_attention_kwargs, + encoder_attention_mask=encoder_attention_mask, + ) + + # 5. Control net blocks + + controlnet_down_block_res_samples = () + + for down_block_res_sample, controlnet_block in zip( + down_block_res_samples, self.controlnet_down_blocks, strict=True + ): + down_block_res_sample = controlnet_block(down_block_res_sample) + controlnet_down_block_res_samples = controlnet_down_block_res_samples + (down_block_res_sample,) + + down_block_res_samples = controlnet_down_block_res_samples + + mid_block_res_sample = self.controlnet_mid_block(sample) + + # 6. scaling + if guess_mode and not self.config.global_pool_conditions: + scales = torch.logspace(-1, 0, len(down_block_res_samples) + 1, device=sample.device) # 0.1 to 1.0 + + scales = scales * conditioning_scale + down_block_res_samples = [ + sample * scale for sample, scale in zip(down_block_res_samples, scales, strict=False) + ] + mid_block_res_sample = mid_block_res_sample * scales[-1] # last one + else: + down_block_res_samples = [sample * conditioning_scale for sample in down_block_res_samples] + mid_block_res_sample = mid_block_res_sample * conditioning_scale + + if self.config.global_pool_conditions: + down_block_res_samples = [torch.mean(sample, dim=(2, 3), keepdim=True) for sample in down_block_res_samples] + mid_block_res_sample = torch.mean(mid_block_res_sample, dim=(2, 3), keepdim=True) + + if not return_dict: + return (down_block_res_samples, mid_block_res_sample) + + return ControlNetOutput( + down_block_res_samples=down_block_res_samples, mid_block_res_sample=mid_block_res_sample + ) + + +diffusers.ControlNetModel = ControlNetModel +diffusers.models.controlnet.ControlNetModel = ControlNetModel + + +# patch LoRACompatibleConv to use original Conv2D forward function +# this needed to make work seamless patch +# NOTE: with this patch, torch.compile crashes on 2.0 torch(already fixed in nightly) +# https://github.com/huggingface/diffusers/pull/4315 +# https://github.com/huggingface/diffusers/blob/main/src/diffusers/models/lora.py#L96C18-L96C18 +def new_LoRACompatibleConv_forward(self, hidden_states, scale: float = 1.0): + if self.lora_layer is None: + return super(diffusers.models.lora.LoRACompatibleConv, self).forward(hidden_states) + else: + return super(diffusers.models.lora.LoRACompatibleConv, self).forward(hidden_states) + ( + scale * self.lora_layer(hidden_states) + ) + + +diffusers.models.lora.LoRACompatibleConv.forward = new_LoRACompatibleConv_forward + +try: + import xformers + + xformers_available = True +except Exception: + xformers_available = False + + +if xformers_available: + # TODO: remove when fixed in diffusers + _xformers_memory_efficient_attention = xformers.ops.memory_efficient_attention + + def new_memory_efficient_attention( + query: torch.Tensor, + key: torch.Tensor, + value: torch.Tensor, + attn_bias=None, + p: float = 0.0, + scale: Optional[float] = None, + *, + op=None, + ): + # diffusers not align shape to 8, which is required by xformers + if attn_bias is not None and type(attn_bias) is torch.Tensor: + orig_size = attn_bias.shape[-1] + new_size = ((orig_size + 7) // 8) * 8 + aligned_attn_bias = torch.zeros( + (attn_bias.shape[0], attn_bias.shape[1], new_size), + device=attn_bias.device, + dtype=attn_bias.dtype, + ) + aligned_attn_bias[:, :, :orig_size] = attn_bias + attn_bias = aligned_attn_bias[:, :, :orig_size] + + return _xformers_memory_efficient_attention( + query=query, + key=key, + value=value, + attn_bias=attn_bias, + p=p, + scale=scale, + op=op, + ) + + xformers.ops.memory_efficient_attention = new_memory_efficient_attention diff --git a/invokeai/backend/util/logging.py b/invokeai/backend/util/logging.py new file mode 100644 index 0000000000000000000000000000000000000000..968604eb3d99390c423efe420dba6cb8bfe1755e --- /dev/null +++ b/invokeai/backend/util/logging.py @@ -0,0 +1,435 @@ +# Copyright (c) 2023 Lincoln D. Stein and The InvokeAI Development Team + +""" +Logging class for InvokeAI that produces console messages. + +Usage: + +from invokeai.backend.util.logging import InvokeAILogger + +logger = InvokeAILogger.get_logger(name='InvokeAI') // Initialization +(or) +logger = InvokeAILogger.get_logger(__name__) // To use the filename +logger.configure() + +logger.critical('this is critical') // Critical Message +logger.error('this is an error') // Error Message +logger.warning('this is a warning') // Warning Message +logger.info('this is info') // Info Message +logger.debug('this is debugging') // Debug Message + +Console messages: + [12-05-2023 20]::[InvokeAI]::CRITICAL --> This is an info message [In Bold Red] + [12-05-2023 20]::[InvokeAI]::ERROR --> This is an info message [In Red] + [12-05-2023 20]::[InvokeAI]::WARNING --> This is an info message [In Yellow] + [12-05-2023 20]::[InvokeAI]::INFO --> This is an info message [In Grey] + [12-05-2023 20]::[InvokeAI]::DEBUG --> This is an info message [In Grey] + +Alternate Method (in this case the logger name will be set to InvokeAI): +import invokeai.backend.util.logging as IAILogger +IAILogger.debug('this is a debugging message') + +## Configuration + +The default configuration will print to stderr on the console. To add +additional logging handlers, call get_logger with an initialized InvokeAIAppConfig +object: + + + config = InvokeAIAppConfig.get_config() + config.parse_args() + logger = InvokeAILogger.get_logger(config=config) + +### 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: + +``` +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: + +``` +### 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 +``` + +""" + +import logging.handlers +import socket +import urllib.parse +from pathlib import Path +from typing import Any, Dict, Optional + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.app.services.config.config_default import get_config + +try: + import syslog + + SYSLOG_AVAILABLE = True +except ImportError: + SYSLOG_AVAILABLE = False + + +# module level functions +def debug(msg: str, *args: str, **kwargs: Any) -> None: # noqa D103 + InvokeAILogger.get_logger().debug(msg, *args, **kwargs) + + +def info(msg: str, *args: str, **kwargs: Any) -> None: # noqa D103 + InvokeAILogger.get_logger().info(msg, *args, **kwargs) + + +def warning(msg: str, *args: str, **kwargs: Any) -> None: # noqa D103 + InvokeAILogger.get_logger().warning(msg, *args, **kwargs) + + +def error(msg: str, *args: str, **kwargs: Any) -> None: # noqa D103 + InvokeAILogger.get_logger().error(msg, *args, **kwargs) + + +def critical(msg: str, *args: str, **kwargs: Any) -> None: # noqa D103 + InvokeAILogger.get_logger().critical(msg, *args, **kwargs) + + +def log(level: int, msg: str, *args: str, **kwargs: Any) -> None: # noqa D103 + InvokeAILogger.get_logger().log(level, msg, *args, **kwargs) + + +def disable(level: int = logging.CRITICAL) -> None: # noqa D103 + logging.disable(level) + + +def basicConfig(**kwargs: Any) -> None: # noqa D103 + logging.basicConfig(**kwargs) + + +_FACILITY_MAP = ( + { + "LOG_KERN": syslog.LOG_KERN, + "LOG_USER": syslog.LOG_USER, + "LOG_MAIL": syslog.LOG_MAIL, + "LOG_DAEMON": syslog.LOG_DAEMON, + "LOG_AUTH": syslog.LOG_AUTH, + "LOG_LPR": syslog.LOG_LPR, + "LOG_NEWS": syslog.LOG_NEWS, + "LOG_UUCP": syslog.LOG_UUCP, + "LOG_CRON": syslog.LOG_CRON, + "LOG_SYSLOG": syslog.LOG_SYSLOG, + "LOG_LOCAL0": syslog.LOG_LOCAL0, + "LOG_LOCAL1": syslog.LOG_LOCAL1, + "LOG_LOCAL2": syslog.LOG_LOCAL2, + "LOG_LOCAL3": syslog.LOG_LOCAL3, + "LOG_LOCAL4": syslog.LOG_LOCAL4, + "LOG_LOCAL5": syslog.LOG_LOCAL5, + "LOG_LOCAL6": syslog.LOG_LOCAL6, + "LOG_LOCAL7": syslog.LOG_LOCAL7, + } + if SYSLOG_AVAILABLE + else {} +) + +_SOCK_MAP = { + "SOCK_STREAM": socket.SOCK_STREAM, + "SOCK_DGRAM": socket.SOCK_DGRAM, +} + + +class InvokeAIFormatter(logging.Formatter): + """Base class for logging formatter.""" + + def format(self, record: logging.LogRecord) -> str: # noqa D102 + formatter = logging.Formatter(self.log_fmt(record.levelno)) + return formatter.format(record) + + def log_fmt(self, levelno: int) -> str: # noqa D102 + return "[%(asctime)s]::[%(name)s]::%(levelname)s --> %(message)s" + + +class InvokeAISyslogFormatter(InvokeAIFormatter): + """Formatting for syslog.""" + + def log_fmt(self, levelno: int) -> str: # noqa D102 + return "%(name)s [%(process)d] <%(levelname)s> %(message)s" + + +class InvokeAILegacyLogFormatter(InvokeAIFormatter): # noqa D102 + """Formatting for the InvokeAI Logger (legacy version).""" + + FORMATS = { + logging.DEBUG: " | %(message)s", + logging.INFO: ">> %(message)s", + logging.WARNING: "** %(message)s", + logging.ERROR: "*** %(message)s", + logging.CRITICAL: "### %(message)s", + } + + def log_fmt(self, levelno: int) -> str: # noqa D102 + format = self.FORMATS.get(levelno) + assert format is not None + return format + + +class InvokeAIPlainLogFormatter(InvokeAIFormatter): + """Custom Formatting for the InvokeAI Logger (plain version).""" + + def log_fmt(self, levelno: int) -> str: # noqa D102 + return "[%(asctime)s]::[%(name)s]::%(levelname)s --> %(message)s" + + +class InvokeAIColorLogFormatter(InvokeAIFormatter): + """Custom Formatting for the InvokeAI Logger.""" + + # Color Codes + grey = "\x1b[38;20m" + yellow = "\x1b[33;20m" + red = "\x1b[31;20m" + cyan = "\x1b[36;20m" + bold_red = "\x1b[31;1m" + reset = "\x1b[0m" + + # Log Format + log_format = "[%(asctime)s]::[%(name)s]::%(levelname)s --> %(message)s" + ## More Formatting Options: %(pathname)s, %(filename)s, %(module)s, %(lineno)d + + # Format Map + FORMATS = { + logging.DEBUG: cyan + log_format + reset, + logging.INFO: grey + log_format + reset, + logging.WARNING: yellow + log_format + reset, + logging.ERROR: red + log_format + reset, + logging.CRITICAL: bold_red + log_format + reset, + } + + def log_fmt(self, levelno: int) -> str: # noqa D102 + format = self.FORMATS.get(levelno) + assert format is not None + return format + + +LOG_FORMATTERS = { + "plain": InvokeAIPlainLogFormatter, + "color": InvokeAIColorLogFormatter, + "syslog": InvokeAISyslogFormatter, + "legacy": InvokeAILegacyLogFormatter, +} + + +class InvokeAILogger(object): # noqa D102 + loggers: Dict[str, logging.Logger] = {} + + @classmethod + def get_logger(cls, name: str = "InvokeAI", config: Optional[InvokeAIAppConfig] = None) -> logging.Logger: # noqa D102 + config = config or get_config() + if name in cls.loggers: + return cls.loggers[name] + + logger = logging.getLogger(name) + logger.setLevel(config.log_level.upper()) # yes, strings work here + for ch in cls.get_loggers(config): + logger.addHandler(ch) + cls.loggers[name] = logger + return cls.loggers[name] + + @classmethod + def get_loggers(cls, config: InvokeAIAppConfig) -> list[logging.Handler]: # noqa D102 + handler_strs = config.log_handlers + handlers = [] + for handler in handler_strs: + handler_name, *args = handler.split("=", 2) + arg = args[0] if len(args) > 0 else None + + # console and file get the fancy formatter. + # syslog gets a simple one + # http gets no custom formatter + formatter = LOG_FORMATTERS[config.log_format] + if handler_name == "console": + ch: logging.Handler = logging.StreamHandler() + ch.setFormatter(formatter()) + handlers.append(ch) + + elif handler_name == "syslog": + ch = cls._parse_syslog_args(arg) + handlers.append(ch) + + elif handler_name == "file": + ch = cls._parse_file_args(arg) + ch.setFormatter(formatter()) + handlers.append(ch) + + elif handler_name == "http": + ch = cls._parse_http_args(arg) + handlers.append(ch) + return handlers + + @staticmethod + def _parse_syslog_args(args: Optional[str] = None) -> logging.Handler: + if not SYSLOG_AVAILABLE: + raise ValueError("syslog is not available on this system") + if not args: + args = "/dev/log" if Path("/dev/log").exists() else "address:localhost:514" + syslog_args: Dict[str, Any] = {} + try: + for a in args.split(","): + arg_name, *arg_value = a.split(":", 2) + if arg_name == "address": + host, *port_list = arg_value + port = 514 if not port_list else int(port_list[0]) + syslog_args["address"] = (host, port) + elif arg_name == "facility": + syslog_args["facility"] = _FACILITY_MAP[arg_value[0]] + elif arg_name == "socktype": + syslog_args["socktype"] = _SOCK_MAP[arg_value[0]] + else: + syslog_args["address"] = arg_name + except Exception: + raise ValueError(f"{args} is not a value argument list for syslog logging") + return logging.handlers.SysLogHandler(**syslog_args) + + @staticmethod + def _parse_file_args(args: Optional[str] = None) -> logging.Handler: # noqa D102 + if not args: + raise ValueError("please provide filename for file logging using format 'file=/path/to/logfile.txt'") + return logging.FileHandler(args) + + @staticmethod + def _parse_http_args(args: Optional[str] = None) -> logging.Handler: # noqa D102 + if not args: + raise ValueError("please provide destination for http logging using format 'http=url'") + arg_list = args.split(",") + url = urllib.parse.urlparse(arg_list.pop(0)) + if url.scheme != "http": + raise ValueError(f"the http logging module can only log to HTTP URLs, but {url.scheme} was specified") + host = url.hostname + path = url.path + port = url.port or 80 + + syslog_args: Dict[str, Any] = {} + for a in arg_list: + arg_name, *arg_value = a.split(":", 2) + if arg_name == "method": + method = arg_value[0] if len(arg_value) > 0 else "GET" + syslog_args[arg_name] = method + else: # TODO: Provide support for SSL context and credentials + pass + return logging.handlers.HTTPHandler(f"{host}:{port}", path, **syslog_args) diff --git a/invokeai/backend/util/mask.py b/invokeai/backend/util/mask.py new file mode 100644 index 0000000000000000000000000000000000000000..45aa32061c2adb5f35e1b62b832d2eeeecb722bd --- /dev/null +++ b/invokeai/backend/util/mask.py @@ -0,0 +1,53 @@ +import torch + + +def to_standard_mask_dim(mask: torch.Tensor) -> torch.Tensor: + """Standardize the dimensions of a mask tensor. + + Args: + mask (torch.Tensor): A mask tensor. The shape can be (1, h, w) or (h, w). + + Returns: + torch.Tensor: The output mask tensor. The shape is (1, h, w). + """ + # Get the mask height and width. + if mask.ndim == 2: + mask = mask.unsqueeze(0) + elif mask.ndim == 3 and mask.shape[0] == 1: + pass + else: + raise ValueError(f"Unsupported mask shape: {mask.shape}. Expected (1, h, w) or (h, w).") + + return mask + + +def to_standard_float_mask(mask: torch.Tensor, out_dtype: torch.dtype) -> torch.Tensor: + """Standardize the format of a mask tensor. + + Args: + mask (torch.Tensor): A mask tensor. The dtype can be any bool, float, or int type. The shape must be (1, h, w) + or (h, w). + + out_dtype (torch.dtype): The dtype of the output mask tensor. Must be a float type. + + Returns: + torch.Tensor: The output mask tensor. The dtype is out_dtype. The shape is (1, h, w). All values are either 0.0 + or 1.0. + """ + + if not out_dtype.is_floating_point: + raise ValueError(f"out_dtype must be a float type, but got {out_dtype}") + + mask = to_standard_mask_dim(mask) + mask = mask.to(out_dtype) + + # Set masked regions to 1.0. + if mask.dtype == torch.bool: + mask = mask.to(out_dtype) + else: + mask = mask.to(out_dtype) + mask_region = mask > 0.5 + mask[mask_region] = 1.0 + mask[~mask_region] = 0.0 + + return mask diff --git a/invokeai/backend/util/mps_fixes.py b/invokeai/backend/util/mps_fixes.py new file mode 100644 index 0000000000000000000000000000000000000000..ce21d33b88fc271d4d2878c38ae6db713b586bf3 --- /dev/null +++ b/invokeai/backend/util/mps_fixes.py @@ -0,0 +1,245 @@ +import math + +import diffusers +import torch + +if torch.backends.mps.is_available(): + torch.empty = torch.zeros + + +_torch_layer_norm = torch.nn.functional.layer_norm + + +def new_layer_norm(input, normalized_shape, weight=None, bias=None, eps=1e-05): + if input.device.type == "mps" and input.dtype == torch.float16: + input = input.float() + if weight is not None: + weight = weight.float() + if bias is not None: + bias = bias.float() + return _torch_layer_norm(input, normalized_shape, weight, bias, eps).half() + else: + return _torch_layer_norm(input, normalized_shape, weight, bias, eps) + + +torch.nn.functional.layer_norm = new_layer_norm + + +_torch_tensor_permute = torch.Tensor.permute + + +def new_torch_tensor_permute(input, *dims): + result = _torch_tensor_permute(input, *dims) + if input.device == "mps" and input.dtype == torch.float16: + result = result.contiguous() + return result + + +torch.Tensor.permute = new_torch_tensor_permute + + +_torch_lerp = torch.lerp + + +def new_torch_lerp(input, end, weight, *, out=None): + if input.device.type == "mps" and input.dtype == torch.float16: + input = input.float() + end = end.float() + if isinstance(weight, torch.Tensor): + weight = weight.float() + if out is not None: + out_fp32 = torch.zeros_like(out, dtype=torch.float32) + else: + out_fp32 = None + result = _torch_lerp(input, end, weight, out=out_fp32) + if out is not None: + out.copy_(out_fp32.half()) + del out_fp32 + return result.half() + + else: + return _torch_lerp(input, end, weight, out=out) + + +torch.lerp = new_torch_lerp + + +_torch_interpolate = torch.nn.functional.interpolate + + +def new_torch_interpolate( + input, + size=None, + scale_factor=None, + mode="nearest", + align_corners=None, + recompute_scale_factor=None, + antialias=False, +): + if input.device.type == "mps" and input.dtype == torch.float16: + return _torch_interpolate( + input.float(), size, scale_factor, mode, align_corners, recompute_scale_factor, antialias + ).half() + else: + return _torch_interpolate(input, size, scale_factor, mode, align_corners, recompute_scale_factor, antialias) + + +torch.nn.functional.interpolate = new_torch_interpolate + +# TODO: refactor it +_SlicedAttnProcessor = diffusers.models.attention_processor.SlicedAttnProcessor + + +class ChunkedSlicedAttnProcessor: + r""" + Processor for implementing sliced attention. + + Args: + slice_size (`int`, *optional*): + The number of steps to compute attention. Uses as many slices as `attention_head_dim // slice_size`, and + `attention_head_dim` must be a multiple of the `slice_size`. + """ + + def __init__(self, slice_size): + assert isinstance(slice_size, int) + slice_size = 1 # TODO: maybe implement chunking in batches too when enough memory + self.slice_size = slice_size + self._sliced_attn_processor = _SlicedAttnProcessor(slice_size) + + def __call__(self, attn, hidden_states, encoder_hidden_states=None, attention_mask=None): + if self.slice_size != 1 or attn.upcast_attention: + return self._sliced_attn_processor(attn, hidden_states, encoder_hidden_states, attention_mask) + + residual = hidden_states + + input_ndim = hidden_states.ndim + + if input_ndim == 4: + batch_size, channel, height, width = hidden_states.shape + hidden_states = hidden_states.view(batch_size, channel, height * width).transpose(1, 2) + + batch_size, sequence_length, _ = ( + hidden_states.shape if encoder_hidden_states is None else encoder_hidden_states.shape + ) + attention_mask = attn.prepare_attention_mask(attention_mask, sequence_length, batch_size) + + if attn.group_norm is not None: + hidden_states = attn.group_norm(hidden_states.transpose(1, 2)).transpose(1, 2) + + query = attn.to_q(hidden_states) + dim = query.shape[-1] + query = attn.head_to_batch_dim(query) + + if encoder_hidden_states is None: + encoder_hidden_states = hidden_states + elif attn.norm_cross: + encoder_hidden_states = attn.norm_encoder_hidden_states(encoder_hidden_states) + + key = attn.to_k(encoder_hidden_states) + value = attn.to_v(encoder_hidden_states) + key = attn.head_to_batch_dim(key) + value = attn.head_to_batch_dim(value) + + batch_size_attention, query_tokens, _ = query.shape + hidden_states = torch.zeros( + (batch_size_attention, query_tokens, dim // attn.heads), device=query.device, dtype=query.dtype + ) + + chunk_tmp_tensor = torch.empty( + self.slice_size, query.shape[1], key.shape[1], dtype=query.dtype, device=query.device + ) + + for i in range(batch_size_attention // self.slice_size): + start_idx = i * self.slice_size + end_idx = (i + 1) * self.slice_size + + query_slice = query[start_idx:end_idx] + key_slice = key[start_idx:end_idx] + attn_mask_slice = attention_mask[start_idx:end_idx] if attention_mask is not None else None + + self.get_attention_scores_chunked( + attn, + query_slice, + key_slice, + attn_mask_slice, + hidden_states[start_idx:end_idx], + value[start_idx:end_idx], + chunk_tmp_tensor, + ) + + hidden_states = attn.batch_to_head_dim(hidden_states) + + # linear proj + hidden_states = attn.to_out[0](hidden_states) + # dropout + hidden_states = attn.to_out[1](hidden_states) + + if input_ndim == 4: + hidden_states = hidden_states.transpose(-1, -2).reshape(batch_size, channel, height, width) + + if attn.residual_connection: + hidden_states = hidden_states + residual + + hidden_states = hidden_states / attn.rescale_output_factor + + return hidden_states + + def get_attention_scores_chunked(self, attn, query, key, attention_mask, hidden_states, value, chunk): + # batch size = 1 + assert query.shape[0] == 1 + assert key.shape[0] == 1 + assert value.shape[0] == 1 + assert hidden_states.shape[0] == 1 + + # dtype = query.dtype + if attn.upcast_attention: + query = query.float() + key = key.float() + + # out_item_size = query.dtype.itemsize + # if attn.upcast_attention: + # out_item_size = torch.float32.itemsize + out_item_size = query.element_size() + if attn.upcast_attention: + out_item_size = 4 + + chunk_size = 2**29 + + out_size = query.shape[1] * key.shape[1] * out_item_size + chunks_count = min(query.shape[1], math.ceil((out_size - 1) / chunk_size)) + chunk_step = max(1, int(query.shape[1] / chunks_count)) + + key = key.transpose(-1, -2) + + def _get_chunk_view(tensor, start, length): + if start + length > tensor.shape[1]: + length = tensor.shape[1] - start + # print(f"view: [{tensor.shape[0]},{tensor.shape[1]},{tensor.shape[2]}] - start: {start}, length: {length}") + return tensor[:, start : start + length] + + for chunk_pos in range(0, query.shape[1], chunk_step): + if attention_mask is not None: + torch.baddbmm( + _get_chunk_view(attention_mask, chunk_pos, chunk_step), + _get_chunk_view(query, chunk_pos, chunk_step), + key, + beta=1, + alpha=attn.scale, + out=chunk, + ) + else: + torch.baddbmm( + torch.zeros((1, 1, 1), device=query.device, dtype=query.dtype), + _get_chunk_view(query, chunk_pos, chunk_step), + key, + beta=0, + alpha=attn.scale, + out=chunk, + ) + chunk = chunk.softmax(dim=-1) + torch.bmm(chunk, value, out=_get_chunk_view(hidden_states, chunk_pos, chunk_step)) + + # del chunk + + +diffusers.models.attention_processor.SlicedAttnProcessor = ChunkedSlicedAttnProcessor diff --git a/invokeai/backend/util/original_weights_storage.py b/invokeai/backend/util/original_weights_storage.py new file mode 100644 index 0000000000000000000000000000000000000000..af945b086f522331e02c42c2374198d2269ce5ae --- /dev/null +++ b/invokeai/backend/util/original_weights_storage.py @@ -0,0 +1,39 @@ +from __future__ import annotations + +from typing import Dict, Iterator, Optional, Tuple + +import torch + +from invokeai.backend.util.devices import TorchDevice + + +class OriginalWeightsStorage: + """A class for tracking the original weights of a model for patch/unpatch operations.""" + + def __init__(self, cached_weights: Optional[Dict[str, torch.Tensor]] = None): + # The original weights of the model. + self._weights: dict[str, torch.Tensor] = {} + # The keys of the weights that have been changed (via `save()`) during the lifetime of this instance. + self._changed_weights: set[str] = set() + if cached_weights: + self._weights.update(cached_weights) + + def save(self, key: str, weight: torch.Tensor, copy: bool = True): + self._changed_weights.add(key) + if key in self._weights: + return + + self._weights[key] = weight.detach().to(device=TorchDevice.CPU_DEVICE, copy=copy) + + def get(self, key: str, copy: bool = False) -> Optional[torch.Tensor]: + weight = self._weights.get(key, None) + if weight is not None and copy: + weight = weight.clone() + return weight + + def contains(self, key: str) -> bool: + return key in self._weights + + def get_changed_weights(self) -> Iterator[Tuple[str, torch.Tensor]]: + for key in self._changed_weights: + yield key, self._weights[key] diff --git a/invokeai/backend/util/silence_warnings.py b/invokeai/backend/util/silence_warnings.py new file mode 100644 index 0000000000000000000000000000000000000000..0cd6d0738d013b6e382bb6b5dcf2a51344b92b30 --- /dev/null +++ b/invokeai/backend/util/silence_warnings.py @@ -0,0 +1,36 @@ +import warnings +from contextlib import ContextDecorator + +from diffusers.utils import logging as diffusers_logging +from transformers import logging as transformers_logging + + +# Inherit from ContextDecorator to allow using SilenceWarnings as both a context manager and a decorator. +class SilenceWarnings(ContextDecorator): + """A context manager that disables warnings from transformers & diffusers modules while active. + + As context manager: + ``` + with SilenceWarnings(): + # do something + ``` + + As decorator: + ``` + @SilenceWarnings() + def some_function(): + # do something + ``` + """ + + def __enter__(self) -> None: + self._transformers_verbosity = transformers_logging.get_verbosity() + self._diffusers_verbosity = diffusers_logging.get_verbosity() + transformers_logging.set_verbosity_error() + diffusers_logging.set_verbosity_error() + warnings.simplefilter("ignore") + + def __exit__(self, *args) -> None: + transformers_logging.set_verbosity(self._transformers_verbosity) + diffusers_logging.set_verbosity(self._diffusers_verbosity) + warnings.simplefilter("default") diff --git a/invokeai/backend/util/test_utils.py b/invokeai/backend/util/test_utils.py new file mode 100644 index 0000000000000000000000000000000000000000..add394e71be94bc84c99a0efc591865d0674d86a --- /dev/null +++ b/invokeai/backend/util/test_utils.py @@ -0,0 +1,63 @@ +import contextlib +from pathlib import Path +from typing import Optional, Union + +import pytest +import torch + +from invokeai.app.services.model_manager import ModelManagerServiceBase +from invokeai.app.services.model_records import UnknownModelException +from invokeai.backend.model_manager import BaseModelType, LoadedModel, ModelType, SubModelType + + +@pytest.fixture(scope="session") +def torch_device(): + return "cuda" if torch.cuda.is_available() else "cpu" + + +def install_and_load_model( + model_manager: ModelManagerServiceBase, + model_path_id_or_url: Union[str, Path], + model_name: str, + base_model: BaseModelType, + model_type: ModelType, + submodel_type: Optional[SubModelType] = None, +) -> LoadedModel: + """Install a model if it is not already installed, then get the LoadedModel for that model. + + This is intended as a utility function for tests. + + Args: + mm2_model_manager (ModelManagerServiceBase): The model manager + model_path_id_or_url (Union[str, Path]): The path, HF ID, URL, etc. where the model can be installed from if it + is not already installed. + model_name (str): The model name, forwarded to ModelManager.get_model(...). + base_model (BaseModelType): The base model, forwarded to ModelManager.get_model(...). + model_type (ModelType): The model type, forwarded to ModelManager.get_model(...). + submodel_type (Optional[SubModelType]): The submodel type, forwarded to ModelManager.get_model(...). + + Returns: + LoadedModelInfo + """ + # If the requested model is already installed, return its LoadedModel + with contextlib.suppress(UnknownModelException): + # TODO: Replace with wrapper call + configs = model_manager.store.search_by_attr( + model_name=model_name, base_model=base_model, model_type=model_type + ) + loaded_model: LoadedModel = model_manager.load.load_model(configs[0]) + return loaded_model + + # Install the requested model. + job = model_manager.install.heuristic_import(model_path_id_or_url) + model_manager.install.wait_for_job(job, timeout=10) + assert job.complete + + try: + loaded_model = model_manager.load.load_model(job.config_out) + return loaded_model + except UnknownModelException as e: + raise Exception( + "Failed to get model info after installing it. There could be a mismatch between the requested model and" + f" the installation id ('{model_path_id_or_url}'). Error: {e}" + ) diff --git a/invokeai/backend/util/util.py b/invokeai/backend/util/util.py new file mode 100644 index 0000000000000000000000000000000000000000..cc654e4d39b248e8a1a2087c2b3837e56e7fc30f --- /dev/null +++ b/invokeai/backend/util/util.py @@ -0,0 +1,76 @@ +import base64 +import io +import os +import re +import unicodedata +from pathlib import Path + +from PIL import Image + + +def slugify(value: str, allow_unicode: bool = False) -> str: + """ + Convert to ASCII if 'allow_unicode' is False. Convert spaces or repeated + dashes to single dashes. Remove characters that aren't alphanumerics, + underscores, or hyphens. Replace slashes with underscores. + Convert to lowercase. Also strip leading and + trailing whitespace, dashes, and underscores. + + Adapted from Django: https://github.com/django/django/blob/main/django/utils/text.py + """ + value = str(value) + if allow_unicode: + value = unicodedata.normalize("NFKC", value) + else: + value = unicodedata.normalize("NFKD", value).encode("ascii", "ignore").decode("ascii") + value = re.sub(r"[/]", "_", value.lower()) + value = re.sub(r"[^.\w\s-]", "", value.lower()) + return re.sub(r"[-\s]+", "-", value).strip("-_") + + +def safe_filename(directory: Path, value: str) -> str: + """Make a string safe to use as a filename.""" + escaped_string = slugify(value) + max_name_length = os.pathconf(directory, "PC_NAME_MAX") if hasattr(os, "pathconf") else 256 + return escaped_string[len(escaped_string) - max_name_length :] + + +def directory_size(directory: Path) -> int: + """ + Return the aggregate size of all files in a directory (bytes). + """ + sum = 0 + for root, dirs, files in os.walk(directory): + for f in files: + sum += Path(root, f).stat().st_size + for d in dirs: + sum += Path(root, d).stat().st_size + return sum + + +def image_to_dataURL(image: Image.Image, image_format: str = "PNG") -> str: + """ + Converts an image into a base64 image dataURL. + """ + buffered = io.BytesIO() + image.save(buffered, format=image_format) + mime_type = Image.MIME.get(image_format.upper(), "image/" + image_format.lower()) + image_base64 = f"data:{mime_type};base64," + base64.b64encode(buffered.getvalue()).decode("UTF-8") + return image_base64 + + +class Chdir(object): + """Context manager to chdir to desired directory and change back after context exits: + Args: + path (Path): The path to the cwd + """ + + def __init__(self, path: Path): + self.path = path + self.original = Path().absolute() + + def __enter__(self): + os.chdir(self.path) + + def __exit__(self, *args): + os.chdir(self.original) diff --git a/invokeai/configs/controlnet/cldm_v15.yaml b/invokeai/configs/controlnet/cldm_v15.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fde1825577acd46dc90d8d7c6730e22be762fccb --- /dev/null +++ b/invokeai/configs/controlnet/cldm_v15.yaml @@ -0,0 +1,79 @@ +model: + target: cldm.cldm.ControlLDM + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + control_key: "hint" + image_size: 64 + channels: 4 + cond_stage_trainable: false + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False + only_mid_control: False + + control_stage_config: + target: cldm.cldm.ControlNet + params: + image_size: 32 # unused + in_channels: 4 + hint_channels: 3 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + unet_config: + target: cldm.cldm.ControlledUnetModel + params: + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + first_stage_config: + target: ldm.models.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: ldm.modules.encoders.modules.FrozenCLIPEmbedder diff --git a/invokeai/configs/controlnet/cldm_v21.yaml b/invokeai/configs/controlnet/cldm_v21.yaml new file mode 100644 index 0000000000000000000000000000000000000000..fc65193647e476e108fce5977f11250d55919106 --- /dev/null +++ b/invokeai/configs/controlnet/cldm_v21.yaml @@ -0,0 +1,85 @@ +model: + target: cldm.cldm.ControlLDM + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + control_key: "hint" + image_size: 64 + channels: 4 + cond_stage_trainable: false + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False + only_mid_control: False + + control_stage_config: + target: cldm.cldm.ControlNet + params: + use_checkpoint: True + image_size: 32 # unused + in_channels: 4 + hint_channels: 3 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_head_channels: 64 # need to fix for flash-attn + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 1 + context_dim: 1024 + legacy: False + + unet_config: + target: cldm.cldm.ControlledUnetModel + params: + use_checkpoint: True + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_head_channels: 64 # need to fix for flash-attn + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 1 + context_dim: 1024 + legacy: False + + first_stage_config: + target: ldm.models.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + #attn_type: "vanilla-xformers" + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: ldm.modules.encoders.modules.FrozenOpenCLIPEmbedder + params: + freeze: True + layer: "penultimate" diff --git a/invokeai/configs/stable-diffusion/sd_xl_base.yaml b/invokeai/configs/stable-diffusion/sd_xl_base.yaml new file mode 100644 index 0000000000000000000000000000000000000000..2022dac9500464efa4bd1118ab227e358d0c6108 --- /dev/null +++ b/invokeai/configs/stable-diffusion/sd_xl_base.yaml @@ -0,0 +1,98 @@ +model: + target: sgm.models.diffusion.DiffusionEngine + params: + scale_factor: 0.13025 + disable_first_stage_autocast: True + + denoiser_config: + target: sgm.modules.diffusionmodules.denoiser.DiscreteDenoiser + params: + num_idx: 1000 + + weighting_config: + target: sgm.modules.diffusionmodules.denoiser_weighting.EpsWeighting + scaling_config: + target: sgm.modules.diffusionmodules.denoiser_scaling.EpsScaling + discretization_config: + target: sgm.modules.diffusionmodules.discretizer.LegacyDDPMDiscretization + + network_config: + target: sgm.modules.diffusionmodules.openaimodel.UNetModel + params: + adm_in_channels: 2816 + num_classes: sequential + use_checkpoint: True + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [4, 2] + num_res_blocks: 2 + channel_mult: [1, 2, 4] + num_head_channels: 64 + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: [1, 2, 10] # note: the first is unused (due to attn_res starting at 2) 32, 16, 8 --> 64, 32, 16 + context_dim: 2048 + spatial_transformer_attn_type: softmax-xformers + legacy: False + + conditioner_config: + target: sgm.modules.GeneralConditioner + params: + emb_models: + # crossattn cond + - is_trainable: False + input_key: txt + target: sgm.modules.encoders.modules.FrozenCLIPEmbedder + params: + layer: hidden + layer_idx: 11 + # crossattn and vector cond + - is_trainable: False + input_key: txt + target: sgm.modules.encoders.modules.FrozenOpenCLIPEmbedder2 + params: + arch: ViT-bigG-14 + version: laion2b_s39b_b160k + freeze: True + layer: penultimate + always_return_pooled: True + legacy: False + # vector cond + - is_trainable: False + input_key: original_size_as_tuple + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + # vector cond + - is_trainable: False + input_key: crop_coords_top_left + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + # vector cond + - is_trainable: False + input_key: target_size_as_tuple + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + + first_stage_config: + target: sgm.models.autoencoder.AutoencoderKLInferenceWrapper + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + attn_type: vanilla-xformers + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: [1, 2, 4, 4] + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity \ No newline at end of file diff --git a/invokeai/configs/stable-diffusion/sd_xl_inpaint.yaml b/invokeai/configs/stable-diffusion/sd_xl_inpaint.yaml new file mode 100644 index 0000000000000000000000000000000000000000..eea5c15a49370ea8b75e30ab50515e137baea543 --- /dev/null +++ b/invokeai/configs/stable-diffusion/sd_xl_inpaint.yaml @@ -0,0 +1,98 @@ +model: + target: sgm.models.diffusion.DiffusionEngine + params: + scale_factor: 0.13025 + disable_first_stage_autocast: True + + denoiser_config: + target: sgm.modules.diffusionmodules.denoiser.DiscreteDenoiser + params: + num_idx: 1000 + + weighting_config: + target: sgm.modules.diffusionmodules.denoiser_weighting.EpsWeighting + scaling_config: + target: sgm.modules.diffusionmodules.denoiser_scaling.EpsScaling + discretization_config: + target: sgm.modules.diffusionmodules.discretizer.LegacyDDPMDiscretization + + network_config: + target: sgm.modules.diffusionmodules.openaimodel.UNetModel + params: + adm_in_channels: 2816 + num_classes: sequential + use_checkpoint: True + in_channels: 9 + out_channels: 4 + model_channels: 320 + attention_resolutions: [4, 2] + num_res_blocks: 2 + channel_mult: [1, 2, 4] + num_head_channels: 64 + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: [1, 2, 10] # note: the first is unused (due to attn_res starting at 2) 32, 16, 8 --> 64, 32, 16 + context_dim: 2048 + spatial_transformer_attn_type: softmax-xformers + legacy: False + + conditioner_config: + target: sgm.modules.GeneralConditioner + params: + emb_models: + # crossattn cond + - is_trainable: False + input_key: txt + target: sgm.modules.encoders.modules.FrozenCLIPEmbedder + params: + layer: hidden + layer_idx: 11 + # crossattn and vector cond + - is_trainable: False + input_key: txt + target: sgm.modules.encoders.modules.FrozenOpenCLIPEmbedder2 + params: + arch: ViT-bigG-14 + version: laion2b_s39b_b160k + freeze: True + layer: penultimate + always_return_pooled: True + legacy: False + # vector cond + - is_trainable: False + input_key: original_size_as_tuple + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + # vector cond + - is_trainable: False + input_key: crop_coords_top_left + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + # vector cond + - is_trainable: False + input_key: target_size_as_tuple + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + + first_stage_config: + target: sgm.models.autoencoder.AutoencoderKLInferenceWrapper + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + attn_type: vanilla-xformers + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: [1, 2, 4, 4] + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity \ No newline at end of file diff --git a/invokeai/configs/stable-diffusion/sd_xl_refiner.yaml b/invokeai/configs/stable-diffusion/sd_xl_refiner.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cab5fe283d77bf86e0f29e99f3ed0d3c7d9c752f --- /dev/null +++ b/invokeai/configs/stable-diffusion/sd_xl_refiner.yaml @@ -0,0 +1,91 @@ +model: + target: sgm.models.diffusion.DiffusionEngine + params: + scale_factor: 0.13025 + disable_first_stage_autocast: True + + denoiser_config: + target: sgm.modules.diffusionmodules.denoiser.DiscreteDenoiser + params: + num_idx: 1000 + + weighting_config: + target: sgm.modules.diffusionmodules.denoiser_weighting.EpsWeighting + scaling_config: + target: sgm.modules.diffusionmodules.denoiser_scaling.EpsScaling + discretization_config: + target: sgm.modules.diffusionmodules.discretizer.LegacyDDPMDiscretization + + network_config: + target: sgm.modules.diffusionmodules.openaimodel.UNetModel + params: + adm_in_channels: 2560 + num_classes: sequential + use_checkpoint: True + in_channels: 4 + out_channels: 4 + model_channels: 384 + attention_resolutions: [4, 2] + num_res_blocks: 2 + channel_mult: [1, 2, 4, 4] + num_head_channels: 64 + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 4 + context_dim: [1280, 1280, 1280, 1280] # 1280 + spatial_transformer_attn_type: softmax-xformers + legacy: False + + conditioner_config: + target: sgm.modules.GeneralConditioner + params: + emb_models: + # crossattn and vector cond + - is_trainable: False + input_key: txt + target: sgm.modules.encoders.modules.FrozenOpenCLIPEmbedder2 + params: + arch: ViT-bigG-14 + version: laion2b_s39b_b160k + legacy: False + freeze: True + layer: penultimate + always_return_pooled: True + # vector cond + - is_trainable: False + input_key: original_size_as_tuple + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + # vector cond + - is_trainable: False + input_key: crop_coords_top_left + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by two + # vector cond + - is_trainable: False + input_key: aesthetic_score + target: sgm.modules.encoders.modules.ConcatTimestepEmbedderND + params: + outdim: 256 # multiplied by one + + first_stage_config: + target: sgm.models.autoencoder.AutoencoderKLInferenceWrapper + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + attn_type: vanilla-xformers + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: [1, 2, 4, 4] + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity diff --git a/invokeai/configs/stable-diffusion/v1-finetune.yaml b/invokeai/configs/stable-diffusion/v1-finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..8bbdb52159f45a9aee9a61e84365d40398e20ae4 --- /dev/null +++ b/invokeai/configs/stable-diffusion/v1-finetune.yaml @@ -0,0 +1,110 @@ +model: + base_learning_rate: 5.0e-03 + target: invokeai.backend.stable_diffusion.diffusion.ddpm.LatentDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: image + cond_stage_key: caption + image_size: 64 + channels: 4 + cond_stage_trainable: true # Note: different from the one we trained before + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False + embedding_reg_weight: 0.0 + + personalization_config: + target: invokeai.backend.stable_diffusion.embedding_manager.EmbeddingManager + params: + placeholder_strings: ["*"] + initializer_words: ["sculpture"] + per_image_tokens: false + num_vectors_per_token: 1 + progressive_words: False + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.FrozenCLIPEmbedder + +data: + target: main.DataModuleFromConfig + params: + batch_size: 1 + num_workers: 2 + wrap: false + train: + target: invokeai.backend.stable_diffusion.data.personalized.PersonalizedBase + params: + size: 512 + set: train + per_image_tokens: false + repeats: 100 + validation: + target: invokeai.backend.stable_diffusion.data.personalized.PersonalizedBase + params: + size: 512 + set: val + per_image_tokens: false + repeats: 10 + +lightning: + modelcheckpoint: + params: + every_n_train_steps: 500 + callbacks: + image_logger: + target: main.ImageLogger + params: + batch_frequency: 500 + max_images: 8 + increase_log_steps: False + + trainer: + benchmark: True + max_steps: 4000000 +# max_steps: 4000 + diff --git a/invokeai/configs/stable-diffusion/v1-finetune_style.yaml b/invokeai/configs/stable-diffusion/v1-finetune_style.yaml new file mode 100644 index 0000000000000000000000000000000000000000..3442971a5bde42f03c8a3c3f7e3742f9de3ecf11 --- /dev/null +++ b/invokeai/configs/stable-diffusion/v1-finetune_style.yaml @@ -0,0 +1,103 @@ +model: + base_learning_rate: 5.0e-03 + target: invokeai.backend.models.diffusion.ddpm.LatentDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: image + cond_stage_key: caption + image_size: 64 + channels: 4 + cond_stage_trainable: true # Note: different from the one we trained before + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False + embedding_reg_weight: 0.0 + + personalization_config: + target: invokeai.backend.stable_diffusion.embedding_manager.EmbeddingManager + params: + placeholder_strings: ["*"] + initializer_words: ["painting"] + per_image_tokens: false + num_vectors_per_token: 1 + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.FrozenCLIPEmbedder + +data: + target: main.DataModuleFromConfig + params: + batch_size: 2 + num_workers: 16 + wrap: false + train: + target: invokeai.backend.stable_diffusion.data.personalized_style.PersonalizedBase + params: + size: 512 + set: train + per_image_tokens: false + repeats: 100 + validation: + target: invokeai.backend.stable_diffusion.data.personalized_style.PersonalizedBase + params: + size: 512 + set: val + per_image_tokens: false + repeats: 10 + +lightning: + callbacks: + image_logger: + target: main.ImageLogger + params: + batch_frequency: 500 + max_images: 8 + increase_log_steps: False + + trainer: + benchmark: True \ No newline at end of file diff --git a/invokeai/configs/stable-diffusion/v1-inference-v.yaml b/invokeai/configs/stable-diffusion/v1-inference-v.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cb413c2567e0a2549b05ed2a8f0efde8056347bd --- /dev/null +++ b/invokeai/configs/stable-diffusion/v1-inference-v.yaml @@ -0,0 +1,80 @@ +model: + base_learning_rate: 1.0e-04 + target: invokeai.backend.models.diffusion.ddpm.LatentDiffusion + params: + parameterization: "v" + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false # Note: different from the one we trained before + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False + + scheduler_config: # 10000 warmup steps + target: invokeai.backend.stable_diffusion.lr_scheduler.LambdaLinearScheduler + params: + warm_up_steps: [ 10000 ] + cycle_lengths: [ 10000000000000 ] # incredibly large number to prevent corner cases + f_start: [ 1.e-6 ] + f_max: [ 1. ] + f_min: [ 1. ] + + personalization_config: + target: invokeai.backend.stable_diffusion.embedding_manager.EmbeddingManager + params: + placeholder_strings: ["*"] + initializer_words: ['sculpture'] + per_image_tokens: false + num_vectors_per_token: 1 + progressive_words: False + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.WeightedFrozenCLIPEmbedder diff --git a/invokeai/configs/stable-diffusion/v1-inference.yaml b/invokeai/configs/stable-diffusion/v1-inference.yaml new file mode 100644 index 0000000000000000000000000000000000000000..7bcfe28f535d805f0ffb1ca3cb5d6e80f8c0c93e --- /dev/null +++ b/invokeai/configs/stable-diffusion/v1-inference.yaml @@ -0,0 +1,79 @@ +model: + base_learning_rate: 1.0e-04 + target: invokeai.backend.models.diffusion.ddpm.LatentDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false # Note: different from the one we trained before + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False + + scheduler_config: # 10000 warmup steps + target: invokeai.backend.stable_diffusion.lr_scheduler.LambdaLinearScheduler + params: + warm_up_steps: [ 10000 ] + cycle_lengths: [ 10000000000000 ] # incredibly large number to prevent corner cases + f_start: [ 1.e-6 ] + f_max: [ 1. ] + f_min: [ 1. ] + + personalization_config: + target: invokeai.backend.stable_diffusion.embedding_manager.EmbeddingManager + params: + placeholder_strings: ["*"] + initializer_words: ['sculpture'] + per_image_tokens: false + num_vectors_per_token: 1 + progressive_words: False + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.WeightedFrozenCLIPEmbedder diff --git a/invokeai/configs/stable-diffusion/v1-inpainting-inference.yaml b/invokeai/configs/stable-diffusion/v1-inpainting-inference.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f6433cf97d2c5d8e2b7647147a550700a8bb6c77 --- /dev/null +++ b/invokeai/configs/stable-diffusion/v1-inpainting-inference.yaml @@ -0,0 +1,79 @@ +model: + base_learning_rate: 7.5e-05 + target: invokeai.backend.models.diffusion.ddpm.LatentInpaintDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false # Note: different from the one we trained before + conditioning_key: hybrid # important + monitor: val/loss_simple_ema + scale_factor: 0.18215 + finetune_keys: null + + scheduler_config: # 10000 warmup steps + target: invokeai.backend.stable_diffusion.lr_scheduler.LambdaLinearScheduler + params: + warm_up_steps: [ 2500 ] # NOTE for resuming. use 10000 if starting from scratch + cycle_lengths: [ 10000000000000 ] # incredibly large number to prevent corner cases + f_start: [ 1.e-6 ] + f_max: [ 1. ] + f_min: [ 1. ] + + personalization_config: + target: invokeai.backend.stable_diffusion.embedding_manager.EmbeddingManager + params: + placeholder_strings: ["*"] + initializer_words: ['sculpture'] + per_image_tokens: false + num_vectors_per_token: 8 + progressive_words: False + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + image_size: 32 # unused + in_channels: 9 # 4 data + 4 downscaled image + 1 mask + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.WeightedFrozenCLIPEmbedder diff --git a/invokeai/configs/stable-diffusion/v1-m1-finetune.yaml b/invokeai/configs/stable-diffusion/v1-m1-finetune.yaml new file mode 100644 index 0000000000000000000000000000000000000000..10255a9b70f52746b395653c83f38a138699e735 --- /dev/null +++ b/invokeai/configs/stable-diffusion/v1-m1-finetune.yaml @@ -0,0 +1,110 @@ +model: + base_learning_rate: 5.0e-03 + target: invokeai.backend.models.diffusion.ddpm.LatentDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: image + cond_stage_key: caption + image_size: 64 + channels: 4 + cond_stage_trainable: true # Note: different from the one we trained before + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False + embedding_reg_weight: 0.0 + + personalization_config: + target: invokeai.backend.stable_diffusion.embedding_manager.EmbeddingManager + params: + placeholder_strings: ["*"] + initializer_words: ['sculpture'] + per_image_tokens: false + num_vectors_per_token: 6 + progressive_words: False + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_heads: 8 + use_spatial_transformer: True + transformer_depth: 1 + context_dim: 768 + use_checkpoint: True + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.FrozenCLIPEmbedder + +data: + target: main.DataModuleFromConfig + params: + batch_size: 1 + num_workers: 2 + wrap: false + train: + target: invokeai.backend.stable_diffusion.data.personalized.PersonalizedBase + params: + size: 512 + set: train + per_image_tokens: false + repeats: 100 + validation: + target: invokeai.backend.stable_diffusion.data.personalized.PersonalizedBase + params: + size: 512 + set: val + per_image_tokens: false + repeats: 10 + +lightning: + modelcheckpoint: + params: + every_n_train_steps: 500 + callbacks: + image_logger: + target: main.ImageLogger + params: + batch_frequency: 500 + max_images: 5 + increase_log_steps: False + + trainer: + benchmark: False + max_steps: 6200 +# max_steps: 4000 + diff --git a/invokeai/configs/stable-diffusion/v2-inference-v.yaml b/invokeai/configs/stable-diffusion/v2-inference-v.yaml new file mode 100644 index 0000000000000000000000000000000000000000..0fe477d5e620707c0bc001494dcdc8711c9e6cdd --- /dev/null +++ b/invokeai/configs/stable-diffusion/v2-inference-v.yaml @@ -0,0 +1,68 @@ +model: + base_learning_rate: 1.0e-4 + target: invokeai.backend.stable_diffusion.diffusion.ddpm.LatentDiffusion + params: + parameterization: "v" + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False # we set this to false because this is an inference only config + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + use_checkpoint: True + use_fp16: True + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_head_channels: 64 # need to fix for flash-attn + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 1 + context_dim: 1024 + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + #attn_type: "vanilla-xformers" + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.FrozenOpenCLIPEmbedder + params: + freeze: True + layer: "penultimate" diff --git a/invokeai/configs/stable-diffusion/v2-inference.yaml b/invokeai/configs/stable-diffusion/v2-inference.yaml new file mode 100644 index 0000000000000000000000000000000000000000..cde92ccdfeaa35fb74ec6e57273de20d7fe0325a --- /dev/null +++ b/invokeai/configs/stable-diffusion/v2-inference.yaml @@ -0,0 +1,67 @@ +model: + base_learning_rate: 1.0e-4 + target: invokeai.backend.stable_diffusion.diffusion.ddpm.LatentDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false + conditioning_key: crossattn + monitor: val/loss_simple_ema + scale_factor: 0.18215 + use_ema: False # we set this to false because this is an inference only config + + unet_config: + target: invokeai.backend.stable_diffusion.diffusionmodules.openaimodel.UNetModel + params: + use_checkpoint: True + use_fp16: True + image_size: 32 # unused + in_channels: 4 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_head_channels: 64 # need to fix for flash-attn + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 1 + context_dim: 1024 + legacy: False + + first_stage_config: + target: invokeai.backend.stable_diffusion.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + #attn_type: "vanilla-xformers" + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: invokeai.backend.stable_diffusion.encoders.modules.FrozenOpenCLIPEmbedder + params: + freeze: True + layer: "penultimate" diff --git a/invokeai/configs/stable-diffusion/v2-inpainting-inference-v.yaml b/invokeai/configs/stable-diffusion/v2-inpainting-inference-v.yaml new file mode 100644 index 0000000000000000000000000000000000000000..37cda460aac66cd8f18b2d1a40c70178d1e4b81d --- /dev/null +++ b/invokeai/configs/stable-diffusion/v2-inpainting-inference-v.yaml @@ -0,0 +1,159 @@ +model: + base_learning_rate: 5.0e-05 + target: ldm.models.diffusion.ddpm.LatentInpaintDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + parameterization: "v" + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false + conditioning_key: hybrid + scale_factor: 0.18215 + monitor: val/loss_simple_ema + finetune_keys: null + use_ema: False + + unet_config: + target: ldm.modules.diffusionmodules.openaimodel.UNetModel + params: + use_checkpoint: True + image_size: 32 # unused + in_channels: 9 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_head_channels: 64 # need to fix for flash-attn + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 1 + context_dim: 1024 + legacy: False + + first_stage_config: + target: ldm.models.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + #attn_type: "vanilla-xformers" + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [ ] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: ldm.modules.encoders.modules.FrozenOpenCLIPEmbedder + params: + freeze: True + layer: "penultimate" + + +data: + target: ldm.data.laion.WebDataModuleFromConfig + params: + tar_base: null # for concat as in LAION-A + p_unsafe_threshold: 0.1 + filter_word_list: "data/filters.yaml" + max_pwatermark: 0.45 + batch_size: 8 + num_workers: 6 + multinode: True + min_size: 512 + train: + shards: + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-0/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-1/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-2/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-3/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-4/{00000..18699}.tar -" #{00000-94333}.tar" + shuffle: 10000 + image_key: jpg + image_transforms: + - target: torchvision.transforms.Resize + params: + size: 512 + interpolation: 3 + - target: torchvision.transforms.RandomCrop + params: + size: 512 + postprocess: + target: ldm.data.laion.AddMask + params: + mode: "512train-large" + p_drop: 0.25 + # NOTE use enough shards to avoid empty validation loops in workers + validation: + shards: + - "pipe:aws s3 cp s3://deep-floyd-s3/datasets/laion_cleaned-part5/{93001..94333}.tar - " + shuffle: 0 + image_key: jpg + image_transforms: + - target: torchvision.transforms.Resize + params: + size: 512 + interpolation: 3 + - target: torchvision.transforms.CenterCrop + params: + size: 512 + postprocess: + target: ldm.data.laion.AddMask + params: + mode: "512train-large" + p_drop: 0.25 + +lightning: + find_unused_parameters: True + modelcheckpoint: + params: + every_n_train_steps: 5000 + + callbacks: + metrics_over_trainsteps_checkpoint: + params: + every_n_train_steps: 10000 + + image_logger: + target: main.ImageLogger + params: + enable_autocast: False + disabled: False + batch_frequency: 1000 + max_images: 4 + increase_log_steps: False + log_first_step: False + log_images_kwargs: + use_ema_scope: False + inpaint: False + plot_progressive_rows: False + plot_diffusion_rows: False + N: 4 + unconditional_guidance_scale: 5.0 + unconditional_guidance_label: [""] + ddim_steps: 50 # todo check these out for depth2img, + ddim_eta: 0.0 # todo check these out for depth2img, + + trainer: + benchmark: True + val_check_interval: 5000000 + num_sanity_val_steps: 0 + accumulate_grad_batches: 1 \ No newline at end of file diff --git a/invokeai/configs/stable-diffusion/v2-inpainting-inference.yaml b/invokeai/configs/stable-diffusion/v2-inpainting-inference.yaml new file mode 100644 index 0000000000000000000000000000000000000000..5aaf13162d48a4e9b8360bd5c65caaf2d632a3db --- /dev/null +++ b/invokeai/configs/stable-diffusion/v2-inpainting-inference.yaml @@ -0,0 +1,158 @@ +model: + base_learning_rate: 5.0e-05 + target: ldm.models.diffusion.ddpm.LatentInpaintDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false + conditioning_key: hybrid + scale_factor: 0.18215 + monitor: val/loss_simple_ema + finetune_keys: null + use_ema: False + + unet_config: + target: ldm.modules.diffusionmodules.openaimodel.UNetModel + params: + use_checkpoint: True + image_size: 32 # unused + in_channels: 9 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_head_channels: 64 # need to fix for flash-attn + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 1 + context_dim: 1024 + legacy: False + + first_stage_config: + target: ldm.models.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + #attn_type: "vanilla-xformers" + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [ ] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: ldm.modules.encoders.modules.FrozenOpenCLIPEmbedder + params: + freeze: True + layer: "penultimate" + + +data: + target: ldm.data.laion.WebDataModuleFromConfig + params: + tar_base: null # for concat as in LAION-A + p_unsafe_threshold: 0.1 + filter_word_list: "data/filters.yaml" + max_pwatermark: 0.45 + batch_size: 8 + num_workers: 6 + multinode: True + min_size: 512 + train: + shards: + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-0/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-1/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-2/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-3/{00000..18699}.tar -" + - "pipe:aws s3 cp s3://stability-aws/laion-a-native/part-4/{00000..18699}.tar -" #{00000-94333}.tar" + shuffle: 10000 + image_key: jpg + image_transforms: + - target: torchvision.transforms.Resize + params: + size: 512 + interpolation: 3 + - target: torchvision.transforms.RandomCrop + params: + size: 512 + postprocess: + target: ldm.data.laion.AddMask + params: + mode: "512train-large" + p_drop: 0.25 + # NOTE use enough shards to avoid empty validation loops in workers + validation: + shards: + - "pipe:aws s3 cp s3://deep-floyd-s3/datasets/laion_cleaned-part5/{93001..94333}.tar - " + shuffle: 0 + image_key: jpg + image_transforms: + - target: torchvision.transforms.Resize + params: + size: 512 + interpolation: 3 + - target: torchvision.transforms.CenterCrop + params: + size: 512 + postprocess: + target: ldm.data.laion.AddMask + params: + mode: "512train-large" + p_drop: 0.25 + +lightning: + find_unused_parameters: True + modelcheckpoint: + params: + every_n_train_steps: 5000 + + callbacks: + metrics_over_trainsteps_checkpoint: + params: + every_n_train_steps: 10000 + + image_logger: + target: main.ImageLogger + params: + enable_autocast: False + disabled: False + batch_frequency: 1000 + max_images: 4 + increase_log_steps: False + log_first_step: False + log_images_kwargs: + use_ema_scope: False + inpaint: False + plot_progressive_rows: False + plot_diffusion_rows: False + N: 4 + unconditional_guidance_scale: 5.0 + unconditional_guidance_label: [""] + ddim_steps: 50 # todo check these out for depth2img, + ddim_eta: 0.0 # todo check these out for depth2img, + + trainer: + benchmark: True + val_check_interval: 5000000 + num_sanity_val_steps: 0 + accumulate_grad_batches: 1 \ No newline at end of file diff --git a/invokeai/configs/stable-diffusion/v2-midas-inference.yaml b/invokeai/configs/stable-diffusion/v2-midas-inference.yaml new file mode 100644 index 0000000000000000000000000000000000000000..f20c30f618b81091e31c2c4cf15325fa38638af4 --- /dev/null +++ b/invokeai/configs/stable-diffusion/v2-midas-inference.yaml @@ -0,0 +1,74 @@ +model: + base_learning_rate: 5.0e-07 + target: ldm.models.diffusion.ddpm.LatentDepth2ImageDiffusion + params: + linear_start: 0.00085 + linear_end: 0.0120 + num_timesteps_cond: 1 + log_every_t: 200 + timesteps: 1000 + first_stage_key: "jpg" + cond_stage_key: "txt" + image_size: 64 + channels: 4 + cond_stage_trainable: false + conditioning_key: hybrid + scale_factor: 0.18215 + monitor: val/loss_simple_ema + finetune_keys: null + use_ema: False + + depth_stage_config: + target: ldm.modules.midas.api.MiDaSInference + params: + model_type: "dpt_hybrid" + + unet_config: + target: ldm.modules.diffusionmodules.openaimodel.UNetModel + params: + use_checkpoint: True + image_size: 32 # unused + in_channels: 5 + out_channels: 4 + model_channels: 320 + attention_resolutions: [ 4, 2, 1 ] + num_res_blocks: 2 + channel_mult: [ 1, 2, 4, 4 ] + num_head_channels: 64 # need to fix for flash-attn + use_spatial_transformer: True + use_linear_in_transformer: True + transformer_depth: 1 + context_dim: 1024 + legacy: False + + first_stage_config: + target: ldm.models.autoencoder.AutoencoderKL + params: + embed_dim: 4 + monitor: val/rec_loss + ddconfig: + #attn_type: "vanilla-xformers" + double_z: true + z_channels: 4 + resolution: 256 + in_channels: 3 + out_ch: 3 + ch: 128 + ch_mult: + - 1 + - 2 + - 4 + - 4 + num_res_blocks: 2 + attn_resolutions: [ ] + dropout: 0.0 + lossconfig: + target: torch.nn.Identity + + cond_stage_config: + target: ldm.modules.encoders.modules.FrozenOpenCLIPEmbedder + params: + freeze: True + layer: "penultimate" + + diff --git a/invokeai/frontend/__init__.py b/invokeai/frontend/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..19eafe46c44b738724be7344dc324a156909c391 --- /dev/null +++ b/invokeai/frontend/__init__.py @@ -0,0 +1,3 @@ +""" +Initialization file for invokeai.frontend +""" diff --git a/invokeai/frontend/cli/__init__.py b/invokeai/frontend/cli/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e69de29bb2d1d6434b8b29ae775ad8c2e48c5391 diff --git a/invokeai/frontend/cli/arg_parser.py b/invokeai/frontend/cli/arg_parser.py new file mode 100644 index 0000000000000000000000000000000000000000..72da8f765609678052a7a844213d77269c6c8f57 --- /dev/null +++ b/invokeai/frontend/cli/arg_parser.py @@ -0,0 +1,46 @@ +from argparse import ArgumentParser, Namespace, RawTextHelpFormatter +from typing import Optional + +from invokeai.version import __version__ + +_root_help = r"""Path to the runtime root directory. If omitted, the app will search for the root directory in the following order: +- The `$INVOKEAI_ROOT` environment variable +- The currently active virtual environment's parent directory +- `$HOME/invokeai`""" + +_config_file_help = r"""Path to the invokeai.yaml configuration file. If omitted, the app will search for the file in the root directory.""" + +_parser = ArgumentParser(description="Invoke Studio", formatter_class=RawTextHelpFormatter) +_parser.add_argument("--root", type=str, help=_root_help) +_parser.add_argument("--config", dest="config_file", type=str, help=_config_file_help) +_parser.add_argument("--version", action="version", version=__version__, help="Displays the version and exits.") + + +class InvokeAIArgs: + """Helper class for parsing CLI args. + + Args should never be parsed within the application code, only in the CLI entrypoints. Parsing args within the + application creates conflicts when running tests or when using application modules directly. + + If the args are needed within the application, the consumer should access them from this class. + + Example: + ``` + # In a CLI wrapper + from invokeai.frontend.cli.arg_parser import InvokeAIArgs + InvokeAIArgs.parse_args() + + # In the application + from invokeai.frontend.cli.arg_parser import InvokeAIArgs + args = InvokeAIArgs.args + """ + + args: Optional[Namespace] = None + did_parse: bool = False + + @staticmethod + def parse_args() -> Optional[Namespace]: + """Parse CLI args and store the result.""" + InvokeAIArgs.args = _parser.parse_args() + InvokeAIArgs.did_parse = True + return InvokeAIArgs.args diff --git a/invokeai/frontend/install/__init__.py b/invokeai/frontend/install/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..2a248eb49f45755fb8e27a12bbb079322925071e --- /dev/null +++ b/invokeai/frontend/install/__init__.py @@ -0,0 +1,3 @@ +""" +Initialization file for invokeai.frontend.config +""" diff --git a/invokeai/frontend/install/import_images.py b/invokeai/frontend/install/import_images.py new file mode 100644 index 0000000000000000000000000000000000000000..c08f379f593086795606f7b51bca37f864ccdc85 --- /dev/null +++ b/invokeai/frontend/install/import_images.py @@ -0,0 +1,786 @@ +# Copyright (c) 2023 - The InvokeAI Team +# Primary Author: David Lovell (github @f412design, discord @techjedi) +# co-author, minor tweaks - Lincoln Stein + +# pylint: disable=line-too-long +# pylint: disable=broad-exception-caught +"""Script to import images into the new database system for 3.0.0""" + +import datetime +import glob +import json +import locale +import os +import re +import shutil +import sqlite3 +from pathlib import Path + +import PIL +import PIL.ImageOps +import PIL.PngImagePlugin +import yaml +from prompt_toolkit import prompt +from prompt_toolkit.completion import PathCompleter +from prompt_toolkit.key_binding import KeyBindings +from prompt_toolkit.shortcuts import message_dialog + +from invokeai.app.services.config.config_default import get_config +from invokeai.app.util.misc import uuid_string + +app_config = get_config() + +bindings = KeyBindings() + + +@bindings.add("c-c") +def _(event): + raise KeyboardInterrupt + + +# release notes +# "Use All" with size dimensions not selectable in the UI will not load dimensions + + +class Config: + """Configuration loader.""" + + def __init__(self): + pass + + TIMESTAMP_STRING = datetime.datetime.utcnow().strftime("%Y%m%dT%H%M%SZ") + + INVOKE_DIRNAME = "invokeai" + YAML_FILENAME = "invokeai.yaml" + DATABASE_FILENAME = "invokeai.db" + + database_path = None + database_backup_dir = None + outputs_path = None + thumbnail_path = None + + def find_and_load(self): + """Find the yaml config file and load""" + root = app_config.root_path + if not self.confirm_and_load(os.path.abspath(root)): + print("\r\nSpecify custom database and outputs paths:") + self.confirm_and_load_from_user() + + self.database_backup_dir = os.path.join(os.path.dirname(self.database_path), "backup") + self.thumbnail_path = os.path.join(self.outputs_path, "thumbnails") + + def confirm_and_load(self, invoke_root): + """Validate a yaml path exists, confirms the user wants to use it and loads config.""" + yaml_path = os.path.join(invoke_root, self.YAML_FILENAME) + if os.path.exists(yaml_path): + db_dir, outdir = self.load_paths_from_yaml(yaml_path) + if os.path.isabs(db_dir): + database_path = os.path.join(db_dir, self.DATABASE_FILENAME) + else: + database_path = os.path.join(invoke_root, db_dir, self.DATABASE_FILENAME) + + if os.path.isabs(outdir): + outputs_path = os.path.join(outdir, "images") + else: + outputs_path = os.path.join(invoke_root, outdir, "images") + + db_exists = os.path.exists(database_path) + outdir_exists = os.path.exists(outputs_path) + + text = f"Found {self.YAML_FILENAME} file at {yaml_path}:" + text += f"\n Database : {database_path}" + text += f"\n Outputs : {outputs_path}" + text += "\n\nUse these paths for import (yes) or choose different ones (no) [Yn]: " + + if db_exists and outdir_exists: + if (prompt(text).strip() or "Y").upper().startswith("Y"): + self.database_path = database_path + self.outputs_path = outputs_path + return True + else: + return False + else: + print(" Invalid: One or more paths in this config did not exist and cannot be used.") + + else: + message_dialog( + title="Path not found", + text=f"Auto-discovery of configuration failed! Could not find ({yaml_path}), Custom paths can be specified.", + ).run() + return False + + def confirm_and_load_from_user(self): + default = "" + while True: + database_path = os.path.expanduser( + prompt( + "Database: Specify absolute path to the database to import into: ", + completer=PathCompleter( + expanduser=True, file_filter=lambda x: Path(x).is_dir() or x.endswith((".db")) + ), + default=default, + ) + ) + if database_path.endswith(".db") and os.path.isabs(database_path) and os.path.exists(database_path): + break + default = database_path + "/" if Path(database_path).is_dir() else database_path + + default = "" + while True: + outputs_path = os.path.expanduser( + prompt( + "Outputs: Specify absolute path to outputs/images directory to import into: ", + completer=PathCompleter(expanduser=True, only_directories=True), + default=default, + ) + ) + + if outputs_path.endswith("images") and os.path.isabs(outputs_path) and os.path.exists(outputs_path): + break + default = outputs_path + "/" if Path(outputs_path).is_dir() else outputs_path + + self.database_path = database_path + self.outputs_path = outputs_path + + return + + def load_paths_from_yaml(self, yaml_path): + """Load an Invoke AI yaml file and get the database and outputs paths.""" + try: + with open(yaml_path, "rt", encoding=locale.getpreferredencoding()) as file: + yamlinfo = yaml.safe_load(file) + db_dir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("db_dir", None) + outdir = yamlinfo.get("InvokeAI", {}).get("Paths", {}).get("outdir", None) + return db_dir, outdir + except Exception: + print(f"Failed to load paths from yaml file! {yaml_path}!") + return None, None + + +class ImportStats: + """DTO for tracking work progress.""" + + def __init__(self): + pass + + time_start = datetime.datetime.utcnow() + count_source_files = 0 + count_skipped_file_exists = 0 + count_skipped_db_exists = 0 + count_imported = 0 + count_imported_by_version = {} + count_file_errors = 0 + + @staticmethod + def get_elapsed_time_string(): + """Get a friendly time string for the time elapsed since processing start.""" + time_now = datetime.datetime.utcnow() + total_seconds = (time_now - ImportStats.time_start).total_seconds() + hours = int((total_seconds) / 3600) + minutes = int(((total_seconds) % 3600) / 60) + seconds = total_seconds % 60 + out_str = f"{hours} hour(s) -" if hours > 0 else "" + out_str += f"{minutes} minute(s) -" if minutes > 0 else "" + out_str += f"{seconds:.2f} second(s)" + return out_str + + +class InvokeAIMetadata: + """DTO for core Invoke AI generation properties parsed from metadata.""" + + def __init__(self): + pass + + def __str__(self): + formatted_str = f"{self.generation_mode}~{self.steps}~{self.cfg_scale}~{self.model_name}~{self.scheduler}~{self.seed}~{self.width}~{self.height}~{self.rand_device}~{self.strength}~{self.init_image}" + formatted_str += f"\r\npositive_prompt: {self.positive_prompt}" + formatted_str += f"\r\nnegative_prompt: {self.negative_prompt}" + return formatted_str + + generation_mode = None + steps = None + cfg_scale = None + model_name = None + scheduler = None + seed = None + width = None + height = None + rand_device = None + strength = None + init_image = None + positive_prompt = None + negative_prompt = None + imported_app_version = None + + def to_json(self): + """Convert the active instance to json format.""" + prop_dict = {} + prop_dict["generation_mode"] = self.generation_mode + # dont render prompt nodes if neither are set to avoid the ui thinking it can set them + # if at least one exists, render them both, but use empty string instead of None if one of them is empty + # this allows the field that is empty to actually be cleared byt he UI instead of leaving the previous value + if self.positive_prompt or self.negative_prompt: + prop_dict["positive_prompt"] = "" if self.positive_prompt is None else self.positive_prompt + prop_dict["negative_prompt"] = "" if self.negative_prompt is None else self.negative_prompt + prop_dict["width"] = self.width + prop_dict["height"] = self.height + # only render seed if it has a value to avoid ui thinking it can set this and then error + if self.seed: + prop_dict["seed"] = self.seed + prop_dict["rand_device"] = self.rand_device + prop_dict["cfg_scale"] = self.cfg_scale + prop_dict["steps"] = self.steps + prop_dict["scheduler"] = self.scheduler + prop_dict["clip_skip"] = 0 + prop_dict["model"] = {} + prop_dict["model"]["model_name"] = self.model_name + prop_dict["model"]["base_model"] = None + prop_dict["controlnets"] = [] + prop_dict["loras"] = [] + prop_dict["vae"] = None + prop_dict["strength"] = self.strength + prop_dict["init_image"] = self.init_image + prop_dict["positive_style_prompt"] = None + prop_dict["negative_style_prompt"] = None + prop_dict["refiner_model"] = None + prop_dict["refiner_cfg_scale"] = None + prop_dict["refiner_steps"] = None + prop_dict["refiner_scheduler"] = None + prop_dict["refiner_aesthetic_store"] = None + prop_dict["refiner_start"] = None + prop_dict["imported_app_version"] = self.imported_app_version + + return json.dumps(prop_dict) + + +class InvokeAIMetadataParser: + """Parses strings with json data to find Invoke AI core metadata properties.""" + + def __init__(self): + pass + + def parse_meta_tag_dream(self, dream_string): + """Take as input an png metadata json node for the 'dream' field variant from prior to 1.15""" + props = InvokeAIMetadata() + + props.imported_app_version = "pre1.15" + seed_match = re.search("-S\\s*(\\d+)", dream_string) + if seed_match is not None: + try: + props.seed = int(seed_match[1]) + except ValueError: + props.seed = None + raw_prompt = re.sub("(-S\\s*\\d+)", "", dream_string) + else: + raw_prompt = dream_string + + pos_prompt, neg_prompt = self.split_prompt(raw_prompt) + + props.positive_prompt = pos_prompt + props.negative_prompt = neg_prompt + + return props + + def parse_meta_tag_sd_metadata(self, tag_value): + """Take as input an png metadata json node for the 'sd-metadata' field variant from 1.15 through 2.3.5 post 2""" + props = InvokeAIMetadata() + + props.imported_app_version = tag_value.get("app_version") + props.model_name = tag_value.get("model_weights") + img_node = tag_value.get("image") + if img_node is not None: + props.generation_mode = img_node.get("type") + props.width = img_node.get("width") + props.height = img_node.get("height") + props.seed = img_node.get("seed") + props.rand_device = "cuda" # hardcoded since all generations pre 3.0 used cuda random noise instead of cpu + props.cfg_scale = img_node.get("cfg_scale") + props.steps = img_node.get("steps") + props.scheduler = self.map_scheduler(img_node.get("sampler")) + props.strength = img_node.get("strength") + if props.strength is None: + props.strength = img_node.get("strength_steps") # try second name for this property + props.init_image = img_node.get("init_image_path") + if props.init_image is None: # try second name for this property + props.init_image = img_node.get("init_img") + # remove the path info from init_image so if we move the init image, it will be correctly relative in the new location + if props.init_image is not None: + props.init_image = os.path.basename(props.init_image) + raw_prompt = img_node.get("prompt") + if isinstance(raw_prompt, list): + raw_prompt = raw_prompt[0].get("prompt") + + props.positive_prompt, props.negative_prompt = self.split_prompt(raw_prompt) + + return props + + def parse_meta_tag_invokeai(self, tag_value): + """Take as input an png metadata json node for the 'invokeai' field variant from 3.0.0 beta 1 through 5""" + props = InvokeAIMetadata() + + props.imported_app_version = "3.0.0 or later" + props.generation_mode = tag_value.get("type") + if props.generation_mode is not None: + props.generation_mode = props.generation_mode.replace("t2l", "txt2img").replace("l2l", "img2img") + + props.width = tag_value.get("width") + props.height = tag_value.get("height") + props.seed = tag_value.get("seed") + props.cfg_scale = tag_value.get("cfg_scale") + props.steps = tag_value.get("steps") + props.scheduler = tag_value.get("scheduler") + props.strength = tag_value.get("strength") + props.positive_prompt = tag_value.get("positive_conditioning") + props.negative_prompt = tag_value.get("negative_conditioning") + + return props + + def map_scheduler(self, old_scheduler): + """Convert the legacy sampler names to matching 3.0 schedulers""" + + # this was more elegant as a case statement, but that's not available in python 3.9 + if old_scheduler is None: + return None + scheduler_map = { + "ddim": "ddim", + "plms": "pnmd", + "k_lms": "lms", + "k_dpm_2": "kdpm_2", + "k_dpm_2_a": "kdpm_2_a", + "dpmpp_2": "dpmpp_2s", + "k_dpmpp_2": "dpmpp_2m", + "k_dpmpp_2_a": None, # invalid, in 2.3.x, selecting this sample would just fallback to last run or plms if new session + "k_euler": "euler", + "k_euler_a": "euler_a", + "k_heun": "heun", + } + return scheduler_map.get(old_scheduler) + + def split_prompt(self, raw_prompt: str): + """Split the unified prompt strings by extracting all negative prompt blocks out into the negative prompt.""" + if raw_prompt is None: + return "", "" + raw_prompt_search = raw_prompt.replace("\r", "").replace("\n", "") + matches = re.findall(r"\[(.+?)\]", raw_prompt_search) + if len(matches) > 0: + negative_prompt = "" + if len(matches) == 1: + negative_prompt = matches[0].strip().strip(",") + else: + for match in matches: + negative_prompt += f"({match.strip().strip(',')})" + positive_prompt = re.sub(r"(\[.+?\])", "", raw_prompt_search).strip() + else: + positive_prompt = raw_prompt_search.strip() + negative_prompt = "" + + return positive_prompt, negative_prompt + + +class DatabaseMapper: + """Class to abstract database functionality.""" + + def __init__(self, database_path, database_backup_dir): + self.database_path = database_path + self.database_backup_dir = database_backup_dir + self.connection = None + self.cursor = None + + def connect(self): + """Open connection to the database.""" + self.connection = sqlite3.connect(self.database_path) + self.cursor = self.connection.cursor() + + def get_board_names(self): + """Get a list of the current board names from the database.""" + sql_get_board_name = "SELECT board_name FROM boards" + self.cursor.execute(sql_get_board_name) + rows = self.cursor.fetchall() + return [row[0] for row in rows] + + def does_image_exist(self, image_name): + """Check database if a image name already exists and return a boolean.""" + sql_get_image_by_name = f"SELECT image_name FROM images WHERE image_name='{image_name}'" + self.cursor.execute(sql_get_image_by_name) + rows = self.cursor.fetchall() + return True if len(rows) > 0 else False + + def add_new_image_to_database(self, filename, width, height, metadata, modified_date_string): + """Add an image to the database.""" + sql_add_image = f"""INSERT INTO images (image_name, image_origin, image_category, width, height, session_id, node_id, metadata, is_intermediate, created_at, updated_at) +VALUES ('{filename}', 'internal', 'general', {width}, {height}, null, null, '{metadata}', 0, '{modified_date_string}', '{modified_date_string}')""" + self.cursor.execute(sql_add_image) + self.connection.commit() + + def get_board_id_with_create(self, board_name): + """Get the board id for supplied name, and create the board if one does not exist.""" + sql_find_board = f"SELECT board_id FROM boards WHERE board_name='{board_name}' COLLATE NOCASE" + self.cursor.execute(sql_find_board) + rows = self.cursor.fetchall() + if len(rows) > 0: + return rows[0][0] + else: + board_date_string = datetime.datetime.utcnow().date().isoformat() + new_board_id = uuid_string() + sql_insert_board = f"INSERT INTO boards (board_id, board_name, created_at, updated_at) VALUES ('{new_board_id}', '{board_name}', '{board_date_string}', '{board_date_string}')" + self.cursor.execute(sql_insert_board) + self.connection.commit() + return new_board_id + + def add_image_to_board(self, filename, board_id): + """Add an image mapping to a board.""" + add_datetime_str = datetime.datetime.utcnow().isoformat() + sql_add_image_to_board = f"""INSERT INTO board_images (board_id, image_name, created_at, updated_at) + VALUES ('{board_id}', '{filename}', '{add_datetime_str}', '{add_datetime_str}')""" + self.cursor.execute(sql_add_image_to_board) + self.connection.commit() + + def disconnect(self): + """Disconnect from the db, cleaning up connections and cursors.""" + if self.cursor is not None: + self.cursor.close() + if self.connection is not None: + self.connection.close() + + def backup(self, timestamp_string): + """Take a backup of the database.""" + if not os.path.exists(self.database_backup_dir): + print(f"Database backup directory {self.database_backup_dir} does not exist -> creating...", end="") + os.makedirs(self.database_backup_dir) + print("Done!") + database_backup_path = os.path.join(self.database_backup_dir, f"backup-{timestamp_string}-invokeai.db") + print(f"Making DB Backup at {database_backup_path}...", end="") + shutil.copy2(self.database_path, database_backup_path) + print("Done!") + + +class MediaImportProcessor: + """Containing class for script functionality.""" + + def __init__(self): + pass + + board_name_id_map = {} + + def get_import_file_list(self): + """Ask the user for the import folder and scan for the list of files to return.""" + while True: + default = "" + while True: + import_dir = os.path.expanduser( + prompt( + "Inputs: Specify absolute path containing InvokeAI .png images to import: ", + completer=PathCompleter(expanduser=True, only_directories=True), + default=default, + ) + ) + if len(import_dir) > 0 and Path(import_dir).is_dir(): + break + default = import_dir + + recurse_directories = ( + (prompt("Include files from subfolders recursively [yN]? ").strip() or "N").upper().startswith("N") + ) + if recurse_directories: + is_recurse = False + matching_file_list = glob.glob(import_dir + "/*.png", recursive=False) + else: + is_recurse = True + matching_file_list = glob.glob(import_dir + "/**/*.png", recursive=True) + + if len(matching_file_list) > 0: + return import_dir, is_recurse, matching_file_list + else: + print(f"The specific path {import_dir} exists, but does not contain .png files!") + + def get_file_details(self, filepath): + """Retrieve the embedded metedata fields and dimensions from an image file.""" + with PIL.Image.open(filepath) as img: + img.load() + png_width, png_height = img.size + img_info = img.info + return img_info, png_width, png_height + + def select_board_option(self, board_names, timestamp_string): + """Allow the user to choose how a board is selected for imported files.""" + while True: + print("\r\nOptions for board selection for imported images:") + print(f"1) Select an existing board name. (found {len(board_names)})") + print("2) Specify a board name to create/add to.") + print("3) Create/add to board named 'IMPORT'.") + print( + f"4) Create/add to board named 'IMPORT' with the current datetime string appended (.e.g IMPORT_{timestamp_string})." + ) + print( + "5) Create/add to board named 'IMPORT' with a the original file app_version appended (.e.g IMPORT_2.2.5)." + ) + input_option = input("Specify desired board option: ") + # This was more elegant as a case statement, but not supported in python 3.9 + if input_option == "1": + if len(board_names) < 1: + print("\r\nThere are no existing board names to choose from. Select another option!") + continue + board_name = self.select_item_from_list( + board_names, "board name", True, "Cancel, go back and choose a different board option." + ) + if board_name is not None: + return board_name + elif input_option == "2": + while True: + board_name = input("Specify new/existing board name: ") + if board_name: + return board_name + elif input_option == "3": + return "IMPORT" + elif input_option == "4": + return f"IMPORT_{timestamp_string}" + elif input_option == "5": + return "IMPORT_APPVERSION" + + def select_item_from_list(self, items, entity_name, allow_cancel, cancel_string): + """A general function to render a list of items to select in the console, prompt the user for a selection and ensure a valid entry is selected.""" + print(f"Select a {entity_name.lower()} from the following list:") + index = 1 + for item in items: + print(f"{index}) {item}") + index += 1 + if allow_cancel: + print(f"{index}) {cancel_string}") + while True: + try: + option_number = int(input("Specify number of selection: ")) + except ValueError: + continue + if allow_cancel and option_number == index: + return None + if option_number >= 1 and option_number <= len(items): + return items[option_number - 1] + + def import_image(self, filepath: str, board_name_option: str, db_mapper: DatabaseMapper, config: Config): + """Import a single file by its path""" + parser = InvokeAIMetadataParser() + file_name = os.path.basename(filepath) + file_destination_path = os.path.join(config.outputs_path, file_name) + + print("===============================================================================") + print(f"Importing {filepath}") + + # check destination to see if the file was previously imported + if os.path.exists(file_destination_path): + print("File already exists in the destination, skipping!") + ImportStats.count_skipped_file_exists += 1 + return + + # check if file name is already referenced in the database + if db_mapper.does_image_exist(file_name): + print("A reference to a file with this name already exists in the database, skipping!") + ImportStats.count_skipped_db_exists += 1 + return + + # load image info and dimensions + img_info, png_width, png_height = self.get_file_details(filepath) + + # parse metadata + destination_needs_meta_update = True + log_version_note = "(Unknown)" + if "invokeai_metadata" in img_info: + # for the latest, we will just re-emit the same json, no need to parse/modify + converted_field = None + latest_json_string = img_info.get("invokeai_metadata") + log_version_note = "3.0.0+" + destination_needs_meta_update = False + else: + if "sd-metadata" in img_info: + converted_field = parser.parse_meta_tag_sd_metadata(json.loads(img_info.get("sd-metadata"))) + elif "invokeai" in img_info: + converted_field = parser.parse_meta_tag_invokeai(json.loads(img_info.get("invokeai"))) + elif "dream" in img_info: + converted_field = parser.parse_meta_tag_dream(img_info.get("dream")) + elif "Dream" in img_info: + converted_field = parser.parse_meta_tag_dream(img_info.get("Dream")) + else: + converted_field = InvokeAIMetadata() + destination_needs_meta_update = False + print("File does not have metadata from known Invoke AI versions, add only, no update!") + + # use the loaded img dimensions if the metadata didnt have them + if converted_field.width is None: + converted_field.width = png_width + if converted_field.height is None: + converted_field.height = png_height + + log_version_note = converted_field.imported_app_version if converted_field else "NoVersion" + log_version_note = log_version_note or "NoVersion" + + latest_json_string = converted_field.to_json() + + print(f"From Invoke AI Version {log_version_note} with dimensions {png_width} x {png_height}.") + + # if metadata needs update, then update metdata and copy in one shot + if destination_needs_meta_update: + print("Updating metadata while copying...", end="") + self.update_file_metadata_while_copying( + filepath, file_destination_path, "invokeai_metadata", latest_json_string + ) + print("Done!") + else: + print("No metadata update necessary, copying only...", end="") + shutil.copy2(filepath, file_destination_path) + print("Done!") + + # create thumbnail + print("Creating thumbnail...", end="") + thumbnail_path = os.path.join(config.thumbnail_path, os.path.splitext(file_name)[0]) + ".webp" + thumbnail_size = 256, 256 + with PIL.Image.open(filepath) as source_image: + source_image.thumbnail(thumbnail_size) + source_image.save(thumbnail_path, "webp") + print("Done!") + + # finalize the dynamic board name if there is an APPVERSION token in it. + if converted_field is not None: + board_name = board_name_option.replace("APPVERSION", converted_field.imported_app_version or "NoVersion") + else: + board_name = board_name_option.replace("APPVERSION", "Latest") + + # maintain a map of alrady created/looked up ids to avoid DB queries + print("Finding/Creating board...", end="") + if board_name in self.board_name_id_map: + board_id = self.board_name_id_map[board_name] + else: + board_id = db_mapper.get_board_id_with_create(board_name) + self.board_name_id_map[board_name] = board_id + print("Done!") + + # add image to db + print("Adding image to database......", end="") + modified_time = datetime.datetime.utcfromtimestamp(os.path.getmtime(filepath)) + db_mapper.add_new_image_to_database(file_name, png_width, png_height, latest_json_string, modified_time) + print("Done!") + + # add image to board + print("Adding image to board......", end="") + db_mapper.add_image_to_board(file_name, board_id) + print("Done!") + + ImportStats.count_imported += 1 + if log_version_note in ImportStats.count_imported_by_version: + ImportStats.count_imported_by_version[log_version_note] += 1 + else: + ImportStats.count_imported_by_version[log_version_note] = 1 + + def update_file_metadata_while_copying(self, filepath, file_destination_path, tag_name, tag_value): + """Perform a metadata update with save to a new destination which accomplishes a copy while updating metadata.""" + with PIL.Image.open(filepath) as target_image: + existing_img_info = target_image.info + metadata = PIL.PngImagePlugin.PngInfo() + # re-add any existing invoke ai tags unless they are the one we are trying to add + for key in existing_img_info: + if key != tag_name and key in ("dream", "Dream", "sd-metadata", "invokeai", "invokeai_metadata"): + metadata.add_text(key, existing_img_info[key]) + metadata.add_text(tag_name, tag_value) + target_image.save(file_destination_path, pnginfo=metadata) + + def process(self): + """Begin main processing.""" + + print("===============================================================================") + print("This script will import images generated by earlier versions of") + print("InvokeAI into the currently installed root directory:") + print(f" {app_config.root_path}") + print("If this is not what you want to do, type ctrl-C now to cancel.") + + # load config + print("===============================================================================") + print("= Configuration & Settings") + + config = Config() + config.find_and_load() + db_mapper = DatabaseMapper(config.database_path, config.database_backup_dir) + db_mapper.connect() + + import_dir, is_recurse, import_file_list = self.get_import_file_list() + ImportStats.count_source_files = len(import_file_list) + + board_names = db_mapper.get_board_names() + board_name_option = self.select_board_option(board_names, config.TIMESTAMP_STRING) + + print("\r\n===============================================================================") + print("= Import Settings Confirmation") + + print() + print(f"Database File Path : {config.database_path}") + print(f"Outputs/Images Directory : {config.outputs_path}") + print(f"Import Image Source Directory : {import_dir}") + print(f" Recurse Source SubDirectories : {'Yes' if is_recurse else 'No'}") + print(f"Count of .png file(s) found : {len(import_file_list)}") + print(f"Board name option specified : {board_name_option}") + print(f"Database backup will be taken at : {config.database_backup_dir}") + + print("\r\nNotes about the import process:") + print("- Source image files will not be modified, only copied to the outputs directory.") + print("- If the same file name already exists in the destination, the file will be skipped.") + print("- If the same file name already has a record in the database, the file will be skipped.") + print("- Invoke AI metadata tags will be updated/written into the imported copy only.") + print( + "- On the imported copy, only Invoke AI known tags (latest and legacy) will be retained (dream, sd-metadata, invokeai, invokeai_metadata)" + ) + print( + "- A property 'imported_app_version' will be added to metadata that can be viewed in the UI's metadata viewer." + ) + print( + "- 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." + ) + + while True: + should_continue = prompt("\nDo you wish to continue with the import [Yn] ? ").lower() or "y" + if should_continue == "n": + print("\r\nCancelling Import") + return + elif should_continue == "y": + print() + break + + db_mapper.backup(config.TIMESTAMP_STRING) + + print() + ImportStats.time_start = datetime.datetime.utcnow() + + for filepath in import_file_list: + try: + self.import_image(filepath, board_name_option, db_mapper, config) + except sqlite3.Error as sql_ex: + print(f"A database related exception was found processing {filepath}, will continue to next file. ") + print("Exception detail:") + print(sql_ex) + ImportStats.count_file_errors += 1 + except Exception as ex: + print(f"Exception processing {filepath}, will continue to next file. ") + print("Exception detail:") + print(ex) + ImportStats.count_file_errors += 1 + + print("\r\n===============================================================================") + print(f"= Import Complete - Elpased Time: {ImportStats.get_elapsed_time_string()}") + print() + print(f"Source File(s) : {ImportStats.count_source_files}") + print(f"Total Imported : {ImportStats.count_imported}") + print(f"Skipped b/c file already exists on disk : {ImportStats.count_skipped_file_exists}") + print(f"Skipped b/c file already exists in db : {ImportStats.count_skipped_db_exists}") + print(f"Errors during import : {ImportStats.count_file_errors}") + if ImportStats.count_imported > 0: + print("\r\nBreakdown of imported files by version:") + for key, version in ImportStats.count_imported_by_version.items(): + print(f" {key:20} : {version}") + + +def main(): + try: + processor = MediaImportProcessor() + processor.process() + except KeyboardInterrupt: + print("\r\n\r\nUser cancelled execution.") + + +if __name__ == "__main__": + main() diff --git a/invokeai/frontend/web/.eslintignore b/invokeai/frontend/web/.eslintignore new file mode 100644 index 0000000000000000000000000000000000000000..1cb448ea803dc935a107417f51a4528f9c7d4ff9 --- /dev/null +++ b/invokeai/frontend/web/.eslintignore @@ -0,0 +1,10 @@ +dist/ +static/ +.husky/ +node_modules/ +patches/ +stats.html +index.html +.yarn/ +*.scss +src/services/api/schema.ts diff --git a/invokeai/frontend/web/.eslintrc.js b/invokeai/frontend/web/.eslintrc.js new file mode 100644 index 0000000000000000000000000000000000000000..434b35edb9d7d3f823c5f67a6885141f9b729649 --- /dev/null +++ b/invokeai/frontend/web/.eslintrc.js @@ -0,0 +1,40 @@ +module.exports = { + extends: ['@invoke-ai/eslint-config-react'], + plugins: ['path', 'i18next'], + rules: { + // TODO(psyche): Enable this rule. Requires no default exports in components - many changes. + 'react-refresh/only-export-components': 'off', + // TODO(psyche): Enable this rule. Requires a lot of eslint-disable-next-line comments. + '@typescript-eslint/consistent-type-assertions': 'off', + // https://github.com/qdanik/eslint-plugin-path + 'path/no-relative-imports': ['error', { maxDepth: 0 }], + // https://github.com/edvardchen/eslint-plugin-i18next/blob/HEAD/docs/rules/no-literal-string.md + 'i18next/no-literal-string': 'error', + // https://eslint.org/docs/latest/rules/no-console + 'no-console': 'error', + // https://eslint.org/docs/latest/rules/no-promise-executor-return + 'no-promise-executor-return': 'error', + // https://eslint.org/docs/latest/rules/require-await + 'require-await': 'error', + 'no-restricted-properties': [ + 'error', + { + object: 'crypto', + property: 'randomUUID', + message: 'Use of crypto.randomUUID is not allowed as it is not available in all browsers.', + }, + ], + }, + overrides: [ + /** + * Overrides for stories + */ + { + files: ['*.stories.tsx'], + rules: { + // We may not have i18n available in stories. + 'i18next/no-literal-string': 'off', + }, + }, + ], +}; diff --git a/invokeai/frontend/web/.gitignore b/invokeai/frontend/web/.gitignore new file mode 100644 index 0000000000000000000000000000000000000000..757d6ebcc84c6803a6086f7c50ffe1def1362581 --- /dev/null +++ b/invokeai/frontend/web/.gitignore @@ -0,0 +1,47 @@ +# Logs +logs +*.log +npm-debug.log* +yarn-debug.log* +yarn-error.log* +pnpm-debug.log* +lerna-debug.log* + +node_modules +.pnpm-store +# We want to distribute the repo +dist +dist/** +dist-ssr +*.local + +# Editor directories and files +.vscode/* +!.vscode/extensions.json +.idea +.DS_Store +*.suo +*.ntvs* +*.njsproj +*.sln +*.sw? + +# build stats +stats.html + +# Yarn - https://yarnpkg.com/getting-started/qa#which-files-should-be-gitignored +.pnp.* +.yarn/* +!.yarn/patches +!.yarn/plugins +!.yarn/releases +!.yarn/sdks +!.yarn/versions + +# Yalc +.yalc +yalc.lock + +# vitest +tsconfig.vitest-temp.json +coverage/ \ No newline at end of file diff --git a/invokeai/frontend/web/.prettierignore b/invokeai/frontend/web/.prettierignore new file mode 100644 index 0000000000000000000000000000000000000000..0f53a0b0a8c2b2a70436d218c606eee1237775b3 --- /dev/null +++ b/invokeai/frontend/web/.prettierignore @@ -0,0 +1,16 @@ +dist/ +public/locales/*.json +!public/locales/en.json +.husky/ +node_modules/ +patches/ +stats.html +index.html +.yarn/ +.yalc/ +*.scss +src/services/api/schema.ts +static/ +src/theme/css/overlayscrollbars.css +src/theme_/css/overlayscrollbars.css +pnpm-lock.yaml diff --git a/invokeai/frontend/web/.prettierrc.js b/invokeai/frontend/web/.prettierrc.js new file mode 100644 index 0000000000000000000000000000000000000000..c7f57d147535e8861f49006b7ed833d793162dec --- /dev/null +++ b/invokeai/frontend/web/.prettierrc.js @@ -0,0 +1,11 @@ +module.exports = { + ...require('@invoke-ai/prettier-config-react'), + overrides: [ + { + files: ['public/locales/*.json'], + options: { + tabWidth: 4, + }, + }, + ], +}; diff --git a/invokeai/frontend/web/.storybook/ReduxInit.tsx b/invokeai/frontend/web/.storybook/ReduxInit.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5359e5868dc0391e789d1dfc7321bd157a3f01e6 --- /dev/null +++ b/invokeai/frontend/web/.storybook/ReduxInit.tsx @@ -0,0 +1,21 @@ +import { PropsWithChildren, memo, useEffect } from 'react'; +import { modelChanged } from '../src/features/controlLayers/store/paramsSlice'; +import { useAppDispatch } from '../src/app/store/storeHooks'; +import { useGlobalModifiersInit } from '@invoke-ai/ui-library'; +/** + * Initializes some state for storybook. Must be in a different component + * so that it is run inside the redux context. + */ +export const ReduxInit = memo((props: PropsWithChildren) => { + const dispatch = useAppDispatch(); + useGlobalModifiersInit(); + useEffect(() => { + dispatch( + modelChanged({ model: { key: 'test_model', hash: 'some_hash', name: 'some name', base: 'sd-1', type: 'main' } }) + ); + }, []); + + return props.children; +}); + +ReduxInit.displayName = 'ReduxInit'; diff --git a/invokeai/frontend/web/.storybook/main.ts b/invokeai/frontend/web/.storybook/main.ts new file mode 100644 index 0000000000000000000000000000000000000000..166383990396fbb6d7f1c0ce1410402c6f4fe685 --- /dev/null +++ b/invokeai/frontend/web/.storybook/main.ts @@ -0,0 +1,22 @@ +import type { StorybookConfig } from '@storybook/react-vite'; + +const config: StorybookConfig = { + stories: ['../src/**/*.mdx', '../src/**/*.stories.@(js|jsx|mjs|ts|tsx)'], + addons: [ + '@storybook/addon-links', + '@storybook/addon-essentials', + '@storybook/addon-interactions', + '@storybook/addon-storysource', + ], + framework: { + name: '@storybook/react-vite', + options: {}, + }, + docs: { + autodocs: 'tag', + }, + core: { + disableTelemetry: true, + }, +}; +export default config; diff --git a/invokeai/frontend/web/.storybook/manager.ts b/invokeai/frontend/web/.storybook/manager.ts new file mode 100644 index 0000000000000000000000000000000000000000..9d5347529a7a811d80c2386b258b56e2f3bb9d7c --- /dev/null +++ b/invokeai/frontend/web/.storybook/manager.ts @@ -0,0 +1,6 @@ +import { addons } from '@storybook/manager-api'; +import { themes } from '@storybook/theming'; + +addons.setConfig({ + theme: themes.dark, +}); diff --git a/invokeai/frontend/web/.storybook/preview.tsx b/invokeai/frontend/web/.storybook/preview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8b21b4823043478bb5c48b89f701904f95973f29 --- /dev/null +++ b/invokeai/frontend/web/.storybook/preview.tsx @@ -0,0 +1,53 @@ +import { Preview } from '@storybook/react'; +import { themes } from '@storybook/theming'; +import i18n from 'i18next'; +import { initReactI18next } from 'react-i18next'; +import { Provider } from 'react-redux'; +import ThemeLocaleProvider from '../src/app/components/ThemeLocaleProvider'; +import { $baseUrl } from '../src/app/store/nanostores/baseUrl'; +import { createStore } from '../src/app/store/store'; +// TODO: Disabled for IDE performance issues with our translation JSON +// eslint-disable-next-line @typescript-eslint/ban-ts-comment +// @ts-ignore +import translationEN from '../public/locales/en.json'; +import { ReduxInit } from './ReduxInit'; +import { $store } from 'app/store/nanostores/store'; + +i18n.use(initReactI18next).init({ + lng: 'en', + resources: { + en: { translation: translationEN }, + }, + debug: true, + interpolation: { + escapeValue: false, + }, + returnNull: false, +}); + +const store = createStore(undefined, false); +$store.set(store); +$baseUrl.set('http://localhost:9090'); + +const preview: Preview = { + decorators: [ + (Story) => { + return ( + + + + + + + + ); + }, + ], + parameters: { + docs: { + theme: themes.dark, + }, + }, +}; + +export default preview; diff --git a/invokeai/frontend/web/README.md b/invokeai/frontend/web/README.md new file mode 100644 index 0000000000000000000000000000000000000000..995a2812b95b7b6b29d63e4abf7039f9b4963d28 --- /dev/null +++ b/invokeai/frontend/web/README.md @@ -0,0 +1,3 @@ +# Invoke UI + + diff --git a/invokeai/frontend/web/__init__.py b/invokeai/frontend/web/__init__.py new file mode 100644 index 0000000000000000000000000000000000000000..e9758b27b6bba4e6768613d545c593c6cb686697 --- /dev/null +++ b/invokeai/frontend/web/__init__.py @@ -0,0 +1,3 @@ +""" +Initialization file for invokeai.frontend.web +""" diff --git a/invokeai/frontend/web/index.html b/invokeai/frontend/web/index.html new file mode 100644 index 0000000000000000000000000000000000000000..d74db800da5890a722775191090f55369c94035e --- /dev/null +++ b/invokeai/frontend/web/index.html @@ -0,0 +1,26 @@ + + + + + + + + + + Invoke - Community Edition + + + + + +
+ + + + diff --git a/invokeai/frontend/web/knip.ts b/invokeai/frontend/web/knip.ts new file mode 100644 index 0000000000000000000000000000000000000000..24fbe6d78d363f15168c4efadc2e40b2b26ce1cc --- /dev/null +++ b/invokeai/frontend/web/knip.ts @@ -0,0 +1,24 @@ +import type { KnipConfig } from 'knip'; + +const config: KnipConfig = { + project: ['src/**/*.{ts,tsx}!'], + ignore: [ + // This file is only used during debugging + 'src/app/store/middleware/debugLoggerMiddleware.ts', + // Autogenerated types - shouldn't ever touch these + 'src/services/api/schema.ts', + 'src/features/nodes/types/v1/**', + 'src/features/nodes/types/v2/**', + 'src/features/parameters/types/parameterSchemas.ts', + // TODO(psyche): maybe we can clean up these utils after canvas v2 release + 'src/features/controlLayers/konva/util.ts', + // TODO(psyche): restore HRF functionality? + 'src/features/hrf/**', + ], + ignoreBinaries: ['only-allow'], + paths: { + 'public/*': ['public/*'], + }, +}; + +export default config; diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json new file mode 100644 index 0000000000000000000000000000000000000000..b8cd3e02000c90a0f806dd55d80feee5802c8948 --- /dev/null +++ b/invokeai/frontend/web/package.json @@ -0,0 +1,164 @@ +{ + "name": "@invoke-ai/invoke-ai-ui", + "private": true, + "version": "0.0.1", + "publishConfig": { + "access": "restricted", + "registry": "https://npm.pkg.github.com" + }, + "main": "./dist/invoke-ai-ui.umd.js", + "module": "./dist/invoke-ai-ui.es.js", + "exports": { + ".": { + "import": "./dist/invoke-ai-ui.es.js", + "require": "./dist/invoke-ai-ui.umd.js" + } + }, + "types": "./dist/index.d.ts", + "files": [ + "dist" + ], + "scripts": { + "dev": "vite dev", + "dev:host": "vite dev --host", + "build": "pnpm run lint && vite build", + "typegen": "node scripts/typegen.js", + "preview": "vite preview", + "lint:knip": "knip --tags=-knipignore", + "lint:dpdm": "dpdm --no-warning --no-tree --transform --exit-code circular:1 src/main.tsx", + "lint:eslint": "eslint --max-warnings=0 .", + "lint:prettier": "prettier --check .", + "lint:tsc": "tsc --noEmit", + "lint": "concurrently -g -c red,green,yellow,blue,magenta pnpm:lint:*", + "fix": "eslint --fix . && prettier --log-level warn --write .", + "preinstall": "npx only-allow pnpm", + "storybook": "storybook dev -p 6006", + "build-storybook": "storybook build", + "test": "vitest", + "test:ui": "vitest --coverage --ui", + "test:no-watch": "vitest --no-watch" + }, + "madge": { + "excludeRegExp": [ + "^index.ts$" + ], + "detectiveOptions": { + "ts": { + "skipTypeImports": true + }, + "tsx": { + "skipTypeImports": true + } + } + }, + "dependencies": { + "@atlaskit/pragmatic-drag-and-drop": "^1.4.0", + "@atlaskit/pragmatic-drag-and-drop-auto-scroll": "^1.4.0", + "@atlaskit/pragmatic-drag-and-drop-hitbox": "^1.0.3", + "@dagrejs/dagre": "^1.1.4", + "@dagrejs/graphlib": "^2.2.4", + "@fontsource-variable/inter": "^5.1.0", + "@invoke-ai/ui-library": "^0.0.43", + "@nanostores/react": "^0.7.3", + "@reduxjs/toolkit": "2.2.3", + "@roarr/browser-log-writer": "^1.3.0", + "async-mutex": "^0.5.0", + "chakra-react-select": "^4.9.2", + "cmdk": "^1.0.0", + "compare-versions": "^6.1.1", + "dateformat": "^5.0.3", + "fracturedjsonjs": "^4.0.2", + "framer-motion": "^11.10.0", + "i18next": "^23.15.1", + "i18next-http-backend": "^2.6.1", + "idb-keyval": "^6.2.1", + "jsondiffpatch": "^0.6.0", + "konva": "^9.3.15", + "lodash-es": "^4.17.21", + "lru-cache": "^11.0.1", + "nanoid": "^5.0.7", + "nanostores": "^0.11.3", + "new-github-issue-url": "^1.0.0", + "overlayscrollbars": "^2.10.0", + "overlayscrollbars-react": "^0.5.6", + "perfect-freehand": "^1.2.2", + "query-string": "^9.1.0", + "raf-throttle": "^2.0.6", + "react": "^18.3.1", + "react-colorful": "^5.6.1", + "react-dom": "^18.3.1", + "react-dropzone": "^14.2.9", + "react-error-boundary": "^4.0.13", + "react-hook-form": "^7.53.0", + "react-hotkeys-hook": "4.5.0", + "react-i18next": "^15.0.2", + "react-icons": "^5.3.0", + "react-redux": "9.1.2", + "react-resizable-panels": "^2.1.4", + "react-use": "^17.5.1", + "react-virtuoso": "^4.10.4", + "reactflow": "^11.11.4", + "redux-dynamic-middlewares": "^2.2.0", + "redux-remember": "^5.1.0", + "redux-undo": "^1.1.0", + "rfdc": "^1.4.1", + "roarr": "^7.21.1", + "serialize-error": "^11.0.3", + "socket.io-client": "^4.8.0", + "stable-hash": "^0.0.4", + "use-debounce": "^10.0.3", + "use-device-pixel-ratio": "^1.1.2", + "uuid": "^10.0.0", + "zod": "^3.23.8", + "zod-validation-error": "^3.4.0" + }, + "peerDependencies": { + "react": "^18.2.0", + "react-dom": "^18.2.0" + }, + "devDependencies": { + "@invoke-ai/eslint-config-react": "^0.0.14", + "@invoke-ai/prettier-config-react": "^0.0.7", + "@storybook/addon-essentials": "^8.3.4", + "@storybook/addon-interactions": "^8.3.4", + "@storybook/addon-links": "^8.3.4", + "@storybook/addon-storysource": "^8.3.4", + "@storybook/manager-api": "^8.3.4", + "@storybook/react": "^8.3.4", + "@storybook/react-vite": "^8.3.4", + "@storybook/theming": "^8.3.4", + "@types/dateformat": "^5.0.2", + "@types/lodash-es": "^4.17.12", + "@types/node": "^20.16.10", + "@types/react": "^18.3.11", + "@types/react-dom": "^18.3.0", + "@types/uuid": "^10.0.0", + "@vitejs/plugin-react-swc": "^3.7.1", + "@vitest/coverage-v8": "^1.6.0", + "@vitest/ui": "^1.6.0", + "concurrently": "^8.2.2", + "csstype": "^3.1.3", + "dpdm": "^3.14.0", + "eslint": "^8.57.1", + "eslint-plugin-i18next": "^6.1.0", + "eslint-plugin-path": "^1.3.0", + "knip": "^5.31.0", + "openapi-types": "^12.1.3", + "openapi-typescript": "^7.4.1", + "prettier": "^3.3.3", + "rollup-plugin-visualizer": "^5.12.0", + "storybook": "^8.3.4", + "tsafe": "^1.7.5", + "type-fest": "^4.26.1", + "typescript": "^5.6.2", + "vite": "^5.4.8", + "vite-plugin-css-injected-by-js": "^3.5.2", + "vite-plugin-dts": "^3.9.1", + "vite-plugin-eslint": "^1.8.1", + "vite-tsconfig-paths": "^4.3.2", + "vitest": "^1.6.0" + }, + "engines": { + "pnpm": "8" + } +} diff --git a/invokeai/frontend/web/patches/reselect@5.0.1.patch b/invokeai/frontend/web/patches/reselect@5.0.1.patch new file mode 100644 index 0000000000000000000000000000000000000000..75d25308b968f384739d7f33467afb1a29434b73 --- /dev/null +++ b/invokeai/frontend/web/patches/reselect@5.0.1.patch @@ -0,0 +1,241 @@ +diff --git a/dist/cjs/reselect.cjs b/dist/cjs/reselect.cjs +index 0ef3a648e253af4ada8f0a2086d6db9302b8ced9..2614db8c901c5a3be4a80d3ffed3be2cf175bf50 100644 +--- a/dist/cjs/reselect.cjs ++++ b/dist/cjs/reselect.cjs +@@ -639,6 +639,8 @@ function weakMapMemoize(func, options = {}) { + return memoized; + } + ++weakMapMemoize = lruMemoize ++ + // src/createSelectorCreator.ts + function createSelectorCreator(memoizeOrOptions, ...memoizeOptionsFromArgs) { + const createSelectorCreatorOptions = typeof memoizeOrOptions === "function" ? { +diff --git a/dist/reselect.browser.mjs b/dist/reselect.browser.mjs +index e8da6c11a333ef9ddf4cca51adbc405fe8f6265d..8bc64f0c19082c0015155d60c59869a46c9f180e 100644 +--- a/dist/reselect.browser.mjs ++++ b/dist/reselect.browser.mjs +@@ -1,2 +1,2 @@ +-var oe={inputStabilityCheck:"once",identityFunctionCheck:"once"},re=e=>{Object.assign(oe,e)};var M="NOT_FOUND";function w(e,t=`expected a function, instead received ${typeof e}`){if(typeof e!="function")throw new TypeError(t)}function V(e,t=`expected an object, instead received ${typeof e}`){if(typeof e!="object")throw new TypeError(t)}function ie(e,t="expected all items to be functions, instead received the following types: "){if(!e.every(n=>typeof n=="function")){let n=e.map(c=>typeof c=="function"?`function ${c.name||"unnamed"}()`:typeof c).join(", ");throw new TypeError(`${t}[${n}]`)}}var O=e=>Array.isArray(e)?e:[e];function K(e){let t=Array.isArray(e[0])?e[0]:e;return ie(t,"createSelector expects all input-selectors to be functions, but received the following types: "),t}function W(e,t){let n=[],{length:c}=e;for(let s=0;sthis._cachedRevision){let{fn:t}=this,n=new Set,c=S;S=n,this._cachedValue=t(),S=c,this.hits++,this._deps=Array.from(n),this._cachedRevision=this.revision}return S?.add(this),this._cachedValue}get revision(){return Math.max(...this._deps.map(t=>t.revision),0)}};function g(e){return e instanceof F||console.warn("Not a valid cell! ",e),e.value}function L(e,t){if(!(e instanceof F))throw new TypeError("setValue must be passed a tracked store created with `createStorage`.");e.value=e._lastValue=t}function $(e,t=v){return new F(e,t)}function Y(e){return w(e,"the first parameter to `createCache` must be a function"),new b(e)}var ce=(e,t)=>!1;function z(){return $(null,ce)}function k(e,t){L(e,t)}var A=e=>{let t=e.collectionTag;t===null&&(t=e.collectionTag=z()),g(t)},h=e=>{let t=e.collectionTag;t!==null&&k(t,null)};var Re=Symbol(),H=0,se=Object.getPrototypeOf({}),I=class{constructor(t){this.value=t;this.value=t,this.tag.value=t}proxy=new Proxy(this,C);tag=z();tags={};children={};collectionTag=null;id=H++},C={get(e,t){function n(){let{value:s}=e,o=Reflect.get(s,t);if(typeof t=="symbol"||t in se)return o;if(typeof o=="object"&&o!==null){let i=e.children[t];return i===void 0&&(i=e.children[t]=E(o)),i.tag&&g(i.tag),i.proxy}else{let i=e.tags[t];return i===void 0&&(i=e.tags[t]=z(),i.value=o),g(i),o}}return n()},ownKeys(e){return A(e),Reflect.ownKeys(e.value)},getOwnPropertyDescriptor(e,t){return Reflect.getOwnPropertyDescriptor(e.value,t)},has(e,t){return Reflect.has(e.value,t)}},N=class{constructor(t){this.value=t;this.value=t,this.tag.value=t}proxy=new Proxy([this],ue);tag=z();tags={};children={};collectionTag=null;id=H++},ue={get([e],t){return t==="length"&&A(e),C.get(e,t)},ownKeys([e]){return C.ownKeys(e)},getOwnPropertyDescriptor([e],t){return C.getOwnPropertyDescriptor(e,t)},has([e],t){return C.has(e,t)}};function E(e){return Array.isArray(e)?new N(e):new I(e)}function D(e,t){let{value:n,tags:c,children:s}=e;if(e.value=t,Array.isArray(n)&&Array.isArray(t)&&n.length!==t.length)h(e);else if(n!==t){let o=0,i=0,r=!1;for(let u in n)o++;for(let u in t)if(i++,!(u in n)){r=!0;break}(r||o!==i)&&h(e)}for(let o in c){let i=n[o],r=t[o];i!==r&&(h(e),k(c[o],r)),typeof r=="object"&&r!==null&&delete c[o]}for(let o in s){let i=s[o],r=t[o];i.value!==r&&(typeof r=="object"&&r!==null?D(i,r):(X(i),delete s[o]))}}function X(e){e.tag&&k(e.tag,null),h(e);for(let t in e.tags)k(e.tags[t],null);for(let t in e.children)X(e.children[t])}function le(e){let t;return{get(n){return t&&e(t.key,n)?t.value:M},put(n,c){t={key:n,value:c}},getEntries(){return t?[t]:[]},clear(){t=void 0}}}function ae(e,t){let n=[];function c(r){let l=n.findIndex(u=>t(r,u.key));if(l>-1){let u=n[l];return l>0&&(n.splice(l,1),n.unshift(u)),u.value}return M}function s(r,l){c(r)===M&&(n.unshift({key:r,value:l}),n.length>e&&n.pop())}function o(){return n}function i(){n=[]}return{get:c,put:s,getEntries:o,clear:i}}var x=(e,t)=>e===t;function j(e){return function(n,c){if(n===null||c===null||n.length!==c.length)return!1;let{length:s}=n;for(let o=0;oo(p.value,a));f&&(a=f.value,r!==0&&r--)}l.put(arguments,a)}return a}return u.clearCache=()=>{l.clear(),u.resetResultsCount()},u.resultsCount=()=>r,u.resetResultsCount=()=>{r=0},u}function me(e){let t=E([]),n=null,c=j(x),s=Y(()=>e.apply(null,t.proxy));function o(){return c(n,arguments)||(D(t,arguments),n=arguments),s.value}return o.clearCache=()=>s.clear(),o}var _=class{constructor(t){this.value=t}deref(){return this.value}},de=typeof WeakRef<"u"?WeakRef:_,fe=0,B=1;function T(){return{s:fe,v:void 0,o:null,p:null}}function R(e,t={}){let n=T(),{resultEqualityCheck:c}=t,s,o=0;function i(){let r=n,{length:l}=arguments;for(let m=0,f=l;m{n=T(),i.resetResultsCount()},i.resultsCount=()=>o,i.resetResultsCount=()=>{o=0},i}function J(e,...t){let n=typeof e=="function"?{memoize:e,memoizeOptions:t}:e;return(...s)=>{let o=0,i=0,r,l={},u=s.pop();typeof u=="object"&&(l=u,u=s.pop()),w(u,`createSelector expects an output function after the inputs, but received: [${typeof u}]`);let a={...n,...l},{memoize:m,memoizeOptions:f=[],argsMemoize:p=R,argsMemoizeOptions:d=[],devModeChecks:y={}}=a,Q=O(f),Z=O(d),q=K(s),P=m(function(){return o++,u.apply(null,arguments)},...Q),Me=!0,ee=p(function(){i++;let ne=W(q,arguments);return r=P.apply(null,ne),r},...Z);return Object.assign(ee,{resultFunc:u,memoizedResultFunc:P,dependencies:q,dependencyRecomputations:()=>i,resetDependencyRecomputations:()=>{i=0},lastResult:()=>r,recomputations:()=>o,resetRecomputations:()=>{o=0},memoize:m,argsMemoize:p})}}var U=J(R);var ye=(e,t=U)=>{V(e,`createStructuredSelector expects first argument to be an object where each property is a selector, instead received a ${typeof e}`);let n=Object.keys(e),c=n.map(o=>e[o]);return t(c,(...o)=>o.reduce((i,r,l)=>(i[n[l]]=r,i),{}))};export{U as createSelector,J as createSelectorCreator,ye as createStructuredSelector,pe as lruMemoize,x as referenceEqualityCheck,re as setGlobalDevModeChecks,me as unstable_autotrackMemoize,R as weakMapMemoize}; ++var oe={inputStabilityCheck:"once",identityFunctionCheck:"once"},re=e=>{Object.assign(oe,e)};var M="NOT_FOUND";function w(e,t=`expected a function, instead received ${typeof e}`){if(typeof e!="function")throw new TypeError(t)}function V(e,t=`expected an object, instead received ${typeof e}`){if(typeof e!="object")throw new TypeError(t)}function ie(e,t="expected all items to be functions, instead received the following types: "){if(!e.every(n=>typeof n=="function")){let n=e.map(c=>typeof c=="function"?`function ${c.name||"unnamed"}()`:typeof c).join(", ");throw new TypeError(`${t}[${n}]`)}}var O=e=>Array.isArray(e)?e:[e];function K(e){let t=Array.isArray(e[0])?e[0]:e;return ie(t,"createSelector expects all input-selectors to be functions, but received the following types: "),t}function W(e,t){let n=[],{length:c}=e;for(let s=0;sthis._cachedRevision){let{fn:t}=this,n=new Set,c=S;S=n,this._cachedValue=t(),S=c,this.hits++,this._deps=Array.from(n),this._cachedRevision=this.revision}return S?.add(this),this._cachedValue}get revision(){return Math.max(...this._deps.map(t=>t.revision),0)}};function g(e){return e instanceof F||console.warn("Not a valid cell! ",e),e.value}function L(e,t){if(!(e instanceof F))throw new TypeError("setValue must be passed a tracked store created with `createStorage`.");e.value=e._lastValue=t}function $(e,t=v){return new F(e,t)}function Y(e){return w(e,"the first parameter to `createCache` must be a function"),new b(e)}var ce=(e,t)=>!1;function z(){return $(null,ce)}function k(e,t){L(e,t)}var A=e=>{let t=e.collectionTag;t===null&&(t=e.collectionTag=z()),g(t)},h=e=>{let t=e.collectionTag;t!==null&&k(t,null)};var Re=Symbol(),H=0,se=Object.getPrototypeOf({}),I=class{constructor(t){this.value=t;this.value=t,this.tag.value=t}proxy=new Proxy(this,C);tag=z();tags={};children={};collectionTag=null;id=H++},C={get(e,t){function n(){let{value:s}=e,o=Reflect.get(s,t);if(typeof t=="symbol"||t in se)return o;if(typeof o=="object"&&o!==null){let i=e.children[t];return i===void 0&&(i=e.children[t]=E(o)),i.tag&&g(i.tag),i.proxy}else{let i=e.tags[t];return i===void 0&&(i=e.tags[t]=z(),i.value=o),g(i),o}}return n()},ownKeys(e){return A(e),Reflect.ownKeys(e.value)},getOwnPropertyDescriptor(e,t){return Reflect.getOwnPropertyDescriptor(e.value,t)},has(e,t){return Reflect.has(e.value,t)}},N=class{constructor(t){this.value=t;this.value=t,this.tag.value=t}proxy=new Proxy([this],ue);tag=z();tags={};children={};collectionTag=null;id=H++},ue={get([e],t){return t==="length"&&A(e),C.get(e,t)},ownKeys([e]){return C.ownKeys(e)},getOwnPropertyDescriptor([e],t){return C.getOwnPropertyDescriptor(e,t)},has([e],t){return C.has(e,t)}};function E(e){return Array.isArray(e)?new N(e):new I(e)}function D(e,t){let{value:n,tags:c,children:s}=e;if(e.value=t,Array.isArray(n)&&Array.isArray(t)&&n.length!==t.length)h(e);else if(n!==t){let o=0,i=0,r=!1;for(let u in n)o++;for(let u in t)if(i++,!(u in n)){r=!0;break}(r||o!==i)&&h(e)}for(let o in c){let i=n[o],r=t[o];i!==r&&(h(e),k(c[o],r)),typeof r=="object"&&r!==null&&delete c[o]}for(let o in s){let i=s[o],r=t[o];i.value!==r&&(typeof r=="object"&&r!==null?D(i,r):(X(i),delete s[o]))}}function X(e){e.tag&&k(e.tag,null),h(e);for(let t in e.tags)k(e.tags[t],null);for(let t in e.children)X(e.children[t])}function le(e){let t;return{get(n){return t&&e(t.key,n)?t.value:M},put(n,c){t={key:n,value:c}},getEntries(){return t?[t]:[]},clear(){t=void 0}}}function ae(e,t){let n=[];function c(r){let l=n.findIndex(u=>t(r,u.key));if(l>-1){let u=n[l];return l>0&&(n.splice(l,1),n.unshift(u)),u.value}return M}function s(r,l){c(r)===M&&(n.unshift({key:r,value:l}),n.length>e&&n.pop())}function o(){return n}function i(){n=[]}return{get:c,put:s,getEntries:o,clear:i}}var x=(e,t)=>e===t;function j(e){return function(n,c){if(n===null||c===null||n.length!==c.length)return!1;let{length:s}=n;for(let o=0;oo(p.value,a));f&&(a=f.value,r!==0&&r--)}l.put(arguments,a)}return a}return u.clearCache=()=>{l.clear(),u.resetResultsCount()},u.resultsCount=()=>r,u.resetResultsCount=()=>{r=0},u}function me(e){let t=E([]),n=null,c=j(x),s=Y(()=>e.apply(null,t.proxy));function o(){return c(n,arguments)||(D(t,arguments),n=arguments),s.value}return o.clearCache=()=>s.clear(),o}var _=class{constructor(t){this.value=t}deref(){return this.value}},de=typeof WeakRef<"u"?WeakRef:_,fe=0,B=1;function T(){return{s:fe,v:void 0,o:null,p:null}}function R(e,t={}){let n=T(),{resultEqualityCheck:c}=t,s,o=0;function i(){let r=n,{length:l}=arguments;for(let m=0,f=l;m{n=T(),i.resetResultsCount()},i.resultsCount=()=>o,i.resetResultsCount=()=>{o=0},i}function J(e,...t){let n=typeof e=="function"?{memoize:e,memoizeOptions:t}:e;return(...s)=>{let o=0,i=0,r,l={},u=s.pop();typeof u=="object"&&(l=u,u=s.pop()),w(u,`createSelector expects an output function after the inputs, but received: [${typeof u}]`);let a={...n,...l},{memoize:m,memoizeOptions:f=[],argsMemoize:p=R,argsMemoizeOptions:d=[],devModeChecks:y={}}=a,Q=O(f),Z=O(d),q=K(s),P=m(function(){return o++,u.apply(null,arguments)},...Q),Me=!0,ee=p(function(){i++;let ne=W(q,arguments);return r=P.apply(null,ne),r},...Z);return Object.assign(ee,{resultFunc:u,memoizedResultFunc:P,dependencies:q,dependencyRecomputations:()=>i,resetDependencyRecomputations:()=>{i=0},lastResult:()=>r,recomputations:()=>o,resetRecomputations:()=>{o=0},memoize:m,argsMemoize:p})}}var U=J(R);var ye=(e,t=U)=>{V(e,`createStructuredSelector expects first argument to be an object where each property is a selector, instead received a ${typeof e}`);let n=Object.keys(e),c=n.map(o=>e[o]);return t(c,(...o)=>o.reduce((i,r,l)=>(i[n[l]]=r,i),{}))};export{U as createSelector,J as createSelectorCreator,ye as createStructuredSelector,pe as lruMemoize,pe as weakMapMemoize,x as referenceEqualityCheck,re as setGlobalDevModeChecks,me as unstable_autotrackMemoize}; + //# sourceMappingURL=reselect.browser.mjs.map +\ No newline at end of file +diff --git a/dist/reselect.legacy-esm.js b/dist/reselect.legacy-esm.js +index 9c18982dd0756ccc240f23383b50b893415ba7b3..041426d1db1d1e78cfe35c4e55e38724b2db35dc 100644 +--- a/dist/reselect.legacy-esm.js ++++ b/dist/reselect.legacy-esm.js +@@ -625,6 +625,8 @@ function weakMapMemoize(func, options = {}) { + return memoized; + } + ++weakMapMemoize = lruMemoize ++ + // src/createSelectorCreator.ts + function createSelectorCreator(memoizeOrOptions, ...memoizeOptionsFromArgs) { + const createSelectorCreatorOptions = typeof memoizeOrOptions === "function" ? { +diff --git a/dist/reselect.mjs b/dist/reselect.mjs +index 531dfe6fc16e83dd27dbe90086b5aafea76adb9e..c27aca00d581919325cc595cfa3021cd53c1fa68 100644 +--- a/dist/reselect.mjs ++++ b/dist/reselect.mjs +@@ -606,6 +606,8 @@ function weakMapMemoize(func, options = {}) { + return memoized; + } + ++weakMapMemoize = lruMemoize ++ + // src/createSelectorCreator.ts + function createSelectorCreator(memoizeOrOptions, ...memoizeOptionsFromArgs) { + const createSelectorCreatorOptions = typeof memoizeOrOptions === "function" ? { +diff --git a/src/weakMapMemoize.ts b/src/weakMapMemoize.ts +index f723071db3a8a17f94431bc77cde2dbee026f57f..ddfeb0d7720e5463041d1474f54e58fdbc18fe6d 100644 +--- a/src/weakMapMemoize.ts ++++ b/src/weakMapMemoize.ts +@@ -1,6 +1,7 @@ + // Original source: + // - https://github.com/facebook/react/blob/0b974418c9a56f6c560298560265dcf4b65784bc/packages/react/src/ReactCache.js + ++import { lruMemoize } from '../dist/reselect.mjs' + import type { + AnyFunction, + DefaultMemoizeFields, +@@ -169,97 +170,99 @@ export interface WeakMapMemoizeOptions { + * @public + * @experimental + */ +-export function weakMapMemoize( +- func: Func, +- options: WeakMapMemoizeOptions> = {} +-) { +- let fnNode = createCacheNode() +- const { resultEqualityCheck } = options ++// export function weakMapMemoize( ++// func: Func, ++// options: WeakMapMemoizeOptions> = {} ++// ) { ++// let fnNode = createCacheNode() ++// const { resultEqualityCheck } = options + +- let lastResult: WeakRef | undefined ++// let lastResult: WeakRef | undefined + +- let resultsCount = 0 ++// let resultsCount = 0 + +- function memoized() { +- let cacheNode = fnNode +- const { length } = arguments +- for (let i = 0, l = length; i < l; i++) { +- const arg = arguments[i] +- if ( +- typeof arg === 'function' || +- (typeof arg === 'object' && arg !== null) +- ) { +- // Objects go into a WeakMap +- let objectCache = cacheNode.o +- if (objectCache === null) { +- cacheNode.o = objectCache = new WeakMap() +- } +- const objectNode = objectCache.get(arg) +- if (objectNode === undefined) { +- cacheNode = createCacheNode() +- objectCache.set(arg, cacheNode) +- } else { +- cacheNode = objectNode +- } +- } else { +- // Primitives go into a regular Map +- let primitiveCache = cacheNode.p +- if (primitiveCache === null) { +- cacheNode.p = primitiveCache = new Map() +- } +- const primitiveNode = primitiveCache.get(arg) +- if (primitiveNode === undefined) { +- cacheNode = createCacheNode() +- primitiveCache.set(arg, cacheNode) +- } else { +- cacheNode = primitiveNode +- } +- } +- } ++// function memoized() { ++// let cacheNode = fnNode ++// const { length } = arguments ++// for (let i = 0, l = length; i < l; i++) { ++// const arg = arguments[i] ++// if ( ++// typeof arg === 'function' || ++// (typeof arg === 'object' && arg !== null) ++// ) { ++// // Objects go into a WeakMap ++// let objectCache = cacheNode.o ++// if (objectCache === null) { ++// cacheNode.o = objectCache = new WeakMap() ++// } ++// const objectNode = objectCache.get(arg) ++// if (objectNode === undefined) { ++// cacheNode = createCacheNode() ++// objectCache.set(arg, cacheNode) ++// } else { ++// cacheNode = objectNode ++// } ++// } else { ++// // Primitives go into a regular Map ++// let primitiveCache = cacheNode.p ++// if (primitiveCache === null) { ++// cacheNode.p = primitiveCache = new Map() ++// } ++// const primitiveNode = primitiveCache.get(arg) ++// if (primitiveNode === undefined) { ++// cacheNode = createCacheNode() ++// primitiveCache.set(arg, cacheNode) ++// } else { ++// cacheNode = primitiveNode ++// } ++// } ++// } + +- const terminatedNode = cacheNode as unknown as TerminatedCacheNode ++// const terminatedNode = cacheNode as unknown as TerminatedCacheNode + +- let result ++// let result + +- if (cacheNode.s === TERMINATED) { +- result = cacheNode.v +- } else { +- // Allow errors to propagate +- result = func.apply(null, arguments as unknown as any[]) +- resultsCount++ +- } ++// if (cacheNode.s === TERMINATED) { ++// result = cacheNode.v ++// } else { ++// // Allow errors to propagate ++// result = func.apply(null, arguments as unknown as any[]) ++// resultsCount++ ++// } + +- terminatedNode.s = TERMINATED ++// terminatedNode.s = TERMINATED + +- if (resultEqualityCheck) { +- const lastResultValue = lastResult?.deref() ?? lastResult +- if ( +- lastResultValue != null && +- resultEqualityCheck(lastResultValue as ReturnType, result) +- ) { +- result = lastResultValue +- resultsCount !== 0 && resultsCount-- +- } ++// if (resultEqualityCheck) { ++// const lastResultValue = lastResult?.deref() ?? lastResult ++// if ( ++// lastResultValue != null && ++// resultEqualityCheck(lastResultValue as ReturnType, result) ++// ) { ++// result = lastResultValue ++// resultsCount !== 0 && resultsCount-- ++// } + +- const needsWeakRef = +- (typeof result === 'object' && result !== null) || +- typeof result === 'function' +- lastResult = needsWeakRef ? new Ref(result) : result +- } +- terminatedNode.v = result +- return result +- } ++// const needsWeakRef = ++// (typeof result === 'object' && result !== null) || ++// typeof result === 'function' ++// lastResult = needsWeakRef ? new Ref(result) : result ++// } ++// terminatedNode.v = result ++// return result ++// } + +- memoized.clearCache = () => { +- fnNode = createCacheNode() +- memoized.resetResultsCount() +- } ++// memoized.clearCache = () => { ++// fnNode = createCacheNode() ++// memoized.resetResultsCount() ++// } + +- memoized.resultsCount = () => resultsCount ++// memoized.resultsCount = () => resultsCount + +- memoized.resetResultsCount = () => { +- resultsCount = 0 +- } ++// memoized.resetResultsCount = () => { ++// resultsCount = 0 ++// } + +- return memoized as Func & Simplify +-} ++// return memoized as Func & Simplify ++// } ++ ++export const weakMapMemoize = lruMemoize diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml new file mode 100644 index 0000000000000000000000000000000000000000..17ead7a8e59a11c9ceef757d54d3dd45831b18c7 --- /dev/null +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -0,0 +1,9601 @@ +lockfileVersion: '6.0' + +settings: + autoInstallPeers: true + excludeLinksFromLockfile: false + +dependencies: + '@atlaskit/pragmatic-drag-and-drop': + specifier: ^1.4.0 + version: 1.4.0 + '@atlaskit/pragmatic-drag-and-drop-auto-scroll': + specifier: ^1.4.0 + version: 1.4.0 + '@atlaskit/pragmatic-drag-and-drop-hitbox': + specifier: ^1.0.3 + version: 1.0.3 + '@dagrejs/dagre': + specifier: ^1.1.4 + version: 1.1.4 + '@dagrejs/graphlib': + specifier: ^2.2.4 + version: 2.2.4 + '@fontsource-variable/inter': + specifier: ^5.1.0 + version: 5.1.0 + '@invoke-ai/ui-library': + specifier: ^0.0.43 + version: 0.0.43(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.1.0)(@types/react@18.3.11)(i18next@23.15.1)(react-dom@18.3.1)(react@18.3.1) + '@nanostores/react': + specifier: ^0.7.3 + version: 0.7.3(nanostores@0.11.3)(react@18.3.1) + '@reduxjs/toolkit': + specifier: 2.2.3 + version: 2.2.3(react-redux@9.1.2)(react@18.3.1) + '@roarr/browser-log-writer': + specifier: ^1.3.0 + version: 1.3.0 + async-mutex: + specifier: ^0.5.0 + version: 0.5.0 + chakra-react-select: + specifier: ^4.9.2 + version: 4.9.2(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/layout@2.3.1)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@emotion/react@11.13.3)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + cmdk: + specifier: ^1.0.0 + version: 1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + compare-versions: + specifier: ^6.1.1 + version: 6.1.1 + dateformat: + specifier: ^5.0.3 + version: 5.0.3 + fracturedjsonjs: + specifier: ^4.0.2 + version: 4.0.2 + framer-motion: + specifier: ^11.10.0 + version: 11.10.0(react-dom@18.3.1)(react@18.3.1) + i18next: + specifier: ^23.15.1 + version: 23.15.1 + i18next-http-backend: + specifier: ^2.6.1 + version: 2.6.1 + idb-keyval: + specifier: ^6.2.1 + version: 6.2.1 + jsondiffpatch: + specifier: ^0.6.0 + version: 0.6.0 + konva: + specifier: ^9.3.15 + version: 9.3.15 + lodash-es: + specifier: ^4.17.21 + version: 4.17.21 + lru-cache: + specifier: ^11.0.1 + version: 11.0.1 + nanoid: + specifier: ^5.0.7 + version: 5.0.7 + nanostores: + specifier: ^0.11.3 + version: 0.11.3 + new-github-issue-url: + specifier: ^1.0.0 + version: 1.0.0 + overlayscrollbars: + specifier: ^2.10.0 + version: 2.10.0 + overlayscrollbars-react: + specifier: ^0.5.6 + version: 0.5.6(overlayscrollbars@2.10.0)(react@18.3.1) + perfect-freehand: + specifier: ^1.2.2 + version: 1.2.2 + query-string: + specifier: ^9.1.0 + version: 9.1.0 + raf-throttle: + specifier: ^2.0.6 + version: 2.0.6 + react: + specifier: ^18.3.1 + version: 18.3.1 + react-colorful: + specifier: ^5.6.1 + version: 5.6.1(react-dom@18.3.1)(react@18.3.1) + react-dom: + specifier: ^18.3.1 + version: 18.3.1(react@18.3.1) + react-dropzone: + specifier: ^14.2.9 + version: 14.2.9(react@18.3.1) + react-error-boundary: + specifier: ^4.0.13 + version: 4.0.13(react@18.3.1) + react-hook-form: + specifier: ^7.53.0 + version: 7.53.0(react@18.3.1) + react-hotkeys-hook: + specifier: 4.5.0 + version: 4.5.0(react-dom@18.3.1)(react@18.3.1) + react-i18next: + specifier: ^15.0.2 + version: 15.0.2(i18next@23.15.1)(react-dom@18.3.1)(react@18.3.1) + react-icons: + specifier: ^5.3.0 + version: 5.3.0(react@18.3.1) + react-redux: + specifier: 9.1.2 + version: 9.1.2(@types/react@18.3.11)(react@18.3.1)(redux@5.0.1) + react-resizable-panels: + specifier: ^2.1.4 + version: 2.1.4(react-dom@18.3.1)(react@18.3.1) + react-use: + specifier: ^17.5.1 + version: 17.5.1(react-dom@18.3.1)(react@18.3.1) + react-virtuoso: + specifier: ^4.10.4 + version: 4.10.4(react-dom@18.3.1)(react@18.3.1) + reactflow: + specifier: ^11.11.4 + version: 11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + redux-dynamic-middlewares: + specifier: ^2.2.0 + version: 2.2.0 + redux-remember: + specifier: ^5.1.0 + version: 5.1.0(redux@5.0.1) + redux-undo: + specifier: ^1.1.0 + version: 1.1.0 + rfdc: + specifier: ^1.4.1 + version: 1.4.1 + roarr: + specifier: ^7.21.1 + version: 7.21.1 + serialize-error: + specifier: ^11.0.3 + version: 11.0.3 + socket.io-client: + specifier: ^4.8.0 + version: 4.8.0 + stable-hash: + specifier: ^0.0.4 + version: 0.0.4 + use-debounce: + specifier: ^10.0.3 + version: 10.0.3(react@18.3.1) + use-device-pixel-ratio: + specifier: ^1.1.2 + version: 1.1.2(react@18.3.1) + uuid: + specifier: ^10.0.0 + version: 10.0.0 + zod: + specifier: ^3.23.8 + version: 3.23.8 + zod-validation-error: + specifier: ^3.4.0 + version: 3.4.0(zod@3.23.8) + +devDependencies: + '@invoke-ai/eslint-config-react': + specifier: ^0.0.14 + version: 0.0.14(eslint@8.57.1)(prettier@3.3.3)(typescript@5.6.2) + '@invoke-ai/prettier-config-react': + specifier: ^0.0.7 + version: 0.0.7(prettier@3.3.3) + '@storybook/addon-essentials': + specifier: ^8.3.4 + version: 8.3.4(storybook@8.3.4) + '@storybook/addon-interactions': + specifier: ^8.3.4 + version: 8.3.4(storybook@8.3.4) + '@storybook/addon-links': + specifier: ^8.3.4 + version: 8.3.4(react@18.3.1)(storybook@8.3.4) + '@storybook/addon-storysource': + specifier: ^8.3.4 + version: 8.3.4(storybook@8.3.4) + '@storybook/manager-api': + specifier: ^8.3.4 + version: 8.3.4(storybook@8.3.4) + '@storybook/react': + specifier: ^8.3.4 + version: 8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4)(typescript@5.6.2) + '@storybook/react-vite': + specifier: ^8.3.4 + version: 8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4)(typescript@5.6.2)(vite@5.4.8) + '@storybook/theming': + specifier: ^8.3.4 + version: 8.3.4(storybook@8.3.4) + '@types/dateformat': + specifier: ^5.0.2 + version: 5.0.2 + '@types/lodash-es': + specifier: ^4.17.12 + version: 4.17.12 + '@types/node': + specifier: ^20.16.10 + version: 20.16.10 + '@types/react': + specifier: ^18.3.11 + version: 18.3.11 + '@types/react-dom': + specifier: ^18.3.0 + version: 18.3.0 + '@types/uuid': + specifier: ^10.0.0 + version: 10.0.0 + '@vitejs/plugin-react-swc': + specifier: ^3.7.1 + version: 3.7.1(vite@5.4.8) + '@vitest/coverage-v8': + specifier: ^1.6.0 + version: 1.6.0(vitest@1.6.0) + '@vitest/ui': + specifier: ^1.6.0 + version: 1.6.0(vitest@1.6.0) + concurrently: + specifier: ^8.2.2 + version: 8.2.2 + csstype: + specifier: ^3.1.3 + version: 3.1.3 + dpdm: + specifier: ^3.14.0 + version: 3.14.0 + eslint: + specifier: ^8.57.1 + version: 8.57.1 + eslint-plugin-i18next: + specifier: ^6.1.0 + version: 6.1.0 + eslint-plugin-path: + specifier: ^1.3.0 + version: 1.3.0(eslint@8.57.1) + knip: + specifier: ^5.31.0 + version: 5.31.0(@types/node@20.16.10)(typescript@5.6.2) + openapi-types: + specifier: ^12.1.3 + version: 12.1.3 + openapi-typescript: + specifier: ^7.4.1 + version: 7.4.1(typescript@5.6.2) + prettier: + specifier: ^3.3.3 + version: 3.3.3 + rollup-plugin-visualizer: + specifier: ^5.12.0 + version: 5.12.0 + storybook: + specifier: ^8.3.4 + version: 8.3.4 + tsafe: + specifier: ^1.7.5 + version: 1.7.5 + type-fest: + specifier: ^4.26.1 + version: 4.26.1 + typescript: + specifier: ^5.6.2 + version: 5.6.2 + vite: + specifier: ^5.4.8 + version: 5.4.8(@types/node@20.16.10) + vite-plugin-css-injected-by-js: + specifier: ^3.5.2 + version: 3.5.2(vite@5.4.8) + vite-plugin-dts: + specifier: ^3.9.1 + version: 3.9.1(@types/node@20.16.10)(typescript@5.6.2)(vite@5.4.8) + vite-plugin-eslint: + specifier: ^1.8.1 + version: 1.8.1(eslint@8.57.1)(vite@5.4.8) + vite-tsconfig-paths: + specifier: ^4.3.2 + version: 4.3.2(typescript@5.6.2)(vite@5.4.8) + vitest: + specifier: ^1.6.0 + version: 1.6.0(@types/node@20.16.10)(@vitest/ui@1.6.0) + +packages: + + /@adobe/css-tools@4.4.0: + resolution: {integrity: sha512-Ff9+ksdQQB3rMncgqDK78uLznstjyfIf2Arnh22pW8kBpLs6rpKDwgnZT46hin5Hl1WzazzK64DOrhSwYpS7bQ==} + dev: true + + /@ampproject/remapping@2.3.0: + resolution: {integrity: sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + dev: true + + /@atlaskit/pragmatic-drag-and-drop-auto-scroll@1.4.0: + resolution: {integrity: sha512-5GoikoTSW13UX76F9TDeWB8x3jbbGlp/Y+3aRkHe1MOBMkrWkwNpJ42MIVhhX/6NSeaZiPumP0KbGJVs2tOWSQ==} + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.4.0 + '@babel/runtime': 7.25.7 + dev: false + + /@atlaskit/pragmatic-drag-and-drop-hitbox@1.0.3: + resolution: {integrity: sha512-/Sbu/HqN2VGLYBhnsG7SbRNg98XKkbF6L7XDdBi+izRybfaK1FeMfodPpm/xnBHPJzwYMdkE0qtLyv6afhgMUA==} + dependencies: + '@atlaskit/pragmatic-drag-and-drop': 1.4.0 + '@babel/runtime': 7.25.7 + dev: false + + /@atlaskit/pragmatic-drag-and-drop@1.4.0: + resolution: {integrity: sha512-qRY3PTJIcxfl/QB8Gwswz+BRvlmgAC5pB+J2hL6dkIxgqAgVwOhAamMUKsrOcFU/axG2Q7RbNs1xfoLKDuhoPg==} + dependencies: + '@babel/runtime': 7.25.7 + bind-event-listener: 3.0.0 + raf-schd: 4.0.3 + dev: false + + /@babel/code-frame@7.25.7: + resolution: {integrity: sha512-0xZJFNE5XMpENsgfHYTw8FbX4kv53mFLn2i3XPoq69LyhYSCBJtitaHx9QnsVTrsogI4Z3+HtEfZ2/GFPOtf5g==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/highlight': 7.25.7 + picocolors: 1.1.0 + + /@babel/compat-data@7.25.7: + resolution: {integrity: sha512-9ickoLz+hcXCeh7jrcin+/SLWm+GkxE2kTvoYyp38p4WkdFXfQJxDFGWp/YHjiKLPx06z2A7W8XKuqbReXDzsw==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/core@7.25.7: + resolution: {integrity: sha512-yJ474Zv3cwiSOO9nXJuqzvwEeM+chDuQ8GJirw+pZ91sCGCyOZ3dJkVE09fTV0VEVzXyLWhh3G/AolYTPX7Mow==} + engines: {node: '>=6.9.0'} + dependencies: + '@ampproject/remapping': 2.3.0 + '@babel/code-frame': 7.25.7 + '@babel/generator': 7.25.7 + '@babel/helper-compilation-targets': 7.25.7 + '@babel/helper-module-transforms': 7.25.7(@babel/core@7.25.7) + '@babel/helpers': 7.25.7 + '@babel/parser': 7.25.7 + '@babel/template': 7.25.7 + '@babel/traverse': 7.25.7 + '@babel/types': 7.25.7 + convert-source-map: 2.0.0 + debug: 4.3.7(supports-color@9.4.0) + gensync: 1.0.0-beta.2 + json5: 2.2.3 + semver: 6.3.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/generator@7.25.7: + resolution: {integrity: sha512-5Dqpl5fyV9pIAD62yK9P7fcA768uVPUyrQmqpqstHWgMma4feF1x/oFysBCVZLY5wJ2GkMUCdsNDnGZrPoR6rA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/types': 7.25.7 + '@jridgewell/gen-mapping': 0.3.5 + '@jridgewell/trace-mapping': 0.3.25 + jsesc: 3.0.2 + + /@babel/helper-compilation-targets@7.25.7: + resolution: {integrity: sha512-DniTEax0sv6isaw6qSQSfV4gVRNtw2rte8HHM45t9ZR0xILaufBRNkpMifCRiAPyvL4ACD6v0gfCwCmtOQaV4A==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/compat-data': 7.25.7 + '@babel/helper-validator-option': 7.25.7 + browserslist: 4.24.0 + lru-cache: 5.1.1 + semver: 6.3.1 + dev: true + + /@babel/helper-module-imports@7.25.7: + resolution: {integrity: sha512-o0xCgpNmRohmnoWKQ0Ij8IdddjyBFE4T2kagL/x6M3+4zUgc+4qTOUBoNe4XxDskt1HPKO007ZPiMgLDq2s7Kw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.25.7 + '@babel/types': 7.25.7 + transitivePeerDependencies: + - supports-color + + /@babel/helper-module-transforms@7.25.7(@babel/core@7.25.7): + resolution: {integrity: sha512-k/6f8dKG3yDz/qCwSM+RKovjMix563SLxQFo0UhRNo239SP6n9u5/eLtKD6EAjwta2JHJ49CsD8pms2HdNiMMQ==} + engines: {node: '>=6.9.0'} + peerDependencies: + '@babel/core': ^7.0.0 + dependencies: + '@babel/core': 7.25.7 + '@babel/helper-module-imports': 7.25.7 + '@babel/helper-simple-access': 7.25.7 + '@babel/helper-validator-identifier': 7.25.7 + '@babel/traverse': 7.25.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-simple-access@7.25.7: + resolution: {integrity: sha512-FPGAkJmyoChQeM+ruBGIDyrT2tKfZJO8NcxdC+CWNJi7N8/rZpSxK7yvBJ5O/nF1gfu5KzN7VKG3YVSLFfRSxQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/traverse': 7.25.7 + '@babel/types': 7.25.7 + transitivePeerDependencies: + - supports-color + dev: true + + /@babel/helper-string-parser@7.25.7: + resolution: {integrity: sha512-CbkjYdsJNHFk8uqpEkpCvRs3YRp9tY6FmFY7wLMSYuGYkrdUi7r2lc4/wqsvlHoMznX3WJ9IP8giGPq68T/Y6g==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-identifier@7.25.7: + resolution: {integrity: sha512-AM6TzwYqGChO45oiuPqwL2t20/HdMC1rTPAesnBCgPCSF1x3oN9MVUwQV2iyz4xqWrctwK5RNC8LV22kaQCNYg==} + engines: {node: '>=6.9.0'} + + /@babel/helper-validator-option@7.25.7: + resolution: {integrity: sha512-ytbPLsm+GjArDYXJ8Ydr1c/KJuutjF2besPNbIZnZ6MKUxi/uTA22t2ymmA4WFjZFpjiAMO0xuuJPqK2nvDVfQ==} + engines: {node: '>=6.9.0'} + dev: true + + /@babel/helpers@7.25.7: + resolution: {integrity: sha512-Sv6pASx7Esm38KQpF/U/OXLwPPrdGHNKoeblRxgZRLXnAtnkEe4ptJPDtAZM7fBLadbc1Q07kQpSiGQ0Jg6tRA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/template': 7.25.7 + '@babel/types': 7.25.7 + dev: true + + /@babel/highlight@7.25.7: + resolution: {integrity: sha512-iYyACpW3iW8Fw+ZybQK+drQre+ns/tKpXbNESfrhNnPLIklLbXr7MYJ6gPEd0iETGLOK+SxMjVvKb/ffmk+FEw==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-validator-identifier': 7.25.7 + chalk: 2.4.2 + js-tokens: 4.0.0 + picocolors: 1.1.0 + + /@babel/parser@7.25.7: + resolution: {integrity: sha512-aZn7ETtQsjjGG5HruveUK06cU3Hljuhd9Iojm4M8WWv3wLE6OkE5PWbDUkItmMgegmccaITudyuW5RPYrYlgWw==} + engines: {node: '>=6.0.0'} + hasBin: true + dependencies: + '@babel/types': 7.25.7 + + /@babel/runtime@7.25.7: + resolution: {integrity: sha512-FjoyLe754PMiYsFaN5C94ttGiOmBNYTf6pLr4xXHAT5uctHb092PBszndLDR5XA/jghQvn4n7JMHl7dmTgbm9w==} + engines: {node: '>=6.9.0'} + dependencies: + regenerator-runtime: 0.14.1 + + /@babel/template@7.25.7: + resolution: {integrity: sha512-wRwtAgI3bAS+JGU2upWNL9lSlDcRCqD05BZ1n3X2ONLH1WilFP6O1otQjeMK/1g0pvYcXC7b/qVUB1keofjtZA==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.25.7 + '@babel/parser': 7.25.7 + '@babel/types': 7.25.7 + + /@babel/traverse@7.25.7: + resolution: {integrity: sha512-jatJPT1Zjqvh/1FyJs6qAHL+Dzb7sTb+xr7Q+gM1b+1oBsMsQQ4FkVKb6dFlJvLlVssqkRzV05Jzervt9yhnzg==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/code-frame': 7.25.7 + '@babel/generator': 7.25.7 + '@babel/parser': 7.25.7 + '@babel/template': 7.25.7 + '@babel/types': 7.25.7 + debug: 4.3.7(supports-color@9.4.0) + globals: 11.12.0 + transitivePeerDependencies: + - supports-color + + /@babel/types@7.25.7: + resolution: {integrity: sha512-vwIVdXG+j+FOpkwqHRcBgHLYNL7XMkufrlaFvL9o6Ai9sJn9+PdyIL5qa0XzTZw084c+u9LOls53eoZWP/W5WQ==} + engines: {node: '>=6.9.0'} + dependencies: + '@babel/helper-string-parser': 7.25.7 + '@babel/helper-validator-identifier': 7.25.7 + to-fast-properties: 2.0.0 + + /@base2/pretty-print-object@1.0.1: + resolution: {integrity: sha512-4iri8i1AqYHJE2DstZYkyEprg6Pq6sKx3xn5FpySk9sNhH7qN2LLlHJCfDTZRILNwQNPD7mATWM0TBui7uC1pA==} + dev: true + + /@bcoe/v8-coverage@0.2.3: + resolution: {integrity: sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw==} + dev: true + + /@chakra-ui/anatomy@2.2.2: + resolution: {integrity: sha512-MV6D4VLRIHr4PkW4zMyqfrNS1mPlCTiCXwvYGtDFQYr+xHFfonhAuf9WjsSc0nyp2m0OdkSLnzmVKkZFLo25Tg==} + dev: false + + /@chakra-ui/anatomy@2.3.4: + resolution: {integrity: sha512-fFIYN7L276gw0Q7/ikMMlZxP7mvnjRaWJ7f3Jsf9VtDOi6eAYIBRrhQe6+SZ0PGmoOkRaBc7gSE5oeIbgFFyrw==} + dev: false + + /@chakra-ui/breakpoint-utils@2.0.8: + resolution: {integrity: sha512-Pq32MlEX9fwb5j5xx8s18zJMARNHlQZH2VH1RZgfgRDpp7DcEgtRW5AInfN5CfqdHLO1dGxA7I3MqEuL5JnIsA==} + dependencies: + '@chakra-ui/shared-utils': 2.0.5 + dev: false + + /@chakra-ui/clickable@2.1.0(react@18.3.1): + resolution: {integrity: sha512-flRA/ClPUGPYabu+/GLREZVZr9j2uyyazCAUHAdrTUEdDYCr31SVGhgh7dgKdtq23bOvAQJpIJjw/0Bs0WvbXw==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.3.1) + '@chakra-ui/shared-utils': 2.0.5 + react: 18.3.1 + dev: false + + /@chakra-ui/color-mode@2.2.0(react@18.3.1): + resolution: {integrity: sha512-niTEA8PALtMWRI9wJ4LL0CSBDo8NBfLNp4GD6/0hstcm3IlbBHTVKxN6HwSaoNYfphDQLxCjT4yG+0BJA5tFpg==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-use-safe-layout-effect': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/descendant@3.1.0(react@18.3.1): + resolution: {integrity: sha512-VxCIAir08g5w27klLyi7PVo8BxhW4tgU/lxQyujkmi4zx7hT9ZdrcQLAted/dAa+aSIZ14S1oV0Q9lGjsAdxUQ==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-context': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/dom-utils@2.1.0: + resolution: {integrity: sha512-ZmF2qRa1QZ0CMLU8M1zCfmw29DmPNtfjR9iTo74U5FPr3i1aoAh7fbJ4qAlZ197Xw9eAW28tvzQuoVWeL5C7fQ==} + dev: false + + /@chakra-ui/form-control@2.2.0(@chakra-ui/system@2.6.2)(react@18.3.1): + resolution: {integrity: sha512-wehLC1t4fafCVJ2RvJQT2jyqsAwX7KymmiGqBu7nQoQz8ApTkGABWpo/QwDh3F/dBLrouHDoOvGmYTqft3Mirw==} + peerDependencies: + '@chakra-ui/system': '>=2.0.0' + react: '>=18' + dependencies: + '@chakra-ui/icon': 3.2.0(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/react-context': 2.1.0(react@18.3.1) + '@chakra-ui/react-types': 2.0.7(react@18.3.1) + '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.3.1) + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/system': 2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/hooks@2.4.2(react@18.3.1): + resolution: {integrity: sha512-LRKiVE1oA7afT5tbbSKAy7Uas2xFHE6IkrQdbhWCHmkHBUtPvjQQDgwtnd4IRZPmoEfNGwoJ/MQpwOM/NRTTwA==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/utils': 2.2.2(react@18.3.1) + '@zag-js/element-size': 0.31.1 + copy-to-clipboard: 3.3.3 + framesync: 6.1.2 + react: 18.3.1 + dev: false + + /@chakra-ui/icon@3.2.0(@chakra-ui/system@2.6.2)(react@18.3.1): + resolution: {integrity: sha512-xxjGLvlX2Ys4H0iHrI16t74rG9EBcpFvJ3Y3B7KMQTrnW34Kf7Da/UC8J67Gtx85mTHW020ml85SVPKORWNNKQ==} + peerDependencies: + '@chakra-ui/system': '>=2.0.0' + react: '>=18' + dependencies: + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/system': 2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/icons@2.2.4(@chakra-ui/react@2.10.2)(react@18.3.1): + resolution: {integrity: sha512-l5QdBgwrAg3Sc2BRqtNkJpfuLw/pWRDwwT58J6c4PqQT6wzXxyNa8Q0PForu1ltB5qEiFb1kxr/F/HO1EwNa6g==} + peerDependencies: + '@chakra-ui/react': '>=2.0.0' + react: '>=18' + dependencies: + '@chakra-ui/react': 2.10.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(@types/react@18.3.11)(framer-motion@11.10.0)(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/layout@2.3.1(@chakra-ui/system@2.6.2)(react@18.3.1): + resolution: {integrity: sha512-nXuZ6WRbq0WdgnRgLw+QuxWAHuhDtVX8ElWqcTK+cSMFg/52eVP47czYBE5F35YhnoW2XBwfNoNgZ7+e8Z01Rg==} + peerDependencies: + '@chakra-ui/system': '>=2.0.0' + react: '>=18' + dependencies: + '@chakra-ui/breakpoint-utils': 2.0.8 + '@chakra-ui/icon': 3.2.0(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/object-utils': 2.1.0 + '@chakra-ui/react-children-utils': 2.0.6(react@18.3.1) + '@chakra-ui/react-context': 2.1.0(react@18.3.1) + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/system': 2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/lazy-utils@2.0.5: + resolution: {integrity: sha512-UULqw7FBvcckQk2n3iPO56TMJvDsNv0FKZI6PlUNJVaGsPbsYxK/8IQ60vZgaTVPtVcjY6BE+y6zg8u9HOqpyg==} + dev: false + + /@chakra-ui/media-query@3.3.0(@chakra-ui/system@2.6.2)(react@18.3.1): + resolution: {integrity: sha512-IsTGgFLoICVoPRp9ykOgqmdMotJG0CnPsKvGQeSFOB/dZfIujdVb14TYxDU4+MURXry1MhJ7LzZhv+Ml7cr8/g==} + peerDependencies: + '@chakra-ui/system': '>=2.0.0' + react: '>=18' + dependencies: + '@chakra-ui/breakpoint-utils': 2.0.8 + '@chakra-ui/react-env': 3.1.0(react@18.3.1) + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/system': 2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/menu@2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.10.0)(react@18.3.1): + resolution: {integrity: sha512-lJS7XEObzJxsOwWQh7yfG4H8FzFPRP5hVPN/CL+JzytEINCSBvsCDHrYPQGp7jzpCi8vnTqQQGQe0f8dwnXd2g==} + peerDependencies: + '@chakra-ui/system': '>=2.0.0' + framer-motion: '>=4.0.0' + react: '>=18' + dependencies: + '@chakra-ui/clickable': 2.1.0(react@18.3.1) + '@chakra-ui/descendant': 3.1.0(react@18.3.1) + '@chakra-ui/lazy-utils': 2.0.5 + '@chakra-ui/popper': 3.1.0(react@18.3.1) + '@chakra-ui/react-children-utils': 2.0.6(react@18.3.1) + '@chakra-ui/react-context': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-animation-state': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-controllable-state': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-disclosure': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-focus-effect': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-outside-click': 2.2.0(react@18.3.1) + '@chakra-ui/react-use-update-effect': 2.1.0(react@18.3.1) + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/system': 2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1) + '@chakra-ui/transition': 2.1.0(framer-motion@11.10.0)(react@18.3.1) + framer-motion: 11.10.0(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/object-utils@2.1.0: + resolution: {integrity: sha512-tgIZOgLHaoti5PYGPTwK3t/cqtcycW0owaiOXoZOcpwwX/vlVb+H1jFsQyWiiwQVPt9RkoSLtxzXamx+aHH+bQ==} + dev: false + + /@chakra-ui/popper@3.1.0(react@18.3.1): + resolution: {integrity: sha512-ciDdpdYbeFG7og6/6J8lkTFxsSvwTdMLFkpVylAF6VNC22jssiWfquj2eyD4rJnzkRFPvIWJq8hvbfhsm+AjSg==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-types': 2.0.7(react@18.3.1) + '@chakra-ui/react-use-merge-refs': 2.1.0(react@18.3.1) + '@popperjs/core': 2.11.8 + react: 18.3.1 + dev: false + + /@chakra-ui/portal@2.1.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-9q9KWf6SArEcIq1gGofNcFPSWEyl+MfJjEUg/un1SMlQjaROOh3zYr+6JAwvcORiX7tyHosnmWC3d3wI2aPSQg==} + peerDependencies: + react: '>=18' + react-dom: '>=18' + dependencies: + '@chakra-ui/react-context': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-safe-layout-effect': 2.1.0(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@chakra-ui/react-children-utils@2.0.6(react@18.3.1): + resolution: {integrity: sha512-QVR2RC7QsOsbWwEnq9YduhpqSFnZGvjjGREV8ygKi8ADhXh93C8azLECCUVgRJF2Wc+So1fgxmjLcbZfY2VmBA==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.3.1 + dev: false + + /@chakra-ui/react-context@2.1.0(react@18.3.1): + resolution: {integrity: sha512-iahyStvzQ4AOwKwdPReLGfDesGG+vWJfEsn0X/NoGph/SkN+HXtv2sCfYFFR9k7bb+Kvc6YfpLlSuLvKMHi2+w==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.3.1 + dev: false + + /@chakra-ui/react-env@3.1.0(react@18.3.1): + resolution: {integrity: sha512-Vr96GV2LNBth3+IKzr/rq1IcnkXv+MLmwjQH6C8BRtn3sNskgDFD5vLkVXcEhagzZMCh8FR3V/bzZPojBOyNhw==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-use-safe-layout-effect': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/react-types@2.0.7(react@18.3.1): + resolution: {integrity: sha512-12zv2qIZ8EHwiytggtGvo4iLT0APris7T0qaAWqzpUGS0cdUtR8W+V1BJ5Ocq+7tA6dzQ/7+w5hmXih61TuhWQ==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-animation-state@2.1.0(react@18.3.1): + resolution: {integrity: sha512-CFZkQU3gmDBwhqy0vC1ryf90BVHxVN8cTLpSyCpdmExUEtSEInSCGMydj2fvn7QXsz/za8JNdO2xxgJwxpLMtg==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/dom-utils': 2.1.0 + '@chakra-ui/react-use-event-listener': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-callback-ref@2.1.0(react@18.3.1): + resolution: {integrity: sha512-efnJrBtGDa4YaxDzDE90EnKD3Vkh5a1t3w7PhnRQmsphLy3g2UieasoKTlT2Hn118TwDjIv5ZjHJW6HbzXA9wQ==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-controllable-state@2.1.0(react@18.3.1): + resolution: {integrity: sha512-QR/8fKNokxZUs4PfxjXuwl0fj/d71WPrmLJvEpCTkHjnzu7LnYvzoe2wB867IdooQJL0G1zBxl0Dq+6W1P3jpg==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-use-callback-ref': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-disclosure@2.1.0(react@18.3.1): + resolution: {integrity: sha512-Ax4pmxA9LBGMyEZJhhUZobg9C0t3qFE4jVF1tGBsrLDcdBeLR9fwOogIPY9Hf0/wqSlAryAimICbr5hkpa5GSw==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-use-callback-ref': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-event-listener@2.1.0(react@18.3.1): + resolution: {integrity: sha512-U5greryDLS8ISP69DKDsYcsXRtAdnTQT+jjIlRYZ49K/XhUR/AqVZCK5BkR1spTDmO9H8SPhgeNKI70ODuDU/Q==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-use-callback-ref': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-focus-effect@2.1.0(react@18.3.1): + resolution: {integrity: sha512-xzVboNy7J64xveLcxTIJ3jv+lUJKDwRM7Szwn9tNzUIPD94O3qwjV7DDCUzN2490nSYDF4OBMt/wuDBtaR3kUQ==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/dom-utils': 2.1.0 + '@chakra-ui/react-use-event-listener': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-safe-layout-effect': 2.1.0(react@18.3.1) + '@chakra-ui/react-use-update-effect': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-merge-refs@2.1.0(react@18.3.1): + resolution: {integrity: sha512-lERa6AWF1cjEtWSGjxWTaSMvneccnAVH4V4ozh8SYiN9fSPZLlSG3kNxfNzdFvMEhM7dnP60vynF7WjGdTgQbQ==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-outside-click@2.2.0(react@18.3.1): + resolution: {integrity: sha512-PNX+s/JEaMneijbgAM4iFL+f3m1ga9+6QK0E5Yh4s8KZJQ/bLwZzdhMz8J/+mL+XEXQ5J0N8ivZN28B82N1kNw==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/react-use-callback-ref': 2.1.0(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-safe-layout-effect@2.1.0(react@18.3.1): + resolution: {integrity: sha512-Knbrrx/bcPwVS1TorFdzrK/zWA8yuU/eaXDkNj24IrKoRlQrSBFarcgAEzlCHtzuhufP3OULPkELTzz91b0tCw==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.3.1 + dev: false + + /@chakra-ui/react-use-update-effect@2.1.0(react@18.3.1): + resolution: {integrity: sha512-ND4Q23tETaR2Qd3zwCKYOOS1dfssojPLJMLvUtUbW5M9uW1ejYWgGUobeAiOVfSplownG8QYMmHTP86p/v0lbA==} + peerDependencies: + react: '>=18' + dependencies: + react: 18.3.1 + dev: false + + /@chakra-ui/react-utils@2.0.12(react@18.3.1): + resolution: {integrity: sha512-GbSfVb283+YA3kA8w8xWmzbjNWk14uhNpntnipHCftBibl0lxtQ9YqMFQLwuFOO0U2gYVocszqqDWX+XNKq9hw==} + peerDependencies: + react: '>=18' + dependencies: + '@chakra-ui/utils': 2.0.15 + react: 18.3.1 + dev: false + + /@chakra-ui/react@2.10.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(@types/react@18.3.11)(framer-motion@11.10.0)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-TfIHTqTlxTHYJZBtpiR5EZasPUrLYKJxdbHkdOJb5G1OQ+2c5kKl5XA7c2pMtsEptzb7KxAAIB62t3hxdfWp1w==} + peerDependencies: + '@emotion/react': '>=11' + '@emotion/styled': '>=11' + framer-motion: '>=4.0.0' + react: '>=18' + react-dom: '>=18' + dependencies: + '@chakra-ui/hooks': 2.4.2(react@18.3.1) + '@chakra-ui/styled-system': 2.11.2(react@18.3.1) + '@chakra-ui/theme': 3.4.6(@chakra-ui/styled-system@2.11.2)(react@18.3.1) + '@chakra-ui/utils': 2.2.2(react@18.3.1) + '@emotion/react': 11.13.3(@types/react@18.3.11)(react@18.3.1) + '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(@types/react@18.3.11)(react@18.3.1) + '@popperjs/core': 2.11.8 + '@zag-js/focus-visible': 0.31.1 + aria-hidden: 1.2.4 + framer-motion: 11.10.0(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-fast-compare: 3.2.2 + react-focus-lock: 2.13.2(@types/react@18.3.11)(react@18.3.1) + react-remove-scroll: 2.6.0(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + dev: false + + /@chakra-ui/shared-utils@2.0.5: + resolution: {integrity: sha512-4/Wur0FqDov7Y0nCXl7HbHzCg4aq86h+SXdoUeuCMD3dSj7dpsVnStLYhng1vxvlbUnLpdF4oz5Myt3i/a7N3Q==} + dev: false + + /@chakra-ui/spinner@2.1.0(@chakra-ui/system@2.6.2)(react@18.3.1): + resolution: {integrity: sha512-hczbnoXt+MMv/d3gE+hjQhmkzLiKuoTo42YhUG7Bs9OSv2lg1fZHW1fGNRFP3wTi6OIbD044U1P9HK+AOgFH3g==} + peerDependencies: + '@chakra-ui/system': '>=2.0.0' + react: '>=18' + dependencies: + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/system': 2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/styled-system@2.11.2(react@18.3.1): + resolution: {integrity: sha512-y++z2Uop+hjfZX9mbH88F1ikazPv32asD2er56zMJBemUAzweXnHTpiCQbluEDSUDhqmghVZAdb+5L4XLbsRxA==} + dependencies: + '@chakra-ui/utils': 2.2.2(react@18.3.1) + csstype: 3.1.3 + transitivePeerDependencies: + - react + dev: false + + /@chakra-ui/styled-system@2.9.2: + resolution: {integrity: sha512-To/Z92oHpIE+4nk11uVMWqo2GGRS86coeMmjxtpnErmWRdLcp1WVCVRAvn+ZwpLiNR+reWFr2FFqJRsREuZdAg==} + dependencies: + '@chakra-ui/shared-utils': 2.0.5 + csstype: 3.1.3 + lodash.mergewith: 4.6.2 + dev: false + + /@chakra-ui/system@2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1): + resolution: {integrity: sha512-EGtpoEjLrUu4W1fHD+a62XR+hzC5YfsWm+6lO0Kybcga3yYEij9beegO0jZgug27V+Rf7vns95VPVP6mFd/DEQ==} + peerDependencies: + '@emotion/react': ^11.0.0 + '@emotion/styled': ^11.0.0 + react: '>=18' + dependencies: + '@chakra-ui/color-mode': 2.2.0(react@18.3.1) + '@chakra-ui/object-utils': 2.1.0 + '@chakra-ui/react-utils': 2.0.12(react@18.3.1) + '@chakra-ui/styled-system': 2.9.2 + '@chakra-ui/theme-utils': 2.0.21 + '@chakra-ui/utils': 2.0.15 + '@emotion/react': 11.13.3(@types/react@18.3.11)(react@18.3.1) + '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(@types/react@18.3.11)(react@18.3.1) + react: 18.3.1 + react-fast-compare: 3.2.2 + dev: false + + /@chakra-ui/theme-tools@2.1.2(@chakra-ui/styled-system@2.9.2): + resolution: {integrity: sha512-Qdj8ajF9kxY4gLrq7gA+Azp8CtFHGO9tWMN2wfF9aQNgG9AuMhPrUzMq9AMQ0MXiYcgNq/FD3eegB43nHVmXVA==} + peerDependencies: + '@chakra-ui/styled-system': '>=2.0.0' + dependencies: + '@chakra-ui/anatomy': 2.2.2 + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/styled-system': 2.9.2 + color2k: 2.0.3 + dev: false + + /@chakra-ui/theme-tools@2.2.6(@chakra-ui/styled-system@2.11.2)(react@18.3.1): + resolution: {integrity: sha512-3UhKPyzKbV3l/bg1iQN9PBvffYp+EBOoYMUaeTUdieQRPFzo2jbYR0lNCxqv8h5aGM/k54nCHU2M/GStyi9F2A==} + peerDependencies: + '@chakra-ui/styled-system': '>=2.0.0' + dependencies: + '@chakra-ui/anatomy': 2.3.4 + '@chakra-ui/styled-system': 2.11.2(react@18.3.1) + '@chakra-ui/utils': 2.2.2(react@18.3.1) + color2k: 2.0.3 + transitivePeerDependencies: + - react + dev: false + + /@chakra-ui/theme-utils@2.0.21: + resolution: {integrity: sha512-FjH5LJbT794r0+VSCXB3lT4aubI24bLLRWB+CuRKHijRvsOg717bRdUN/N1fEmEpFnRVrbewttWh/OQs0EWpWw==} + dependencies: + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/styled-system': 2.9.2 + '@chakra-ui/theme': 3.3.1(@chakra-ui/styled-system@2.9.2) + lodash.mergewith: 4.6.2 + dev: false + + /@chakra-ui/theme@3.3.1(@chakra-ui/styled-system@2.9.2): + resolution: {integrity: sha512-Hft/VaT8GYnItGCBbgWd75ICrIrIFrR7lVOhV/dQnqtfGqsVDlrztbSErvMkoPKt0UgAkd9/o44jmZ6X4U2nZQ==} + peerDependencies: + '@chakra-ui/styled-system': '>=2.8.0' + dependencies: + '@chakra-ui/anatomy': 2.2.2 + '@chakra-ui/shared-utils': 2.0.5 + '@chakra-ui/styled-system': 2.9.2 + '@chakra-ui/theme-tools': 2.1.2(@chakra-ui/styled-system@2.9.2) + dev: false + + /@chakra-ui/theme@3.4.6(@chakra-ui/styled-system@2.11.2)(react@18.3.1): + resolution: {integrity: sha512-ZwFBLfiMC3URwaO31ONXoKH9k0TX0OW3UjdPF3EQkQpYyrk/fm36GkkzajjtdpWEd7rzDLRsQjPmvwNaSoNDtg==} + peerDependencies: + '@chakra-ui/styled-system': '>=2.8.0' + dependencies: + '@chakra-ui/anatomy': 2.3.4 + '@chakra-ui/styled-system': 2.11.2(react@18.3.1) + '@chakra-ui/theme-tools': 2.2.6(@chakra-ui/styled-system@2.11.2)(react@18.3.1) + '@chakra-ui/utils': 2.2.2(react@18.3.1) + transitivePeerDependencies: + - react + dev: false + + /@chakra-ui/transition@2.1.0(framer-motion@11.10.0)(react@18.3.1): + resolution: {integrity: sha512-orkT6T/Dt+/+kVwJNy7zwJ+U2xAZ3EU7M3XCs45RBvUnZDr/u9vdmaM/3D/rOpmQJWgQBwKPJleUXrYWUagEDQ==} + peerDependencies: + framer-motion: '>=4.0.0' + react: '>=18' + dependencies: + '@chakra-ui/shared-utils': 2.0.5 + framer-motion: 11.10.0(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + dev: false + + /@chakra-ui/utils@2.0.15: + resolution: {integrity: sha512-El4+jL0WSaYYs+rJbuYFDbjmfCcfGDmRY95GO4xwzit6YAPZBLcR65rOEwLps+XWluZTy1xdMrusg/hW0c1aAA==} + dependencies: + '@types/lodash.mergewith': 4.6.7 + css-box-model: 1.2.1 + framesync: 6.1.2 + lodash.mergewith: 4.6.2 + dev: false + + /@chakra-ui/utils@2.2.2(react@18.3.1): + resolution: {integrity: sha512-jUPLT0JzRMWxpdzH6c+t0YMJYrvc5CLericgITV3zDSXblkfx3DsYXqU11DJTSGZI9dUKzM1Wd0Wswn4eJwvFQ==} + peerDependencies: + react: '>=16.8.0' + dependencies: + '@types/lodash.mergewith': 4.6.9 + lodash.mergewith: 4.6.2 + react: 18.3.1 + dev: false + + /@dagrejs/dagre@1.1.4: + resolution: {integrity: sha512-QUTc54Cg/wvmlEUxB+uvoPVKFazM1H18kVHBQNmK2NbrDR5ihOCR6CXLnDSZzMcSQKJtabPUWridBOlJM3WkDg==} + dependencies: + '@dagrejs/graphlib': 2.2.4 + dev: false + + /@dagrejs/graphlib@2.2.4: + resolution: {integrity: sha512-mepCf/e9+SKYy1d02/UkvSy6+6MoyXhVxP8lLDfA7BPE1X1d4dR0sZznmbM8/XVJ1GPM+Svnx7Xj6ZweByWUkw==} + engines: {node: '>17.0.0'} + dev: false + + /@emotion/babel-plugin@11.12.0: + resolution: {integrity: sha512-y2WQb+oP8Jqvvclh8Q55gLUyb7UFvgv7eJfsj7td5TToBrIUtPay2kMrZi4xjq9qw2vD0ZR5fSho0yqoFgX7Rw==} + dependencies: + '@babel/helper-module-imports': 7.25.7 + '@babel/runtime': 7.25.7 + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/serialize': 1.3.2 + babel-plugin-macros: 3.1.0 + convert-source-map: 1.9.0 + escape-string-regexp: 4.0.0 + find-root: 1.1.0 + source-map: 0.5.7 + stylis: 4.2.0 + transitivePeerDependencies: + - supports-color + dev: false + + /@emotion/cache@11.13.1: + resolution: {integrity: sha512-iqouYkuEblRcXmylXIwwOodiEK5Ifl7JcX7o6V4jI3iW4mLXX3dmt5xwBtIkJiQEXFAI+pC8X0i67yiPkH9Ucw==} + dependencies: + '@emotion/memoize': 0.9.0 + '@emotion/sheet': 1.4.0 + '@emotion/utils': 1.4.1 + '@emotion/weak-memoize': 0.4.0 + stylis: 4.2.0 + dev: false + + /@emotion/hash@0.9.2: + resolution: {integrity: sha512-MyqliTZGuOm3+5ZRSaaBGP3USLw6+EGykkwZns2EPC5g8jJ4z9OrdZY9apkl3+UP9+sdz76YYkwCKP5gh8iY3g==} + dev: false + + /@emotion/is-prop-valid@0.8.8: + resolution: {integrity: sha512-u5WtneEAr5IDG2Wv65yhunPSMLIpuKsbuOktRojfrEiEvRyC85LgPMZI63cr7NUqT8ZIGdSVg8ZKGxIug4lXcA==} + requiresBuild: true + dependencies: + '@emotion/memoize': 0.7.4 + dev: false + optional: true + + /@emotion/is-prop-valid@1.3.1: + resolution: {integrity: sha512-/ACwoqx7XQi9knQs/G0qKvv5teDMhD7bXYns9N/wM8ah8iNb8jZ2uNO0YOgiq2o2poIvVtJS2YALasQuMSQ7Kw==} + dependencies: + '@emotion/memoize': 0.9.0 + dev: false + + /@emotion/memoize@0.7.4: + resolution: {integrity: sha512-Ja/Vfqe3HpuzRsG1oBtWTHk2PGZ7GR+2Vz5iYGelAw8dx32K0y7PjVuxK6z1nMpZOqAFsRUPCkK1YjJ56qJlgw==} + requiresBuild: true + dev: false + optional: true + + /@emotion/memoize@0.9.0: + resolution: {integrity: sha512-30FAj7/EoJ5mwVPOWhAyCX+FPfMDrVecJAM+Iw9NRoSl4BBAQeqj4cApHHUXOVvIPgLVDsCFoz/hGD+5QQD1GQ==} + dev: false + + /@emotion/react@11.13.3(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-lIsdU6JNrmYfJ5EbUCf4xW1ovy5wKQ2CkPRM4xogziOxH1nXxBSjpC9YqbFAP7circxMfYp+6x676BqWcEiixg==} + peerDependencies: + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@emotion/babel-plugin': 11.12.0 + '@emotion/cache': 11.13.1 + '@emotion/serialize': 1.3.2 + '@emotion/use-insertion-effect-with-fallbacks': 1.1.0(react@18.3.1) + '@emotion/utils': 1.4.1 + '@emotion/weak-memoize': 0.4.0 + '@types/react': 18.3.11 + hoist-non-react-statics: 3.3.2 + react: 18.3.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@emotion/serialize@1.3.2: + resolution: {integrity: sha512-grVnMvVPK9yUVE6rkKfAJlYZgo0cu3l9iMC77V7DW6E1DUIrU68pSEXRmFZFOFB1QFo57TncmOcvcbMDWsL4yA==} + dependencies: + '@emotion/hash': 0.9.2 + '@emotion/memoize': 0.9.0 + '@emotion/unitless': 0.10.0 + '@emotion/utils': 1.4.1 + csstype: 3.1.3 + dev: false + + /@emotion/sheet@1.4.0: + resolution: {integrity: sha512-fTBW9/8r2w3dXWYM4HCB1Rdp8NLibOw2+XELH5m5+AkWiL/KqYX6dc0kKYlaYyKjrQ6ds33MCdMPEwgs2z1rqg==} + dev: false + + /@emotion/styled@11.13.0(@emotion/react@11.13.3)(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-tkzkY7nQhW/zC4hztlwucpT8QEZ6eUzpXDRhww/Eej4tFfO0FxQYWRyg/c5CCXa4d/f174kqeXYjuQRnhzf6dA==} + peerDependencies: + '@emotion/react': ^11.0.0-rc.0 + '@types/react': '*' + react: '>=16.8.0' + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@emotion/babel-plugin': 11.12.0 + '@emotion/is-prop-valid': 1.3.1 + '@emotion/react': 11.13.3(@types/react@18.3.11)(react@18.3.1) + '@emotion/serialize': 1.3.2 + '@emotion/use-insertion-effect-with-fallbacks': 1.1.0(react@18.3.1) + '@emotion/utils': 1.4.1 + '@types/react': 18.3.11 + react: 18.3.1 + transitivePeerDependencies: + - supports-color + dev: false + + /@emotion/unitless@0.10.0: + resolution: {integrity: sha512-dFoMUuQA20zvtVTuxZww6OHoJYgrzfKM1t52mVySDJnMSEa08ruEvdYQbhvyu6soU+NeLVd3yKfTfT0NeV6qGg==} + dev: false + + /@emotion/use-insertion-effect-with-fallbacks@1.1.0(react@18.3.1): + resolution: {integrity: sha512-+wBOcIV5snwGgI2ya3u99D7/FJquOIniQT1IKyDsBmEgwvpxMNeS65Oib7OnE2d2aY+3BU4OiH+0Wchf8yk3Hw==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.3.1 + dev: false + + /@emotion/utils@1.4.1: + resolution: {integrity: sha512-BymCXzCG3r72VKJxaYVwOXATqXIZ85cuvg0YOUDxMGNrKc1DJRZk8MgV5wyXRyEayIMd4FuXJIUgTBXvDNW5cA==} + dev: false + + /@emotion/weak-memoize@0.4.0: + resolution: {integrity: sha512-snKqtPW01tN0ui7yu9rGv69aJXr/a/Ywvl11sUjNtEcRc+ng/mQriFL0wLXMef74iHa/EkftbDzU9F8iFbH+zg==} + dev: false + + /@esbuild/aix-ppc64@0.21.5: + resolution: {integrity: sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/aix-ppc64@0.23.1: + resolution: {integrity: sha512-6VhYk1diRqrhBAqpJEdjASR/+WVRtfjpqKuNw11cLiaWpAT/Uu+nokB+UJnevzy/P9C/ty6AOe0dwueMrGh/iQ==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [aix] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.21.5: + resolution: {integrity: sha512-c0uX9VAUBQ7dTDCjq+wdyGLowMdtR/GoC2U5IYk/7D1H1JYC0qseD7+11iMP2mRLN9RcCMRcjC4YMclCzGwS/A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm64@0.23.1: + resolution: {integrity: sha512-xw50ipykXcLstLeWH7WRdQuysJqejuAGPd30vd1i5zSyKK3WE+ijzHmLKxdiCMtH1pHz78rOg0BKSYOSB/2Khw==} + engines: {node: '>=18'} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.21.5: + resolution: {integrity: sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg==} + engines: {node: '>=12'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-arm@0.23.1: + resolution: {integrity: sha512-uz6/tEy2IFm9RYOyvKl88zdzZfwEfKZmnX9Cj1BHjeSGNuGLuMD1kR8y5bteYmwqKm1tj8m4cb/aKEorr6fHWQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.21.5: + resolution: {integrity: sha512-D7aPRUUNHRBwHxzxRvp856rjUHRFW1SdQATKXH2hqA0kAZb1hKmi02OpYRacl0TxIGz/ZmXWlbZgjwWYaCakTA==} + engines: {node: '>=12'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/android-x64@0.23.1: + resolution: {integrity: sha512-nlN9B69St9BwUoB+jkyU090bru8L0NA3yFvAd7k8dNsVH8bi9a8cUAUSEcEEgTp2z3dbEDGJGfP6VUnkQnlReg==} + engines: {node: '>=18'} + cpu: [x64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.21.5: + resolution: {integrity: sha512-DwqXqZyuk5AiWWf3UfLiRDJ5EDd49zg6O9wclZ7kUMv2WRFr4HKjXp/5t8JZ11QbQfUS6/cRCKGwYhtNAY88kQ==} + engines: {node: '>=12'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-arm64@0.23.1: + resolution: {integrity: sha512-YsS2e3Wtgnw7Wq53XXBLcV6JhRsEq8hkfg91ESVadIrzr9wO6jJDMZnCQbHm1Guc5t/CdDiFSSfWP58FNuvT3Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.21.5: + resolution: {integrity: sha512-se/JjF8NlmKVG4kNIuyWMV/22ZaerB+qaSi5MdrXtd6R08kvs2qCN4C09miupktDitvh8jRFflwGFBQcxZRjbw==} + engines: {node: '>=12'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/darwin-x64@0.23.1: + resolution: {integrity: sha512-aClqdgTDVPSEGgoCS8QDG37Gu8yc9lTHNAQlsztQ6ENetKEO//b8y31MMu2ZaPbn4kVsIABzVLXYLhCGekGDqw==} + engines: {node: '>=18'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.21.5: + resolution: {integrity: sha512-5JcRxxRDUJLX8JXp/wcBCy3pENnCgBR9bN6JsY4OmhfUtIHe3ZW0mawA7+RDAcMLrMIZaf03NlQiX9DGyB8h4g==} + engines: {node: '>=12'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-arm64@0.23.1: + resolution: {integrity: sha512-h1k6yS8/pN/NHlMl5+v4XPfikhJulk4G+tKGFIOwURBSFzE8bixw1ebjluLOjfwtLqY0kewfjLSrO6tN2MgIhA==} + engines: {node: '>=18'} + cpu: [arm64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.21.5: + resolution: {integrity: sha512-J95kNBj1zkbMXtHVH29bBriQygMXqoVQOQYA+ISs0/2l3T9/kj42ow2mpqerRBxDJnmkUDCaQT/dfNXWX/ZZCQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/freebsd-x64@0.23.1: + resolution: {integrity: sha512-lK1eJeyk1ZX8UklqFd/3A60UuZ/6UVfGT2LuGo3Wp4/z7eRTRYY+0xOu2kpClP+vMTi9wKOfXi2vjUpO1Ro76g==} + engines: {node: '>=18'} + cpu: [x64] + os: [freebsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.21.5: + resolution: {integrity: sha512-ibKvmyYzKsBeX8d8I7MH/TMfWDXBF3db4qM6sy+7re0YXya+K1cem3on9XgdT2EQGMu4hQyZhan7TeQ8XkGp4Q==} + engines: {node: '>=12'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm64@0.23.1: + resolution: {integrity: sha512-/93bf2yxencYDnItMYV/v116zff6UyTjo4EtEQjUBeGiVpMmffDNUyD9UN2zV+V3LRV3/on4xdZ26NKzn6754g==} + engines: {node: '>=18'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.21.5: + resolution: {integrity: sha512-bPb5AHZtbeNGjCKVZ9UGqGwo8EUu4cLq68E95A53KlxAPRmUyYv2D6F0uUI65XisGOL1hBP5mTronbgo+0bFcA==} + engines: {node: '>=12'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-arm@0.23.1: + resolution: {integrity: sha512-CXXkzgn+dXAPs3WBwE+Kvnrf4WECwBdfjfeYHpMeVxWE0EceB6vhWGShs6wi0IYEqMSIzdOF1XjQ/Mkm5d7ZdQ==} + engines: {node: '>=18'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.21.5: + resolution: {integrity: sha512-YvjXDqLRqPDl2dvRODYmmhz4rPeVKYvppfGYKSNGdyZkA01046pLWyRKKI3ax8fbJoK5QbxblURkwK/MWY18Tg==} + engines: {node: '>=12'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ia32@0.23.1: + resolution: {integrity: sha512-VTN4EuOHwXEkXzX5nTvVY4s7E/Krz7COC8xkftbbKRYAl96vPiUssGkeMELQMOnLOJ8k3BY1+ZY52tttZnHcXQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.21.5: + resolution: {integrity: sha512-uHf1BmMG8qEvzdrzAqg2SIG/02+4/DHB6a9Kbya0XDvwDEKCoC8ZRWI5JJvNdUjtciBGFQ5PuBlpEOXQj+JQSg==} + engines: {node: '>=12'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-loong64@0.23.1: + resolution: {integrity: sha512-Vx09LzEoBa5zDnieH8LSMRToj7ir/Jeq0Gu6qJ/1GcBq9GkfoEAoXvLiW1U9J1qE/Y/Oyaq33w5p2ZWrNNHNEw==} + engines: {node: '>=18'} + cpu: [loong64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.21.5: + resolution: {integrity: sha512-IajOmO+KJK23bj52dFSNCMsz1QP1DqM6cwLUv3W1QwyxkyIWecfafnI555fvSGqEKwjMXVLokcV5ygHW5b3Jbg==} + engines: {node: '>=12'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-mips64el@0.23.1: + resolution: {integrity: sha512-nrFzzMQ7W4WRLNUOU5dlWAqa6yVeI0P78WKGUo7lg2HShq/yx+UYkeNSE0SSfSure0SqgnsxPvmAUu/vu0E+3Q==} + engines: {node: '>=18'} + cpu: [mips64el] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.21.5: + resolution: {integrity: sha512-1hHV/Z4OEfMwpLO8rp7CvlhBDnjsC3CttJXIhBi+5Aj5r+MBvy4egg7wCbe//hSsT+RvDAG7s81tAvpL2XAE4w==} + engines: {node: '>=12'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-ppc64@0.23.1: + resolution: {integrity: sha512-dKN8fgVqd0vUIjxuJI6P/9SSSe/mB9rvA98CSH2sJnlZ/OCZWO1DJvxj8jvKTfYUdGfcq2dDxoKaC6bHuTlgcw==} + engines: {node: '>=18'} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.21.5: + resolution: {integrity: sha512-2HdXDMd9GMgTGrPWnJzP2ALSokE/0O5HhTUvWIbD3YdjME8JwvSCnNGBnTThKGEB91OZhzrJ4qIIxk/SBmyDDA==} + engines: {node: '>=12'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-riscv64@0.23.1: + resolution: {integrity: sha512-5AV4Pzp80fhHL83JM6LoA6pTQVWgB1HovMBsLQ9OZWLDqVY8MVobBXNSmAJi//Csh6tcY7e7Lny2Hg1tElMjIA==} + engines: {node: '>=18'} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.21.5: + resolution: {integrity: sha512-zus5sxzqBJD3eXxwvjN1yQkRepANgxE9lgOW2qLnmr8ikMTphkjgXu1HR01K4FJg8h1kEEDAqDcZQtbrRnB41A==} + engines: {node: '>=12'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-s390x@0.23.1: + resolution: {integrity: sha512-9ygs73tuFCe6f6m/Tb+9LtYxWR4c9yg7zjt2cYkjDbDpV/xVn+68cQxMXCjUpYwEkze2RcU/rMnfIXNRFmSoDw==} + engines: {node: '>=18'} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.21.5: + resolution: {integrity: sha512-1rYdTpyv03iycF1+BhzrzQJCdOuAOtaqHTWJZCWvijKD2N5Xu0TtVC8/+1faWqcP9iBCWOmjmhoH94dH82BxPQ==} + engines: {node: '>=12'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/linux-x64@0.23.1: + resolution: {integrity: sha512-EV6+ovTsEXCPAp58g2dD68LxoP/wK5pRvgy0J/HxPGB009omFPv3Yet0HiaqvrIrgPTBuC6wCH1LTOY91EO5hQ==} + engines: {node: '>=18'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.21.5: + resolution: {integrity: sha512-Woi2MXzXjMULccIwMnLciyZH4nCIMpWQAs049KEeMvOcNADVxo0UBIQPfSmxB3CWKedngg7sWZdLvLczpe0tLg==} + engines: {node: '>=12'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/netbsd-x64@0.23.1: + resolution: {integrity: sha512-aevEkCNu7KlPRpYLjwmdcuNz6bDFiE7Z8XC4CPqExjTvrHugh28QzUXVOZtiYghciKUacNktqxdpymplil1beA==} + engines: {node: '>=18'} + cpu: [x64] + os: [netbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-arm64@0.23.1: + resolution: {integrity: sha512-3x37szhLexNA4bXhLrCC/LImN/YtWis6WXr1VESlfVtVeoFJBRINPJ3f0a/6LV8zpikqoUg4hyXw0sFBt5Cr+Q==} + engines: {node: '>=18'} + cpu: [arm64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.21.5: + resolution: {integrity: sha512-HLNNw99xsvx12lFBUwoT8EVCsSvRNDVxNpjZ7bPn947b8gJPzeHWyNVhFsaerc0n3TsbOINvRP2byTZ5LKezow==} + engines: {node: '>=12'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/openbsd-x64@0.23.1: + resolution: {integrity: sha512-aY2gMmKmPhxfU+0EdnN+XNtGbjfQgwZj43k8G3fyrDM/UdZww6xrWxmDkuz2eCZchqVeABjV5BpildOrUbBTqA==} + engines: {node: '>=18'} + cpu: [x64] + os: [openbsd] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.21.5: + resolution: {integrity: sha512-6+gjmFpfy0BHU5Tpptkuh8+uw3mnrvgs+dSPQXQOv3ekbordwnzTVEb4qnIvQcYXq6gzkyTnoZ9dZG+D4garKg==} + engines: {node: '>=12'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/sunos-x64@0.23.1: + resolution: {integrity: sha512-RBRT2gqEl0IKQABT4XTj78tpk9v7ehp+mazn2HbUeZl1YMdaGAQqhapjGTCe7uw7y0frDi4gS0uHzhvpFuI1sA==} + engines: {node: '>=18'} + cpu: [x64] + os: [sunos] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.21.5: + resolution: {integrity: sha512-Z0gOTd75VvXqyq7nsl93zwahcTROgqvuAcYDUr+vOv8uHhNSKROyU961kgtCD1e95IqPKSQKH7tBTslnS3tA8A==} + engines: {node: '>=12'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-arm64@0.23.1: + resolution: {integrity: sha512-4O+gPR5rEBe2FpKOVyiJ7wNDPA8nGzDuJ6gN4okSA1gEOYZ67N8JPk58tkWtdtPeLz7lBnY6I5L3jdsr3S+A6A==} + engines: {node: '>=18'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.21.5: + resolution: {integrity: sha512-SWXFF1CL2RVNMaVs+BBClwtfZSvDgtL//G/smwAc5oVK/UPu2Gu9tIaRgFmYFFKrmg3SyAjSrElf0TiJ1v8fYA==} + engines: {node: '>=12'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-ia32@0.23.1: + resolution: {integrity: sha512-BcaL0Vn6QwCwre3Y717nVHZbAa4UBEigzFm6VdsVdT/MbZ38xoj1X9HPkZhbmaBGUD1W8vxAfffbDe8bA6AKnQ==} + engines: {node: '>=18'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.21.5: + resolution: {integrity: sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw==} + engines: {node: '>=12'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@esbuild/win32-x64@0.23.1: + resolution: {integrity: sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg==} + engines: {node: '>=18'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@eslint-community/eslint-utils@4.4.0(eslint@8.57.1): + resolution: {integrity: sha512-1/sA4dwrzBAyeUoQ6oxahHKmrZvsnLCg4RfxW3ZFGGmQkSNQPFNLV9CUEFQP1x9EYXHTo5p6xdhZM1Ne9p/AfA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || >=8.0.0 + dependencies: + eslint: 8.57.1 + eslint-visitor-keys: 3.4.3 + dev: true + + /@eslint-community/regexpp@4.11.1: + resolution: {integrity: sha512-m4DVN9ZqskZoLU5GlWZadwDnYo3vAEydiUayB9widCl9ffWx2IvPnp6n3on5rJmziJSw9Bv+Z3ChDVdMwXCY8Q==} + engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0} + dev: true + + /@eslint/eslintrc@2.1.4: + resolution: {integrity: sha512-269Z39MS6wVJtsoUl10L60WdkhJVdPG24Q4eZTH3nnF6lpvSShEK3wQjDX9JRWAUPvPh7COouPpU9IrqaZFvtQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + ajv: 6.12.6 + debug: 4.3.7(supports-color@9.4.0) + espree: 9.6.1 + globals: 13.24.0 + ignore: 5.3.2 + import-fresh: 3.3.0 + js-yaml: 4.1.0 + minimatch: 3.1.2 + strip-json-comments: 3.1.1 + transitivePeerDependencies: + - supports-color + dev: true + + /@eslint/js@8.57.1: + resolution: {integrity: sha512-d9zaMRSTIKDLhctzH12MtXvJKSSUhaHcjV+2Z+GK+EEY7XKpP5yR4x+N3TAcHTcu963nIr+TMcCb4DBCYX1z6Q==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@floating-ui/core@1.6.8: + resolution: {integrity: sha512-7XJ9cPU+yI2QeLS+FCSlqNFZJq8arvswefkZrYI1yQBbftw6FyrZOxYSh+9S7z7TpeWlRt9zJ5IhM1WIL334jA==} + dependencies: + '@floating-ui/utils': 0.2.8 + dev: false + + /@floating-ui/dom@1.6.11: + resolution: {integrity: sha512-qkMCxSR24v2vGkhYDo/UzxfJN3D4syqSjyuTFz6C7XcpU1pASPRieNI0Kj5VP3/503mOfYiGY891ugBX1GlABQ==} + dependencies: + '@floating-ui/core': 1.6.8 + '@floating-ui/utils': 0.2.8 + dev: false + + /@floating-ui/utils@0.2.8: + resolution: {integrity: sha512-kym7SodPp8/wloecOpcmSnWJsK7M0E5Wg8UcFA+uO4B9s5d0ywXOEro/8HM9x0rW+TljRzul/14UYz3TleT3ig==} + dev: false + + /@fontsource-variable/inter@5.1.0: + resolution: {integrity: sha512-Wj2dUGP0vUpxRGQTXQTCNJO+aLcFcQm+gUPXfj/aS877bQkEPBPv9JvZJpwdm2vzelt8NTZ+ausKlBCJjh2XIg==} + dev: false + + /@humanwhocodes/config-array@0.13.0: + resolution: {integrity: sha512-DZLEEqFWQFiyK6h5YIeynKx7JlvCYWL0cImfSRXZ9l4Sg2efkFGTuFf6vzXjK1cq6IYkU+Eg/JizXw+TD2vRNw==} + engines: {node: '>=10.10.0'} + deprecated: Use @eslint/config-array instead + dependencies: + '@humanwhocodes/object-schema': 2.0.3 + debug: 4.3.7(supports-color@9.4.0) + minimatch: 3.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@humanwhocodes/module-importer@1.0.1: + resolution: {integrity: sha512-bxveV4V8v5Yb4ncFTT3rPSgZBOpCkjfK0y4oVVVJwIuDVBRMDXrPyXRL988i5ap9m9bnyEEjWfm5WkBmtffLfA==} + engines: {node: '>=12.22'} + dev: true + + /@humanwhocodes/object-schema@2.0.3: + resolution: {integrity: sha512-93zYdMES/c1D69yZiKDBj0V24vqNzB/koF26KPaagAfd3P/4gUlh3Dys5ogAK+Exi9QyzlD8x/08Zt7wIKcDcA==} + deprecated: Use @eslint/object-schema instead + dev: true + + /@invoke-ai/eslint-config-react@0.0.14(eslint@8.57.1)(prettier@3.3.3)(typescript@5.6.2): + resolution: {integrity: sha512-6ZUY9zgdDhv2WUoLdDKOQdU9ImnH0CBOFtRlOaNOh34IOsNRfn+JA7wqA0PKnkiNrlfPkIQWhn4GRJp68NT5bw==} + peerDependencies: + eslint: ^8.56.0 + prettier: ^3.2.5 + typescript: ^5.3.3 + dependencies: + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0)(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + eslint: 8.57.1 + eslint-config-prettier: 9.1.0(eslint@8.57.1) + eslint-plugin-import: 2.30.0(@typescript-eslint/parser@7.18.0)(eslint@8.57.1) + eslint-plugin-react: 7.37.1(eslint@8.57.1) + eslint-plugin-react-hooks: 4.6.2(eslint@8.57.1) + eslint-plugin-react-refresh: 0.4.12(eslint@8.57.1) + eslint-plugin-simple-import-sort: 12.1.1(eslint@8.57.1) + eslint-plugin-storybook: 0.8.0(eslint@8.57.1)(typescript@5.6.2) + eslint-plugin-unused-imports: 3.2.0(@typescript-eslint/eslint-plugin@7.18.0)(eslint@8.57.1) + prettier: 3.3.3 + typescript: 5.6.2 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + + /@invoke-ai/prettier-config-react@0.0.7(prettier@3.3.3): + resolution: {integrity: sha512-vQeWzqwih116TBlIJII93L8ictj6uv7PxcSlAGNZrzG2UcaCFMsQqKCsB/qio26uihgv/EtvN6XAF96SnE0TKw==} + peerDependencies: + prettier: ^3.2.5 + dependencies: + prettier: 3.3.3 + dev: true + + /@invoke-ai/ui-library@0.0.43(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@fontsource-variable/inter@5.1.0)(@types/react@18.3.11)(i18next@23.15.1)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-t3fPYyks07ue3dEBPJuTHbeDLnDckDCOrtvc07mMDbLOnlPEZ0StaeiNGH+oO8qLzAuMAlSTdswgHfzTc2MmPw==} + peerDependencies: + '@fontsource-variable/inter': ^5.0.16 + react: ^18.2.0 + react-dom: ^18.2.0 + dependencies: + '@chakra-ui/anatomy': 2.3.4 + '@chakra-ui/icons': 2.2.4(@chakra-ui/react@2.10.2)(react@18.3.1) + '@chakra-ui/layout': 2.3.1(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/portal': 2.1.0(react-dom@18.3.1)(react@18.3.1) + '@chakra-ui/react': 2.10.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(@types/react@18.3.11)(framer-motion@11.10.0)(react-dom@18.3.1)(react@18.3.1) + '@chakra-ui/styled-system': 2.11.2(react@18.3.1) + '@chakra-ui/theme-tools': 2.2.6(@chakra-ui/styled-system@2.11.2)(react@18.3.1) + '@emotion/react': 11.13.3(@types/react@18.3.11)(react@18.3.1) + '@emotion/styled': 11.13.0(@emotion/react@11.13.3)(@types/react@18.3.11)(react@18.3.1) + '@fontsource-variable/inter': 5.1.0 + '@nanostores/react': 0.7.3(nanostores@0.11.3)(react@18.3.1) + chakra-react-select: 4.9.2(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/layout@2.3.1)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@emotion/react@11.13.3)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + framer-motion: 10.18.0(react-dom@18.3.1)(react@18.3.1) + lodash-es: 4.17.21 + nanostores: 0.11.3 + overlayscrollbars: 2.10.0 + overlayscrollbars-react: 0.5.6(overlayscrollbars@2.10.0)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-i18next: 15.0.2(i18next@23.15.1)(react-dom@18.3.1)(react@18.3.1) + react-icons: 5.3.0(react@18.3.1) + react-select: 5.8.0(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + transitivePeerDependencies: + - '@chakra-ui/form-control' + - '@chakra-ui/icon' + - '@chakra-ui/media-query' + - '@chakra-ui/menu' + - '@chakra-ui/spinner' + - '@chakra-ui/system' + - '@types/react' + - i18next + - react-native + - supports-color + dev: false + + /@isaacs/cliui@8.0.2: + resolution: {integrity: sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA==} + engines: {node: '>=12'} + dependencies: + string-width: 5.1.2 + string-width-cjs: /string-width@4.2.3 + strip-ansi: 7.1.0 + strip-ansi-cjs: /strip-ansi@6.0.1 + wrap-ansi: 8.1.0 + wrap-ansi-cjs: /wrap-ansi@7.0.0 + dev: true + + /@istanbuljs/schema@0.1.3: + resolution: {integrity: sha512-ZXRY4jNvVgSVQ8DL3LTcakaAtXwTVUxE81hslsyD2AtoXW/wVob10HkOJ1X/pAlcI7D+2YoZKg5do8G/w6RYgA==} + engines: {node: '>=8'} + dev: true + + /@jest/schemas@29.6.3: + resolution: {integrity: sha512-mo5j5X+jIZmJQveBKeS/clAueipV7KgiX1vMgCxam1RNYiqE1w62n0/tJJnHtjW8ZHcQco5gY85jA3mi0L+nSA==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@sinclair/typebox': 0.27.8 + dev: true + + /@joshwooding/vite-plugin-react-docgen-typescript@0.3.0(typescript@5.6.2)(vite@5.4.8): + resolution: {integrity: sha512-2D6y7fNvFmsLmRt6UCOFJPvFoPMJGT0Uh1Wg0RaigUp7kdQPs6yYn8Dmx6GZkOH/NW0yMTwRz/p0SRMMRo50vA==} + peerDependencies: + typescript: '>= 4.3.x' + vite: ^3.0.0 || ^4.0.0 || ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + glob: 7.2.3 + glob-promise: 4.2.2(glob@7.2.3) + magic-string: 0.27.0 + react-docgen-typescript: 2.2.2(typescript@5.6.2) + typescript: 5.6.2 + vite: 5.4.8(@types/node@20.16.10) + dev: true + + /@jridgewell/gen-mapping@0.3.5: + resolution: {integrity: sha512-IzL8ZoEDIBRWEzlCcRhOaCupYyN5gdIK+Q6fbFdPDg6HqX6jpkItn7DFIpW9LQzXG6Df9sA7+OKnq0qlz/GaQg==} + engines: {node: '>=6.0.0'} + dependencies: + '@jridgewell/set-array': 1.2.1 + '@jridgewell/sourcemap-codec': 1.5.0 + '@jridgewell/trace-mapping': 0.3.25 + + /@jridgewell/resolve-uri@3.1.2: + resolution: {integrity: sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw==} + engines: {node: '>=6.0.0'} + + /@jridgewell/set-array@1.2.1: + resolution: {integrity: sha512-R8gLRTZeyp03ymzP/6Lil/28tGeGEzhx1q2k703KGWRAI1VdvPIXdG70VJc2pAMw3NA6JKL5hhFu1sJX0Mnn/A==} + engines: {node: '>=6.0.0'} + + /@jridgewell/sourcemap-codec@1.5.0: + resolution: {integrity: sha512-gv3ZRaISU3fjPAgNsriBRqGWQL6quFx04YMPW/zD8XMLsU32mhCCbfbO6KZFLjvYpCZ8zyDEgqsgf+PwPaM7GQ==} + + /@jridgewell/trace-mapping@0.3.25: + resolution: {integrity: sha512-vNk6aEwybGtawWmy/PzwnGDOjCkLWSD2wqvjGGAgOAwCGWySYXfYoxt00IJkTF+8Lb57DwOb3Aa0o9CApepiYQ==} + dependencies: + '@jridgewell/resolve-uri': 3.1.2 + '@jridgewell/sourcemap-codec': 1.5.0 + + /@mdx-js/react@3.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-9ZrPIU4MGf6et1m1ov3zKf+q9+deetI51zprKB1D/z3NOb+rUxxtEl3mCjW5wTGh6VhRdwPueh1oRzi6ezkA8A==} + peerDependencies: + '@types/react': '>=16' + react: '>=16' + dependencies: + '@types/mdx': 2.0.13 + '@types/react': 18.3.11 + react: 18.3.1 + dev: true + + /@microsoft/api-extractor-model@7.28.13(@types/node@20.16.10): + resolution: {integrity: sha512-39v/JyldX4MS9uzHcdfmjjfS6cYGAoXV+io8B5a338pkHiSt+gy2eXQ0Q7cGFJ7quSa1VqqlMdlPrB6sLR/cAw==} + dependencies: + '@microsoft/tsdoc': 0.14.2 + '@microsoft/tsdoc-config': 0.16.2 + '@rushstack/node-core-library': 4.0.2(@types/node@20.16.10) + transitivePeerDependencies: + - '@types/node' + dev: true + + /@microsoft/api-extractor@7.43.0(@types/node@20.16.10): + resolution: {integrity: sha512-GFhTcJpB+MI6FhvXEI9b2K0snulNLWHqC/BbcJtyNYcKUiw7l3Lgis5ApsYncJ0leALX7/of4XfmXk+maT111w==} + hasBin: true + dependencies: + '@microsoft/api-extractor-model': 7.28.13(@types/node@20.16.10) + '@microsoft/tsdoc': 0.14.2 + '@microsoft/tsdoc-config': 0.16.2 + '@rushstack/node-core-library': 4.0.2(@types/node@20.16.10) + '@rushstack/rig-package': 0.5.2 + '@rushstack/terminal': 0.10.0(@types/node@20.16.10) + '@rushstack/ts-command-line': 4.19.1(@types/node@20.16.10) + lodash: 4.17.21 + minimatch: 3.0.8 + resolve: 1.22.8 + semver: 7.5.4 + source-map: 0.6.1 + typescript: 5.4.2 + transitivePeerDependencies: + - '@types/node' + dev: true + + /@microsoft/tsdoc-config@0.16.2: + resolution: {integrity: sha512-OGiIzzoBLgWWR0UdRJX98oYO+XKGf7tiK4Zk6tQ/E4IJqGCe7dvkTvgDZV5cFJUzLGDOjeAXrnZoA6QkVySuxw==} + dependencies: + '@microsoft/tsdoc': 0.14.2 + ajv: 6.12.6 + jju: 1.4.0 + resolve: 1.19.0 + dev: true + + /@microsoft/tsdoc@0.14.2: + resolution: {integrity: sha512-9b8mPpKrfeGRuhFH5iO1iwCLeIIsV6+H1sRfxbkoGXIyQE2BTsPd9zqSqQJ+pv5sJ/hT5M1zvOFL02MnEezFug==} + dev: true + + /@nanostores/react@0.7.3(nanostores@0.11.3)(react@18.3.1): + resolution: {integrity: sha512-/XuLAMENRu/Q71biW4AZ4qmU070vkZgiQ28gaTSNRPm2SZF5zGAR81zPE1MaMB4SeOp6ZTst92NBaG75XSspNg==} + engines: {node: ^18.0.0 || >=20.0.0} + peerDependencies: + nanostores: ^0.9.0 || ^0.10.0 || ^0.11.0 + react: '>=18.0.0' + dependencies: + nanostores: 0.11.3 + react: 18.3.1 + dev: false + + /@nodelib/fs.scandir@2.1.5: + resolution: {integrity: sha512-vq24Bq3ym5HEQm2NKCr3yXDwjc7vTsEThRDnkp2DK9p1uqLR+DHurm/NOTo0KG7HYHU7eppKZj3MyqYuMBf62g==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + run-parallel: 1.2.0 + dev: true + + /@nodelib/fs.stat@2.0.5: + resolution: {integrity: sha512-RkhPPp2zrqDAQA/2jNhnztcPAlv64XdhIp7a7454A5ovI7Bukxgt7MX7udwAu3zg1DcpPU0rz3VV1SeaqvY4+A==} + engines: {node: '>= 8'} + dev: true + + /@nodelib/fs.walk@1.2.8: + resolution: {integrity: sha512-oGB+UxlgWcgQkgwo8GcEGwemoTFt3FIO9ababBmaGwXIoBKZ+GTy0pP185beGg7Llih/NSHSV2XAs1lnznocSg==} + engines: {node: '>= 8'} + dependencies: + '@nodelib/fs.scandir': 2.1.5 + fastq: 1.17.1 + dev: true + + /@pkgjs/parseargs@0.11.0: + resolution: {integrity: sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg==} + engines: {node: '>=14'} + requiresBuild: true + dev: true + optional: true + + /@polka/url@1.0.0-next.28: + resolution: {integrity: sha512-8LduaNlMZGwdZ6qWrKlfa+2M4gahzFkprZiAt2TF8uS0qQgBizKXpXURqvTJ4WtmupWxaLqjRb2UCTe72mu+Aw==} + dev: true + + /@popperjs/core@2.11.8: + resolution: {integrity: sha512-P1st0aksCrn9sGZhp8GMYwBnQsbvAWsZAX44oXNNvLHGqAOcoVxmjZiohstwQ7SqKnbR47akdNi+uleWD8+g6A==} + dev: false + + /@radix-ui/primitive@1.0.1: + resolution: {integrity: sha512-yQ8oGX2GVsEYMWGxcovu1uGWPCxV5BFfeeYxqPmuAzUyLT9qmaMXSAhXpb0WrspIeqYzdJpkh2vHModJPgRIaw==} + dependencies: + '@babel/runtime': 7.25.7 + dev: false + + /@radix-ui/react-compose-refs@1.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-fDSBgd44FKHa1FRMU59qBMPFcl2PZE+2nmqunj+BWFyYYjnhIDWL2ItDs3rrbJDQOtzt5nIebLCQc4QRfz6LJw==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-context@1.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-ebbrdFoYTcuZ0v4wG5tedGnp9tzcV8awzsxYph7gXUyvnNLuTIcCk1q17JEbnVhXAKG9oX3KtchwiMIAYp9NLg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-dialog@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-GjWJX/AUpB703eEBanuBnIWdIXg6NvJFCXcNlSZk4xdszCdhrJgBoUd1cGk67vFO+WdA2pfI/plOpqz/5GUP6Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-context': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-dismissable-layer': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-focus-guards': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-focus-scope': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-id': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-portal': 1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-presence': 1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-slot': 1.0.2(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-use-controllable-state': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + aria-hidden: 1.2.4 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-remove-scroll: 2.5.5(@types/react@18.3.11)(react@18.3.1) + dev: false + + /@radix-ui/react-dismissable-layer@1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-aJeDjQhywg9LBu2t/At58hCvr7pEm0o2Ke1x33B+MhjNmmZ17sy4KImo0KPLgsnc/zN7GPdce8Cnn0SWvwZO7g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/primitive': 1.0.1 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-use-escape-keydown': 1.0.3(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-focus-guards@1.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-Rect2dWbQ8waGzhMavsIbmSVCgYxkXLxxR3ZvCX79JOglzdEy4JXMb98lq4hPxUbLr77nP0UOGf4rcMU+s1pUA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-focus-scope@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-sL04Mgvf+FmyvZeYfNu1EPAaaxD+aw7cYeIB9L9Fvq8+urhltTRaEo5ysKOpHuKPclsZcSUMKlN05x4u+CINpA==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-id@1.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-tI7sT/kqYp8p96yGWY1OAnLHrqDgzHefRBKQ2YAkBS5ja7QLcZ9Z/uY7bEjPUatf8RomoXM8/1sMj1IJaE5UzQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-portal@1.0.4(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Qki+C/EuGUVCQTOTD5vzJzJuMUlewbzuKyUy+/iHM2uwGiru9gZeBJtHAPKAEkB5KWGi9mP/CHKcY0wt1aW45Q==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-presence@1.0.1(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-UXLW4UAbIY5ZjcvzjfRFo5gxva8QirC9hF7wRE4U5gz+TP0DbRk+//qyuAQ1McDxBt1xNMBTaciFGvEmJvAZCg==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@radix-ui/react-use-layout-effect': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-primitive@1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-yi58uVyoAcK/Nq1inRY56ZSjKypBNKTa/1mcL8qdl6oJeEaDbOldlzrGn7P6Q3Id5d+SYNGc5AJgc4vGhjs5+g==} + peerDependencies: + '@types/react': '*' + '@types/react-dom': '*' + react: ^16.8 || ^17.0 || ^18.0 + react-dom: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-slot': 1.0.2(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + '@types/react-dom': 18.3.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /@radix-ui/react-slot@1.0.2(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-YeTpuq4deV+6DusvVUW4ivBgnkHwECUu0BiN43L5UCDFgdhsRUWAghhTF5MbvNTPzmiFOx90asDSUjWuCNapwg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-compose-refs': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-callback-ref@1.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-D94LjX4Sp0xJFVaoQOd3OO9k7tpBYNOXdVhkltUbGv2Qb9OXdrg/CpsjlZv7ia14Sylv398LswWBVVu5nqKzAQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-controllable-state@1.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-Svl5GY5FQeN758fWKrjM6Qb7asvXeiZltlT4U2gVfl8Gx5UAv2sMR0LWo8yhsIZh2oQ0eFdZ59aoOOMV7b47VA==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-escape-keydown@1.0.3(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-vyL82j40hcFicA+M4Ex7hVkB9vHgSse1ZWomAqV2Je3RleKGO5iM8KMOEtfoSB0PnIelMd2lATjTGMYqN5ylTg==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@radix-ui/react-use-callback-ref': 1.0.1(@types/react@18.3.11)(react@18.3.1) + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@radix-ui/react-use-layout-effect@1.0.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-v/5RegiJWYdoCvMnITBkNNx6bCj20fiaJnWtRkU18yITptraXjffz5Qbn05uOiQnOvi+dbkznkoaMltz1GnszQ==} + peerDependencies: + '@types/react': '*' + react: ^16.8 || ^17.0 || ^18.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /@reactflow/background@11.3.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Gewd7blEVT5Lh6jqrvOgd4G6Qk17eGKQfsDXgyRSqM+CTwDqRldG2LsWN4sNeno6sbqVIC2fZ+rAUBFA9ZEUDA==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + classcat: 5.0.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/controls@11.2.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-MiJp5VldFD7FrqaBNIrQ85dxChrG6ivuZ+dcFhPQUwOK3HfYgX2RHdBua+gx+40p5Vw5It3dVNp/my4Z3jF0dw==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + classcat: 5.0.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/core@11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-H4vODklsjAq3AMq6Np4LE12i1I4Ta9PrDHuBR9GmL8uzTt2l2jh4CiQbEMpvMDcp7xi4be0hgXj+Ysodde/i7Q==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@types/d3': 7.4.3 + '@types/d3-drag': 3.0.7 + '@types/d3-selection': 3.0.10 + '@types/d3-zoom': 3.0.8 + classcat: 5.0.5 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/minimap@11.7.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-mpwLKKrEAofgFJdkhwR5UQ1JYWlcAAL/ZU/bctBkuNTT1yqV+y0buoNVImsRehVYhJwffSWeSHaBR5/GJjlCSQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@types/d3-selection': 3.0.10 + '@types/d3-zoom': 3.0.8 + classcat: 5.0.5 + d3-selection: 3.0.0 + d3-zoom: 3.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/node-resizer@2.2.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-fwqnks83jUlYr6OHcdFEedumWKChTHRGw/kbCxj0oqBd+ekfs+SIp4ddyNU0pdx96JIm5iNFS0oNrmEiJbbSaA==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + classcat: 5.0.5 + d3-drag: 3.0.0 + d3-selection: 3.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@reactflow/node-toolbar@1.3.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-rbynXQnH/xFNu4P9H+hVqlEUafDCkEoCy0Dg9mG22Sg+rY/0ck6KkrAQrYrTgXusd+cEJOMK0uOOFCK2/5rSGQ==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/core': 11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + classcat: 5.0.5 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + zustand: 4.5.5(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /@redocly/ajv@8.11.2: + resolution: {integrity: sha512-io1JpnwtIcvojV7QKDUSIuMN/ikdOUd1ReEnUnMKGfDVridQZ31J0MmIuqwuRjWDZfmvr+Q0MqCcfHM2gTivOg==} + dependencies: + fast-deep-equal: 3.1.3 + json-schema-traverse: 1.0.0 + require-from-string: 2.0.2 + uri-js-replace: 1.0.1 + dev: true + + /@redocly/config@0.12.1: + resolution: {integrity: sha512-RW3rSirfsPdr0uvATijRDU3f55SuZV3m7/ppdTDvGw4IB0cmeZRkFmqTrchxMqWP50Gfg1tpHnjdxUCNo0E2qg==} + dev: true + + /@redocly/openapi-core@1.25.4(supports-color@9.4.0): + resolution: {integrity: sha512-qnpr4Z1rzfXdtxQxt/lfGD0wW3UVrm3qhrTpzLG5R/Ze+z+1u8sSRiQHp9N+RT3IuMjh00wq59nop9x9PPa1jQ==} + engines: {node: '>=14.19.0', npm: '>=7.0.0'} + dependencies: + '@redocly/ajv': 8.11.2 + '@redocly/config': 0.12.1 + colorette: 1.4.0 + https-proxy-agent: 7.0.5(supports-color@9.4.0) + js-levenshtein: 1.1.6 + js-yaml: 4.1.0 + lodash.isequal: 4.5.0 + minimatch: 5.1.6 + node-fetch: 2.7.0 + pluralize: 8.0.0 + yaml-ast-parser: 0.0.43 + transitivePeerDependencies: + - encoding + - supports-color + dev: true + + /@reduxjs/toolkit@2.2.3(react-redux@9.1.2)(react@18.3.1): + resolution: {integrity: sha512-76dll9EnJXg4EVcI5YNxZA/9hSAmZsFqzMmNRHvIlzw2WS/twfcVX3ysYrWGJMClwEmChQFC4yRq74tn6fdzRA==} + peerDependencies: + react: ^16.9.0 || ^17.0.0 || ^18 + react-redux: ^7.2.1 || ^8.1.3 || ^9.0.0 + peerDependenciesMeta: + react: + optional: true + react-redux: + optional: true + dependencies: + immer: 10.1.1 + react: 18.3.1 + react-redux: 9.1.2(@types/react@18.3.11)(react@18.3.1)(redux@5.0.1) + redux: 5.0.1 + redux-thunk: 3.1.0(redux@5.0.1) + reselect: 5.1.1 + dev: false + + /@roarr/browser-log-writer@1.3.0: + resolution: {integrity: sha512-RTzjxrm0CpTSoESmsO6104VymAksDS/yJEkaZrL/OLfbM6q+J+jLRBLtJxhJHSY03pBWOEE3wRh+pVwfKtBPqg==} + engines: {node: '>=12.0'} + dependencies: + boolean: 3.2.0 + globalthis: 1.0.4 + liqe: 3.8.0 + dev: false + + /@rollup/pluginutils@4.2.1: + resolution: {integrity: sha512-iKnFXr7NkdZAIHiIWE+BX5ULi/ucVFYWD6TbAV+rZctiRTY2PL6tsIKhoIOaoskiWAkgu+VsbXgUVDNLHf+InQ==} + engines: {node: '>= 8.0.0'} + dependencies: + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@rollup/pluginutils@5.1.2: + resolution: {integrity: sha512-/FIdS3PyZ39bjZlwqFnWqCOVnW7o963LtKMwQOD0NhQqw22gSr2YY1afu3FxRip4ZCZNsD5jq6Aaz6QV3D/Njw==} + engines: {node: '>=14.0.0'} + peerDependencies: + rollup: ^1.20.0||^2.0.0||^3.0.0||^4.0.0 + peerDependenciesMeta: + rollup: + optional: true + dependencies: + '@types/estree': 1.0.6 + estree-walker: 2.0.2 + picomatch: 2.3.1 + dev: true + + /@rollup/rollup-android-arm-eabi@4.24.0: + resolution: {integrity: sha512-Q6HJd7Y6xdB48x8ZNVDOqsbh2uByBhgK8PiQgPhwkIw/HC/YX5Ghq2mQY5sRMZWHb3VsFkWooUVOZHKr7DmDIA==} + cpu: [arm] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-android-arm64@4.24.0: + resolution: {integrity: sha512-ijLnS1qFId8xhKjT81uBHuuJp2lU4x2yxa4ctFPtG+MqEE6+C5f/+X/bStmxapgmwLwiL3ih122xv8kVARNAZA==} + cpu: [arm64] + os: [android] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-arm64@4.24.0: + resolution: {integrity: sha512-bIv+X9xeSs1XCk6DVvkO+S/z8/2AMt/2lMqdQbMrmVpgFvXlmde9mLcbQpztXm1tajC3raFDqegsH18HQPMYtA==} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-darwin-x64@4.24.0: + resolution: {integrity: sha512-X6/nOwoFN7RT2svEQWUsW/5C/fYMBe4fnLK9DQk4SX4mgVBiTA9h64kjUYPvGQ0F/9xwJ5U5UfTbl6BEjaQdBQ==} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-gnueabihf@4.24.0: + resolution: {integrity: sha512-0KXvIJQMOImLCVCz9uvvdPgfyWo93aHHp8ui3FrtOP57svqrF/roSSR5pjqL2hcMp0ljeGlU4q9o/rQaAQ3AYA==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm-musleabihf@4.24.0: + resolution: {integrity: sha512-it2BW6kKFVh8xk/BnHfakEeoLPv8STIISekpoF+nBgWM4d55CZKc7T4Dx1pEbTnYm/xEKMgy1MNtYuoA8RFIWw==} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-gnu@4.24.0: + resolution: {integrity: sha512-i0xTLXjqap2eRfulFVlSnM5dEbTVque/3Pi4g2y7cxrs7+a9De42z4XxKLYJ7+OhE3IgxvfQM7vQc43bwTgPwA==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-arm64-musl@4.24.0: + resolution: {integrity: sha512-9E6MKUJhDuDh604Qco5yP/3qn3y7SLXYuiC0Rpr89aMScS2UAmK1wHP2b7KAa1nSjWJc/f/Lc0Wl1L47qjiyQw==} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-powerpc64le-gnu@4.24.0: + resolution: {integrity: sha512-2XFFPJ2XMEiF5Zi2EBf4h73oR1V/lycirxZxHZNc93SqDN/IWhYYSYj8I9381ikUFXZrz2v7r2tOVk2NBwxrWw==} + cpu: [ppc64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-riscv64-gnu@4.24.0: + resolution: {integrity: sha512-M3Dg4hlwuntUCdzU7KjYqbbd+BLq3JMAOhCKdBE3TcMGMZbKkDdJ5ivNdehOssMCIokNHFOsv7DO4rlEOfyKpg==} + cpu: [riscv64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-s390x-gnu@4.24.0: + resolution: {integrity: sha512-mjBaoo4ocxJppTorZVKWFpy1bfFj9FeCMJqzlMQGjpNPY9JwQi7OuS1axzNIk0nMX6jSgy6ZURDZ2w0QW6D56g==} + cpu: [s390x] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-gnu@4.24.0: + resolution: {integrity: sha512-ZXFk7M72R0YYFN5q13niV0B7G8/5dcQ9JDp8keJSfr3GoZeXEoMHP/HlvqROA3OMbMdfr19IjCeNAnPUG93b6A==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-linux-x64-musl@4.24.0: + resolution: {integrity: sha512-w1i+L7kAXZNdYl+vFvzSZy8Y1arS7vMgIy8wusXJzRrPyof5LAb02KGr1PD2EkRcl73kHulIID0M501lN+vobQ==} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-arm64-msvc@4.24.0: + resolution: {integrity: sha512-VXBrnPWgBpVDCVY6XF3LEW0pOU51KbaHhccHw6AS6vBWIC60eqsH19DAeeObl+g8nKAz04QFdl/Cefta0xQtUQ==} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-ia32-msvc@4.24.0: + resolution: {integrity: sha512-xrNcGDU0OxVcPTH/8n/ShH4UevZxKIO6HJFK0e15XItZP2UcaiLFd5kiX7hJnqCbSztUF8Qot+JWBC/QXRPYWQ==} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rollup/rollup-win32-x64-msvc@4.24.0: + resolution: {integrity: sha512-fbMkAF7fufku0N2dE5TBXcNlg0pt0cJue4xBRE2Qc5Vqikxr4VCgKj/ht6SMdFcOacVA9rqF70APJ8RN/4vMJw==} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@rtsao/scc@1.1.0: + resolution: {integrity: sha512-zt6OdqaDoOnJ1ZYsCYGt9YmWzDXl4vQdKTyJev62gFhRGKdx7mcT54V9KIjg+d2wi9EXsPvAPKe7i7WjfVWB8g==} + dev: true + + /@rushstack/node-core-library@4.0.2(@types/node@20.16.10): + resolution: {integrity: sha512-hyES82QVpkfQMeBMteQUnrhASL/KHPhd7iJ8euduwNJG4mu2GSOKybf0rOEjOm1Wz7CwJEUm9y0yD7jg2C1bfg==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@types/node': 20.16.10 + fs-extra: 7.0.1 + import-lazy: 4.0.0 + jju: 1.4.0 + resolve: 1.22.8 + semver: 7.5.4 + z-schema: 5.0.5 + dev: true + + /@rushstack/rig-package@0.5.2: + resolution: {integrity: sha512-mUDecIJeH3yYGZs2a48k+pbhM6JYwWlgjs2Ca5f2n1G2/kgdgP9D/07oglEGf6mRyXEnazhEENeYTSNDRCwdqA==} + dependencies: + resolve: 1.22.8 + strip-json-comments: 3.1.1 + dev: true + + /@rushstack/terminal@0.10.0(@types/node@20.16.10): + resolution: {integrity: sha512-UbELbXnUdc7EKwfH2sb8ChqNgapUOdqcCIdQP4NGxBpTZV2sQyeekuK3zmfQSa/MN+/7b4kBogl2wq0vpkpYGw==} + peerDependencies: + '@types/node': '*' + peerDependenciesMeta: + '@types/node': + optional: true + dependencies: + '@rushstack/node-core-library': 4.0.2(@types/node@20.16.10) + '@types/node': 20.16.10 + supports-color: 8.1.1 + dev: true + + /@rushstack/ts-command-line@4.19.1(@types/node@20.16.10): + resolution: {integrity: sha512-J7H768dgcpG60d7skZ5uSSwyCZs/S2HrWP1Ds8d1qYAyaaeJmpmmLr9BVw97RjFzmQPOYnoXcKA4GkqDCkduQg==} + dependencies: + '@rushstack/terminal': 0.10.0(@types/node@20.16.10) + '@types/argparse': 1.0.38 + argparse: 1.0.10 + string-argv: 0.3.2 + transitivePeerDependencies: + - '@types/node' + dev: true + + /@sinclair/typebox@0.27.8: + resolution: {integrity: sha512-+Fj43pSMwJs4KRrH/938Uf+uAELIgVBmQzg/q1YG10djyfA3TnrU8N8XzqCh/okZdszqBQTZf96idMfE5lnwTA==} + dev: true + + /@snyk/github-codeowners@1.1.0: + resolution: {integrity: sha512-lGFf08pbkEac0NYgVf4hdANpAgApRjNByLXB+WBip3qj1iendOIyAwP2GKkKbQMNVy2r1xxDf0ssfWscoiC+Vw==} + engines: {node: '>=8.10'} + hasBin: true + dependencies: + commander: 4.1.1 + ignore: 5.3.2 + p-map: 4.0.0 + dev: true + + /@socket.io/component-emitter@3.1.2: + resolution: {integrity: sha512-9BCxFwvbGg/RsZK9tjXd8s4UcwR0MWeFQ1XEKIQVVvAGJyINdrqKMcTRyLoK8Rse1GjzLV9cwjWV1olXRWEXVA==} + dev: false + + /@storybook/addon-actions@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-1y0yD3upKcyzNwwA6loAGW2cRDqExwl4oAT7GJQA4tmabI+fNwmANSgU/ezLvvSUf4Qo0eJHg2Zcn8y+Apq2eA==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + '@types/uuid': 9.0.8 + dequal: 2.0.3 + polished: 4.3.1 + storybook: 8.3.4 + uuid: 9.0.1 + dev: true + + /@storybook/addon-backgrounds@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-o3nl7cN3x8erJNxLEv8YptanEQAnbqnaseOAsvSC6/nnSAcRYBSs3BvekKvo4CcpS2mxn7F5NJTBFYnCXzy8EA==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + memoizerific: 1.11.3 + storybook: 8.3.4 + ts-dedent: 2.2.0 + dev: true + + /@storybook/addon-controls@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-qQcaK6dczsb6wXkzGZKOjUYNA7FfKBewRv6NvoVKYY6LfhllGOkmUAtYpdtQG8adsZWTSoZaAOJS2vP2uM67lw==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + dequal: 2.0.3 + lodash: 4.17.21 + storybook: 8.3.4 + ts-dedent: 2.2.0 + dev: true + + /@storybook/addon-docs@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-TWauhqF/gJgfwPuWeM6KM3LwC+ErCOM+K2z16w3vgao9s67sij8lnrdAoQ0hjA+kw2/KAdCakFS6FyciG81qog==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@mdx-js/react': 3.0.1(@types/react@18.3.11)(react@18.3.1) + '@storybook/blocks': 8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4) + '@storybook/csf-plugin': 8.3.4(storybook@8.3.4) + '@storybook/global': 5.0.0 + '@storybook/react-dom-shim': 8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4) + '@types/react': 18.3.11 + fs-extra: 11.2.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + rehype-external-links: 3.0.0 + rehype-slug: 6.0.0 + storybook: 8.3.4 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - webpack-sources + dev: true + + /@storybook/addon-essentials@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-C3+3hpmSn/8zdx5sXEP0eE6zMzxgRosHVZYfe9nBcMiEDp6UKVUyHVetWxEULOEgN46ysjcpllZ0bUkRYxi2IQ==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/addon-actions': 8.3.4(storybook@8.3.4) + '@storybook/addon-backgrounds': 8.3.4(storybook@8.3.4) + '@storybook/addon-controls': 8.3.4(storybook@8.3.4) + '@storybook/addon-docs': 8.3.4(storybook@8.3.4) + '@storybook/addon-highlight': 8.3.4(storybook@8.3.4) + '@storybook/addon-measure': 8.3.4(storybook@8.3.4) + '@storybook/addon-outline': 8.3.4(storybook@8.3.4) + '@storybook/addon-toolbars': 8.3.4(storybook@8.3.4) + '@storybook/addon-viewport': 8.3.4(storybook@8.3.4) + storybook: 8.3.4 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - webpack-sources + dev: true + + /@storybook/addon-highlight@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-rxZTeuZyZ7RnU+xmRhS01COFLbGnVEmlUNxBw8ArsrTEZKW5PbKpIxNLTj9F0zdH8H0MfryJGP+Aadcm0oHWlw==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + storybook: 8.3.4 + dev: true + + /@storybook/addon-interactions@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-ORxqe35wUmF7EDHo45mdDHiju3Ryk2pZ1vO9PyvW6ZItNlHt/IxAr7T/TysGejZ/eTBg6tMZR3ExGky3lTg/CQ==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + '@storybook/instrumenter': 8.3.4(storybook@8.3.4) + '@storybook/test': 8.3.4(storybook@8.3.4) + polished: 4.3.1 + storybook: 8.3.4 + ts-dedent: 2.2.0 + dev: true + + /@storybook/addon-links@8.3.4(react@18.3.1)(storybook@8.3.4): + resolution: {integrity: sha512-R1DjARmxRIKJDGIG6uxmQ1yFNyoQbb+QIPUFjgWCak8+AdLJbC7W+Esvo9F5hQfh6czyy0piiM3qj5hpQJVh3A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.4 + peerDependenciesMeta: + react: + optional: true + dependencies: + '@storybook/csf': 0.1.11 + '@storybook/global': 5.0.0 + react: 18.3.1 + storybook: 8.3.4 + ts-dedent: 2.2.0 + dev: true + + /@storybook/addon-measure@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-IJ6WKEbqmG+r7sukFjo+bVmPB2Zry04sylGx/OGyOh7zIhhqAqpwOwMHP0uQrc3tLNnUM6qB/o83UyYX79ql+A==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + storybook: 8.3.4 + tiny-invariant: 1.3.3 + dev: true + + /@storybook/addon-outline@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-kRRJTTLKM8gMfeh/e83djN5XLlc0hFtr9zKWxuZxaXt9Hmr+9tH/PRFtVK/S4SgqnBDoXk49Wgv6raiwj5/e3A==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + storybook: 8.3.4 + ts-dedent: 2.2.0 + dev: true + + /@storybook/addon-storysource@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-uHTUiK7dzWRZAKpPafBH3U5PWAP7+J97lg66HDKAHpmmQdy7v3HfXaYNX1FoI+PeC5piUxFETXM0z+BNvJCknA==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/source-loader': 8.3.4(storybook@8.3.4) + estraverse: 5.3.0 + storybook: 8.3.4 + tiny-invariant: 1.3.3 + dev: true + + /@storybook/addon-toolbars@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-Km1YciVIxqluDbd1xmHjANNFyMonEOtnA6e4MrnBnC9XkPXSigeFlj0JvxyI/zjBsLBoFRmQiwq55W6l3hQ9sA==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + storybook: 8.3.4 + dev: true + + /@storybook/addon-viewport@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-fU4LdXSSqIOLbCEh2leq/tZUYlFliXZBWr/+igQHdUoU7HY8RIImXqVUaR9wlCaTb48WezAWT60vJtwNijyIiQ==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + memoizerific: 1.11.3 + storybook: 8.3.4 + dev: true + + /@storybook/blocks@8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4): + resolution: {integrity: sha512-1g4aCrd5CcN+pVhF2ATu9ZRVvAIgBMb2yF9KkCuTpdvqKDuDNK3sGb0CxjS7jp3LOvyjJr9laTOQsz8v8MQc5A==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.4 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + '@storybook/csf': 0.1.11 + '@storybook/global': 5.0.0 + '@storybook/icons': 1.2.12(react-dom@18.3.1)(react@18.3.1) + '@types/lodash': 4.17.10 + color-convert: 2.0.1 + dequal: 2.0.3 + lodash: 4.17.21 + markdown-to-jsx: 7.5.0(react@18.3.1) + memoizerific: 1.11.3 + polished: 4.3.1 + react: 18.3.1 + react-colorful: 5.6.1(react-dom@18.3.1)(react@18.3.1) + react-dom: 18.3.1(react@18.3.1) + storybook: 8.3.4 + telejson: 7.2.0 + ts-dedent: 2.2.0 + util-deprecate: 1.0.2 + dev: true + + /@storybook/builder-vite@8.3.4(storybook@8.3.4)(typescript@5.6.2)(vite@5.4.8): + resolution: {integrity: sha512-Sa6SZ7LeHpkrnuvua8P8MR8e8a+MPKbyMmr9TqCCy8Ud/t4AM4kHY3JpJGtrgeK9l43fBnBwfdZYoRl5J6oWeA==} + peerDependencies: + '@preact/preset-vite': '*' + storybook: ^8.3.4 + typescript: '>= 4.3.x' + vite: ^4.0.0 || ^5.0.0 + vite-plugin-glimmerx: '*' + peerDependenciesMeta: + '@preact/preset-vite': + optional: true + typescript: + optional: true + vite-plugin-glimmerx: + optional: true + dependencies: + '@storybook/csf-plugin': 8.3.4(storybook@8.3.4) + '@types/find-cache-dir': 3.2.1 + browser-assert: 1.2.1 + es-module-lexer: 1.5.4 + express: 4.21.0 + find-cache-dir: 3.3.2 + fs-extra: 11.2.0 + magic-string: 0.30.11 + storybook: 8.3.4 + ts-dedent: 2.2.0 + typescript: 5.6.2 + vite: 5.4.8(@types/node@20.16.10) + transitivePeerDependencies: + - supports-color + - webpack-sources + dev: true + + /@storybook/components@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-iQzLJd87uGbFBbYNqlrN/ABrnx3dUrL0tjPCarzglzshZoPCNOsllJeJx5TJwB9kCxSZ8zB9TTOgr7NXl+oyVA==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + storybook: 8.3.4 + dev: true + + /@storybook/core@8.3.4: + resolution: {integrity: sha512-4PZB91JJpuKfcjeOR2LXj3ABaPLLSd2P/SfYOKNCygrDstsQa/yay3/yN5Z9yi1cIG84KRr6/sUW+0x8HsGLPg==} + dependencies: + '@storybook/csf': 0.1.11 + '@types/express': 4.17.21 + better-opn: 3.0.2 + browser-assert: 1.2.1 + esbuild: 0.23.1 + esbuild-register: 3.6.0(esbuild@0.23.1) + express: 4.21.0 + jsdoc-type-pratt-parser: 4.1.0 + process: 0.11.10 + recast: 0.23.9 + semver: 7.6.3 + util: 0.12.5 + ws: 8.18.0 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /@storybook/csf-plugin@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-ZMFWYxeTN4GxCn8dyIH4roECyLDy29yv/QKM+pHM3AC5Ny2HWI35SohWao4fGBAFxPQFbR5hPN8xa6ofHPSSTg==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + storybook: 8.3.4 + unplugin: 1.14.1 + transitivePeerDependencies: + - webpack-sources + dev: true + + /@storybook/csf@0.0.1: + resolution: {integrity: sha512-USTLkZze5gkel8MYCujSRBVIrUQ3YPBrLOx7GNk/0wttvVtlzWXAq9eLbQ4p/NicGxP+3T7KPEMVV//g+yubpw==} + dependencies: + lodash: 4.17.21 + dev: true + + /@storybook/csf@0.1.11: + resolution: {integrity: sha512-dHYFQH3mA+EtnCkHXzicbLgsvzYjcDJ1JWsogbItZogkPHgSJM/Wr71uMkcvw8v9mmCyP4NpXJuu6bPoVsOnzg==} + dependencies: + type-fest: 2.19.0 + dev: true + + /@storybook/global@5.0.0: + resolution: {integrity: sha512-FcOqPAXACP0I3oJ/ws6/rrPT9WGhu915Cg8D02a9YxLo0DE9zI+a9A5gRGvmQ09fiWPukqI8ZAEoQEdWUKMQdQ==} + dev: true + + /@storybook/icons@1.2.12(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-UxgyK5W3/UV4VrI3dl6ajGfHM4aOqMAkFLWe2KibeQudLf6NJpDrDMSHwZj+3iKC4jFU7dkKbbtH2h/al4sW3Q==} + engines: {node: '>=14.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: true + + /@storybook/instrumenter@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-jVhfNOPekOyJmta0BTkQl9Z6rgRbFHlc0eV4z1oSrzaawSlc9TFzAeDCtCP57vg3FuBX8ydDYAvyZ7s4xPpLyg==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/global': 5.0.0 + '@vitest/utils': 2.1.2 + storybook: 8.3.4 + util: 0.12.5 + dev: true + + /@storybook/manager-api@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-tBx7MBfPUrKSlD666zmVjtIvoNArwCciZiW/UJ8IWmomrTJRfFBnVvPVM2gp1lkDIzRHYmz5x9BHbYaEDNcZWQ==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + storybook: 8.3.4 + dev: true + + /@storybook/preview-api@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-/YKQ3QDVSHmtFXXCShf5w0XMlg8wkfTpdYxdGv1CKFV8DU24f3N7KWulAgeWWCWQwBzZClDa9kzxmroKlQqx3A==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + storybook: 8.3.4 + dev: true + + /@storybook/react-dom-shim@8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4): + resolution: {integrity: sha512-L4llDvjaAzqPx6h4ddZMh36wPr75PrI2S8bXy+flLqAeVRYnRt4WNKGuxqH0t0U6MwId9+vlCZ13JBfFuY7eQQ==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.4 + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + storybook: 8.3.4 + dev: true + + /@storybook/react-vite@8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4)(typescript@5.6.2)(vite@5.4.8): + resolution: {integrity: sha512-0Xm8eTH+jQ7SV4moLkPN4G6U2IDrqXPXUqsZdXaccepIMcD4G75foQFm2LOrFJuY+IMySPspKeTqf8OLskPppw==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.4 + vite: ^4.0.0 || ^5.0.0 + dependencies: + '@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.6.2)(vite@5.4.8) + '@rollup/pluginutils': 5.1.2 + '@storybook/builder-vite': 8.3.4(storybook@8.3.4)(typescript@5.6.2)(vite@5.4.8) + '@storybook/react': 8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4)(typescript@5.6.2) + find-up: 5.0.0 + magic-string: 0.30.11 + react: 18.3.1 + react-docgen: 7.0.3 + react-dom: 18.3.1(react@18.3.1) + resolve: 1.22.8 + storybook: 8.3.4 + tsconfig-paths: 4.2.0 + vite: 5.4.8(@types/node@20.16.10) + transitivePeerDependencies: + - '@preact/preset-vite' + - '@storybook/test' + - rollup + - supports-color + - typescript + - vite-plugin-glimmerx + - webpack-sources + dev: true + + /@storybook/react@8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4)(typescript@5.6.2): + resolution: {integrity: sha512-PA7iQL4/9X2/iLrv+AUPNtlhTHJWhDao9gQIT1Hef39FtFk+TU9lZGbv+g29R1H9V3cHP5162nG2aTu395kmbA==} + engines: {node: '>=18.0.0'} + peerDependencies: + '@storybook/test': 8.3.4 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0-beta + storybook: ^8.3.4 + typescript: '>= 4.2.x' + peerDependenciesMeta: + '@storybook/test': + optional: true + typescript: + optional: true + dependencies: + '@storybook/components': 8.3.4(storybook@8.3.4) + '@storybook/global': 5.0.0 + '@storybook/manager-api': 8.3.4(storybook@8.3.4) + '@storybook/preview-api': 8.3.4(storybook@8.3.4) + '@storybook/react-dom-shim': 8.3.4(react-dom@18.3.1)(react@18.3.1)(storybook@8.3.4) + '@storybook/theming': 8.3.4(storybook@8.3.4) + '@types/escodegen': 0.0.6 + '@types/estree': 0.0.51 + '@types/node': 22.7.4 + acorn: 7.4.1 + acorn-jsx: 5.3.2(acorn@7.4.1) + acorn-walk: 7.2.0 + escodegen: 2.1.0 + html-tags: 3.3.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-element-to-jsx-string: 15.0.0(react-dom@18.3.1)(react@18.3.1) + semver: 7.6.3 + storybook: 8.3.4 + ts-dedent: 2.2.0 + type-fest: 2.19.0 + typescript: 5.6.2 + util-deprecate: 1.0.2 + dev: true + + /@storybook/source-loader@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-wH//LuWfa2iOmjykSqsub8M8e0EdhEUZoHUFhwBeizfYQQHaMaSEBhhAQCaWWKmdGB9lnCe1cioQ32c2IWtBIw==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/csf': 0.1.11 + estraverse: 5.3.0 + lodash: 4.17.21 + prettier: 3.3.3 + storybook: 8.3.4 + dev: true + + /@storybook/test@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-HRiUenitln8QPHu6DEWUg9s9cEoiGN79lMykzXzw9shaUvdEIhWCsh82YKtmB3GJPj6qcc6dZL/Aio8srxyGAg==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + '@storybook/csf': 0.1.11 + '@storybook/global': 5.0.0 + '@storybook/instrumenter': 8.3.4(storybook@8.3.4) + '@testing-library/dom': 10.4.0 + '@testing-library/jest-dom': 6.5.0 + '@testing-library/user-event': 14.5.2(@testing-library/dom@10.4.0) + '@vitest/expect': 2.0.5 + '@vitest/spy': 2.0.5 + storybook: 8.3.4 + util: 0.12.5 + dev: true + + /@storybook/theming@8.3.4(storybook@8.3.4): + resolution: {integrity: sha512-D4XVsQgTtpHEHLhwkx59aGy1GBwOedVr/mNns7hFrH8FjEpxrrWCuZQASq1ZpCl8LXlh7uvmT5sM2rOdQbGuGg==} + peerDependencies: + storybook: ^8.3.4 + dependencies: + storybook: 8.3.4 + dev: true + + /@swc/core-darwin-arm64@1.7.26: + resolution: {integrity: sha512-FF3CRYTg6a7ZVW4yT9mesxoVVZTrcSWtmZhxKCYJX9brH4CS/7PRPjAKNk6kzWgWuRoglP7hkjQcd6EpMcZEAw==} + engines: {node: '>=10'} + cpu: [arm64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-darwin-x64@1.7.26: + resolution: {integrity: sha512-az3cibZdsay2HNKmc4bjf62QVukuiMRh5sfM5kHR/JMTrLyS6vSw7Ihs3UTkZjUxkLTT8ro54LI6sV6sUQUbLQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm-gnueabihf@1.7.26: + resolution: {integrity: sha512-VYPFVJDO5zT5U3RpCdHE5v1gz4mmR8BfHecUZTmD2v1JeFY6fv9KArJUpjrHEEsjK/ucXkQFmJ0jaiWXmpOV9Q==} + engines: {node: '>=10'} + cpu: [arm] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-gnu@1.7.26: + resolution: {integrity: sha512-YKevOV7abpjcAzXrhsl+W48Z9mZvgoVs2eP5nY+uoMAdP2b3GxC0Df1Co0I90o2lkzO4jYBpTMcZlmUXLdXn+Q==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-arm64-musl@1.7.26: + resolution: {integrity: sha512-3w8iZICMkQQON0uIcvz7+Q1MPOW6hJ4O5ETjA0LSP/tuKqx30hIniCGOgPDnv3UTMruLUnQbtBwVCZTBKR3Rkg==} + engines: {node: '>=10'} + cpu: [arm64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-gnu@1.7.26: + resolution: {integrity: sha512-c+pp9Zkk2lqb06bNGkR2Looxrs7FtGDMA4/aHjZcCqATgp348hOKH5WPvNLBl+yPrISuWjbKDVn3NgAvfvpH4w==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-linux-x64-musl@1.7.26: + resolution: {integrity: sha512-PgtyfHBF6xG87dUSSdTJHwZ3/8vWZfNIXQV2GlwEpslrOkGqy+WaiiyE7Of7z9AvDILfBBBcJvJ/r8u980wAfQ==} + engines: {node: '>=10'} + cpu: [x64] + os: [linux] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-arm64-msvc@1.7.26: + resolution: {integrity: sha512-9TNXPIJqFynlAOrRD6tUQjMq7KApSklK3R/tXgIxc7Qx+lWu8hlDQ/kVPLpU7PWvMMwC/3hKBW+p5f+Tms1hmA==} + engines: {node: '>=10'} + cpu: [arm64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-ia32-msvc@1.7.26: + resolution: {integrity: sha512-9YngxNcG3177GYdsTum4V98Re+TlCeJEP4kEwEg9EagT5s3YejYdKwVAkAsJszzkXuyRDdnHUpYbTrPG6FiXrQ==} + engines: {node: '>=10'} + cpu: [ia32] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core-win32-x64-msvc@1.7.26: + resolution: {integrity: sha512-VR+hzg9XqucgLjXxA13MtV5O3C0bK0ywtLIBw/+a+O+Oc6mxFWHtdUeXDbIi5AiPbn0fjgVJMqYnyjGyyX8u0w==} + engines: {node: '>=10'} + cpu: [x64] + os: [win32] + requiresBuild: true + dev: true + optional: true + + /@swc/core@1.7.26: + resolution: {integrity: sha512-f5uYFf+TmMQyYIoxkn/evWhNGuUzC730dFwAKGwBVHHVoPyak1/GvJUm6i1SKl+2Hrj9oN0i3WSoWWZ4pgI8lw==} + engines: {node: '>=10'} + requiresBuild: true + peerDependencies: + '@swc/helpers': '*' + peerDependenciesMeta: + '@swc/helpers': + optional: true + dependencies: + '@swc/counter': 0.1.3 + '@swc/types': 0.1.12 + optionalDependencies: + '@swc/core-darwin-arm64': 1.7.26 + '@swc/core-darwin-x64': 1.7.26 + '@swc/core-linux-arm-gnueabihf': 1.7.26 + '@swc/core-linux-arm64-gnu': 1.7.26 + '@swc/core-linux-arm64-musl': 1.7.26 + '@swc/core-linux-x64-gnu': 1.7.26 + '@swc/core-linux-x64-musl': 1.7.26 + '@swc/core-win32-arm64-msvc': 1.7.26 + '@swc/core-win32-ia32-msvc': 1.7.26 + '@swc/core-win32-x64-msvc': 1.7.26 + dev: true + + /@swc/counter@0.1.3: + resolution: {integrity: sha512-e2BR4lsJkkRlKZ/qCHPw9ZaSxc0MVUd7gtbtaB7aMvHeJVYe8sOB8DBZkP2DtISHGSku9sCK6T6cnY0CtXrOCQ==} + dev: true + + /@swc/types@0.1.12: + resolution: {integrity: sha512-wBJA+SdtkbFhHjTMYH+dEH1y4VpfGdAc2Kw/LK09i9bXd/K6j6PkDcFCEzb6iVfZMkPRrl/q0e3toqTAJdkIVA==} + dependencies: + '@swc/counter': 0.1.3 + dev: true + + /@testing-library/dom@10.4.0: + resolution: {integrity: sha512-pemlzrSESWbdAloYml3bAJMEfNh1Z7EduzqPKprCH5S341frlpYnUEW0H72dLxa6IsYr+mPno20GiSm+h9dEdQ==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.25.7 + '@babel/runtime': 7.25.7 + '@types/aria-query': 5.0.4 + aria-query: 5.3.0 + chalk: 4.1.2 + dom-accessibility-api: 0.5.16 + lz-string: 1.5.0 + pretty-format: 27.5.1 + dev: true + + /@testing-library/jest-dom@6.5.0: + resolution: {integrity: sha512-xGGHpBXYSHUUr6XsKBfs85TWlYKpTc37cSBBVrXcib2MkHLboWlkClhWF37JKlDb9KEq3dHs+f2xR7XJEWGBxA==} + engines: {node: '>=14', npm: '>=6', yarn: '>=1'} + dependencies: + '@adobe/css-tools': 4.4.0 + aria-query: 5.3.2 + chalk: 3.0.0 + css.escape: 1.5.1 + dom-accessibility-api: 0.6.3 + lodash: 4.17.21 + redent: 3.0.0 + dev: true + + /@testing-library/user-event@14.5.2(@testing-library/dom@10.4.0): + resolution: {integrity: sha512-YAh82Wh4TIrxYLmfGcixwD18oIjyC1pFQC2Y01F2lzV2HTMiYrI0nze0FD0ocB//CKS/7jIUgae+adPqxK5yCQ==} + engines: {node: '>=12', npm: '>=6'} + peerDependencies: + '@testing-library/dom': '>=7.21.4' + dependencies: + '@testing-library/dom': 10.4.0 + dev: true + + /@types/argparse@1.0.38: + resolution: {integrity: sha512-ebDJ9b0e702Yr7pWgB0jzm+CX4Srzz8RcXtLJDJB+BSccqMa36uyH/zUsSYao5+BD1ytv3k3rPYCq4mAE1hsXA==} + dev: true + + /@types/aria-query@5.0.4: + resolution: {integrity: sha512-rfT93uj5s0PRL7EzccGMs3brplhcrghnDoV26NqKhCAS1hVo+WdNsPvE/yb6ilfr5hi2MEk6d5EWJTKdxg8jVw==} + dev: true + + /@types/babel__core@7.20.5: + resolution: {integrity: sha512-qoQprZvz5wQFJwMDqeseRXWv3rqMvhgpbXFfVyWhbx9X47POIA6i/+dXefEmZKoAgOaTdaIgNSMqMIU61yRyzA==} + dependencies: + '@babel/parser': 7.25.7 + '@babel/types': 7.25.7 + '@types/babel__generator': 7.6.8 + '@types/babel__template': 7.4.4 + '@types/babel__traverse': 7.20.6 + dev: true + + /@types/babel__generator@7.6.8: + resolution: {integrity: sha512-ASsj+tpEDsEiFr1arWrlN6V3mdfjRMZt6LtK/Vp/kreFLnr5QH5+DhvD5nINYZXzwJvXeGq+05iUXcAzVrqWtw==} + dependencies: + '@babel/types': 7.25.7 + dev: true + + /@types/babel__template@7.4.4: + resolution: {integrity: sha512-h/NUaSyG5EyxBIp8YRxo4RMe2/qQgvyowRwVMzhYhBCONbW8PUsg4lkFMrhgZhUe5z3L3MiLDuvyJ/CaPa2A8A==} + dependencies: + '@babel/parser': 7.25.7 + '@babel/types': 7.25.7 + dev: true + + /@types/babel__traverse@7.20.6: + resolution: {integrity: sha512-r1bzfrm0tomOI8g1SzvCaQHo6Lcv6zu0EA+W2kHrt8dyrHQxGzBBL4kdkzIS+jBMV+EYcMAEAqXqYaLJq5rOZg==} + dependencies: + '@babel/types': 7.25.7 + dev: true + + /@types/body-parser@1.19.5: + resolution: {integrity: sha512-fB3Zu92ucau0iQ0JMCFQE7b/dv8Ot07NI3KaZIkIUNXq82k4eBAqUaneXfleGY9JWskeS9y+u0nXMyspcuQrCg==} + dependencies: + '@types/connect': 3.4.38 + '@types/node': 20.16.10 + dev: true + + /@types/connect@3.4.38: + resolution: {integrity: sha512-K6uROf1LD88uDQqJCktA4yzL1YYAK6NgfsI0v/mTgyPKWsX1CnJ0XPSDhViejru1GcRkLWb8RlzFYJRqGUbaug==} + dependencies: + '@types/node': 20.16.10 + dev: true + + /@types/d3-array@3.2.1: + resolution: {integrity: sha512-Y2Jn2idRrLzUfAKV2LyRImR+y4oa2AntrgID95SHJxuMUrkNXmanDSed71sRNZysveJVt1hLLemQZIady0FpEg==} + dev: false + + /@types/d3-axis@3.0.6: + resolution: {integrity: sha512-pYeijfZuBd87T0hGn0FO1vQ/cgLk6E1ALJjfkC0oJ8cbwkZl3TpgS8bVBLZN+2jjGgg38epgxb2zmoGtSfvgMw==} + dependencies: + '@types/d3-selection': 3.0.10 + dev: false + + /@types/d3-brush@3.0.6: + resolution: {integrity: sha512-nH60IZNNxEcrh6L1ZSMNA28rj27ut/2ZmI3r96Zd+1jrZD++zD3LsMIjWlvg4AYrHn/Pqz4CF3veCxGjtbqt7A==} + dependencies: + '@types/d3-selection': 3.0.10 + dev: false + + /@types/d3-chord@3.0.6: + resolution: {integrity: sha512-LFYWWd8nwfwEmTZG9PfQxd17HbNPksHBiJHaKuY1XeqscXacsS2tyoo6OdRsjf+NQYeB6XrNL3a25E3gH69lcg==} + dev: false + + /@types/d3-color@3.1.3: + resolution: {integrity: sha512-iO90scth9WAbmgv7ogoq57O9YpKmFBbmoEoCHDB2xMBY0+/KVrqAaCDyCE16dUspeOvIxFFRI+0sEtqDqy2b4A==} + dev: false + + /@types/d3-contour@3.0.6: + resolution: {integrity: sha512-BjzLgXGnCWjUSYGfH1cpdo41/hgdWETu4YxpezoztawmqsvCeep+8QGfiY6YbDvfgHz/DkjeIkkZVJavB4a3rg==} + dependencies: + '@types/d3-array': 3.2.1 + '@types/geojson': 7946.0.14 + dev: false + + /@types/d3-delaunay@6.0.4: + resolution: {integrity: sha512-ZMaSKu4THYCU6sV64Lhg6qjf1orxBthaC161plr5KuPHo3CNm8DTHiLw/5Eq2b6TsNP0W0iJrUOFscY6Q450Hw==} + dev: false + + /@types/d3-dispatch@3.0.6: + resolution: {integrity: sha512-4fvZhzMeeuBJYZXRXrRIQnvUYfyXwYmLsdiN7XXmVNQKKw1cM8a5WdID0g1hVFZDqT9ZqZEY5pD44p24VS7iZQ==} + dev: false + + /@types/d3-drag@3.0.7: + resolution: {integrity: sha512-HE3jVKlzU9AaMazNufooRJ5ZpWmLIoc90A37WU2JMmeq28w1FQqCZswHZ3xR+SuxYftzHq6WU6KJHvqxKzTxxQ==} + dependencies: + '@types/d3-selection': 3.0.10 + dev: false + + /@types/d3-dsv@3.0.7: + resolution: {integrity: sha512-n6QBF9/+XASqcKK6waudgL0pf/S5XHPPI8APyMLLUHd8NqouBGLsU8MgtO7NINGtPBtk9Kko/W4ea0oAspwh9g==} + dev: false + + /@types/d3-ease@3.0.2: + resolution: {integrity: sha512-NcV1JjO5oDzoK26oMzbILE6HW7uVXOHLQvHshBUW4UMdZGfiY6v5BeQwh9a9tCzv+CeefZQHJt5SRgK154RtiA==} + dev: false + + /@types/d3-fetch@3.0.7: + resolution: {integrity: sha512-fTAfNmxSb9SOWNB9IoG5c8Hg6R+AzUHDRlsXsDZsNp6sxAEOP0tkP3gKkNSO/qmHPoBFTxNrjDprVHDQDvo5aA==} + dependencies: + '@types/d3-dsv': 3.0.7 + dev: false + + /@types/d3-force@3.0.10: + resolution: {integrity: sha512-ZYeSaCF3p73RdOKcjj+swRlZfnYpK1EbaDiYICEEp5Q6sUiqFaFQ9qgoshp5CzIyyb/yD09kD9o2zEltCexlgw==} + dev: false + + /@types/d3-format@3.0.4: + resolution: {integrity: sha512-fALi2aI6shfg7vM5KiR1wNJnZ7r6UuggVqtDA+xiEdPZQwy/trcQaHnwShLuLdta2rTymCNpxYTiMZX/e09F4g==} + dev: false + + /@types/d3-geo@3.1.0: + resolution: {integrity: sha512-856sckF0oP/diXtS4jNsiQw/UuK5fQG8l/a9VVLeSouf1/PPbBE1i1W852zVwKwYCBkFJJB7nCFTbk6UMEXBOQ==} + dependencies: + '@types/geojson': 7946.0.14 + dev: false + + /@types/d3-hierarchy@3.1.7: + resolution: {integrity: sha512-tJFtNoYBtRtkNysX1Xq4sxtjK8YgoWUNpIiUee0/jHGRwqvzYxkq0hGVbbOGSz+JgFxxRu4K8nb3YpG3CMARtg==} + dev: false + + /@types/d3-interpolate@3.0.4: + resolution: {integrity: sha512-mgLPETlrpVV1YRJIglr4Ez47g7Yxjl1lj7YKsiMCb27VJH9W8NVM6Bb9d8kkpG/uAQS5AmbA48q2IAolKKo1MA==} + dependencies: + '@types/d3-color': 3.1.3 + dev: false + + /@types/d3-path@3.1.0: + resolution: {integrity: sha512-P2dlU/q51fkOc/Gfl3Ul9kicV7l+ra934qBFXCFhrZMOL6du1TM0pm1ThYvENukyOn5h9v+yMJ9Fn5JK4QozrQ==} + dev: false + + /@types/d3-polygon@3.0.2: + resolution: {integrity: sha512-ZuWOtMaHCkN9xoeEMr1ubW2nGWsp4nIql+OPQRstu4ypeZ+zk3YKqQT0CXVe/PYqrKpZAi+J9mTs05TKwjXSRA==} + dev: false + + /@types/d3-quadtree@3.0.6: + resolution: {integrity: sha512-oUzyO1/Zm6rsxKRHA1vH0NEDG58HrT5icx/azi9MF1TWdtttWl0UIUsjEQBBh+SIkrpd21ZjEv7ptxWys1ncsg==} + dev: false + + /@types/d3-random@3.0.3: + resolution: {integrity: sha512-Imagg1vJ3y76Y2ea0871wpabqp613+8/r0mCLEBfdtqC7xMSfj9idOnmBYyMoULfHePJyxMAw3nWhJxzc+LFwQ==} + dev: false + + /@types/d3-scale-chromatic@3.0.3: + resolution: {integrity: sha512-laXM4+1o5ImZv3RpFAsTRn3TEkzqkytiOY0Dz0sq5cnd1dtNlk6sHLon4OvqaiJb28T0S/TdsBI3Sjsy+keJrw==} + dev: false + + /@types/d3-scale@4.0.8: + resolution: {integrity: sha512-gkK1VVTr5iNiYJ7vWDI+yUFFlszhNMtVeneJ6lUTKPjprsvLLI9/tgEGiXJOnlINJA8FyA88gfnQsHbybVZrYQ==} + dependencies: + '@types/d3-time': 3.0.3 + dev: false + + /@types/d3-selection@3.0.10: + resolution: {integrity: sha512-cuHoUgS/V3hLdjJOLTT691+G2QoqAjCVLmr4kJXR4ha56w1Zdu8UUQ5TxLRqudgNjwXeQxKMq4j+lyf9sWuslg==} + dev: false + + /@types/d3-shape@3.1.6: + resolution: {integrity: sha512-5KKk5aKGu2I+O6SONMYSNflgiP0WfZIQvVUMan50wHsLG1G94JlxEVnCpQARfTtzytuY0p/9PXXZb3I7giofIA==} + dependencies: + '@types/d3-path': 3.1.0 + dev: false + + /@types/d3-time-format@4.0.3: + resolution: {integrity: sha512-5xg9rC+wWL8kdDj153qZcsJ0FWiFt0J5RB6LYUNZjwSnesfblqrI/bJ1wBdJ8OQfncgbJG5+2F+qfqnqyzYxyg==} + dev: false + + /@types/d3-time@3.0.3: + resolution: {integrity: sha512-2p6olUZ4w3s+07q3Tm2dbiMZy5pCDfYwtLXXHUnVzXgQlZ/OyPtUz6OL382BkOuGlLXqfT+wqv8Fw2v8/0geBw==} + dev: false + + /@types/d3-timer@3.0.2: + resolution: {integrity: sha512-Ps3T8E8dZDam6fUyNiMkekK3XUsaUEik+idO9/YjPtfj2qruF8tFBXS7XhtE4iIXBLxhmLjP3SXpLhVf21I9Lw==} + dev: false + + /@types/d3-transition@3.0.8: + resolution: {integrity: sha512-ew63aJfQ/ms7QQ4X7pk5NxQ9fZH/z+i24ZfJ6tJSfqxJMrYLiK01EAs2/Rtw/JreGUsS3pLPNV644qXFGnoZNQ==} + dependencies: + '@types/d3-selection': 3.0.10 + dev: false + + /@types/d3-zoom@3.0.8: + resolution: {integrity: sha512-iqMC4/YlFCSlO8+2Ii1GGGliCAY4XdeG748w5vQUbevlbDu0zSjH/+jojorQVBK/se0j6DUFNPBGSqD3YWYnDw==} + dependencies: + '@types/d3-interpolate': 3.0.4 + '@types/d3-selection': 3.0.10 + dev: false + + /@types/d3@7.4.3: + resolution: {integrity: sha512-lZXZ9ckh5R8uiFVt8ogUNf+pIrK4EsWrx2Np75WvF/eTpJ0FMHNhjXk8CKEx/+gpHbNQyJWehbFaTvqmHWB3ww==} + dependencies: + '@types/d3-array': 3.2.1 + '@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.6 + '@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.0 + '@types/d3-polygon': 3.0.2 + '@types/d3-quadtree': 3.0.6 + '@types/d3-random': 3.0.3 + '@types/d3-scale': 4.0.8 + '@types/d3-scale-chromatic': 3.0.3 + '@types/d3-selection': 3.0.10 + '@types/d3-shape': 3.1.6 + '@types/d3-time': 3.0.3 + '@types/d3-time-format': 4.0.3 + '@types/d3-timer': 3.0.2 + '@types/d3-transition': 3.0.8 + '@types/d3-zoom': 3.0.8 + dev: false + + /@types/dateformat@5.0.2: + resolution: {integrity: sha512-M95hNBMa/hnwErH+a+VOD/sYgTmo15OTYTM2Hr52/e0OdOuY+Crag+kd3/ioZrhg0WGbl9Sm3hR7UU+MH6rfOw==} + dev: true + + /@types/diff-match-patch@1.0.36: + resolution: {integrity: sha512-xFdR6tkm0MWvBfO8xXCSsinYxHcqkQUlcHeSpMC2ukzOb6lwQAfDmW+Qt0AvlGd8HpsS28qKsB+oPeJn9I39jg==} + dev: false + + /@types/doctrine@0.0.9: + resolution: {integrity: sha512-eOIHzCUSH7SMfonMG1LsC2f8vxBFtho6NGBznK41R84YzPuvSBzrhEps33IsQiOW9+VL6NQ9DbjQJznk/S4uRA==} + dev: true + + /@types/escodegen@0.0.6: + resolution: {integrity: sha512-AjwI4MvWx3HAOaZqYsjKWyEObT9lcVV0Y0V8nXo6cXzN8ZiMxVhf6F3d/UNvXVGKrEzL/Dluc5p+y9GkzlTWig==} + dev: true + + /@types/eslint@8.56.12: + resolution: {integrity: sha512-03ruubjWyOHlmljCVoxSuNDdmfZDzsrrz0P2LeJsOXr+ZwFQ+0yQIwNCwt/GYhV7Z31fgtXJTAEs+FYlEL851g==} + dependencies: + '@types/estree': 1.0.6 + '@types/json-schema': 7.0.15 + dev: true + + /@types/estree@0.0.51: + resolution: {integrity: sha512-CuPgU6f3eT/XgKKPqKd/gLZV1Xmvf1a2R5POBOGQa6uv82xpls89HU5zKeVoyR8XzHd1RGNOlQlvUe3CFkjWNQ==} + dev: true + + /@types/estree@1.0.6: + resolution: {integrity: sha512-AYnb1nQyY49te+VRAVgmzfcgjYS91mY5P0TKUDCLEM+gNnA+3T6rWITXRLYCpahpqSQbN5cE+gHpnPyXjHWxcw==} + dev: true + + /@types/express-serve-static-core@4.19.6: + resolution: {integrity: sha512-N4LZ2xG7DatVqhCZzOGb1Yi5lMbXSZcmdLDe9EzSndPV2HpWYWzRbaerl2n27irrm94EPpprqa8KpskPT085+A==} + dependencies: + '@types/node': 20.16.10 + '@types/qs': 6.9.16 + '@types/range-parser': 1.2.7 + '@types/send': 0.17.4 + dev: true + + /@types/express@4.17.21: + resolution: {integrity: sha512-ejlPM315qwLpaQlQDTjPdsUFSc6ZsP4AN6AlWnogPjQ7CVi7PYF3YVz+CY3jE2pwYf7E/7HlDAN0rV2GxTG0HQ==} + dependencies: + '@types/body-parser': 1.19.5 + '@types/express-serve-static-core': 4.19.6 + '@types/qs': 6.9.16 + '@types/serve-static': 1.15.7 + dev: true + + /@types/find-cache-dir@3.2.1: + resolution: {integrity: sha512-frsJrz2t/CeGifcu/6uRo4b+SzAwT4NYCVPu1GN8IB9XTzrpPkGuV0tmh9mN+/L0PklAlsC3u5Fxt0ju00LXIw==} + dev: true + + /@types/geojson@7946.0.14: + resolution: {integrity: sha512-WCfD5Ht3ZesJUsONdhvm84dmzWOiOzOAqOncN0++w0lBw1o8OuDNJF2McvvCef/yBqb/HYRahp1BYtODFQ8bRg==} + dev: false + + /@types/glob@7.2.0: + resolution: {integrity: sha512-ZUxbzKl0IfJILTS6t7ip5fQQM/J3TJYubDm3nMbgubNNYS62eXeUpoLUC8/7fJNiFYHTrGPQn7hspDUzIHX3UA==} + dependencies: + '@types/minimatch': 5.1.2 + '@types/node': 20.16.10 + dev: true + + /@types/hast@3.0.4: + resolution: {integrity: sha512-WPs+bbQw5aCj+x6laNGWLH3wviHtoCv/P3+otBhbOhJgG8qtpdAMlTCxLtsTWA7LH1Oh/bFCHsBn0TPS5m30EQ==} + dependencies: + '@types/unist': 3.0.3 + dev: true + + /@types/http-errors@2.0.4: + resolution: {integrity: sha512-D0CFMMtydbJAegzOyHjtiKPLlvnm3iTZyZRSZoLq2mRhDdmLfIWOCYPfQJ4cu2erKghU++QvjcUjp/5h7hESpA==} + dev: true + + /@types/js-cookie@2.2.7: + resolution: {integrity: sha512-aLkWa0C0vO5b4Sr798E26QgOkss68Un0bLjs7u9qxzPT5CG+8DuNTffWES58YzJs3hrVAOs1wonycqEBqNJubA==} + dev: false + + /@types/json-schema@7.0.15: + resolution: {integrity: sha512-5+fP8P8MFNC+AyZCDxrB2pkZFPGzqQWUzpSeuuVLvm8VMcorNYavBqoFcxK8bQz4Qsbn4oUEEem4wDLfcysGHA==} + dev: true + + /@types/json5@0.0.29: + resolution: {integrity: sha512-dRLjCWHYg4oaA77cxO64oO+7JwCwnIzkZPdrrC71jQmQtlhM556pwKo5bUzqvZndkVbeFLIIi+9TC40JNF5hNQ==} + dev: true + + /@types/lodash-es@4.17.12: + resolution: {integrity: sha512-0NgftHUcV4v34VhXm8QBSftKVXtbkBG3ViCjs6+eJ5a6y6Mi/jiFGPc1sC7QK+9BFhWrURE3EOggmWaSxL9OzQ==} + dependencies: + '@types/lodash': 4.17.10 + dev: true + + /@types/lodash.mergewith@4.6.7: + resolution: {integrity: sha512-3m+lkO5CLRRYU0fhGRp7zbsGi6+BZj0uTVSwvcKU+nSlhjA9/QRNfuSGnD2mX6hQA7ZbmcCkzk5h4ZYGOtk14A==} + dependencies: + '@types/lodash': 4.17.10 + dev: false + + /@types/lodash.mergewith@4.6.9: + resolution: {integrity: sha512-fgkoCAOF47K7sxrQ7Mlud2TH023itugZs2bUg8h/KzT+BnZNrR2jAOmaokbLunHNnobXVWOezAeNn/lZqwxkcw==} + dependencies: + '@types/lodash': 4.17.10 + dev: false + + /@types/lodash@4.17.10: + resolution: {integrity: sha512-YpS0zzoduEhuOWjAotS6A5AVCva7X4lVlYLF0FYHAY9sdraBfnatttHItlWeZdGhuEkf+OzMNg2ZYAx8t+52uQ==} + + /@types/mdx@2.0.13: + resolution: {integrity: sha512-+OWZQfAYyio6YkJb3HLxDrvnx6SWWDbC0zVPfBRzUk0/nqoDyf6dNxQi3eArPe8rJ473nobTMQ/8Zk+LxJ+Yuw==} + dev: true + + /@types/mime@1.3.5: + resolution: {integrity: sha512-/pyBZWSLD2n0dcHE3hq8s8ZvcETHtEuF+3E7XVt0Ig2nvsVQXdghHVcEkIWjy9A0wKfTn97a/PSDYohKIlnP/w==} + dev: true + + /@types/minimatch@5.1.2: + resolution: {integrity: sha512-K0VQKziLUWkVKiRVrx4a40iPaxTUefQmjtkQofBkYRcoaaL/8rhwDWww9qWbrgicNOgnpIsMxyNIUM4+n6dUIA==} + dev: true + + /@types/node@20.16.10: + resolution: {integrity: sha512-vQUKgWTjEIRFCvK6CyriPH3MZYiYlNy0fKiEYHWbcoWLEgs4opurGGKlebrTLqdSMIbXImH6XExNiIyNUv3WpA==} + dependencies: + undici-types: 6.19.8 + dev: true + + /@types/node@22.7.4: + resolution: {integrity: sha512-y+NPi1rFzDs1NdQHHToqeiX2TIS79SWEAw9GYhkkx8bD0ChpfqC+n2j5OXOCpzfojBEBt6DnEnnG9MY0zk1XLg==} + dependencies: + undici-types: 6.19.8 + dev: true + + /@types/parse-json@4.0.2: + resolution: {integrity: sha512-dISoDXWWQwUquiKsyZ4Ng+HX2KsPL7LyHKHQwgGFEA3IaKac4Obd+h2a/a6waisAoepJlBcx9paWqjA8/HVjCw==} + dev: false + + /@types/prop-types@15.7.13: + resolution: {integrity: sha512-hCZTSvwbzWGvhqxp/RqVqwU999pBf2vp7hzIjiYOsl8wqOmUxkQ6ddw1cV3l8811+kdUFus/q4d1Y3E3SyEifA==} + + /@types/qs@6.9.16: + resolution: {integrity: sha512-7i+zxXdPD0T4cKDuxCUXJ4wHcsJLwENa6Z3dCu8cfCK743OGy5Nu1RmAGqDPsoTDINVEcdXKRvR/zre+P2Ku1A==} + dev: true + + /@types/range-parser@1.2.7: + resolution: {integrity: sha512-hKormJbkJqzQGhziax5PItDUTMAM9uE2XXQmM37dyd4hVM+5aVl7oVxMVUiVQn2oCQFN/LKCZdvSM0pFRqbSmQ==} + dev: true + + /@types/react-dom@18.3.0: + resolution: {integrity: sha512-EhwApuTmMBmXuFOikhQLIBUn6uFg81SwLMOAUgodJF14SOBOCMdU04gDoYi0WOJJHD144TL32z4yDqCW3dnkQg==} + dependencies: + '@types/react': 18.3.11 + + /@types/react-transition-group@4.4.11: + resolution: {integrity: sha512-RM05tAniPZ5DZPzzNFP+DmrcOdD0efDUxMy3145oljWSl3x9ZV5vhme98gTxFrj2lhXvmGNnUiuDyJgY9IKkNA==} + dependencies: + '@types/react': 18.3.11 + dev: false + + /@types/react@18.3.11: + resolution: {integrity: sha512-r6QZ069rFTjrEYgFdOck1gK7FLVsgJE7tTz0pQBczlBNUhBNk0MQH4UbnFSwjpQLMkLzgqvBBa+qGpLje16eTQ==} + dependencies: + '@types/prop-types': 15.7.13 + csstype: 3.1.3 + + /@types/resolve@1.20.6: + resolution: {integrity: sha512-A4STmOXPhMUtHH+S6ymgE2GiBSMqf4oTvcQZMcHzokuTLVYzXTB8ttjcgxOVaAp2lGwEdzZ0J+cRbbeevQj1UQ==} + dev: true + + /@types/semver@7.5.8: + resolution: {integrity: sha512-I8EUhyrgfLrcTkzV3TSsGyl1tSuPrEDzr0yd5m90UgNxQkyDXULk3b6MlQqTCpZpNtWe1K0hzclnZkTcLBe2UQ==} + dev: true + + /@types/send@0.17.4: + resolution: {integrity: sha512-x2EM6TJOybec7c52BX0ZspPodMsQUd5L6PRwOunVyVUhXiBSKf3AezDL8Dgvgt5o0UfKNfuA0eMLr2wLT4AiBA==} + dependencies: + '@types/mime': 1.3.5 + '@types/node': 20.16.10 + dev: true + + /@types/serve-static@1.15.7: + resolution: {integrity: sha512-W8Ym+h8nhuRwaKPaDw34QUkwsGi6Rc4yYqvKFo5rm2FUEhCFbzVWrxXUxuKK8TASjWsysJY0nsmNCGhCOIsrOw==} + dependencies: + '@types/http-errors': 2.0.4 + '@types/node': 20.16.10 + '@types/send': 0.17.4 + dev: true + + /@types/unist@3.0.3: + resolution: {integrity: sha512-ko/gIFJRv177XgZsZcBwnqJN5x/Gien8qNOn0D5bQU/zAzVf9Zt3BlcUiLqhV9y4ARk0GbT3tnUiPNgnTXzc/Q==} + dev: true + + /@types/use-sync-external-store@0.0.3: + resolution: {integrity: sha512-EwmlvuaxPNej9+T4v5AuBPJa2x2UOJVdjCtDHgcDqitUeOtjnJKJ+apYjVcAoBEMjKW1VVFGZLUb5+qqa09XFA==} + dev: false + + /@types/uuid@10.0.0: + resolution: {integrity: sha512-7gqG38EyHgyP1S+7+xomFtL+ZNHcKv6DwNaCZmJmo1vgMugyF3TCnXVg4t1uk89mLNwnLtnY3TpOpCOyp1/xHQ==} + dev: true + + /@types/uuid@9.0.8: + resolution: {integrity: sha512-jg+97EGIcY9AGHJJRaaPVgetKDsrTgbRjQ5Msgjh/DQKEFl0DtyRr/VCOyD1T2R1MNeWPK/u7JoGhlDZnKBAfA==} + dev: true + + /@typescript-eslint/eslint-plugin@7.18.0(@typescript-eslint/parser@7.18.0)(eslint@8.57.1)(typescript@5.6.2): + resolution: {integrity: sha512-94EQTWZ40mzBc42ATNIBimBEDltSJ9RQHCC8vc/PDbxi4k8dVwUAv4o98dk50M1zB+JGFxp43FP7f8+FP8R6Sw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + '@typescript-eslint/parser': ^7.0.0 + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@eslint-community/regexpp': 4.11.1 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/type-utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 7.18.0 + eslint: 8.57.1 + graphemer: 1.4.0 + ignore: 5.3.2 + natural-compare: 1.4.0 + ts-api-utils: 1.3.0(typescript@5.6.2) + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/parser@7.18.0(eslint@8.57.1)(typescript@5.6.2): + resolution: {integrity: sha512-4Z+L8I2OqhZV8qA132M4wNL30ypZGYOQVBfMgxDH/K5UX0PNqTu1c6za9ST5r9+tavvHiTWmBnKzpCJ/GlVFtg==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7(supports-color@9.4.0) + eslint: 8.57.1 + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/scope-manager@5.62.0: + resolution: {integrity: sha512-VXuvVvZeQCQb5Zgf4HAxc04q5j+WrNAtNh9OwCsCgpKqESMTu3tF/jhZ3xG6T4NZwWl65Bg8KuS2uEvhSfLl0w==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + dev: true + + /@typescript-eslint/scope-manager@7.18.0: + resolution: {integrity: sha512-jjhdIE/FPF2B7Z1uzc6i3oWKbGcHb87Qw7AWj6jmEqNOfDFbJWtjt/XfwCpvNkpGWlcJaog5vTR+VV8+w9JflA==} + engines: {node: ^18.18.0 || >=20.0.0} + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + dev: true + + /@typescript-eslint/type-utils@7.18.0(eslint@8.57.1)(typescript@5.6.2): + resolution: {integrity: sha512-XL0FJXuCLaDuX2sYqZUUSOJ2sG5/i1AAze+axqmLnSkNEVMVYLF+cbwlB2w8D1tinFuSikHmFta+P+HOofrLeA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + '@typescript-eslint/utils': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + debug: 4.3.7(supports-color@9.4.0) + eslint: 8.57.1 + ts-api-utils: 1.3.0(typescript@5.6.2) + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/types@5.62.0: + resolution: {integrity: sha512-87NVngcbVXUahrRTqIK27gD2t5Cu1yuCXxbLcFtCzZGlfyVWWh8mLHkoxzjsB6DDNnvdL+fW8MiwPEJyGJQDgQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /@typescript-eslint/types@7.18.0: + resolution: {integrity: sha512-iZqi+Ds1y4EDYUtlOOC+aUmxnE9xS/yCigkjA7XpTKV6nCBd3Hp/PRGGmdwnfkV2ThMyYldP1wRpm/id99spTQ==} + engines: {node: ^18.18.0 || >=20.0.0} + dev: true + + /@typescript-eslint/typescript-estree@5.62.0(typescript@5.6.2): + resolution: {integrity: sha512-CmcQ6uY7b9y694lKdRB8FEel7JbU/40iSAPomu++SjLMntB+2Leay2LO6i8VnJk58MtE9/nQSFIH6jpyRWyYzA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/visitor-keys': 5.62.0 + debug: 4.3.7(supports-color@9.4.0) + globby: 11.1.0 + is-glob: 4.0.3 + semver: 7.6.3 + tsutils: 3.21.0(typescript@5.6.2) + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/typescript-estree@7.18.0(typescript@5.6.2): + resolution: {integrity: sha512-aP1v/BSPnnyhMHts8cf1qQ6Q1IFwwRvAQGRvBFkWlo3/lH29OXA3Pts+c10nxRxIBrDnoMqzhgdwVe5f2D6OzA==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/visitor-keys': 7.18.0 + debug: 4.3.7(supports-color@9.4.0) + globby: 11.1.0 + is-glob: 4.0.3 + minimatch: 9.0.5 + semver: 7.6.3 + ts-api-utils: 1.3.0(typescript@5.6.2) + typescript: 5.6.2 + transitivePeerDependencies: + - supports-color + dev: true + + /@typescript-eslint/utils@5.62.0(eslint@8.57.1)(typescript@5.6.2): + resolution: {integrity: sha512-n8oxjeb5aIbPFEtmQxQYOLI0i9n5ySBEY/ZEHHZqKQSFnxio1rv6dthascc9dLuwrL0RC5mPCxB7vnAVGAYWAQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + eslint: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@types/json-schema': 7.0.15 + '@types/semver': 7.5.8 + '@typescript-eslint/scope-manager': 5.62.0 + '@typescript-eslint/types': 5.62.0 + '@typescript-eslint/typescript-estree': 5.62.0(typescript@5.6.2) + eslint: 8.57.1 + eslint-scope: 5.1.1 + semver: 7.6.3 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/utils@7.18.0(eslint@8.57.1)(typescript@5.6.2): + resolution: {integrity: sha512-kK0/rNa2j74XuHVcoCZxdFBMF+aq/vH83CXAOHieC+2Gis4mF8jJXT5eAfyD3K0sAxtPuwxaIOIOvhwzVDt/kw==} + engines: {node: ^18.18.0 || >=20.0.0} + peerDependencies: + eslint: ^8.56.0 + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@typescript-eslint/scope-manager': 7.18.0 + '@typescript-eslint/types': 7.18.0 + '@typescript-eslint/typescript-estree': 7.18.0(typescript@5.6.2) + eslint: 8.57.1 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /@typescript-eslint/visitor-keys@5.62.0: + resolution: {integrity: sha512-07ny+LHRzQXepkGg6w0mFY41fVUNBrL2Roj/++7V1txKugfjm/Ci/qSND03r2RhlJhJYMcTn9AhhSSqQp0Ysyw==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + '@typescript-eslint/types': 5.62.0 + eslint-visitor-keys: 3.4.3 + dev: true + + /@typescript-eslint/visitor-keys@7.18.0: + resolution: {integrity: sha512-cDF0/Gf81QpY3xYyJKDV14Zwdmid5+uuENhjH2EqFaF0ni+yAyq/LzMaIJdhNJXZI7uLzwIlA+V7oWoyn6Curg==} + engines: {node: ^18.18.0 || >=20.0.0} + dependencies: + '@typescript-eslint/types': 7.18.0 + eslint-visitor-keys: 3.4.3 + dev: true + + /@ungap/structured-clone@1.2.0: + resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} + dev: true + + /@vitejs/plugin-react-swc@3.7.1(vite@5.4.8): + resolution: {integrity: sha512-vgWOY0i1EROUK0Ctg1hwhtC3SdcDjZcdit4Ups4aPkDcB1jYhmo+RMYWY87cmXMhvtD5uf8lV89j2w16vkdSVg==} + peerDependencies: + vite: ^4 || ^5 + dependencies: + '@swc/core': 1.7.26 + vite: 5.4.8(@types/node@20.16.10) + transitivePeerDependencies: + - '@swc/helpers' + dev: true + + /@vitest/coverage-v8@1.6.0(vitest@1.6.0): + resolution: {integrity: sha512-KvapcbMY/8GYIG0rlwwOKCVNRc0OL20rrhFkg/CHNzncV03TE2XWvO5w9uZYoxNiMEBacAJt3unSOiZ7svePew==} + peerDependencies: + vitest: 1.6.0 + dependencies: + '@ampproject/remapping': 2.3.0 + '@bcoe/v8-coverage': 0.2.3 + debug: 4.3.7(supports-color@9.4.0) + istanbul-lib-coverage: 3.2.2 + istanbul-lib-report: 3.0.1 + istanbul-lib-source-maps: 5.0.6 + istanbul-reports: 3.1.7 + magic-string: 0.30.11 + magicast: 0.3.5 + picocolors: 1.1.0 + std-env: 3.7.0 + strip-literal: 2.1.0 + test-exclude: 6.0.0 + vitest: 1.6.0(@types/node@20.16.10)(@vitest/ui@1.6.0) + transitivePeerDependencies: + - supports-color + dev: true + + /@vitest/expect@1.6.0: + resolution: {integrity: sha512-ixEvFVQjycy/oNgHjqsL6AZCDduC+tflRluaHIzKIsdbzkLn2U/iBnVeJwB6HsIjQBdfMR8Z0tRxKUsvFJEeWQ==} + dependencies: + '@vitest/spy': 1.6.0 + '@vitest/utils': 1.6.0 + chai: 4.5.0 + dev: true + + /@vitest/expect@2.0.5: + resolution: {integrity: sha512-yHZtwuP7JZivj65Gxoi8upUN2OzHTi3zVfjwdpu2WrvCZPLwsJ2Ey5ILIPccoW23dd/zQBlJ4/dhi7DWNyXCpA==} + dependencies: + '@vitest/spy': 2.0.5 + '@vitest/utils': 2.0.5 + chai: 5.1.1 + tinyrainbow: 1.2.0 + dev: true + + /@vitest/pretty-format@2.0.5: + resolution: {integrity: sha512-h8k+1oWHfwTkyTkb9egzwNMfJAEx4veaPSnMeKbVSjp4euqGSbQlm5+6VHwTr7u4FJslVVsUG5nopCaAYdOmSQ==} + dependencies: + tinyrainbow: 1.2.0 + dev: true + + /@vitest/pretty-format@2.1.2: + resolution: {integrity: sha512-FIoglbHrSUlOJPDGIrh2bjX1sNars5HbxlcsFKCtKzu4+5lpsRhOCVcuzp0fEhAGHkPZRIXVNzPcpSlkoZ3LuA==} + dependencies: + tinyrainbow: 1.2.0 + dev: true + + /@vitest/runner@1.6.0: + resolution: {integrity: sha512-P4xgwPjwesuBiHisAVz/LSSZtDjOTPYZVmNAnpHHSR6ONrf8eCJOFRvUwdHn30F5M1fxhqtl7QZQUk2dprIXAg==} + dependencies: + '@vitest/utils': 1.6.0 + p-limit: 5.0.0 + pathe: 1.1.2 + dev: true + + /@vitest/snapshot@1.6.0: + resolution: {integrity: sha512-+Hx43f8Chus+DCmygqqfetcAZrDJwvTj0ymqjQq4CvmpKFSTVteEOBzCusu1x2tt4OJcvBflyHUE0DZSLgEMtQ==} + dependencies: + magic-string: 0.30.11 + pathe: 1.1.2 + pretty-format: 29.7.0 + dev: true + + /@vitest/spy@1.6.0: + resolution: {integrity: sha512-leUTap6B/cqi/bQkXUu6bQV5TZPx7pmMBKBQiI0rJA8c3pB56ZsaTbREnF7CJfmvAS4V2cXIBAh/3rVwrrCYgw==} + dependencies: + tinyspy: 2.2.1 + dev: true + + /@vitest/spy@2.0.5: + resolution: {integrity: sha512-c/jdthAhvJdpfVuaexSrnawxZz6pywlTPe84LUB2m/4t3rl2fTo9NFGBG4oWgaD+FTgDDV8hJ/nibT7IfH3JfA==} + dependencies: + tinyspy: 3.0.2 + dev: true + + /@vitest/ui@1.6.0(vitest@1.6.0): + resolution: {integrity: sha512-k3Lyo+ONLOgylctiGovRKy7V4+dIN2yxstX3eY5cWFXH6WP+ooVX79YSyi0GagdTQzLmT43BF27T0s6dOIPBXA==} + peerDependencies: + vitest: 1.6.0 + dependencies: + '@vitest/utils': 1.6.0 + fast-glob: 3.3.2 + fflate: 0.8.2 + flatted: 3.3.1 + pathe: 1.1.2 + picocolors: 1.1.0 + sirv: 2.0.4 + vitest: 1.6.0(@types/node@20.16.10)(@vitest/ui@1.6.0) + dev: true + + /@vitest/utils@1.6.0: + resolution: {integrity: sha512-21cPiuGMoMZwiOHa2i4LXkMkMkCGzA+MVFV70jRwHo95dL4x/ts5GZhML1QWuy7yfp3WzK3lRvZi3JnXTYqrBw==} + dependencies: + diff-sequences: 29.6.3 + estree-walker: 3.0.3 + loupe: 2.3.7 + pretty-format: 29.7.0 + dev: true + + /@vitest/utils@2.0.5: + resolution: {integrity: sha512-d8HKbqIcya+GR67mkZbrzhS5kKhtp8dQLcmRZLGTscGVg7yImT82cIrhtn2L8+VujWcy6KZweApgNmPsTAO/UQ==} + dependencies: + '@vitest/pretty-format': 2.0.5 + estree-walker: 3.0.3 + loupe: 3.1.1 + tinyrainbow: 1.2.0 + dev: true + + /@vitest/utils@2.1.2: + resolution: {integrity: sha512-zMO2KdYy6mx56btx9JvAqAZ6EyS3g49krMPPrgOp1yxGZiA93HumGk+bZ5jIZtOg5/VBYl5eBmGRQHqq4FG6uQ==} + dependencies: + '@vitest/pretty-format': 2.1.2 + loupe: 3.1.1 + tinyrainbow: 1.2.0 + dev: true + + /@volar/language-core@1.11.1: + resolution: {integrity: sha512-dOcNn3i9GgZAcJt43wuaEykSluAuOkQgzni1cuxLxTV0nJKanQztp7FxyswdRILaKH+P2XZMPRp2S4MV/pElCw==} + dependencies: + '@volar/source-map': 1.11.1 + dev: true + + /@volar/source-map@1.11.1: + resolution: {integrity: sha512-hJnOnwZ4+WT5iupLRnuzbULZ42L7BWWPMmruzwtLhJfpDVoZLjNBxHDi2sY2bgZXCKlpU5XcsMFoYrsQmPhfZg==} + dependencies: + muggle-string: 0.3.1 + dev: true + + /@volar/typescript@1.11.1: + resolution: {integrity: sha512-iU+t2mas/4lYierSnoFOeRFQUhAEMgsFuQxoxvwn5EdQopw43j+J27a4lt9LMInx1gLJBC6qL14WYGlgymaSMQ==} + dependencies: + '@volar/language-core': 1.11.1 + path-browserify: 1.0.1 + dev: true + + /@vue/compiler-core@3.5.10: + resolution: {integrity: sha512-iXWlk+Cg/ag7gLvY0SfVucU8Kh2CjysYZjhhP70w9qI4MvSox4frrP+vDGvtQuzIcgD8+sxM6lZvCtdxGunTAA==} + dependencies: + '@babel/parser': 7.25.7 + '@vue/shared': 3.5.10 + entities: 4.5.0 + estree-walker: 2.0.2 + source-map-js: 1.2.1 + dev: true + + /@vue/compiler-dom@3.5.10: + resolution: {integrity: sha512-DyxHC6qPcktwYGKOIy3XqnHRrrXyWR2u91AjP+nLkADko380srsC2DC3s7Y1Rk6YfOlxOlvEQKa9XXmLI+W4ZA==} + dependencies: + '@vue/compiler-core': 3.5.10 + '@vue/shared': 3.5.10 + dev: true + + /@vue/language-core@1.8.27(typescript@5.6.2): + resolution: {integrity: sha512-L8Kc27VdQserNaCUNiSFdDl9LWT24ly8Hpwf1ECy3aFb9m6bDhBGQYOujDm21N7EW3moKIOKEanQwe1q5BK+mA==} + peerDependencies: + typescript: '*' + peerDependenciesMeta: + typescript: + optional: true + dependencies: + '@volar/language-core': 1.11.1 + '@volar/source-map': 1.11.1 + '@vue/compiler-dom': 3.5.10 + '@vue/shared': 3.5.10 + computeds: 0.0.1 + minimatch: 9.0.5 + muggle-string: 0.3.1 + path-browserify: 1.0.1 + typescript: 5.6.2 + vue-template-compiler: 2.7.16 + dev: true + + /@vue/shared@3.5.10: + resolution: {integrity: sha512-VkkBhU97Ki+XJ0xvl4C9YJsIZ2uIlQ7HqPpZOS3m9VCvmROPaChZU6DexdMJqvz9tbgG+4EtFVrSuailUq5KGQ==} + dev: true + + /@xobotyi/scrollbar-width@1.9.5: + resolution: {integrity: sha512-N8tkAACJx2ww8vFMneJmaAgmjAG1tnVBZJRLRcx061tmsLRZHSEZSLuGWnwPtunsSLvSqXQ2wfp7Mgqg1I+2dQ==} + dev: false + + /@zag-js/dom-query@0.31.1: + resolution: {integrity: sha512-oiuohEXAXhBxpzzNm9k2VHGEOLC1SXlXSbRPcfBZ9so5NRQUA++zCE7cyQJqGLTZR0t3itFLlZqDbYEXRrefwg==} + dev: false + + /@zag-js/element-size@0.31.1: + resolution: {integrity: sha512-4T3yvn5NqqAjhlP326Fv+w9RqMIBbNN9H72g5q2ohwzhSgSfZzrKtjL4rs9axY/cw9UfMfXjRjEE98e5CMq7WQ==} + dev: false + + /@zag-js/focus-visible@0.31.1: + resolution: {integrity: sha512-dbLksz7FEwyFoANbpIlNnd3bVm0clQSUsnP8yUVQucStZPsuWjCrhL2jlAbGNrTrahX96ntUMXHb/sM68TibFg==} + dependencies: + '@zag-js/dom-query': 0.31.1 + dev: false + + /accepts@1.3.8: + resolution: {integrity: sha512-PYAthTa2m2VKxuvSD3DPC/Gy+U+sOA1LAuT8mkmRuvw+NACSaeXEQ+NHcVF7rONl6qcaxV3Uuemwawk+7+SJLw==} + engines: {node: '>= 0.6'} + dependencies: + mime-types: 2.1.35 + negotiator: 0.6.3 + dev: true + + /acorn-jsx@5.3.2(acorn@7.4.1): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 7.4.1 + dev: true + + /acorn-jsx@5.3.2(acorn@8.12.1): + resolution: {integrity: sha512-rq9s+JNhf0IChjtDXxllJ7g41oZk5SlXtp0LHwyA5cejwn7vKmKp4pPri6YEePv2PU65sAsegbXtIinmDFDXgQ==} + peerDependencies: + acorn: ^6.0.0 || ^7.0.0 || ^8.0.0 + dependencies: + acorn: 8.12.1 + dev: true + + /acorn-walk@7.2.0: + resolution: {integrity: sha512-OPdCF6GsMIP+Az+aWfAAOEt2/+iVDKE7oy6lJ098aoe59oAmK76qV6Gw60SbZ8jHuG2wH058GF4pLFbYamYrVA==} + engines: {node: '>=0.4.0'} + dev: true + + /acorn-walk@8.3.4: + resolution: {integrity: sha512-ueEepnujpqee2o5aIYnvHU6C0A42MNdsIDeqy5BydrkuC5R1ZuUFnm27EeFJGoEHJQgn3uleRvmTXaJgfXbt4g==} + engines: {node: '>=0.4.0'} + dependencies: + acorn: 8.12.1 + dev: true + + /acorn@7.4.1: + resolution: {integrity: sha512-nQyp0o1/mNdbTO1PO6kHkwSrmgZ0MT/jCCpNiwbUjGoRN4dlBhqJtoQuCnEOKzgTVwg0ZWiCoQy6SxMebQVh8A==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /acorn@8.12.1: + resolution: {integrity: sha512-tcpGyI9zbizT9JbV6oYE477V6mTlXvvi0T0G3SNIYE2apm/G5huBa1+K89VGeovbg+jycCrfhl3ADxErOuO6Jg==} + engines: {node: '>=0.4.0'} + hasBin: true + dev: true + + /agent-base@7.1.1(supports-color@9.4.0): + resolution: {integrity: sha512-H0TSyFNDMomMNJQBn8wFV5YC/2eJ+VXECwOadZJT554xP6cODZHPX3H9QMQECxvrgiSOP1pHjy1sMWQVYJOUOA==} + engines: {node: '>= 14'} + dependencies: + debug: 4.3.7(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + dev: true + + /aggregate-error@3.1.0: + resolution: {integrity: sha512-4I7Td01quW/RpocfNayFdFVk1qSuoh0E7JrbRJ16nH01HhKFQ88INq9Sd+nd72zqRySlr9BmDA8xlEJ6vJMrYA==} + engines: {node: '>=8'} + dependencies: + clean-stack: 2.2.0 + indent-string: 4.0.0 + dev: true + + /ajv@6.12.6: + resolution: {integrity: sha512-j3fVLgvTo527anyYyJOGTYJbG+vnnQYvE0m5mmkc1TK+nxAppkCLMIL0aZ4dblVCNoGShhm+kzE4ZUykBoMg4g==} + dependencies: + fast-deep-equal: 3.1.3 + fast-json-stable-stringify: 2.1.0 + json-schema-traverse: 0.4.1 + uri-js: 4.4.1 + dev: true + + /ansi-colors@4.1.3: + resolution: {integrity: sha512-/6w/C21Pm1A7aZitlI5Ni/2J6FFQN8i1Cvz3kHABAAbw93v/NlvKdVOqz7CCWz/3iv/JplRSEEZ83XION15ovw==} + engines: {node: '>=6'} + dev: true + + /ansi-regex@5.0.1: + resolution: {integrity: sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ==} + engines: {node: '>=8'} + dev: true + + /ansi-regex@6.1.0: + resolution: {integrity: sha512-7HSX4QQb4CspciLpVFwyRe79O3xsIZDDLER21kERQ71oaPodF8jL725AgJMFAYbooIqolJoRLuM81SpeUkpkvA==} + engines: {node: '>=12'} + dev: true + + /ansi-styles@3.2.1: + resolution: {integrity: sha512-VT0ZI6kZRdTh8YyJw3SMbYm/u+NqfsAxEpWO0Pf9sq8/e94WxxOpPKx9FR1FlyCtOVDNOQ+8ntlqFxiRc+r5qA==} + engines: {node: '>=4'} + dependencies: + color-convert: 1.9.3 + + /ansi-styles@4.3.0: + resolution: {integrity: sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg==} + engines: {node: '>=8'} + dependencies: + color-convert: 2.0.1 + dev: true + + /ansi-styles@5.2.0: + resolution: {integrity: sha512-Cxwpt2SfTzTtXcfOlzGEee8O+c+MmUgGrNiBcXnuWxuFJHe6a5Hz7qwhwe5OgaSYI0IJvkLqWX1ASG+cJOkEiA==} + engines: {node: '>=10'} + dev: true + + /ansi-styles@6.2.1: + resolution: {integrity: sha512-bN798gFfQX+viw3R7yrGWRqnrN2oRkEkUjjl4JNn4E8GxxbjtG3FbrEIIY3l8/hrwUwIeCZvi4QuOTP4MErVug==} + engines: {node: '>=12'} + dev: true + + /argparse@1.0.10: + resolution: {integrity: sha512-o5Roy6tNG4SL/FOkCAN6RzjiakZS25RLYFrcMttJqbdd8BWrnA+fGz57iN5Pb06pvBGvl5gQ0B48dJlslXvoTg==} + dependencies: + sprintf-js: 1.0.3 + dev: true + + /argparse@2.0.1: + resolution: {integrity: sha512-8+9WqebbFzpX9OR+Wa6O29asIogeRMzcGtAINdpMHHyAg10f05aSFVBbcEqGf/PXw1EjAZ+q2/bEBg3DvurK3Q==} + dev: true + + /aria-hidden@1.2.4: + resolution: {integrity: sha512-y+CcFFwelSXpLZk/7fMB2mUbGtX9lKycf1MWJ7CaTIERyitVlyQx6C+sxcROU2BAJ24OiZyK+8wj2i8AlBoS3A==} + engines: {node: '>=10'} + dependencies: + tslib: 2.7.0 + dev: false + + /aria-query@5.3.0: + resolution: {integrity: sha512-b0P0sZPKtyu8HkeRAfCq0IfURZK+SuwMjY1UXGBU27wpAiTwQAIlq56IbIO+ytk/JjS1fMR14ee5WBBfKi5J6A==} + dependencies: + dequal: 2.0.3 + dev: true + + /aria-query@5.3.2: + resolution: {integrity: sha512-COROpnaoap1E2F000S62r6A60uHZnmlvomhfyT2DlTcrY1OrBKn2UhH7qn5wTC9zMvD0AY7csdPSNwKP+7WiQw==} + engines: {node: '>= 0.4'} + dev: true + + /array-buffer-byte-length@1.0.1: + resolution: {integrity: sha512-ahC5W1xgou+KTXix4sAO8Ki12Q+jf4i0+tmk3sC+zgcynshkHxzpXdImBehiUYKKKDwvfFiJl1tZt6ewscS1Mg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + is-array-buffer: 3.0.4 + dev: true + + /array-flatten@1.1.1: + resolution: {integrity: sha512-PCVAQswWemu6UdxsDFFX/+gVeYqKAod3D3UVm91jHwynguOwAvYPhx8nNlM++NqRcK6CxxpUafjmhIdKiHibqg==} + dev: true + + /array-includes@3.1.8: + resolution: {integrity: sha512-itaWrbYbqpGXkGhZPGUulwnhVf5Hpy1xiCFsGqyIGglbBxmG5vSjxQen3/WGOjPpNEv1RtBLKxbmVXm8HpJStQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + is-string: 1.0.7 + dev: true + + /array-union@2.1.0: + resolution: {integrity: sha512-HGyxoOTYUyCM6stUe6EJgnd4EoewAI7zMdfqO+kGjnlZmBDz/cR5pf8r/cR4Wq60sL/p0IkcjUEEPwS3GFrIyw==} + engines: {node: '>=8'} + dev: true + + /array.prototype.findlast@1.2.5: + resolution: {integrity: sha512-CVvd6FHg1Z3POpBLxO6E6zr+rSKEQ9L6rZHAaY7lLfhKsWYUBBOuMs0e9o24oopj6H+geRCX0YJ+TJLBK2eHyQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + dev: true + + /array.prototype.findlastindex@1.2.5: + resolution: {integrity: sha512-zfETvRFA8o7EiNn++N5f/kaCw221hrpGsDmcpndVupkPzEc1Wuf3VgC0qby1BbHs7f5DVYjgtEU2LLh5bqeGfQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-shim-unscopables: 1.0.2 + dev: true + + /array.prototype.flat@1.3.2: + resolution: {integrity: sha512-djYB+Zx2vLewY8RWlNCUdHjDXs2XOgm602S9E7P/UpHgfeHL00cRiIF+IN/G/aUJ7kGPb6yO/ErDI5V2s8iycA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + dev: true + + /array.prototype.flatmap@1.3.2: + resolution: {integrity: sha512-Ewyx0c9PmpcsByhSW4r+9zDU7sGjFc86qf/kKtuSCRdhfbk0SNLLkaT5qvcHnRGgc5NP/ly/y+qkXkqONX54CQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-shim-unscopables: 1.0.2 + dev: true + + /array.prototype.tosorted@1.1.4: + resolution: {integrity: sha512-p6Fx8B7b7ZhL/gmUsAy0D15WhvDccw3mnGNbZpi3pmeJdxtWsj2jEaI4Y6oo3XiHfzuSgPwKc04MYt6KgvC/wA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-shim-unscopables: 1.0.2 + dev: true + + /arraybuffer.prototype.slice@1.0.3: + resolution: {integrity: sha512-bMxMKAjg13EBSVscxTaYA4mRc5t1UAXa2kXiGTNfZ079HIWXEkKmkgFrh/nJqamaLSrXO5H4WFFkPEaLJWbs3A==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.1 + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + is-array-buffer: 3.0.4 + is-shared-array-buffer: 1.0.3 + dev: true + + /assertion-error@1.1.0: + resolution: {integrity: sha512-jgsaNduz+ndvGyFt3uSuWqvy4lCnIJiovtouQN5JZHOKCS2QuhEdbcQHFhVksz2N2U9hXJo8odG7ETyWlEeuDw==} + dev: true + + /assertion-error@2.0.1: + resolution: {integrity: sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA==} + engines: {node: '>=12'} + dev: true + + /ast-types@0.16.1: + resolution: {integrity: sha512-6t10qk83GOG8p0vKmaCr8eiilZwO171AvbROMtvvNiwrTly62t+7XkA8RdIIVbpMhCASAsxgAzdRSwh6nw/5Dg==} + engines: {node: '>=4'} + dependencies: + tslib: 2.7.0 + dev: true + + /async-mutex@0.5.0: + resolution: {integrity: sha512-1A94B18jkJ3DYq284ohPxoXbfTA5HsQ7/Mf4DEhcyLx3Bz27Rh59iScbB6EPiP+B+joue6YCxcMXSbFC1tZKwA==} + dependencies: + tslib: 2.7.0 + dev: false + + /attr-accept@2.2.2: + resolution: {integrity: sha512-7prDjvt9HmqiZ0cl5CRjtS84sEyhsHP2coDkaZKRKVfCDo9s7iw7ChVmar78Gu9pC4SoR/28wFu/G5JJhTnqEg==} + engines: {node: '>=4'} + dev: false + + /available-typed-arrays@1.0.7: + resolution: {integrity: sha512-wvUjBtSGN7+7SjNpq/9M2Tg350UZD3q62IFZLbRAR1bSMlCo1ZaeW+BJ+D090e4hIIZLBcTDWe4Mh4jvUDajzQ==} + engines: {node: '>= 0.4'} + dependencies: + possible-typed-array-names: 1.0.0 + dev: true + + /babel-plugin-macros@3.1.0: + resolution: {integrity: sha512-Cg7TFGpIr01vOQNODXOOaGz2NpCU5gl8x1qJFbb6hbZxR7XrcE2vtbAsTAbJ7/xwJtUuJEw8K8Zr/AE0LHlesg==} + engines: {node: '>=10', npm: '>=6'} + dependencies: + '@babel/runtime': 7.25.7 + cosmiconfig: 7.1.0 + resolve: 1.22.8 + dev: false + + /balanced-match@1.0.2: + resolution: {integrity: sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw==} + dev: true + + /base64-js@1.5.1: + resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + dev: true + + /better-opn@3.0.2: + resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} + engines: {node: '>=12.0.0'} + dependencies: + open: 8.4.2 + dev: true + + /bind-event-listener@3.0.0: + resolution: {integrity: sha512-PJvH288AWQhKs2v9zyfYdPzlPqf5bXbGMmhmUIY9x4dAUGIWgomO771oBQNwJnMQSnUIXhKu6sgzpBRXTlvb8Q==} + dev: false + + /bl@4.1.0: + resolution: {integrity: sha512-1W07cM9gS6DcLperZfFSj+bWLtaPGSOHWhPiGzXmvVJbRLdG82sH/Kn8EtW1VqWVA54AKf2h5k5BbnIbwF3h6w==} + dependencies: + buffer: 5.7.1 + inherits: 2.0.4 + readable-stream: 3.6.2 + dev: true + + /body-parser@1.20.3: + resolution: {integrity: sha512-7rAxByjUMqQ3/bHJy7D6OGXvx/MMc4IqBn/X0fcM1QUcAItpZrBEYhWGem+tzXH90c+G01ypMcYJBO9Y30203g==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dependencies: + bytes: 3.1.2 + content-type: 1.0.5 + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + on-finished: 2.4.1 + qs: 6.13.0 + raw-body: 2.5.2 + type-is: 1.6.18 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /boolean@3.2.0: + resolution: {integrity: sha512-d0II/GO9uf9lfUHH2BQsjxzRJZBdsjgsBiW4BvhWk/3qoKwQFjIDVN19PfX8F2D/r9PCMTtLWjYVCFrpeYUzsw==} + dev: false + + /brace-expansion@1.1.11: + resolution: {integrity: sha512-iCuPHDFgrHX7H2vEI/5xpz07zSHB00TpugqhmYtVmMO6518mCuRMoOYFldEBl0g187ufozdaHgWKcYFb61qGiA==} + dependencies: + balanced-match: 1.0.2 + concat-map: 0.0.1 + dev: true + + /brace-expansion@2.0.1: + resolution: {integrity: sha512-XnAIvQ8eM+kC6aULx6wuQiwVsnzsi9d3WxzV3FpWTGA19F621kwdbsAcFKXgKUHZWsy+mY6iL1sHTxWEFCytDA==} + dependencies: + balanced-match: 1.0.2 + dev: true + + /braces@3.0.3: + resolution: {integrity: sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==} + engines: {node: '>=8'} + dependencies: + fill-range: 7.1.1 + dev: true + + /browser-assert@1.2.1: + resolution: {integrity: sha512-nfulgvOR6S4gt9UKCeGJOuSGBPGiFT6oQ/2UBnvTY/5aQ1PnksW72fhZkM30DzoRRv2WpwZf1vHHEr3mtuXIWQ==} + dev: true + + /browserslist@4.24.0: + resolution: {integrity: sha512-Rmb62sR1Zpjql25eSanFGEhAxcFwfA1K0GuQcLoaJBAcENegrQut3hYdhXFF1obQfiDyqIW/cLM5HSJ/9k884A==} + engines: {node: ^6 || ^7 || ^8 || ^9 || ^10 || ^11 || ^12 || >=13.7} + hasBin: true + dependencies: + caniuse-lite: 1.0.30001666 + electron-to-chromium: 1.5.31 + node-releases: 2.0.18 + update-browserslist-db: 1.1.1(browserslist@4.24.0) + dev: true + + /buffer@5.7.1: + resolution: {integrity: sha512-EHcyIPBQ4BSGlvjB16k5KgAJ27CIsHY/2JBmCRReo48y9rQ3MaUzWX3KVlBa4U7MyX02HdVj0K7C3WaB3ju7FQ==} + dependencies: + base64-js: 1.5.1 + ieee754: 1.2.1 + dev: true + + /bytes@3.1.2: + resolution: {integrity: sha512-/Nf7TyzTx6S3yRJObOAV7956r8cr2+Oj8AC5dt8wSP3BQAoeX58NoHyCU8P8zGkNXStjTSi6fzO6F0pBdcYbEg==} + engines: {node: '>= 0.8'} + dev: true + + /cac@6.7.14: + resolution: {integrity: sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ==} + engines: {node: '>=8'} + dev: true + + /call-bind@1.0.7: + resolution: {integrity: sha512-GHTSNSYICQ7scH7sZ+M2rFopRoLh8t2bLSW6BbgrtLsahOIB5iyAVJf9GjWK3cYTDaMj4XdBpM1cA6pIS0Kv2w==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + set-function-length: 1.2.2 + dev: true + + /callsites@3.1.0: + resolution: {integrity: sha512-P8BjAsXvZS+VIDUI11hHCQEv74YT67YUi5JJFNWIqL235sBmjX4+qx9Muvls5ivyNENctx46xQLQ3aTuE7ssaQ==} + engines: {node: '>=6'} + + /caniuse-lite@1.0.30001666: + resolution: {integrity: sha512-gD14ICmoV5ZZM1OdzPWmpx+q4GyefaK06zi8hmfHV5xe4/2nOQX3+Dw5o+fSqOws2xVwL9j+anOPFwHzdEdV4g==} + dev: true + + /chai@4.5.0: + resolution: {integrity: sha512-RITGBfijLkBddZvnn8jdqoTypxvqbOLYQkGGxXzeFjVHvudaPw0HNFD9x928/eUwYWd2dPCugVqspGALTZZQKw==} + engines: {node: '>=4'} + dependencies: + assertion-error: 1.1.0 + check-error: 1.0.3 + deep-eql: 4.1.4 + get-func-name: 2.0.2 + loupe: 2.3.7 + pathval: 1.1.1 + type-detect: 4.1.0 + dev: true + + /chai@5.1.1: + resolution: {integrity: sha512-pT1ZgP8rPNqUgieVaEY+ryQr6Q4HXNg8Ei9UnLUrjN4IA7dvQC5JB+/kxVcPNDHyBcc/26CXPkbNzq3qwrOEKA==} + engines: {node: '>=12'} + dependencies: + assertion-error: 2.0.1 + check-error: 2.1.1 + deep-eql: 5.0.2 + loupe: 3.1.1 + pathval: 2.0.0 + dev: true + + /chakra-react-select@4.9.2(@chakra-ui/form-control@2.2.0)(@chakra-ui/icon@3.2.0)(@chakra-ui/layout@2.3.1)(@chakra-ui/media-query@3.3.0)(@chakra-ui/menu@2.2.1)(@chakra-ui/spinner@2.1.0)(@chakra-ui/system@2.6.2)(@emotion/react@11.13.3)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-uhvKAJ1I2lbIwdn+wx0YvxX5rtQVI0gXL0apx0CXm3blIxk7qf6YuCh2TnGuGKst8gj8jUFZyhYZiGlcvgbBRQ==} + peerDependencies: + '@chakra-ui/form-control': ^2.0.0 + '@chakra-ui/icon': ^3.0.0 + '@chakra-ui/layout': ^2.0.0 + '@chakra-ui/media-query': ^3.0.0 + '@chakra-ui/menu': ^2.0.0 + '@chakra-ui/spinner': ^2.0.0 + '@chakra-ui/system': ^2.0.0 + '@emotion/react': ^11.8.1 + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@chakra-ui/form-control': 2.2.0(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/icon': 3.2.0(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/layout': 2.3.1(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/media-query': 3.3.0(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/menu': 2.2.1(@chakra-ui/system@2.6.2)(framer-motion@11.10.0)(react@18.3.1) + '@chakra-ui/spinner': 2.1.0(@chakra-ui/system@2.6.2)(react@18.3.1) + '@chakra-ui/system': 2.6.2(@emotion/react@11.13.3)(@emotion/styled@11.13.0)(react@18.3.1) + '@emotion/react': 11.13.3(@types/react@18.3.11)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-select: 5.8.0(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - supports-color + dev: false + + /chalk@2.4.2: + resolution: {integrity: sha512-Mti+f9lpJNcwF4tWV8/OrTTtF1gZi+f8FqlyAdouralcFWFQWF2+NgCHShjkCb+IFBLq9buZwE1xckQU4peSuQ==} + engines: {node: '>=4'} + dependencies: + ansi-styles: 3.2.1 + escape-string-regexp: 1.0.5 + supports-color: 5.5.0 + + /chalk@3.0.0: + resolution: {integrity: sha512-4D3B6Wf41KOYRFdszmDqMCGq5VV/uMAB273JILmO+3jAlh8X4qDtdtgCR3fxtbLEMzSx22QdhnDcJvu2u1fVwg==} + engines: {node: '>=8'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /chalk@4.1.2: + resolution: {integrity: sha512-oKnbhFyRIXpUuez8iBMmyEa4nbj4IOQyuhc/wy9kY7/WVPcwIO9VA668Pu8RkO7+0G76SLROeyw9CpQ061i4mA==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + supports-color: 7.2.0 + dev: true + + /chalk@5.3.0: + resolution: {integrity: sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==} + engines: {node: ^12.17.0 || ^14.13 || >=16.0.0} + dev: false + + /change-case@5.4.4: + resolution: {integrity: sha512-HRQyTk2/YPEkt9TnUPbOpr64Uw3KOicFWPVBb+xiHvd6eBx/qPr9xqfBFDT8P2vWsvvz4jbEkfDe71W3VyNu2w==} + dev: true + + /check-error@1.0.3: + resolution: {integrity: sha512-iKEoDYaRmd1mxM90a2OEfWhjsjPpYPuQ+lMYsoxB126+t8fw7ySEO48nmDg5COTjxDI65/Y2OWpeEHk3ZOe8zg==} + dependencies: + get-func-name: 2.0.2 + dev: true + + /check-error@2.1.1: + resolution: {integrity: sha512-OAlb+T7V4Op9OwdkjmguYRqncdlx5JiofwOAUkmTF+jNdHwzTaTs4sRAGpzLF3oOz5xAyDGrPgeIDFQmDOTiJw==} + engines: {node: '>= 16'} + dev: true + + /classcat@5.0.5: + resolution: {integrity: sha512-JhZUT7JFcQy/EzW605k/ktHtncoo9vnyW/2GspNYwFlN1C/WmjuV/xtS04e9SOkL2sTdw0VAZ2UGCcQ9lR6p6w==} + dev: false + + /clean-stack@2.2.0: + resolution: {integrity: sha512-4diC9HaTE+KRAMWhDhrGOECgWZxoevMc5TlkObMqNSsVU62PYzXZ/SMTjzyGAFF1YusgxGcSWTEXBhp0CPwQ1A==} + engines: {node: '>=6'} + dev: true + + /cli-cursor@3.1.0: + resolution: {integrity: sha512-I/zHAwsKf9FqGoXM4WWRACob9+SNukZTd94DWF57E4toouRulbCxcUh6RKUEOQlYTHJnzkPMySvPNaaSLNfLZw==} + engines: {node: '>=8'} + dependencies: + restore-cursor: 3.1.0 + dev: true + + /cli-spinners@2.9.2: + resolution: {integrity: sha512-ywqV+5MmyL4E7ybXgKys4DugZbX0FC6LnwrhjuykIjnK9k8OQacQ7axGKnjDXWNhns0xot3bZI5h55H8yo9cJg==} + engines: {node: '>=6'} + dev: true + + /cliui@8.0.1: + resolution: {integrity: sha512-BSeNnyus75C4//NQ9gQt1/csTXyo/8Sb+afLAkzAptFuMsod9HFokGNudZpi/oQV73hnVK+sR+5PVRMd+Dr7YQ==} + engines: {node: '>=12'} + dependencies: + string-width: 4.2.3 + strip-ansi: 6.0.1 + wrap-ansi: 7.0.0 + dev: true + + /clone@1.0.4: + resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} + engines: {node: '>=0.8'} + requiresBuild: true + dev: true + + /cmdk@1.0.0(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-gDzVf0a09TvoJ5jnuPvygTB77+XdOSwEmJ88L6XPFPlv7T3RxbP9jgenfylrAMD0+Le1aO0nVjQUzl2g+vjz5Q==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + dependencies: + '@radix-ui/react-dialog': 1.0.5(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@radix-ui/react-primitive': 1.0.3(@types/react-dom@18.3.0)(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - '@types/react-dom' + dev: false + + /color-convert@1.9.3: + resolution: {integrity: sha512-QfAUtd+vFdAtFQcC8CCyYt1fYWxSqAiK2cSD6zDB8N3cpsEBAvRxp9zOGg6G/SHHJYAT88/az/IuDGALsNVbGg==} + dependencies: + color-name: 1.1.3 + + /color-convert@2.0.1: + resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} + engines: {node: '>=7.0.0'} + dependencies: + color-name: 1.1.4 + dev: true + + /color-name@1.1.3: + resolution: {integrity: sha512-72fSenhMw2HZMTVHeCA9KCmpEIbzWiQsjN+BHcBbS9vr1mtt+vJjPdksIBNUmKAW8TFUDPJK5SUU3QhE9NEXDw==} + + /color-name@1.1.4: + resolution: {integrity: sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA==} + dev: true + + /color2k@2.0.3: + resolution: {integrity: sha512-zW190nQTIoXcGCaU08DvVNFTmQhUpnJfVuAKfWqUQkflXKpaDdpaYoM0iluLS9lgJNHyBF58KKA2FBEwkD7wog==} + dev: false + + /colorette@1.4.0: + resolution: {integrity: sha512-Y2oEozpomLn7Q3HFP7dpww7AtMJplbM9lGZP6RDfHqmbeRjiwRg4n6VM6j4KLmRke85uWEI7JqF17f3pqdRA0g==} + dev: true + + /commander@2.20.3: + resolution: {integrity: sha512-GpVkmM8vF2vQUkj2LvZmD35JxeJOLCwJ9cUkugyk2nuhbv3+mJvpLYYt+0+USMxE+oj+ey/lJEnhZw75x/OMcQ==} + dev: false + + /commander@4.1.1: + resolution: {integrity: sha512-NOKm8xhkzAjzFx8B2v5OAHT+u5pRQc2UCa2Vq9jYL/31o2wi9mxBA7LIFs3sV5VSC49z6pEhfbMULvShKj26WA==} + engines: {node: '>= 6'} + dev: true + + /commander@9.5.0: + resolution: {integrity: sha512-KRs7WVDKg86PWiuAqhDrAQnTXZKraVcCc6vFdL14qrZ/DcWwuRo7VoiYXalXO7S5GKpqYiVEwCbgFDfxNHKJBQ==} + engines: {node: ^12.20.0 || >=14} + requiresBuild: true + dev: true + optional: true + + /commondir@1.0.1: + resolution: {integrity: sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg==} + dev: true + + /compare-versions@6.1.1: + resolution: {integrity: sha512-4hm4VPpIecmlg59CHXnRDnqGplJFrbLG4aFEl5vl6cK1u76ws3LLvX7ikFnTDl5vo39sjWD6AaDPYodJp/NNHg==} + dev: false + + /computeds@0.0.1: + resolution: {integrity: sha512-7CEBgcMjVmitjYo5q8JTJVra6X5mQ20uTThdK+0kR7UEaDrAWEQcRiBtWJzga4eRpP6afNwwLsX2SET2JhVB1Q==} + dev: true + + /concat-map@0.0.1: + resolution: {integrity: sha512-/Srv4dswyQNBfohGpz9o6Yb3Gz3SrUDqBH5rTuhGR7ahtlbYKnVxw2bCFMRljaA7EXHaXZ8wsHdodFvbkhKmqg==} + dev: true + + /concurrently@8.2.2: + resolution: {integrity: sha512-1dP4gpXFhei8IOtlXRE/T/4H88ElHgTiUzh71YUmtjTEHMSRS2Z/fgOxHSxxusGHogsRfxNq1vyAwxSC+EVyDg==} + engines: {node: ^14.13.0 || >=16.0.0} + hasBin: true + dependencies: + chalk: 4.1.2 + date-fns: 2.30.0 + lodash: 4.17.21 + rxjs: 7.8.1 + shell-quote: 1.8.1 + spawn-command: 0.0.2 + supports-color: 8.1.1 + tree-kill: 1.2.2 + yargs: 17.7.2 + dev: true + + /confbox@0.1.7: + resolution: {integrity: sha512-uJcB/FKZtBMCJpK8MQji6bJHgu1tixKPxRLeGkNzBoOZzpnZUJm0jm2/sBDWcuBx1dYgxV4JU+g5hmNxCyAmdA==} + dev: true + + /content-disposition@0.5.4: + resolution: {integrity: sha512-FveZTNuGw04cxlAiWbzi6zTAL/lhehaWbTtgluJh4/E95DqMwTmha3KZN1aAWA8cFIhHzMZUvLevkw5Rqk+tSQ==} + engines: {node: '>= 0.6'} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /content-type@1.0.5: + resolution: {integrity: sha512-nTjqfcBFEipKdXCv4YDQWCfmcLZKm81ldF0pAopTvyrFGVbcR6P/VAAd5G7N+0tTr8QqiU0tFadD6FK4NtJwOA==} + engines: {node: '>= 0.6'} + dev: true + + /convert-source-map@1.9.0: + resolution: {integrity: sha512-ASFBup0Mz1uyiIjANan1jzLQami9z1PoYSZCiiYW2FczPbenXc45FZdBZLzOT+r6+iciuEModtmCti+hjaAk0A==} + dev: false + + /convert-source-map@2.0.0: + resolution: {integrity: sha512-Kvp459HrV2FEJ1CAsi1Ku+MY3kasH19TFykTz2xWmMeq6bk2NU3XXvfJ+Q61m0xktWwt+1HSYf3JZsTms3aRJg==} + dev: true + + /cookie-signature@1.0.6: + resolution: {integrity: sha512-QADzlaHc8icV8I7vbaJXJwod9HWYp8uCqf1xa4OfNu1T7JVxQIrUgOWtHdNDtPiywmFbiS12VjotIXLrKM3orQ==} + dev: true + + /cookie@0.6.0: + resolution: {integrity: sha512-U71cyTamuh1CRNCfpGY6to28lxvNwPG4Guz/EVjgf3Jmzv0vlDp1atT9eS5dDjMYHucpHbWns6Lwf3BKz6svdw==} + engines: {node: '>= 0.6'} + dev: true + + /copy-to-clipboard@3.3.3: + resolution: {integrity: sha512-2KV8NhB5JqC3ky0r9PMCAZKbUHSwtEo4CwCs0KXgruG43gX5PMqDEBbVU4OUzw2MuAWUfsuFmWvEKG5QRfSnJA==} + dependencies: + toggle-selection: 1.0.6 + dev: false + + /cosmiconfig@7.1.0: + resolution: {integrity: sha512-AdmX6xUzdNASswsFtmwSt7Vj8po9IuqXm0UXz7QKPuEUmPB4XyjGfaAr2PSuELMwkRMVH1EpIkX5bTZGRB3eCA==} + engines: {node: '>=10'} + dependencies: + '@types/parse-json': 4.0.2 + import-fresh: 3.3.0 + parse-json: 5.2.0 + path-type: 4.0.0 + yaml: 1.10.2 + dev: false + + /cross-fetch@4.0.0: + resolution: {integrity: sha512-e4a5N8lVvuLgAWgnCrLr2PP0YyDOTHa9H/Rj54dirp61qXnNq46m82bRhNqIA5VccJtWBvPTFRV3TtvHUKPB1g==} + dependencies: + node-fetch: 2.7.0 + transitivePeerDependencies: + - encoding + dev: false + + /cross-spawn@7.0.3: + resolution: {integrity: sha512-iRDPJKUPVEND7dHPO8rkbOnPpyDygcDFtWjpeWNCgy8WP2rXcxXL8TskReQl6OrB2G7+UJrags1q15Fudc7G6w==} + engines: {node: '>= 8'} + dependencies: + path-key: 3.1.1 + shebang-command: 2.0.0 + which: 2.0.2 + dev: true + + /css-box-model@1.2.1: + resolution: {integrity: sha512-a7Vr4Q/kd/aw96bnJG332W9V9LkJO69JRcaCYDUqjp6/z0w6VcZjgAcTbgFxEPfBgdnAwlh3iwu+hLopa+flJw==} + dependencies: + tiny-invariant: 1.3.3 + dev: false + + /css-in-js-utils@3.1.0: + resolution: {integrity: sha512-fJAcud6B3rRu+KHYk+Bwf+WFL2MDCJJ1XG9x137tJQ0xYxor7XziQtuGFbWNdqrvF4Tk26O3H73nfVqXt/fW1A==} + dependencies: + hyphenate-style-name: 1.1.0 + dev: false + + /css-tree@1.1.3: + resolution: {integrity: sha512-tRpdppF7TRazZrjJ6v3stzv93qxRcSsFmW6cX0Zm2NVKpxE1WV1HblnghVv9TreireHkqI/VDEsfolRF1p6y7Q==} + engines: {node: '>=8.0.0'} + dependencies: + mdn-data: 2.0.14 + source-map: 0.6.1 + dev: false + + /css.escape@1.5.1: + resolution: {integrity: sha512-YUifsXXuknHlUsmlgyY0PKzgPOr7/FjCePfHNt0jxm83wHZi44VDMQ7/fGNkjY3/jV1MC+1CmZbaHzugyeRtpg==} + dev: true + + /csstype@3.1.3: + resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + + /d3-color@3.1.0: + resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} + engines: {node: '>=12'} + dev: false + + /d3-dispatch@3.0.1: + resolution: {integrity: sha512-rzUyPU/S7rwUflMyLc1ETDeBj0NRuHKKAcvukozwhshr6g6c5d8zh4c2gQjY2bZ0dXeGLWc1PF174P2tVvKhfg==} + engines: {node: '>=12'} + dev: false + + /d3-drag@3.0.0: + resolution: {integrity: sha512-pWbUJLdETVA8lQNJecMxoXfH6x+mO2UQo8rSmZ+QqxcbyA3hfeprFgIT//HW2nlHChWeIIMwS2Fq+gEARkhTkg==} + engines: {node: '>=12'} + dependencies: + d3-dispatch: 3.0.1 + d3-selection: 3.0.0 + dev: false + + /d3-ease@3.0.1: + resolution: {integrity: sha512-wR/XK3D3XcLIZwpbvQwQ5fK+8Ykds1ip7A2Txe0yxncXSdq1L9skcG7blcedkOX+ZcgxGAmLX1FrRGbADwzi0w==} + engines: {node: '>=12'} + dev: false + + /d3-interpolate@3.0.1: + resolution: {integrity: sha512-3bYs1rOD33uo8aqJfKP3JWPAibgw8Zm2+L9vBKEHJ2Rg+viTR7o5Mmv5mZcieN+FRYaAOWX5SJATX6k1PWz72g==} + engines: {node: '>=12'} + dependencies: + d3-color: 3.1.0 + dev: false + + /d3-selection@3.0.0: + resolution: {integrity: sha512-fmTRWbNMmsmWq6xJV8D19U/gw/bwrHfNXxrIN+HfZgnzqTHp9jOmKMhsTUjXOJnZOdZY9Q28y4yebKzqDKlxlQ==} + engines: {node: '>=12'} + dev: false + + /d3-timer@3.0.1: + resolution: {integrity: sha512-ndfJ/JxxMd3nw31uyKoY2naivF+r29V+Lc0svZxe1JvvIRmi8hUsrMvdOwgS1o6uBHmiz91geQ0ylPP0aj1VUA==} + engines: {node: '>=12'} + dev: false + + /d3-transition@3.0.1(d3-selection@3.0.0): + resolution: {integrity: sha512-ApKvfjsSR6tg06xrL434C0WydLr7JewBB3V+/39RMHsaXTOG0zmt/OAXeng5M5LBm0ojmxJrpomQVZ1aPvBL4w==} + engines: {node: '>=12'} + peerDependencies: + d3-selection: 2 - 3 + 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 + dev: false + + /d3-zoom@3.0.0: + resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} + engines: {node: '>=12'} + 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) + dev: false + + /data-view-buffer@1.0.1: + resolution: {integrity: sha512-0lht7OugA5x3iJLOWFhWK/5ehONdprk0ISXqVFn/NFrDu+cuc8iADFrGQz5BnRK7LLU3JmkbXSxaqX+/mXYtUA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: true + + /data-view-byte-length@1.0.1: + resolution: {integrity: sha512-4J7wRJD3ABAzr8wP+OcIcqq2dlUKp4DVflx++hs5h5ZKydWMI6/D/fAot+yh6g2tHh8fLFTvNOaVN357NvSrOQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: true + + /data-view-byte-offset@1.0.0: + resolution: {integrity: sha512-t/Ygsytq+R995EJ5PZlD4Cu56sWa8InXySaViRzw9apusqsOO2bQP+SbYzAhR0pFKoB+43lYy8rWban9JSuXnA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-data-view: 1.0.1 + dev: true + + /date-fns@2.30.0: + resolution: {integrity: sha512-fnULvOpxnC5/Vg3NCiWelDsLiUc9bRwAPs/+LfTLNvetFCtCTN+yQz15C/fs4AwX1R9K5GLtLfn8QW+dWisaAw==} + engines: {node: '>=0.11'} + dependencies: + '@babel/runtime': 7.25.7 + dev: true + + /dateformat@5.0.3: + resolution: {integrity: sha512-Kvr6HmPXUMerlLcLF+Pwq3K7apHpYmGDVqrxcDasBg86UcKeTSNWbEzU8bwdXnxnR44FtMhJAxI4Bov6Y/KUfA==} + engines: {node: '>=12.20'} + dev: false + + /de-indent@1.0.2: + resolution: {integrity: sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg==} + dev: true + + /debug@2.6.9: + resolution: {integrity: sha512-bC7ElrdJaJnPbAP+1EotYvqZsb3ecl5wi6Bfi6BJTUcNowp6cvspg0jXznRTKDjm/E7AdgFBVeAPVMNcKGsHMA==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.0.0 + dev: true + + /debug@3.2.7: + resolution: {integrity: sha512-CFjzYYAi4ThfiQvizrFQevTTXHtnCqWfe7x1AhgEscTz6ZbLbfoLRLPugTQyBth6f8ZERVUSyWHFD/7Wu4t1XQ==} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + dev: true + + /debug@4.3.7(supports-color@9.4.0): + resolution: {integrity: sha512-Er2nc/H7RrMXZBFCEim6TCmMk02Z8vLC2Rbi1KEBggpo0fS6l0S1nnapwmIi3yW/+GOJap1Krg4w0Hg80oCqgQ==} + engines: {node: '>=6.0'} + peerDependencies: + supports-color: '*' + peerDependenciesMeta: + supports-color: + optional: true + dependencies: + ms: 2.1.3 + supports-color: 9.4.0 + + /decode-uri-component@0.4.1: + resolution: {integrity: sha512-+8VxcR21HhTy8nOt6jf20w0c9CADrw1O8d+VZ/YzzCt4bJ3uBjw+D1q2osAB8RnpwwaeYBxy0HyKQxD5JBMuuQ==} + engines: {node: '>=14.16'} + dev: false + + /deep-eql@4.1.4: + resolution: {integrity: sha512-SUwdGfqdKOwxCPeVYjwSyRpJ7Z+fhpwIAtmCUdZIWZ/YP5R9WAsyuSgpLVDi9bjWoN2LXHNss/dk3urXtdQxGg==} + engines: {node: '>=6'} + dependencies: + type-detect: 4.1.0 + dev: true + + /deep-eql@5.0.2: + resolution: {integrity: sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q==} + engines: {node: '>=6'} + dev: true + + /deep-is@0.1.4: + resolution: {integrity: sha512-oIPzksmTg4/MriiaYGO+okXDT7ztn/w3Eptv/+gSIdMdKsJo0u4CfYNFJPy+4SKMuCqGw2wxnA+URMg3t8a/bQ==} + dev: true + + /defaults@1.0.4: + resolution: {integrity: sha512-eFuaLoy/Rxalv2kr+lqMlUnrDWV+3j4pljOIJgLIhI058IQfWJ7vXhyEIHu+HtC738klGALYxOKDO0bQP3tg8A==} + requiresBuild: true + dependencies: + clone: 1.0.4 + dev: true + + /define-data-property@1.1.4: + resolution: {integrity: sha512-rBMvIzlpA8v6E+SJZoo++HAYqsLrkg7MSfIinMPFhmkorw7X+dOXVJQs+QT69zGkzMyfDnIMN2Wid1+NbL3T+A==} + engines: {node: '>= 0.4'} + dependencies: + es-define-property: 1.0.0 + es-errors: 1.3.0 + gopd: 1.0.1 + + /define-lazy-prop@2.0.0: + resolution: {integrity: sha512-Ds09qNh8yw3khSjiJjiUInaGX9xlqZDY7JVryGxdxV7NPeuqQfplOpQ66yJFZut3jLa5zOwkXw1g9EI2uKh4Og==} + engines: {node: '>=8'} + dev: true + + /define-properties@1.2.1: + resolution: {integrity: sha512-8QmQKqEASLd5nx0U1B1okLElbUuuttJ/AnYmRXbbbGDWh6uS208EjD4Xqq/I9wK7u0v6O08XhTWnt5XtEbR6Dg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + has-property-descriptors: 1.0.2 + object-keys: 1.1.1 + + /depd@2.0.0: + resolution: {integrity: sha512-g7nH6P6dyDioJogAAGprGpCtVImJhpPk/roCzdb3fIh61/s/nPsfR6onyMwkCAR/OlC3yBC0lESvUoQEAssIrw==} + engines: {node: '>= 0.8'} + dev: true + + /dequal@2.0.3: + resolution: {integrity: sha512-0je+qPKHEMohvfRTCEo3CrPG6cAzAYgmzKyxRiYSSDkS6eGJdyVJm7WaYA5ECaAD9wLB2T4EEeymA5aFVcYXCA==} + engines: {node: '>=6'} + dev: true + + /destroy@1.2.0: + resolution: {integrity: sha512-2sJGJTaXIIaR1w4iJSNoN0hnMY7Gpc/n8D4qSCJw8QqFWXf7cuAgnEHxBpweaVcPevC2l3KpjYCx3NypQQgaJg==} + engines: {node: '>= 0.8', npm: 1.2.8000 || >= 1.4.16} + dev: true + + /detect-node-es@1.1.0: + resolution: {integrity: sha512-ypdmJU/TbBby2Dxibuv7ZLW3Bs1QEmM7nHjEANfohJLvE0XVujisn1qPJcZxg+qDucsr+bP6fLD1rPS3AhJ7EQ==} + dev: false + + /diff-match-patch@1.0.5: + resolution: {integrity: sha512-IayShXAgj/QMXgB0IWmKx+rOPuGMhqm5w6jvFxmVenXKIzRqTAAsbBPT3kWQeGANj3jGgvcvv4yK6SxqYmikgw==} + dev: false + + /diff-sequences@29.6.3: + resolution: {integrity: sha512-EjePK1srD3P08o2j4f0ExnylqRs5B9tJjcp9t1krH2qRi8CCdsYfwe9JgSLurFBWwq4uOlipzfk5fHNvwFKr8Q==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dev: true + + /dir-glob@3.0.1: + resolution: {integrity: sha512-WkrWp9GR4KXfKGYzOLmTuGVi1UWFfws377n9cc55/tb6DuqyF6pcQ5AbiHEshaDpY9v6oaSr2XCDidGmMwdzIA==} + engines: {node: '>=8'} + dependencies: + path-type: 4.0.0 + dev: true + + /discontinuous-range@1.0.0: + resolution: {integrity: sha512-c68LpLbO+7kP/b1Hr1qs8/BJ09F5khZGTxqxZuhzxpmwJKOgRFHJWIb9/KmqnqHhLdO55aOxFH/EGBvUQbL/RQ==} + dev: false + + /doctrine@2.1.0: + resolution: {integrity: sha512-35mSku4ZXK0vfCuHEDAwt55dg2jNajHZ1odvF+8SSr82EsZY4QmXfuWso8oEd8zRhVObSN18aM0CjSdoBX7zIw==} + engines: {node: '>=0.10.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /doctrine@3.0.0: + resolution: {integrity: sha512-yS+Q5i3hBf7GBkd4KG8a7eBNNWNGLTaEwwYWUijIYM7zrlYDM0BFXHjjPWlWZ1Rg7UaddZeIDmi9jF3HmqiQ2w==} + engines: {node: '>=6.0.0'} + dependencies: + esutils: 2.0.3 + dev: true + + /dom-accessibility-api@0.5.16: + resolution: {integrity: sha512-X7BJ2yElsnOJ30pZF4uIIDfBEVgF4XEBxL9Bxhy6dnrm5hkzqmsWHGTiHqRiITNhMyFLyAiWndIJP7Z1NTteDg==} + dev: true + + /dom-accessibility-api@0.6.3: + resolution: {integrity: sha512-7ZgogeTnjuHbo+ct10G9Ffp0mif17idi0IyWNVA/wcwcm7NPOD/WEHVP3n7n3MhXqxoIYm8d6MuZohYWIZ4T3w==} + dev: true + + /dom-helpers@5.2.1: + resolution: {integrity: sha512-nRCa7CK3VTrM2NmGkIy4cbK7IZlgBE/PYMn55rrXefr5xXDP0LdtfPnblFDoVdcAfslJ7or6iqAUnx0CCGIWQA==} + dependencies: + '@babel/runtime': 7.25.7 + csstype: 3.1.3 + dev: false + + /dpdm@3.14.0: + resolution: {integrity: sha512-YJzsFSyEtj88q5eTELg3UWU7TVZkG1dpbF4JDQ3t1b07xuzXmdoGeSz9TKOke1mUuOpWlk4q+pBh+aHzD6GBTg==} + hasBin: true + dependencies: + chalk: 4.1.2 + fs-extra: 11.2.0 + glob: 10.4.5 + ora: 5.4.1 + tslib: 2.7.0 + typescript: 5.6.2 + yargs: 17.7.2 + dev: true + + /eastasianwidth@0.2.0: + resolution: {integrity: sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA==} + dev: true + + /easy-table@1.2.0: + resolution: {integrity: sha512-OFzVOv03YpvtcWGe5AayU5G2hgybsg3iqA6drU8UaoZyB9jLGMTrz9+asnLp/E+6qPh88yEI1gvyZFZ41dmgww==} + dependencies: + ansi-regex: 5.0.1 + optionalDependencies: + wcwidth: 1.0.1 + dev: true + + /ee-first@1.1.1: + resolution: {integrity: sha512-WMwm9LhRUo+WUaRN+vRuETqG89IgZphVSNkdFgeb6sS/E4OrDIN7t48CAewSHXc6C8lefD8KKfr5vY61brQlow==} + dev: true + + /electron-to-chromium@1.5.31: + resolution: {integrity: sha512-QcDoBbQeYt0+3CWcK/rEbuHvwpbT/8SV9T3OSgs6cX1FlcUAkgrkqbg9zLnDrMM/rLamzQwal4LYFCiWk861Tg==} + dev: true + + /emoji-regex@8.0.0: + resolution: {integrity: sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A==} + dev: true + + /emoji-regex@9.2.2: + resolution: {integrity: sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg==} + dev: true + + /encodeurl@1.0.2: + resolution: {integrity: sha512-TPJXq8JqFaVYm2CWmPvnP2Iyo4ZSM7/QKcSmuMLDObfpH5fi7RUGmd/rTDf+rut/saiDiQEeVTNgAmJEdAOx0w==} + engines: {node: '>= 0.8'} + dev: true + + /encodeurl@2.0.0: + resolution: {integrity: sha512-Q0n9HRi4m6JuGIV1eFlmvJB7ZEVxu93IrMyiMsGC0lrMJMWzRgx6WGquyfQgZVb31vhGgXnfmPNNXmxnOkRBrg==} + engines: {node: '>= 0.8'} + dev: true + + /engine.io-client@6.6.1: + resolution: {integrity: sha512-aYuoak7I+R83M/BBPIOs2to51BmFIpC1wZe6zZzMrT2llVsHy5cvcmdsJgP2Qz6smHu+sD9oexiSUAVd8OfBPw==} + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7(supports-color@9.4.0) + engine.io-parser: 5.2.3 + ws: 8.17.1 + xmlhttprequest-ssl: 2.1.1 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /engine.io-parser@5.2.3: + resolution: {integrity: sha512-HqD3yTBfnBxIrbnM1DoD6Pcq8NECnh8d4As1Qgh0z5Gg3jRRIqijury0CL3ghu/edArpUYiYqQiDUQBIs4np3Q==} + engines: {node: '>=10.0.0'} + dev: false + + /enhanced-resolve@5.17.1: + resolution: {integrity: sha512-LMHl3dXhTcfv8gM4kEzIUeTQ+7fpdA0l2tUf34BddXPkz2A5xJ5L/Pchd5BL6rdccM9QGvu0sWZzK1Z1t4wwyg==} + engines: {node: '>=10.13.0'} + dependencies: + graceful-fs: 4.2.11 + tapable: 2.2.1 + dev: true + + /entities@4.5.0: + resolution: {integrity: sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw==} + engines: {node: '>=0.12'} + dev: true + + /error-ex@1.3.2: + resolution: {integrity: sha512-7dFHNmqeFSEt2ZBsCriorKnn3Z2pj+fd9kmI6QoWw4//DL+icEBfc0U7qJCisqrTsKTjw4fNFy2pW9OqStD84g==} + dependencies: + is-arrayish: 0.2.1 + dev: false + + /error-stack-parser@2.1.4: + resolution: {integrity: sha512-Sk5V6wVazPhq5MhpO+AUxJn5x7XSXGl1R93Vn7i+zS15KDVxQijejNCrz8340/2bgLBjR9GtEG8ZVKONDjcqGQ==} + dependencies: + stackframe: 1.3.4 + dev: false + + /es-abstract@1.23.3: + resolution: {integrity: sha512-e+HfNH61Bj1X9/jLc5v1owaLYuHdeHHSQlkhCBiTK8rBvKaULl/beGMxwrMXjpYrv4pz22BlY570vVePA2ho4A==} + engines: {node: '>= 0.4'} + dependencies: + array-buffer-byte-length: 1.0.1 + arraybuffer.prototype.slice: 1.0.3 + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + data-view-buffer: 1.0.1 + data-view-byte-length: 1.0.1 + data-view-byte-offset: 1.0.0 + es-define-property: 1.0.0 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + es-set-tostringtag: 2.0.3 + es-to-primitive: 1.2.1 + function.prototype.name: 1.1.6 + get-intrinsic: 1.2.4 + get-symbol-description: 1.0.2 + globalthis: 1.0.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + internal-slot: 1.0.7 + is-array-buffer: 3.0.4 + is-callable: 1.2.7 + is-data-view: 1.0.1 + is-negative-zero: 2.0.3 + is-regex: 1.1.4 + is-shared-array-buffer: 1.0.3 + is-string: 1.0.7 + is-typed-array: 1.1.13 + is-weakref: 1.0.2 + object-inspect: 1.13.2 + object-keys: 1.1.1 + object.assign: 4.1.5 + regexp.prototype.flags: 1.5.3 + safe-array-concat: 1.1.2 + safe-regex-test: 1.0.3 + string.prototype.trim: 1.2.9 + string.prototype.trimend: 1.0.8 + string.prototype.trimstart: 1.0.8 + typed-array-buffer: 1.0.2 + typed-array-byte-length: 1.0.1 + typed-array-byte-offset: 1.0.2 + typed-array-length: 1.0.6 + unbox-primitive: 1.0.2 + which-typed-array: 1.1.15 + dev: true + + /es-define-property@1.0.0: + resolution: {integrity: sha512-jxayLKShrEqqzJ0eumQbVhTYQM27CfT1T35+gCgDFoL82JLsXqTJ76zv6A0YLOgEnLUMvLzsDsGIrl8NFpT2gQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 + + /es-errors@1.3.0: + resolution: {integrity: sha512-Zf5H2Kxt2xjTvbJvP2ZWLEICxA6j+hAmMzIlypy4xcBg1vKVnx89Wy0GbS+kf5cwCVFFzdCFh2XSCFNULS6csw==} + engines: {node: '>= 0.4'} + + /es-iterator-helpers@1.0.19: + resolution: {integrity: sha512-zoMwbCcH5hwUkKJkT8kDIBZSz9I6mVG//+lDCinLCGov4+r7NIy0ld8o03M0cJxl2spVf6ESYVS6/gpIfq1FFw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-set-tostringtag: 2.0.3 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + has-property-descriptors: 1.0.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + iterator.prototype: 1.1.2 + safe-array-concat: 1.1.2 + dev: true + + /es-module-lexer@1.5.4: + resolution: {integrity: sha512-MVNK56NiMrOwitFB7cqDwq0CQutbw+0BvLshJSse0MUNU+y1FC3bUS/AQg7oUng+/wKrrki7JfmwtVHkVfPLlw==} + dev: true + + /es-object-atoms@1.0.0: + resolution: {integrity: sha512-MZ4iQ6JwHOBQjahnjwaC1ZtIBH+2ohjamzAO3oaHcXYup7qxjF2fixyH+Q71voWHeOkI2q/TnJao/KfXYIZWbw==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + dev: true + + /es-set-tostringtag@2.0.3: + resolution: {integrity: sha512-3T8uNMC3OQTHkFUsFq8r/BwAXLHvU/9O9mE0fBc/MY5iq/8H7ncvO947LmYA6ldWw9Uh8Yhf25zu6n7nML5QWQ==} + engines: {node: '>= 0.4'} + dependencies: + get-intrinsic: 1.2.4 + has-tostringtag: 1.0.2 + hasown: 2.0.2 + dev: true + + /es-shim-unscopables@1.0.2: + resolution: {integrity: sha512-J3yBRXCzDu4ULnQwxyToo/OjdMx6akgVC7K6few0a7F/0wLtmKKN7I73AH5T2836UuXRqN7Qg+IIUw/+YJksRw==} + dependencies: + hasown: 2.0.2 + dev: true + + /es-to-primitive@1.2.1: + resolution: {integrity: sha512-QCOllgZJtaUo9miYBcLChTUaHNjJF3PYs1VidD7AwiEj1kYxKeQTctLAezAOH5ZKRH0g2IgPn6KwB4IT8iRpvA==} + engines: {node: '>= 0.4'} + dependencies: + is-callable: 1.2.7 + is-date-object: 1.0.5 + is-symbol: 1.0.4 + dev: true + + /esbuild-register@3.6.0(esbuild@0.23.1): + resolution: {integrity: sha512-H2/S7Pm8a9CL1uhp9OvjwrBh5Pvx0H8qVOxNu8Wed9Y7qv56MPtq+GGM8RJpq6glYJn9Wspr8uw7l55uyinNeg==} + peerDependencies: + esbuild: '>=0.12 <1' + dependencies: + debug: 4.3.7(supports-color@9.4.0) + esbuild: 0.23.1 + transitivePeerDependencies: + - supports-color + dev: true + + /esbuild@0.21.5: + resolution: {integrity: sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw==} + engines: {node: '>=12'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.21.5 + '@esbuild/android-arm': 0.21.5 + '@esbuild/android-arm64': 0.21.5 + '@esbuild/android-x64': 0.21.5 + '@esbuild/darwin-arm64': 0.21.5 + '@esbuild/darwin-x64': 0.21.5 + '@esbuild/freebsd-arm64': 0.21.5 + '@esbuild/freebsd-x64': 0.21.5 + '@esbuild/linux-arm': 0.21.5 + '@esbuild/linux-arm64': 0.21.5 + '@esbuild/linux-ia32': 0.21.5 + '@esbuild/linux-loong64': 0.21.5 + '@esbuild/linux-mips64el': 0.21.5 + '@esbuild/linux-ppc64': 0.21.5 + '@esbuild/linux-riscv64': 0.21.5 + '@esbuild/linux-s390x': 0.21.5 + '@esbuild/linux-x64': 0.21.5 + '@esbuild/netbsd-x64': 0.21.5 + '@esbuild/openbsd-x64': 0.21.5 + '@esbuild/sunos-x64': 0.21.5 + '@esbuild/win32-arm64': 0.21.5 + '@esbuild/win32-ia32': 0.21.5 + '@esbuild/win32-x64': 0.21.5 + dev: true + + /esbuild@0.23.1: + resolution: {integrity: sha512-VVNz/9Sa0bs5SELtn3f7qhJCDPCF5oMEl5cO9/SSinpE9hbPVvxbd572HH5AKiP7WD8INO53GgfDDhRjkylHEg==} + engines: {node: '>=18'} + hasBin: true + requiresBuild: true + optionalDependencies: + '@esbuild/aix-ppc64': 0.23.1 + '@esbuild/android-arm': 0.23.1 + '@esbuild/android-arm64': 0.23.1 + '@esbuild/android-x64': 0.23.1 + '@esbuild/darwin-arm64': 0.23.1 + '@esbuild/darwin-x64': 0.23.1 + '@esbuild/freebsd-arm64': 0.23.1 + '@esbuild/freebsd-x64': 0.23.1 + '@esbuild/linux-arm': 0.23.1 + '@esbuild/linux-arm64': 0.23.1 + '@esbuild/linux-ia32': 0.23.1 + '@esbuild/linux-loong64': 0.23.1 + '@esbuild/linux-mips64el': 0.23.1 + '@esbuild/linux-ppc64': 0.23.1 + '@esbuild/linux-riscv64': 0.23.1 + '@esbuild/linux-s390x': 0.23.1 + '@esbuild/linux-x64': 0.23.1 + '@esbuild/netbsd-x64': 0.23.1 + '@esbuild/openbsd-arm64': 0.23.1 + '@esbuild/openbsd-x64': 0.23.1 + '@esbuild/sunos-x64': 0.23.1 + '@esbuild/win32-arm64': 0.23.1 + '@esbuild/win32-ia32': 0.23.1 + '@esbuild/win32-x64': 0.23.1 + dev: true + + /escalade@3.2.0: + resolution: {integrity: sha512-WUj2qlxaQtO4g6Pq5c29GTcWGDyd8itL8zTlipgECz3JesAiiOKotd8JU6otB3PACgG6xkJUyVhboMS+bje/jA==} + engines: {node: '>=6'} + dev: true + + /escape-html@1.0.3: + resolution: {integrity: sha512-NiSupZ4OeuGwr68lGIeym/ksIZMJodUGOSCZ/FSnTxcrekbvqrgdUxlJOMpijaKZVjAJrWrGs/6Jy8OMuyj9ow==} + dev: true + + /escape-string-regexp@1.0.5: + resolution: {integrity: sha512-vbRorB5FUQWvla16U8R/qgaFIya2qGzwDrNmCZuYKrbdSUMG6I1ZCGQRefkRVhuOkIGVne7BQ35DSfo1qvJqFg==} + engines: {node: '>=0.8.0'} + + /escape-string-regexp@4.0.0: + resolution: {integrity: sha512-TtpcNJ3XAzx3Gq8sWRzJaVajRs0uVxA2YAkdb1jm2YkPz4G6egUFAyA3n5vtEIZefPk5Wa4UXbKuS5fKkJWdgA==} + engines: {node: '>=10'} + + /escodegen@2.1.0: + resolution: {integrity: sha512-2NlIDTwUWJN0mRPQOdtQBzbUHvdGY2P1VXSyU83Q3xKxM7WHX2Ql8dKq782Q9TgQUNOLEzEYu9bzLNj1q88I5w==} + engines: {node: '>=6.0'} + hasBin: true + dependencies: + esprima: 4.0.1 + estraverse: 5.3.0 + esutils: 2.0.3 + optionalDependencies: + source-map: 0.6.1 + dev: true + + /eslint-config-prettier@9.1.0(eslint@8.57.1): + resolution: {integrity: sha512-NSWl5BFQWEPi1j4TjVNItzYV7dZXZ+wP6I6ZhrBGpChQhZRUaElihE9uRRkcbRnNb76UMKDF3r+WTmNcGPKsqw==} + hasBin: true + peerDependencies: + eslint: '>=7.0.0' + dependencies: + eslint: 8.57.1 + dev: true + + /eslint-import-resolver-node@0.3.9: + resolution: {integrity: sha512-WFj2isz22JahUv+B788TlO3N6zL3nNJGU8CcZbPZvVEkBPaJdCV4vy5wyghty5ROFbCRnm132v8BScu5/1BQ8g==} + dependencies: + debug: 3.2.7 + is-core-module: 2.15.1 + resolve: 1.22.8 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-module-utils@2.12.0(@typescript-eslint/parser@7.18.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.1): + resolution: {integrity: sha512-wALZ0HFoytlyh/1+4wuZ9FJCD/leWHQzzrxJ8+rebyReSLk7LApMyd3WJaLVoN+D5+WIdJyDK1c6JnE65V4Zyg==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: '*' + eslint-import-resolver-node: '*' + eslint-import-resolver-typescript: '*' + eslint-import-resolver-webpack: '*' + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + eslint: + optional: true + eslint-import-resolver-node: + optional: true + eslint-import-resolver-typescript: + optional: true + eslint-import-resolver-webpack: + optional: true + dependencies: + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + debug: 3.2.7 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + transitivePeerDependencies: + - supports-color + dev: true + + /eslint-plugin-i18next@6.1.0: + resolution: {integrity: sha512-upFtY6JyrJk8+nKp7utxlYyq5PMo/+FdgJIXpA29QdAaGR1whVmybUz2F5W+0TQYqIirekq4cSwWlej/ealBuA==} + engines: {node: '>=0.10.0'} + dependencies: + lodash: 4.17.21 + requireindex: 1.1.0 + dev: true + + /eslint-plugin-import@2.30.0(@typescript-eslint/parser@7.18.0)(eslint@8.57.1): + resolution: {integrity: sha512-/mHNE9jINJfiD2EKkg1BKyPyUk4zdnT54YgbOgfjSakWT5oyX/qQLVNTkehyfpcMxZXMy1zyonZ2v7hZTX43Yw==} + engines: {node: '>=4'} + peerDependencies: + '@typescript-eslint/parser': '*' + eslint: ^2 || ^3 || ^4 || ^5 || ^6 || ^7.2.0 || ^8 + peerDependenciesMeta: + '@typescript-eslint/parser': + optional: true + dependencies: + '@rtsao/scc': 1.1.0 + '@typescript-eslint/parser': 7.18.0(eslint@8.57.1)(typescript@5.6.2) + array-includes: 3.1.8 + array.prototype.findlastindex: 1.2.5 + array.prototype.flat: 1.3.2 + array.prototype.flatmap: 1.3.2 + debug: 3.2.7 + doctrine: 2.1.0 + eslint: 8.57.1 + eslint-import-resolver-node: 0.3.9 + eslint-module-utils: 2.12.0(@typescript-eslint/parser@7.18.0)(eslint-import-resolver-node@0.3.9)(eslint@8.57.1) + hasown: 2.0.2 + is-core-module: 2.15.1 + is-glob: 4.0.3 + minimatch: 3.1.2 + object.fromentries: 2.0.8 + object.groupby: 1.0.3 + object.values: 1.2.0 + semver: 6.3.1 + tsconfig-paths: 3.15.0 + transitivePeerDependencies: + - eslint-import-resolver-typescript + - eslint-import-resolver-webpack + - supports-color + dev: true + + /eslint-plugin-path@1.3.0(eslint@8.57.1): + resolution: {integrity: sha512-Q/8AusuMaATyh67mjhCURrf7IW1Uq3jcKlfPVHSb6mNRFEJuIIx4xYvewP8n8eRmzBic8457Vw/sBL5U0y9S/g==} + engines: {node: '>= 12.22.0'} + peerDependencies: + eslint: '>=6.0.0' + dependencies: + eslint: 8.57.1 + load-tsconfig: 0.2.5 + dev: true + + /eslint-plugin-react-hooks@4.6.2(eslint@8.57.1): + resolution: {integrity: sha512-QzliNJq4GinDBcD8gPB5v0wh6g8q3SUi6EFF0x8N/BL9PoVs0atuGc47ozMRyOWAKdwaZ5OnbOEa3WR+dSGKuQ==} + engines: {node: '>=10'} + peerDependencies: + eslint: ^3.0.0 || ^4.0.0 || ^5.0.0 || ^6.0.0 || ^7.0.0 || ^8.0.0-0 + dependencies: + eslint: 8.57.1 + dev: true + + /eslint-plugin-react-refresh@0.4.12(eslint@8.57.1): + resolution: {integrity: sha512-9neVjoGv20FwYtCP6CB1dzR1vr57ZDNOXst21wd2xJ/cTlM2xLq0GWVlSNTdMn/4BtP6cHYBMCSp1wFBJ9jBsg==} + peerDependencies: + eslint: '>=7' + dependencies: + eslint: 8.57.1 + dev: true + + /eslint-plugin-react@7.37.1(eslint@8.57.1): + resolution: {integrity: sha512-xwTnwDqzbDRA8uJ7BMxPs/EXRB3i8ZfnOIp8BsxEQkT0nHPp+WWceqGgo6rKb9ctNi8GJLDT4Go5HAWELa/WMg==} + engines: {node: '>=4'} + peerDependencies: + eslint: ^3 || ^4 || ^5 || ^6 || ^7 || ^8 || ^9.7 + dependencies: + array-includes: 3.1.8 + array.prototype.findlast: 1.2.5 + array.prototype.flatmap: 1.3.2 + array.prototype.tosorted: 1.1.4 + doctrine: 2.1.0 + es-iterator-helpers: 1.0.19 + eslint: 8.57.1 + estraverse: 5.3.0 + hasown: 2.0.2 + jsx-ast-utils: 3.3.5 + minimatch: 3.1.2 + object.entries: 1.1.8 + object.fromentries: 2.0.8 + object.values: 1.2.0 + prop-types: 15.8.1 + resolve: 2.0.0-next.5 + semver: 6.3.1 + string.prototype.matchall: 4.0.11 + string.prototype.repeat: 1.0.0 + dev: true + + /eslint-plugin-simple-import-sort@12.1.1(eslint@8.57.1): + resolution: {integrity: sha512-6nuzu4xwQtE3332Uz0to+TxDQYRLTKRESSc2hefVT48Zc8JthmN23Gx9lnYhu0FtkRSL1oxny3kJ2aveVhmOVA==} + peerDependencies: + eslint: '>=5.0.0' + dependencies: + eslint: 8.57.1 + dev: true + + /eslint-plugin-storybook@0.8.0(eslint@8.57.1)(typescript@5.6.2): + resolution: {integrity: sha512-CZeVO5EzmPY7qghO2t64oaFM+8FTaD4uzOEjHKp516exyTKo+skKAL9GI3QALS2BXhyALJjNtwbmr1XinGE8bA==} + engines: {node: '>= 18'} + peerDependencies: + eslint: '>=6' + dependencies: + '@storybook/csf': 0.0.1 + '@typescript-eslint/utils': 5.62.0(eslint@8.57.1)(typescript@5.6.2) + eslint: 8.57.1 + requireindex: 1.2.0 + ts-dedent: 2.2.0 + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /eslint-plugin-unused-imports@3.2.0(@typescript-eslint/eslint-plugin@7.18.0)(eslint@8.57.1): + resolution: {integrity: sha512-6uXyn6xdINEpxE1MtDjxQsyXB37lfyO2yKGVVgtD7WEWQGORSOZjgrD6hBhvGv4/SO+TOlS+UnC6JppRqbuwGQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + peerDependencies: + '@typescript-eslint/eslint-plugin': 6 - 7 + eslint: '8' + peerDependenciesMeta: + '@typescript-eslint/eslint-plugin': + optional: true + dependencies: + '@typescript-eslint/eslint-plugin': 7.18.0(@typescript-eslint/parser@7.18.0)(eslint@8.57.1)(typescript@5.6.2) + eslint: 8.57.1 + eslint-rule-composer: 0.3.0 + dev: true + + /eslint-rule-composer@0.3.0: + resolution: {integrity: sha512-bt+Sh8CtDmn2OajxvNO+BX7Wn4CIWMpTRm3MaiKPCQcnnlm0CS2mhui6QaoeQugs+3Kj2ESKEEGJUdVafwhiCg==} + engines: {node: '>=4.0.0'} + dev: true + + /eslint-scope@5.1.1: + resolution: {integrity: sha512-2NxwbF/hZ0KpepYN0cNbo+FN6XoK7GaHlQhgx/hIZl6Va0bF45RQOOwhLIy8lQDbuCiadSLCBnH2CFYquit5bw==} + engines: {node: '>=8.0.0'} + dependencies: + esrecurse: 4.3.0 + estraverse: 4.3.0 + dev: true + + /eslint-scope@7.2.2: + resolution: {integrity: sha512-dOt21O7lTMhDM+X9mB4GX+DZrZtCUJPL/wlcTqxyrx5IvO0IYtILdtrQGQp+8n5S0gwSVmOf9NQrjMOgfQZlIg==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + esrecurse: 4.3.0 + estraverse: 5.3.0 + dev: true + + /eslint-visitor-keys@3.4.3: + resolution: {integrity: sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dev: true + + /eslint@8.57.1: + resolution: {integrity: sha512-ypowyDxpVSYpkXr9WPv2PAZCtNip1Mv5KTW0SCurXv/9iOpcrH9PaqUElksqEB6pChqHGDRCFTyrZlGhnLNGiA==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + hasBin: true + dependencies: + '@eslint-community/eslint-utils': 4.4.0(eslint@8.57.1) + '@eslint-community/regexpp': 4.11.1 + '@eslint/eslintrc': 2.1.4 + '@eslint/js': 8.57.1 + '@humanwhocodes/config-array': 0.13.0 + '@humanwhocodes/module-importer': 1.0.1 + '@nodelib/fs.walk': 1.2.8 + '@ungap/structured-clone': 1.2.0 + ajv: 6.12.6 + chalk: 4.1.2 + cross-spawn: 7.0.3 + debug: 4.3.7(supports-color@9.4.0) + doctrine: 3.0.0 + escape-string-regexp: 4.0.0 + eslint-scope: 7.2.2 + eslint-visitor-keys: 3.4.3 + espree: 9.6.1 + esquery: 1.6.0 + esutils: 2.0.3 + fast-deep-equal: 3.1.3 + file-entry-cache: 6.0.1 + find-up: 5.0.0 + glob-parent: 6.0.2 + globals: 13.24.0 + graphemer: 1.4.0 + ignore: 5.3.2 + imurmurhash: 0.1.4 + is-glob: 4.0.3 + is-path-inside: 3.0.3 + js-yaml: 4.1.0 + json-stable-stringify-without-jsonify: 1.0.1 + levn: 0.4.1 + lodash.merge: 4.6.2 + minimatch: 3.1.2 + natural-compare: 1.4.0 + optionator: 0.9.4 + strip-ansi: 6.0.1 + text-table: 0.2.0 + transitivePeerDependencies: + - supports-color + dev: true + + /espree@9.6.1: + resolution: {integrity: sha512-oruZaFkjorTpF32kDSI5/75ViwGeZginGGy2NoOSg3Q9bnwlnmDm4HLnkl0RE3n+njDXR037aY1+x58Z/zFdwQ==} + engines: {node: ^12.22.0 || ^14.17.0 || >=16.0.0} + dependencies: + acorn: 8.12.1 + acorn-jsx: 5.3.2(acorn@8.12.1) + eslint-visitor-keys: 3.4.3 + dev: true + + /esprima@4.0.1: + resolution: {integrity: sha512-eGuFFw7Upda+g4p+QHvnW0RyTX/SVeJBDM/gCtMARO0cLuT2HcEKnTPvhjV6aGeqrCB/sbNop0Kszm0jsaWU4A==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /esquery@1.6.0: + resolution: {integrity: sha512-ca9pw9fomFcKPvFLXhBKUK90ZvGibiGOvRJNbjljY7s7uq/5YO4BOzcYtJqExdx99rF6aAcnRxHmcUHcz6sQsg==} + engines: {node: '>=0.10'} + dependencies: + estraverse: 5.3.0 + dev: true + + /esrecurse@4.3.0: + resolution: {integrity: sha512-KmfKL3b6G+RXvP8N1vr3Tq1kL/oCFgn2NYXEtqP8/L3pKapUA4G8cFVaoF3SU323CD4XypR/ffioHmkti6/Tag==} + engines: {node: '>=4.0'} + dependencies: + estraverse: 5.3.0 + dev: true + + /estraverse@4.3.0: + resolution: {integrity: sha512-39nnKffWz8xN1BU/2c79n9nB9HDzo0niYUqx6xyqUnyoAnQyyWpOTdZEeiCch8BBu515t4wp9ZmgVfVhn9EBpw==} + engines: {node: '>=4.0'} + dev: true + + /estraverse@5.3.0: + resolution: {integrity: sha512-MMdARuVEQziNTeJD8DgMqmhwR11BRQ/cBP+pLtYdSTnf3MIO8fFeiINEbX36ZdNlfU/7A9f3gUw49B3oQsvwBA==} + engines: {node: '>=4.0'} + dev: true + + /estree-walker@2.0.2: + resolution: {integrity: sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w==} + dev: true + + /estree-walker@3.0.3: + resolution: {integrity: sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g==} + dependencies: + '@types/estree': 1.0.6 + dev: true + + /esutils@2.0.3: + resolution: {integrity: sha512-kVscqXk4OCp68SZ0dkgEKVi6/8ij300KBWTJq32P/dYeWTSwK41WyTxalN1eRmA5Z9UU/LX9D7FWSmV9SAYx6g==} + engines: {node: '>=0.10.0'} + dev: true + + /etag@1.8.1: + resolution: {integrity: sha512-aIL5Fx7mawVa300al2BnEE4iNvo1qETxLrPI/o05L7z6go7fCw1J6EQmbK4FmJ2AS7kgVF/KEZWufBfdClMcPg==} + engines: {node: '>= 0.6'} + dev: true + + /execa@8.0.1: + resolution: {integrity: sha512-VyhnebXciFV2DESc+p6B+y0LjSm0krU4OgJN44qFAhBY0TJ+1V61tYD2+wHusZ6F9n5K+vl8k0sTy7PEfV4qpg==} + engines: {node: '>=16.17'} + dependencies: + cross-spawn: 7.0.3 + get-stream: 8.0.1 + human-signals: 5.0.0 + is-stream: 3.0.0 + merge-stream: 2.0.0 + npm-run-path: 5.3.0 + onetime: 6.0.0 + signal-exit: 4.1.0 + strip-final-newline: 3.0.0 + dev: true + + /express@4.21.0: + resolution: {integrity: sha512-VqcNGcj/Id5ZT1LZ/cfihi3ttTn+NJmkli2eZADigjq29qTlWi/hAQ43t/VLPq8+UX06FCEx3ByOYet6ZFblng==} + engines: {node: '>= 0.10.0'} + dependencies: + accepts: 1.3.8 + array-flatten: 1.1.1 + body-parser: 1.20.3 + content-disposition: 0.5.4 + content-type: 1.0.5 + cookie: 0.6.0 + cookie-signature: 1.0.6 + debug: 2.6.9 + depd: 2.0.0 + encodeurl: 2.0.0 + escape-html: 1.0.3 + etag: 1.8.1 + finalhandler: 1.3.1 + fresh: 0.5.2 + http-errors: 2.0.0 + merge-descriptors: 1.0.3 + methods: 1.1.2 + on-finished: 2.4.1 + parseurl: 1.3.3 + path-to-regexp: 0.1.10 + proxy-addr: 2.0.7 + qs: 6.13.0 + range-parser: 1.2.1 + safe-buffer: 5.2.1 + send: 0.19.0 + serve-static: 1.16.2 + setprototypeof: 1.2.0 + statuses: 2.0.1 + type-is: 1.6.18 + utils-merge: 1.0.1 + vary: 1.1.2 + transitivePeerDependencies: + - supports-color + dev: true + + /fast-deep-equal@3.1.3: + resolution: {integrity: sha512-f3qQ9oQy9j2AhBe/H9VC91wLmKBCCU/gDOnKNAYG5hswO7BLKj09Hc5HYNz9cGI++xlpDCIgDaitVs03ATR84Q==} + + /fast-glob@3.3.2: + resolution: {integrity: sha512-oX2ruAFQwf/Orj8m737Y5adxDQO0LAB7/S5MnxCdTNDd4p6BsyIVsv9JQsATbTSq8KHRpLwIHbVlUNatxd+1Ow==} + engines: {node: '>=8.6.0'} + dependencies: + '@nodelib/fs.stat': 2.0.5 + '@nodelib/fs.walk': 1.2.8 + glob-parent: 5.1.2 + merge2: 1.4.1 + micromatch: 4.0.8 + dev: true + + /fast-json-stable-stringify@2.1.0: + resolution: {integrity: sha512-lhd/wF+Lk98HZoTCtlVraHtfh5XYijIjalXck7saUtuanSDyLMxnHhSXEDJqHxD7msR8D0uCmqlkwjCV8xvwHw==} + dev: true + + /fast-levenshtein@2.0.6: + resolution: {integrity: sha512-DCXu6Ifhqcks7TZKY3Hxp3y6qphY5SJZmrWMDrKcERSOXWQdMhU9Ig/PYrzyw/ul9jOIyh0N4M0tbC5hodg8dw==} + dev: true + + /fast-printf@1.6.9: + resolution: {integrity: sha512-FChq8hbz65WMj4rstcQsFB0O7Cy++nmbNfLYnD9cYv2cRn8EG6k/MGn9kO/tjO66t09DLDugj3yL+V2o6Qftrg==} + engines: {node: '>=10.0'} + dependencies: + boolean: 3.2.0 + dev: false + + /fast-shallow-equal@1.0.0: + resolution: {integrity: sha512-HPtaa38cPgWvaCFmRNhlc6NG7pv6NUHqjPgVAkWGoB9mQMwYB27/K0CvOM5Czy+qpT3e8XJ6Q4aPAnzpNpzNaw==} + dev: false + + /fastest-stable-stringify@2.0.2: + resolution: {integrity: sha512-bijHueCGd0LqqNK9b5oCMHc0MluJAx0cwqASgbWMvkO01lCYgIhacVRLcaDz3QnyYIRNJRDwMb41VuT6pHJ91Q==} + dev: false + + /fastq@1.17.1: + resolution: {integrity: sha512-sRVD3lWVIXWg6By68ZN7vho9a1pQcN/WBFaAAsDDFzlJjvoGx0P8z7V1t72grFJfJhu3YPZBuu25f7Kaw2jN1w==} + dependencies: + reusify: 1.0.4 + dev: true + + /fflate@0.8.2: + resolution: {integrity: sha512-cPJU47OaAoCbg0pBvzsgpTPhmhqI5eJjh/JIu8tPj5q+T7iLvW/JAYUqmE7KOB4R1ZyEhzBaIQpQpardBF5z8A==} + dev: true + + /file-entry-cache@6.0.1: + resolution: {integrity: sha512-7Gps/XWymbLk2QLYK4NzpMOrYjMhdIxXuIvy2QBsLE6ljuodKvdkWs/cpyJJ3CVIVpH0Oi1Hvg1ovbMzLdFBBg==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flat-cache: 3.2.0 + dev: true + + /file-selector@0.6.0: + resolution: {integrity: sha512-QlZ5yJC0VxHxQQsQhXvBaC7VRJ2uaxTf+Tfpu4Z/OcVQJVpZO+DGU0rkoVW5ce2SccxugvpBJoMvUs59iILYdw==} + engines: {node: '>= 12'} + dependencies: + tslib: 2.7.0 + dev: false + + /fill-range@7.1.1: + resolution: {integrity: sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==} + engines: {node: '>=8'} + dependencies: + to-regex-range: 5.0.1 + dev: true + + /filter-obj@5.1.0: + resolution: {integrity: sha512-qWeTREPoT7I0bifpPUXtxkZJ1XJzxWtfoWWkdVGqa+eCr3SHW/Ocp89o8vLvbUuQnadybJpjOKu4V+RwO6sGng==} + engines: {node: '>=14.16'} + dev: false + + /finalhandler@1.3.1: + resolution: {integrity: sha512-6BN9trH7bp3qvnrRyzsBz+g3lZxTNZTbVO2EV1CS0WIcDbawYVdYvGflME/9QP0h0pYlCDBCTjYa9nZzMDpyxQ==} + engines: {node: '>= 0.8'} + dependencies: + debug: 2.6.9 + encodeurl: 2.0.0 + escape-html: 1.0.3 + on-finished: 2.4.1 + parseurl: 1.3.3 + statuses: 2.0.1 + unpipe: 1.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /find-cache-dir@3.3.2: + resolution: {integrity: sha512-wXZV5emFEjrridIgED11OoUKLxiYjAcqot/NJdAkOhlJ+vGzwhOAfcG5OX1jP+S0PcjEn8bdMJv+g2jwQ3Onig==} + engines: {node: '>=8'} + dependencies: + commondir: 1.0.1 + make-dir: 3.1.0 + pkg-dir: 4.2.0 + dev: true + + /find-root@1.1.0: + resolution: {integrity: sha512-NKfW6bec6GfKc0SGx1e07QZY9PE99u0Bft/0rzSD5k3sO/vwkVUpDUKVm5Gpp5Ue3YfShPFTX2070tDs5kB9Ng==} + dev: false + + /find-up@4.1.0: + resolution: {integrity: sha512-PpOwAdQ/YlXQ2vj8a3h8IipDuYRi3wceVQQGYWxNINccq40Anw7BlsEXCMbt1Zt+OLA6Fq9suIpIWD0OsnISlw==} + engines: {node: '>=8'} + dependencies: + locate-path: 5.0.0 + path-exists: 4.0.0 + dev: true + + /find-up@5.0.0: + resolution: {integrity: sha512-78/PXT1wlLLDgTzDs7sjq9hzz0vXD+zn+7wypEe4fXQxCmdmqfGsEPQxmiCSQI3ajFV91bVSsvNtrJRiW6nGng==} + engines: {node: '>=10'} + dependencies: + locate-path: 6.0.0 + path-exists: 4.0.0 + dev: true + + /flat-cache@3.2.0: + resolution: {integrity: sha512-CYcENa+FtcUKLmhhqyctpclsq7QF38pKjZHsGNiSQF5r4FtoKDWabFDl3hzaEQMvT1LHEysw5twgLvpYYb4vbw==} + engines: {node: ^10.12.0 || >=12.0.0} + dependencies: + flatted: 3.3.1 + keyv: 4.5.4 + rimraf: 3.0.2 + dev: true + + /flatted@3.3.1: + resolution: {integrity: sha512-X8cqMLLie7KsNUDSdzeN8FYK9rEt4Dt67OsG/DNGnYTSDBG4uFAJFBnUeiV+zCVAvwFy56IjM9sH51jVaEhNxw==} + dev: true + + /focus-lock@1.3.5: + resolution: {integrity: sha512-QFaHbhv9WPUeLYBDe/PAuLKJ4Dd9OPvKs9xZBr3yLXnUrDNaVXKu2baDBXe3naPY30hgHYSsf2JW4jzas2mDEQ==} + engines: {node: '>=10'} + dependencies: + tslib: 2.7.0 + dev: false + + /for-each@0.3.3: + resolution: {integrity: sha512-jqYfLp7mo9vIyQf8ykW2v7A+2N4QjeCeI5+Dz9XraiO1ign81wjiH7Fb9vSOWvQfNtmSa4H2RoQTrrXivdUZmw==} + dependencies: + is-callable: 1.2.7 + dev: true + + /foreground-child@3.3.0: + resolution: {integrity: sha512-Ld2g8rrAyMYFXBhEqMz8ZAHBi4J4uS1i/CxGMDnjyFWddMXLVcDp051DZfu+t7+ab7Wv6SMqpWmyFIj5UbfFvg==} + engines: {node: '>=14'} + dependencies: + cross-spawn: 7.0.3 + signal-exit: 4.1.0 + dev: true + + /forwarded@0.2.0: + resolution: {integrity: sha512-buRG0fpBtRHSTCOASe6hD258tEubFoRLb4ZNA6NxMVHNw2gOcwHo9wyablzMzOA5z9xA9L1KNjk/Nt6MT9aYow==} + engines: {node: '>= 0.6'} + dev: true + + /fracturedjsonjs@4.0.2: + resolution: {integrity: sha512-+vGJH9wK0EEhbbn50V2sOebLRaar1VL3EXr02kxchIwpkhQk0ItrPjIOtYPYuU9hNFpVzxjrPgzjtMJih+ae4A==} + dev: false + + /framer-motion@10.18.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-oGlDh1Q1XqYPksuTD/usb0I70hq95OUzmL9+6Zd+Hs4XV0oaISBa/UUMSjYiq6m8EUF32132mOJ8xVZS+I0S6w==} + peerDependencies: + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + react: + optional: true + react-dom: + optional: true + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.7.0 + optionalDependencies: + '@emotion/is-prop-valid': 0.8.8 + dev: false + + /framer-motion@11.10.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-HOEJGdcDBmIipuMpfcrgQfheuRlPM9TySlzaP8WrZVAdBsLzxfeB4XBuFNVc4YO4OTSpac/oAASPJGAPISvVgQ==} + peerDependencies: + '@emotion/is-prop-valid': '*' + react: ^18.0.0 + react-dom: ^18.0.0 + peerDependenciesMeta: + '@emotion/is-prop-valid': + optional: true + react: + optional: true + react-dom: + optional: true + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + tslib: 2.7.0 + dev: false + + /framesync@6.1.2: + resolution: {integrity: sha512-jBTqhX6KaQVDyus8muwZbBeGGP0XgujBRbQ7gM7BRdS3CadCZIHiawyzYLnafYcvZIh5j8WE7cxZKFn7dXhu9g==} + dependencies: + tslib: 2.4.0 + dev: false + + /fresh@0.5.2: + resolution: {integrity: sha512-zJ2mQYM18rEFOudeV4GShTGIQ7RbzA7ozbU9I/XBpm7kqgMywgmylMwXHxZJmkVoYkna9d2pVXVXPdYTP9ej8Q==} + engines: {node: '>= 0.6'} + dev: true + + /fs-extra@11.2.0: + resolution: {integrity: sha512-PmDi3uwK5nFuXh7XDTlVnS17xJS7vW36is2+w3xcv8SVxiB4NyATf4ctkVY5bkSjX0Y4nbvZCq1/EjtEyr9ktw==} + engines: {node: '>=14.14'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 6.1.0 + universalify: 2.0.1 + dev: true + + /fs-extra@7.0.1: + resolution: {integrity: sha512-YJDaCJZEnBmcbw13fvdAM9AwNOJwOzrE4pqMqBq5nFiEqXUqHwlK4B+3pUw6JNvfSPtX05xFHtYy/1ni01eGCw==} + engines: {node: '>=6 <7 || >=8'} + dependencies: + graceful-fs: 4.2.11 + jsonfile: 4.0.0 + universalify: 0.1.2 + dev: true + + /fs.realpath@1.0.0: + resolution: {integrity: sha512-OO0pH2lK6a0hZnAdau5ItzHPI6pUlvI7jMVnxUQRtw4owF2wk8lOSabtGDCTP4Ggrg2MbGnWO9X8K1t4+fGMDw==} + dev: true + + /fsevents@2.3.3: + resolution: {integrity: sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==} + engines: {node: ^8.16.0 || ^10.6.0 || >=11.0.0} + os: [darwin] + requiresBuild: true + dev: true + optional: true + + /function-bind@1.1.2: + resolution: {integrity: sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA==} + + /function.prototype.name@1.1.6: + resolution: {integrity: sha512-Z5kx79swU5P27WEayXM1tBi5Ze/lbIyiNgU3qyXUOf9b2rgXYyF9Dy9Cx+IQv/Lc8WCG6L82zwUPpSS9hGehIg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + functions-have-names: 1.2.3 + dev: true + + /functions-have-names@1.2.3: + resolution: {integrity: sha512-xckBUXyTIqT97tq2x2AMb+g163b5JFysYk0x4qxNFwbfQkmNZoiRHb6sPzI9/QV33WeuvVYBUIiD4NzNIyqaRQ==} + dev: true + + /gensync@1.0.0-beta.2: + resolution: {integrity: sha512-3hN7NaskYvMDLQY55gnW3NQ+mesEAepTqlg+VEbj7zzqEMBVNhzcGYYeqFo/TlYz6eQiFcp1HcsCZO+nGgS8zg==} + engines: {node: '>=6.9.0'} + dev: true + + /get-caller-file@2.0.5: + resolution: {integrity: sha512-DyFP3BM/3YHTQOCUL/w0OZHR0lpKeGrxotcHWcqNEdnltqFwXVfhEBQ94eIo34AfQpo0rGki4cyIiftY06h2Fg==} + engines: {node: 6.* || 8.* || >= 10.*} + dev: true + + /get-func-name@2.0.2: + resolution: {integrity: sha512-8vXOvuE167CtIc3OyItco7N/dpRtBbYOsPsXCz7X/PMnlGjYjSGuZJgM1Y7mmew7BKf9BqvLX2tnOVy1BBUsxQ==} + dev: true + + /get-intrinsic@1.2.4: + resolution: {integrity: sha512-5uYhsJH8VJBTv7oslg4BznJYhDoRI6waYCxMmCdnTrcCrHA/fCFKoTFz2JKKE0HdDFUF7/oQuhzumXJK7paBRQ==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + function-bind: 1.1.2 + has-proto: 1.0.3 + has-symbols: 1.0.3 + hasown: 2.0.2 + + /get-nonce@1.0.1: + resolution: {integrity: sha512-FJhYRoDaiatfEkUK8HKlicmu/3SGFD51q3itKDGoSTysQJBnfOcxU5GxnhE1E6soB76MbT0MBtnKJuXyAx+96Q==} + engines: {node: '>=6'} + dev: false + + /get-stream@8.0.1: + resolution: {integrity: sha512-VaUJspBffn/LMCJVoMvSAdmscJyS1auj5Zulnn5UoYcY531UWmdwhRWkcGKnGU93m5HSXP9LP2usOryrBtQowA==} + engines: {node: '>=16'} + dev: true + + /get-symbol-description@1.0.2: + resolution: {integrity: sha512-g0QYk1dZBxGwk+Ngc+ltRH2IBp2f7zBkBMBJZCDerh6EhlhSR6+9irMCuT/09zD6qkarHUSn529sK/yL4S27mg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + dev: true + + /github-slugger@2.0.0: + resolution: {integrity: sha512-IaOQ9puYtjrkq7Y0Ygl9KDZnrf/aiUJYUpVf89y8kyaxbRG7Y1SrX/jaumrv81vc61+kiMempujsM3Yw7w5qcw==} + dev: true + + /glob-parent@5.1.2: + resolution: {integrity: sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==} + engines: {node: '>= 6'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-parent@6.0.2: + resolution: {integrity: sha512-XxwI8EOhVQgWp6iDL+3b0r86f4d6AX6zSU55HfB4ydCEuXLXc5FcYeOu+nnGftS4TEju/11rt4KJPTMgbfmv4A==} + engines: {node: '>=10.13.0'} + dependencies: + is-glob: 4.0.3 + dev: true + + /glob-promise@4.2.2(glob@7.2.3): + resolution: {integrity: sha512-xcUzJ8NWN5bktoTIX7eOclO1Npxd/dyVqUJxlLIDasT4C7KZyqlPIwkdJ0Ypiy3p2ZKahTjK4M9uC3sNSfNMzw==} + engines: {node: '>=12'} + peerDependencies: + glob: ^7.1.6 + dependencies: + '@types/glob': 7.2.0 + glob: 7.2.3 + dev: true + + /glob@10.4.5: + resolution: {integrity: sha512-7Bv8RF0k6xjo7d4A/PxYLbUCfb6c+Vpd2/mB2yRDlew7Jb5hEXiCD9ibfO7wpk8i4sevK6DFny9h7EYbM3/sHg==} + hasBin: true + dependencies: + foreground-child: 3.3.0 + jackspeak: 3.4.3 + minimatch: 9.0.5 + minipass: 7.1.2 + package-json-from-dist: 1.0.1 + path-scurry: 1.11.1 + dev: true + + /glob@7.2.3: + resolution: {integrity: sha512-nFR0zLpU2YCaRxwoCJvL6UvCH2JFyFVIvwTLsIf21AuHlMskA1hhTdk+LlYJtOlYt9v6dvszD2BGRqBL+iQK9Q==} + deprecated: Glob versions prior to v9 are no longer supported + dependencies: + fs.realpath: 1.0.0 + inflight: 1.0.6 + inherits: 2.0.4 + minimatch: 3.1.2 + once: 1.4.0 + path-is-absolute: 1.0.1 + dev: true + + /globals@11.12.0: + resolution: {integrity: sha512-WOBp/EEGUiIsJSp7wcv/y6MO+lV9UoncWqxuFfm8eBwzWNgyfBd6Gz+IeKQ9jCmyhoH99g15M3T+QaVHFjizVA==} + engines: {node: '>=4'} + + /globals@13.24.0: + resolution: {integrity: sha512-AhO5QUcj8llrbG09iWhPU2B204J1xnPeL8kQmVorSsy+Sjj1sk8gIyh6cUocGmH4L0UuhAJy+hJMRA4mgA4mFQ==} + engines: {node: '>=8'} + dependencies: + type-fest: 0.20.2 + dev: true + + /globalthis@1.0.4: + resolution: {integrity: sha512-DpLKbNU4WylpxJykQujfCcwYWiV/Jhm50Goo0wrVILAv5jOr9d+H+UR3PhSCD2rCCEIg0uc+G+muBTwD54JhDQ==} + engines: {node: '>= 0.4'} + dependencies: + define-properties: 1.2.1 + gopd: 1.0.1 + + /globby@11.1.0: + resolution: {integrity: sha512-jhIXaOzy1sb8IyocaruWSn1TjmnBVs8Ayhcy83rmxNJ8q2uWKCAj3CnJY+KpGSXCueAPc0i05kVvVKtP1t9S3g==} + engines: {node: '>=10'} + dependencies: + array-union: 2.1.0 + dir-glob: 3.0.1 + fast-glob: 3.3.2 + ignore: 5.3.2 + merge2: 1.4.1 + slash: 3.0.0 + dev: true + + /globrex@0.1.2: + resolution: {integrity: sha512-uHJgbwAMwNFf5mLst7IWLNg14x1CkeqglJb/K3doi4dw6q2IvAAmM/Y81kevy83wP+Sst+nutFTYOGg3d1lsxg==} + dev: true + + /gopd@1.0.1: + resolution: {integrity: sha512-d65bNlIadxvpb/A2abVdlqKqV563juRnZ1Wtk6s1sIR8uNsXR70xqIzVqxVf1eTqDunwT2MkczEeaezCKTZhwA==} + dependencies: + get-intrinsic: 1.2.4 + + /graceful-fs@4.2.11: + resolution: {integrity: sha512-RbJ5/jmFcNNCcDV5o9eTnBLJ/HszWV0P73bc+Ff4nS/rJj+YaS6IGyiOL0VoBYX+l1Wrl3k63h/KrH+nhJ0XvQ==} + dev: true + + /graphemer@1.4.0: + resolution: {integrity: sha512-EtKwoO6kxCL9WO5xipiHTZlSzBm7WLT627TqC/uVRd0HKmq8NXyebnNYxDoBi7wt8eTWrUrKXCOVaFq9x1kgag==} + dev: true + + /has-bigints@1.0.2: + resolution: {integrity: sha512-tSvCKtBr9lkF0Ex0aQiP9N+OpV4zi2r/Nee5VkRDbaqv35RLYMzbwQfFSZZH0kR+Rd6302UJZ2p/bJCEoR3VoQ==} + dev: true + + /has-flag@3.0.0: + resolution: {integrity: sha512-sKJf1+ceQBr4SMkvQnBDNDtf4TXpVhVGateu0t918bl30FnbE2m4vNLX+VWe/dpjlb+HugGYzW7uQXH98HPEYw==} + engines: {node: '>=4'} + + /has-flag@4.0.0: + resolution: {integrity: sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ==} + engines: {node: '>=8'} + dev: true + + /has-property-descriptors@1.0.2: + resolution: {integrity: sha512-55JNKuIW+vq4Ke1BjOTjM2YctQIvCT7GFzHwmfZPGo5wnrgkid0YQtnAleFSqumZm4az3n2BS+erby5ipJdgrg==} + dependencies: + es-define-property: 1.0.0 + + /has-proto@1.0.3: + resolution: {integrity: sha512-SJ1amZAJUiZS+PhsVLf5tGydlaVB8EdFpaSO4gmiUKUOxk8qzn5AIy4ZeJUmh22znIdk/uMAUT2pl3FxzVUH+Q==} + engines: {node: '>= 0.4'} + + /has-symbols@1.0.3: + resolution: {integrity: sha512-l3LCuF6MgDNwTDKkdYGEihYjt5pRPbEg46rtlmnSPlUbgmB8LOIrKJbYYFBSbnPaJexMKtiPO8hmeRjRz2Td+A==} + engines: {node: '>= 0.4'} + + /has-tostringtag@1.0.2: + resolution: {integrity: sha512-NqADB8VjPFLM2V0VvHUewwwsw0ZWBaIdgo+ieHtK3hasLz4qeCRjYcqfB6AQrBggRKppKF8L52/VqdVsO47Dlw==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: true + + /hasown@2.0.2: + resolution: {integrity: sha512-0hJU9SCPvmMzIBdZFqNPXWa6dqh7WdH0cII9y+CyS8rG3nL48Bclra9HmKhVVUHyPWNH5Y7xDwAB7bfgSjkUMQ==} + engines: {node: '>= 0.4'} + dependencies: + function-bind: 1.1.2 + + /hast-util-heading-rank@3.0.0: + resolution: {integrity: sha512-EJKb8oMUXVHcWZTDepnr+WNbfnXKFNf9duMesmr4S8SXTJBJ9M4Yok08pu9vxdJwdlGRhVumk9mEhkEvKGifwA==} + dependencies: + '@types/hast': 3.0.4 + dev: true + + /hast-util-is-element@3.0.0: + resolution: {integrity: sha512-Val9mnv2IWpLbNPqc/pUem+a7Ipj2aHacCwgNfTiK0vJKl0LF+4Ba4+v1oPHFpf3bLYmreq0/l3Gud9S5OH42g==} + dependencies: + '@types/hast': 3.0.4 + dev: true + + /hast-util-to-string@3.0.1: + resolution: {integrity: sha512-XelQVTDWvqcl3axRfI0xSeoVKzyIFPwsAGSLIsKdJKQMXDYJS4WYrBNF/8J7RdhIcFI2BOHgAifggsvsxp/3+A==} + dependencies: + '@types/hast': 3.0.4 + dev: true + + /he@1.2.0: + resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} + hasBin: true + dev: true + + /hoist-non-react-statics@3.3.2: + resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + dependencies: + react-is: 16.13.1 + dev: false + + /html-escaper@2.0.2: + resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} + dev: true + + /html-parse-stringify@3.0.1: + resolution: {integrity: sha512-KknJ50kTInJ7qIScF3jeaFRpMpE8/lfiTdzf/twXyPBLAGrLRTmkz3AdTnKeh40X8k9L2fdYwEp/42WGXIRGcg==} + dependencies: + void-elements: 3.1.0 + dev: false + + /html-tags@3.3.1: + resolution: {integrity: sha512-ztqyC3kLto0e9WbNp0aeP+M3kTt+nbaIveGmUxAtZa+8iFgKLUOD4YKM5j+f3QD89bra7UeumolZHKuOXnTmeQ==} + engines: {node: '>=8'} + dev: true + + /http-errors@2.0.0: + resolution: {integrity: sha512-FtwrG/euBzaEjYeRqOgly7G0qviiXoJWnvEH2Z1plBdXgbyjv34pHTSb9zoeHMyDy33+DWy5Wt9Wo+TURtOYSQ==} + engines: {node: '>= 0.8'} + dependencies: + depd: 2.0.0 + inherits: 2.0.4 + setprototypeof: 1.2.0 + statuses: 2.0.1 + toidentifier: 1.0.1 + dev: true + + /https-proxy-agent@7.0.5(supports-color@9.4.0): + resolution: {integrity: sha512-1e4Wqeblerz+tMKPIq2EMGiiWW1dIjZOksyHWSUm1rmuvw/how9hBHZ38lAGj5ID4Ik6EdkOw7NmWPy6LAwalw==} + engines: {node: '>= 14'} + dependencies: + agent-base: 7.1.1(supports-color@9.4.0) + debug: 4.3.7(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + dev: true + + /human-signals@5.0.0: + resolution: {integrity: sha512-AXcZb6vzzrFAUE61HnN4mpLqd/cSIwNQjtNWR0euPm6y0iqx3G4gOXaIDdtdDwZmhwe82LA6+zinmW4UBWVePQ==} + engines: {node: '>=16.17.0'} + dev: true + + /hyphenate-style-name@1.1.0: + resolution: {integrity: sha512-WDC/ui2VVRrz3jOVi+XtjqkDjiVjTtFaAGiW37k6b+ohyQ5wYDOGkvCZa8+H0nx3gyvv0+BST9xuOgIyGQ00gw==} + dev: false + + /i18next-http-backend@2.6.1: + resolution: {integrity: sha512-rCilMAnlEQNeKOZY1+x8wLM5IpYOj10guGvEpeC59tNjj6MMreLIjIW8D1RclhD3ifLwn6d/Y9HEM1RUE6DSog==} + dependencies: + cross-fetch: 4.0.0 + transitivePeerDependencies: + - encoding + dev: false + + /i18next@23.15.1: + resolution: {integrity: sha512-wB4abZ3uK7EWodYisHl/asf8UYEhrI/vj/8aoSsrj/ZDxj4/UXPOa1KvFt1Fq5hkUHquNqwFlDprmjZ8iySgYA==} + dependencies: + '@babel/runtime': 7.25.7 + dev: false + + /iconv-lite@0.4.24: + resolution: {integrity: sha512-v3MXnZAcvnywkTUEZomIActle7RXXeedOR31wwl7VlyoXO4Qi9arvSenNQWne1TcRwhCL1HwLI21bEqdpj8/rA==} + engines: {node: '>=0.10.0'} + dependencies: + safer-buffer: 2.1.2 + dev: true + + /idb-keyval@6.2.1: + resolution: {integrity: sha512-8Sb3veuYCyrZL+VBt9LJfZjLUPWVvqn8tG28VqYNFCo43KHcKuq+b4EiXGeuaLAQWL2YmyDgMp2aSpH9JHsEQg==} + dev: false + + /ieee754@1.2.1: + resolution: {integrity: sha512-dcyqhDvX1C46lXZcVqCpK+FtMRQVdIMN6/Df5js2zouUsqG7I6sFxitIC+7KYK29KdXOLHdu9zL4sFnoVQnqaA==} + dev: true + + /ignore@5.3.2: + resolution: {integrity: sha512-hsBTNUqQTDwkWtcdYI2i06Y/nUBEsNEDJKjWdigLvegy8kDuJAS8uRlpkkcQpyEXL0Z/pjDy5HBmMjRCJ2gq+g==} + engines: {node: '>= 4'} + dev: true + + /immer@10.1.1: + resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} + dev: false + + /import-fresh@3.3.0: + resolution: {integrity: sha512-veYYhQa+D1QBKznvhUHxb8faxlrwUnxseDAbAp457E0wLNio2bOSKnjYDhMj+YiAq61xrMGhQk9iXVk5FzgQMw==} + engines: {node: '>=6'} + dependencies: + parent-module: 1.0.1 + resolve-from: 4.0.0 + + /import-lazy@4.0.0: + resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} + engines: {node: '>=8'} + dev: true + + /imurmurhash@0.1.4: + resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} + engines: {node: '>=0.8.19'} + dev: true + + /indent-string@4.0.0: + resolution: {integrity: sha512-EdDDZu4A2OyIK7Lr/2zG+w5jmbuk1DVBnEwREQvBzspBJkCEbRa8GxU1lghYcaGJCnRWibjDXlq779X1/y5xwg==} + engines: {node: '>=8'} + dev: true + + /index-to-position@0.1.2: + resolution: {integrity: sha512-MWDKS3AS1bGCHLBA2VLImJz42f7bJh8wQsTGCzI3j519/CASStoDONUBVz2I/VID0MpiX3SGSnbOD2xUalbE5g==} + engines: {node: '>=18'} + dev: true + + /inflight@1.0.6: + resolution: {integrity: sha512-k92I/b08q4wvFscXCLvqfsHCrjrF7yiXsQuIVvVE7N82W3+aqpzuUdBbfhWcy/FZR3/4IgflMgKLOsvPDrGCJA==} + deprecated: This module is not supported, and leaks memory. Do not use it. Check out lru-cache if you want a good and tested way to coalesce async requests by a key value, which is much more comprehensive and powerful. + dependencies: + once: 1.4.0 + wrappy: 1.0.2 + dev: true + + /inherits@2.0.4: + resolution: {integrity: sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==} + dev: true + + /inline-style-prefixer@7.0.1: + resolution: {integrity: sha512-lhYo5qNTQp3EvSSp3sRvXMbVQTLrvGV6DycRMJ5dm2BLMiJ30wpXKdDdgX+GmJZ5uQMucwRKHamXSst3Sj/Giw==} + dependencies: + css-in-js-utils: 3.1.0 + dev: false + + /internal-slot@1.0.7: + resolution: {integrity: sha512-NGnrKwXzSms2qUUih/ILZ5JBqNTSa1+ZmP6flaIp6KmSElgE9qdndzS3cqjrDovwFdmwsGsLdeFgB6suw+1e9g==} + engines: {node: '>= 0.4'} + dependencies: + es-errors: 1.3.0 + hasown: 2.0.2 + side-channel: 1.0.6 + dev: true + + /invariant@2.2.4: + resolution: {integrity: sha512-phJfQVBuaJM5raOpJjSfkiD6BpbCE4Ns//LaXl6wGYtUBY83nWS6Rf9tXm2e8VaK60JEjYldbPif/A2B1C2gNA==} + dependencies: + loose-envify: 1.4.0 + dev: false + + /ipaddr.js@1.9.1: + resolution: {integrity: sha512-0KI/607xoxSToH7GjN1FfSbLoU0+btTicjsQSWQlh/hZykN8KpmMf7uYwPW3R+akZ6R/w18ZlXSHBYXiYUPO3g==} + engines: {node: '>= 0.10'} + dev: true + + /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} + dev: true + + /is-arguments@1.1.1: + resolution: {integrity: sha512-8Q7EARjzEnKpt/PCD7e1cgUS0a6X8u5tdSiMqXhojOdoV9TsMsiO+9VLC5vAmO8N7/GmXn7yjR8qnA6bVAEzfA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: true + + /is-array-buffer@3.0.4: + resolution: {integrity: sha512-wcjaerHw0ydZwfhiKbXJWLDY8A7yV7KhjQOpb83hGgGfId/aQa4TOvwyzn2PuswW2gPCYEL/nEAiSVpdOj1lXw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + dev: true + + /is-arrayish@0.2.1: + resolution: {integrity: sha512-zz06S8t0ozoDXMG+ube26zeCTNXcKIPJZJi8hBrF4idCLms4CG9QtK7qBl1boi5ODzFpjswb5JPmHCbMpjaYzg==} + dev: false + + /is-async-function@2.0.0: + resolution: {integrity: sha512-Y1JXKrfykRJGdlDwdKlLpLyMIiWqWvuSd17TvZk68PLAOGOoF4Xyav1z0Xhoi+gCYjZVeC5SI+hYFOfvXmGRCA==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-bigint@1.0.4: + resolution: {integrity: sha512-zB9CruMamjym81i2JZ3UMn54PKGsQzsJeo6xvN3HJJ4CAsQNB6iRutp2To77OfCNuoxspsIhzaPoO1zyCEhFOg==} + dependencies: + has-bigints: 1.0.2 + dev: true + + /is-boolean-object@1.1.2: + resolution: {integrity: sha512-gDYaKHJmnj4aWxyj6YHyXVpdQawtVLHU5cb+eztPGczf6cjuTdwve5ZIEfgXqH4e57An1D1AKf8CZ3kYrQRqYA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: true + + /is-callable@1.2.7: + resolution: {integrity: sha512-1BC0BVFhS/p0qtw6enp8e+8OD0UrK0oFLztSjNzhcKA3WDuJxxAPXzPuPtKkjEY9UUoEWlX/8fgKeu2S8i9JTA==} + engines: {node: '>= 0.4'} + dev: true + + /is-core-module@2.15.1: + resolution: {integrity: sha512-z0vtXSwucUJtANQWldhbtbt7BnL0vxiFjIdDLAatwhDYty2bad6s+rijD6Ri4YuYJubLzIJLUidCh09e1djEVQ==} + engines: {node: '>= 0.4'} + dependencies: + hasown: 2.0.2 + + /is-data-view@1.0.1: + resolution: {integrity: sha512-AHkaJrsUVW6wq6JS8y3JnM/GJF/9cf+k20+iDzlSaJrinEo5+7vRiteOSwBhHRiAyQATN1AmY4hwzxJKPmYf+w==} + engines: {node: '>= 0.4'} + dependencies: + is-typed-array: 1.1.13 + dev: true + + /is-date-object@1.0.5: + resolution: {integrity: sha512-9YQaSxsAiSwcvS33MBk3wTCVnWK+HhF8VZR2jRxehM16QcVOdHqPn4VPHmRK4lSr38n9JriurInLcP90xsYNfQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-docker@2.2.1: + resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} + engines: {node: '>=8'} + hasBin: true + dev: true + + /is-extglob@2.1.1: + resolution: {integrity: sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==} + engines: {node: '>=0.10.0'} + dev: true + + /is-finalizationregistry@1.0.2: + resolution: {integrity: sha512-0by5vtUJs8iFQb5TYUHHPudOR+qXYIMKtiUzvLIZITZUjknFmziyBJuLhVRc+Ds0dREFlskDNJKYIdIzu/9pfw==} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-fullwidth-code-point@3.0.0: + resolution: {integrity: sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg==} + engines: {node: '>=8'} + dev: true + + /is-generator-function@1.0.10: + resolution: {integrity: sha512-jsEjy9l3yiXEQ+PsXdmBwEPcOxaXWLspKdplFUVI9vq1iZgIekeC0L167qeu86czQaxed3q/Uzuw0swL0irL8A==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-glob@4.0.3: + resolution: {integrity: sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==} + engines: {node: '>=0.10.0'} + dependencies: + is-extglob: 2.1.1 + dev: true + + /is-interactive@1.0.0: + resolution: {integrity: sha512-2HvIEKRoqS62guEC+qBjpvRubdX910WCMuJTZ+I9yvqKU2/12eSL549HMwtabb4oupdj2sMP50k+XJfB/8JE6w==} + engines: {node: '>=8'} + dev: true + + /is-map@2.0.3: + resolution: {integrity: sha512-1Qed0/Hr2m+YqxnM09CjA2d/i6YZNfF6R2oRAOj36eUdS6qIV/huPJNSEpKbupewFs+ZsJlxsjjPbc0/afW6Lw==} + engines: {node: '>= 0.4'} + dev: true + + /is-negative-zero@2.0.3: + resolution: {integrity: sha512-5KoIu2Ngpyek75jXodFvnafB6DJgr3u8uuK0LEZJjrU19DrMD3EVERaR8sjz8CCGgpZvxPl9SuE1GMVPFHx1mw==} + engines: {node: '>= 0.4'} + dev: true + + /is-number-object@1.0.7: + resolution: {integrity: sha512-k1U0IRzLMo7ZlYIfzRu23Oh6MiIFasgpb9X76eqfFZAqwH44UI4KTBvBYIZ1dSL9ZzChTB9ShHfLkR4pdW5krQ==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-number@7.0.0: + resolution: {integrity: sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==} + engines: {node: '>=0.12.0'} + dev: true + + /is-path-inside@3.0.3: + resolution: {integrity: sha512-Fd4gABb+ycGAmKou8eMftCupSir5lRxqf4aD/vd0cD2qc4HL07OjCeuHMr8Ro4CoMaeCKDB0/ECBOVWjTwUvPQ==} + engines: {node: '>=8'} + dev: true + + /is-plain-object@5.0.0: + resolution: {integrity: sha512-VRSzKkbMm5jMDoKLbltAkFQ5Qr7VDiTFGXxYFXXowVj387GeGNOCsOH6Msy00SGZ3Fp84b1Naa1psqgcCIEP5Q==} + engines: {node: '>=0.10.0'} + dev: true + + /is-regex@1.1.4: + resolution: {integrity: sha512-kvRdxDsxZjhzUX07ZnLydzS1TU/TJlTUHHY4YLL87e37oUA49DfkLqgy+VjFocowy29cKvcSiu+kIv728jTTVg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + has-tostringtag: 1.0.2 + dev: true + + /is-set@2.0.3: + resolution: {integrity: sha512-iPAjerrse27/ygGLxw+EBR9agv9Y6uLeYVJMu+QNCoouJ1/1ri0mGrcWpfCqFZuzzx3WjtwxG098X+n4OuRkPg==} + engines: {node: '>= 0.4'} + dev: true + + /is-shared-array-buffer@1.0.3: + resolution: {integrity: sha512-nA2hv5XIhLR3uVzDDfCIknerhx8XUKnstuOERPNNIinXG7v9u+ohXF67vxm4TPTEPU6lm61ZkwP3c9PCB97rhg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-stream@3.0.0: + resolution: {integrity: sha512-LnQR4bZ9IADDRSkvpqMGvt/tEJWclzklNgSw48V5EAaAeDd6qGvN8ei6k5p0tvxSR171VmGyHuTiAOfxAbr8kA==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /is-string@1.0.7: + resolution: {integrity: sha512-tE2UXzivje6ofPW7l23cjDOMa09gb7xlAqG6jG5ej6uPV32TlWP3NKPigtaGeHNu9fohccRYvIiZMfOOnOYUtg==} + engines: {node: '>= 0.4'} + dependencies: + has-tostringtag: 1.0.2 + dev: true + + /is-symbol@1.0.4: + resolution: {integrity: sha512-C/CPBqKWnvdcxqIARxyOh4v1UUEOCHpgDa0WYgpKDFMszcrPcffg5uhwSgPCLD2WWxmq6isisz87tzT01tuGhg==} + engines: {node: '>= 0.4'} + dependencies: + has-symbols: 1.0.3 + dev: true + + /is-typed-array@1.1.13: + resolution: {integrity: sha512-uZ25/bUAlUY5fR4OKT4rZQEBrzQWYV9ZJYGGsUmEJ6thodVJ1HX64ePQ6Z0qPWP+m+Uq6e9UugrE38jeYsDSMw==} + engines: {node: '>= 0.4'} + dependencies: + which-typed-array: 1.1.15 + dev: true + + /is-unicode-supported@0.1.0: + resolution: {integrity: sha512-knxG2q4UC3u8stRGyAVJCOdxFmv5DZiRcdlIaAQXAbSfJya+OhopNotLQrstBhququ4ZpuKbDc/8S6mgXgPFPw==} + engines: {node: '>=10'} + dev: true + + /is-weakmap@2.0.2: + resolution: {integrity: sha512-K5pXYOm9wqY1RgjpL3YTkF39tni1XajUIkawTLUo9EZEVUFga5gSQJF8nNS7ZwJQ02y+1YCNYcMh+HIf1ZqE+w==} + engines: {node: '>= 0.4'} + dev: true + + /is-weakref@1.0.2: + resolution: {integrity: sha512-qctsuLZmIQ0+vSSMfoVvyFe2+GSEvnmZ2ezTup1SBse9+twCCeial6EEi3Nc2KFcf6+qz2FBPnjXsk8xhKSaPQ==} + dependencies: + call-bind: 1.0.7 + dev: true + + /is-weakset@2.0.3: + resolution: {integrity: sha512-LvIm3/KWzS9oRFHugab7d+M/GcBXuXX5xZkzPmN+NxihdQlZUQ4dWuSV1xR/sq6upL1TJEDrfBgRepHFdBtSNQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + dev: true + + /is-wsl@2.2.0: + resolution: {integrity: sha512-fKzAra0rGJUUBwGBgNkHZuToZcn+TtXHpeCgmkMJMMYx1sQDYaCSyjJBSCa2nH1DGm7s3n1oBnohoVTBaN7Lww==} + engines: {node: '>=8'} + dependencies: + is-docker: 2.2.1 + dev: true + + /isarray@2.0.5: + resolution: {integrity: sha512-xHjhDr3cNBK0BzdUJSPXZntQUx/mwMS5Rw4A7lPJ90XGAO6ISP/ePDNuo0vhqOZU+UD5JoodwCAAoZQd3FeAKw==} + dev: true + + /isexe@2.0.0: + resolution: {integrity: sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw==} + dev: true + + /istanbul-lib-coverage@3.2.2: + resolution: {integrity: sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg==} + engines: {node: '>=8'} + dev: true + + /istanbul-lib-report@3.0.1: + resolution: {integrity: sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw==} + engines: {node: '>=10'} + dependencies: + istanbul-lib-coverage: 3.2.2 + make-dir: 4.0.0 + supports-color: 7.2.0 + dev: true + + /istanbul-lib-source-maps@5.0.6: + resolution: {integrity: sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A==} + engines: {node: '>=10'} + dependencies: + '@jridgewell/trace-mapping': 0.3.25 + debug: 4.3.7(supports-color@9.4.0) + istanbul-lib-coverage: 3.2.2 + transitivePeerDependencies: + - supports-color + dev: true + + /istanbul-reports@3.1.7: + resolution: {integrity: sha512-BewmUXImeuRk2YY0PVbxgKAysvhRPUQE0h5QRM++nVWyubKGV0l8qQ5op8+B2DOmwSe63Jivj0BjkPQVf8fP5g==} + engines: {node: '>=8'} + dependencies: + html-escaper: 2.0.2 + istanbul-lib-report: 3.0.1 + dev: true + + /iterator.prototype@1.1.2: + resolution: {integrity: sha512-DR33HMMr8EzwuRL8Y9D3u2BMj8+RqSE850jfGu59kS7tbmPLzGkZmVSfyCFSDxuZiEY6Rzt3T2NA/qU+NwVj1w==} + dependencies: + define-properties: 1.2.1 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + reflect.getprototypeof: 1.0.6 + set-function-name: 2.0.2 + dev: true + + /jackspeak@3.4.3: + resolution: {integrity: sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw==} + dependencies: + '@isaacs/cliui': 8.0.2 + optionalDependencies: + '@pkgjs/parseargs': 0.11.0 + dev: true + + /jiti@2.1.1: + resolution: {integrity: sha512-1BRk+NppnvjWLfEqPQtDc3JTs2eiXY9cKBM+VOk5WO+uwWHIuLeWEo3Y1LTqjguKiK9KcLDYA3IdP7gWqcbRig==} + hasBin: true + dev: true + + /jju@1.4.0: + resolution: {integrity: sha512-8wb9Yw966OSxApiCt0K3yNJL8pnNeIv+OEq2YMidz4FKP6nonSRoOXc80iXY4JaN2FC11B9qsNmDsm+ZOfMROA==} + dev: true + + /js-cookie@2.2.1: + resolution: {integrity: sha512-HvdH2LzI/EAZcUwA8+0nKNtWHqS+ZmijLA30RwZA0bo7ToCckjK5MkGhjED9KoRcXO6BaGI3I9UIzSA1FKFPOQ==} + dev: false + + /js-levenshtein@1.1.6: + resolution: {integrity: sha512-X2BB11YZtrRqY4EnQcLX5Rh373zbK4alC1FW7D7MBhL2gtcC17cTnr6DmfHZeS0s2rTHjUTMMHfG7gO8SSdw+g==} + engines: {node: '>=0.10.0'} + dev: true + + /js-tokens@4.0.0: + resolution: {integrity: sha512-RdJUflcE3cUzKiMqQgsCu06FPu9UdIJO0beYbPhHN4k6apgJtifcoCtT9bcxOpYBtpD2kCM6Sbzg4CausW/PKQ==} + + /js-tokens@9.0.0: + resolution: {integrity: sha512-WriZw1luRMlmV3LGJaR6QOJjWwgLUTf89OwT2lUOyjX2dJGBwgmIkbcz+7WFZjrZM635JOIR517++e/67CP9dQ==} + dev: true + + /js-yaml@4.1.0: + resolution: {integrity: sha512-wpxZs9NoxZaJESJGIZTyDEaYpl0FKSA+FB9aJiyemKhMwkxQg63h4T1KJgUGHpTqPDNRcmmYLugrRjJlBtWvRA==} + hasBin: true + dependencies: + argparse: 2.0.1 + dev: true + + /jsdoc-type-pratt-parser@4.1.0: + resolution: {integrity: sha512-Hicd6JK5Njt2QB6XYFS7ok9e37O8AYk3jTcppG4YVQnYjOemymvTcmc7OWsmq/Qqj5TdRFO5/x/tIPmBeRtGHg==} + engines: {node: '>=12.0.0'} + dev: true + + /jsesc@3.0.2: + resolution: {integrity: sha512-xKqzzWXDttJuOcawBt4KnKHHIf5oQ/Cxax+0PWFG+DFDgHNAdi+TXECADI+RYiFUMmx8792xsMbbgXj4CwnP4g==} + engines: {node: '>=6'} + hasBin: true + + /json-buffer@3.0.1: + resolution: {integrity: sha512-4bV5BfR2mqfQTJm+V5tPPdf+ZpuhiIvTuAB5g8kcrXOZpTT/QwwVRWBywX1ozr6lEuPdbHxwaJlm9G6mI2sfSQ==} + dev: true + + /json-parse-even-better-errors@2.3.1: + resolution: {integrity: sha512-xyFwyhro/JEof6Ghe2iz2NcXoj2sloNsWr/XsERDK/oiPCfaNhl5ONfp+jQdAZRQQ0IJWNzH9zIZF7li91kh2w==} + dev: false + + /json-schema-traverse@0.4.1: + resolution: {integrity: sha512-xbbCH5dCYU5T8LcEhhuh7HJ88HXuW3qsI3Y0zOZFKfZEHcpWiHU/Jxzk629Brsab/mMiHQti9wMP+845RPe3Vg==} + dev: true + + /json-schema-traverse@1.0.0: + resolution: {integrity: sha512-NM8/P9n3XjXhIZn1lLhkFaACTOURQXjWhV4BA/RnOv8xvgqtqpAX9IO4mRQxSx1Rlo4tqzeqb0sOlruaOy3dug==} + dev: true + + /json-stable-stringify-without-jsonify@1.0.1: + resolution: {integrity: sha512-Bdboy+l7tA3OGW6FjyFHWkP5LuByj1Tk33Ljyq0axyzdk9//JSi2u3fP1QSmd1KNwq6VOKYGlAu87CisVir6Pw==} + dev: true + + /json5@1.0.2: + resolution: {integrity: sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA==} + hasBin: true + dependencies: + minimist: 1.2.8 + dev: true + + /json5@2.2.3: + resolution: {integrity: sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg==} + engines: {node: '>=6'} + hasBin: true + dev: true + + /jsondiffpatch@0.6.0: + resolution: {integrity: sha512-3QItJOXp2AP1uv7waBkao5nCvhEv+QmJAd38Ybq7wNI74Q+BBmnLn4EDKz6yI9xGAIQoUF87qHt+kc1IVxB4zQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + '@types/diff-match-patch': 1.0.36 + chalk: 5.3.0 + diff-match-patch: 1.0.5 + dev: false + + /jsonfile@4.0.0: + resolution: {integrity: sha512-m6F1R3z8jjlf2imQHS2Qez5sjKWQzbuuhuJ/FKYFRZvPE3PuHcSMVZzfsLhGVOkfd20obL5SWEBew5ShlquNxg==} + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsonfile@6.1.0: + resolution: {integrity: sha512-5dgndWOriYSm5cnYaJNhalLNDKOqFwyDB/rr1E9ZsGciGvKPs8R2xYGCacuf3z6K1YKDz182fd+fY3cn3pMqXQ==} + dependencies: + universalify: 2.0.1 + optionalDependencies: + graceful-fs: 4.2.11 + dev: true + + /jsx-ast-utils@3.3.5: + resolution: {integrity: sha512-ZZow9HBI5O6EPgSJLUb8n2NKgmVWTwCvHGwFuJlMjvLFqlGG6pjirPhtdsseaLZjSibD8eegzmYpUZwoIlj2cQ==} + engines: {node: '>=4.0'} + dependencies: + array-includes: 3.1.8 + array.prototype.flat: 1.3.2 + object.assign: 4.1.5 + object.values: 1.2.0 + dev: true + + /keyv@4.5.4: + resolution: {integrity: sha512-oxVHkHR/EJf2CNXnWxRLW6mg7JyCCUcG0DtEGmL2ctUo1PNTin1PUil+r/+4r5MpVgC/fn1kjsx7mjSujKqIpw==} + dependencies: + json-buffer: 3.0.1 + dev: true + + /knip@5.31.0(@types/node@20.16.10)(typescript@5.6.2): + resolution: {integrity: sha512-4hR+qHx/id7mniCWWUqA4MXwGjYFN75xv3qLmEkl9Hm6eCKAhv0wGP0CyrXKUYxVyDplJQsqQaAlsjuRKYsdPA==} + engines: {node: '>=18.6.0'} + hasBin: true + peerDependencies: + '@types/node': '>=18' + typescript: '>=5.0.4' + dependencies: + '@nodelib/fs.walk': 1.2.8 + '@snyk/github-codeowners': 1.1.0 + '@types/node': 20.16.10 + easy-table: 1.2.0 + enhanced-resolve: 5.17.1 + fast-glob: 3.3.2 + jiti: 2.1.1 + js-yaml: 4.1.0 + minimist: 1.2.8 + picocolors: 1.1.0 + picomatch: 4.0.2 + pretty-ms: 9.1.0 + smol-toml: 1.3.0 + strip-json-comments: 5.0.1 + summary: 2.1.0 + typescript: 5.6.2 + zod: 3.23.8 + zod-validation-error: 3.4.0(zod@3.23.8) + dev: true + + /kolorist@1.8.0: + resolution: {integrity: sha512-Y+60/zizpJ3HRH8DCss+q95yr6145JXZo46OTpFvDZWLfRCE4qChOyk1b26nMaNpfHHgxagk9dXT5OP0Tfe+dQ==} + dev: true + + /konva@9.3.15: + resolution: {integrity: sha512-6jceV1u75a41Fwky7HIg7Xr092sn9g+emE/F4KrkNey9j5IwM/No91z4g13P9kbh0NePzC20YvfyGVS5EzliUA==} + dev: false + + /levn@0.4.1: + resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + type-check: 0.4.0 + dev: true + + /lines-and-columns@1.2.4: + resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} + dev: false + + /liqe@3.8.0: + resolution: {integrity: sha512-cZ1rDx4XzxONBTskSPBp7/KwJ9qbUdF8EPnY4VjKXwHF1Krz9lgnlMTh1G7kd+KtPYvUte1mhuZeQSnk7KiSBg==} + engines: {node: '>=12.0'} + dependencies: + nearley: 2.20.1 + ts-error: 1.0.6 + dev: false + + /load-tsconfig@0.2.5: + resolution: {integrity: sha512-IXO6OCs9yg8tMKzfPZ1YmheJbZCiEsnBdcB03l0OcfK9prKnJb96siuHCr5Fl37/yo9DnKU+TLpxzTUspw9shg==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: true + + /local-pkg@0.5.0: + resolution: {integrity: sha512-ok6z3qlYyCDS4ZEU27HaU6x/xZa9Whf8jD4ptH5UZTQYZVYeb9bnZ3ojVhiJNLiXK1Hfc0GNbLXcmZ5plLDDBg==} + engines: {node: '>=14'} + dependencies: + mlly: 1.7.1 + pkg-types: 1.2.0 + dev: true + + /locate-path@5.0.0: + resolution: {integrity: sha512-t7hw9pI+WvuwNJXwk5zVHpyhIqzg2qTlklJOf0mVxGSbe3Fp2VieZcduNYjaLDoy6p9uGpQEGWG87WpMKlNq8g==} + engines: {node: '>=8'} + dependencies: + p-locate: 4.1.0 + dev: true + + /locate-path@6.0.0: + resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} + engines: {node: '>=10'} + dependencies: + p-locate: 5.0.0 + dev: true + + /lodash-es@4.17.21: + resolution: {integrity: sha512-mKnC+QJ9pWVzv+C4/U3rRsHapFfHvQFoFB92e52xeyGMcX6/OlIl78je1u8vePzYZSkkogMPJ2yjxxsb89cxyw==} + dev: false + + /lodash.get@4.4.2: + resolution: {integrity: sha512-z+Uw/vLuy6gQe8cfaFWD7p0wVv8fJl3mbzXh33RS+0oW2wvUqiRXiQ69gLWSLpgB5/6sU+r6BlQR0MBILadqTQ==} + dev: true + + /lodash.isequal@4.5.0: + resolution: {integrity: sha512-pDo3lu8Jhfjqls6GkMgpahsF9kCyayhgykjyLMNFTKWrpVdAQtYyB4muAMWozBB4ig/dtWAmsMxLEI8wuz+DYQ==} + dev: true + + /lodash.merge@4.6.2: + resolution: {integrity: sha512-0KpjqXRVvrYyCsX1swR/XTK0va6VQkQM6MNo7PqW77ByjAhoARA8EfrP1N4+KlKj8YS0ZUCtRT/YUuhyYDujIQ==} + dev: true + + /lodash.mergewith@4.6.2: + resolution: {integrity: sha512-GK3g5RPZWTRSeLSpgP8Xhra+pnjBC56q9FZYe1d5RN3TJ35dbkGy3YqBSMbyCrlbi+CM9Z3Jk5yTL7RCsqboyQ==} + dev: false + + /lodash@4.17.21: + resolution: {integrity: sha512-v2kDEe57lecTulaDIuNTPy3Ry4gLGJ6Z1O3vE1krgXZNrsQ+LFTGHVxVjcXPs17LhbZVGedAJv8XZ1tvj5FvSg==} + dev: true + + /log-symbols@4.1.0: + resolution: {integrity: sha512-8XPvpAA8uyhfteu8pIvQxpJZ7SYYdpUivZpGy6sFsBuKRY/7rQGavedeB8aK+Zkyq6upMFVL/9AW6vOYzfRyLg==} + engines: {node: '>=10'} + dependencies: + chalk: 4.1.2 + is-unicode-supported: 0.1.0 + dev: true + + /loose-envify@1.4.0: + resolution: {integrity: sha512-lyuxPGr/Wfhrlem2CL/UcnUc1zcqKAImBDzukY7Y5F/yQiNdko6+fRLevlw1HgMySw7f611UIY408EtxRSoK3Q==} + hasBin: true + dependencies: + js-tokens: 4.0.0 + + /loupe@2.3.7: + resolution: {integrity: sha512-zSMINGVYkdpYSOBmLi0D1Uo7JU9nVdQKrHxC8eYlV+9YKK9WePqAlL7lSlorG/U2Fw1w0hTBmaa/jrQ3UbPHtA==} + dependencies: + get-func-name: 2.0.2 + dev: true + + /loupe@3.1.1: + resolution: {integrity: sha512-edNu/8D5MKVfGVFRhFf8aAxiTM6Wumfz5XsaatSxlD3w4R1d/WEKUTydCdPGbl9K7QG/Ca3GnDV2sIKIpXRQcw==} + dependencies: + get-func-name: 2.0.2 + dev: true + + /lru-cache@10.4.3: + resolution: {integrity: sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ==} + dev: true + + /lru-cache@11.0.1: + resolution: {integrity: sha512-CgeuL5uom6j/ZVrg7G/+1IXqRY8JXX4Hghfy5YE0EhoYQWvndP1kufu58cmZLNIDKnRhZrXfdS9urVWx98AipQ==} + engines: {node: 20 || >=22} + dev: false + + /lru-cache@5.1.1: + resolution: {integrity: sha512-KpNARQA3Iwv+jTA0utUVVbrh+Jlrr1Fv0e56GGzAFOXN7dk/FviaDW8LHmK52DlcH4WP2n6gI8vN1aesBFgo9w==} + dependencies: + yallist: 3.1.1 + dev: true + + /lru-cache@6.0.0: + resolution: {integrity: sha512-Jo6dJ04CmSjuznwJSS3pUeWmd/H0ffTlkXXgwZi+eq1UCmqQwCh+eLsYOYCwY991i2Fah4h1BEMCx4qThGbsiA==} + engines: {node: '>=10'} + dependencies: + yallist: 4.0.0 + dev: true + + /lz-string@1.5.0: + resolution: {integrity: sha512-h5bgJWpxJNswbU7qCrV0tIKQCaS3blPDrqKWx+QxzuzL1zGUzij9XCWLrSLsJPu5t+eWA/ycetzYAO5IOMcWAQ==} + hasBin: true + dev: true + + /magic-string@0.27.0: + resolution: {integrity: sha512-8UnnX2PeRAPZuN12svgR9j7M1uWMovg/CEnIwIG0LFkXSJJe4PdfUGiTGl8V9bsBHFUtfVINcSyYxd7q+kx9fA==} + engines: {node: '>=12'} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + dev: true + + /magic-string@0.30.11: + resolution: {integrity: sha512-+Wri9p0QHMy+545hKww7YAu5NyzF8iomPL/RQazugQ9+Ez4Ic3mERMd8ZTX5rfK944j+560ZJi8iAwgak1Ac7A==} + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + dev: true + + /magicast@0.3.5: + resolution: {integrity: sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ==} + dependencies: + '@babel/parser': 7.25.7 + '@babel/types': 7.25.7 + source-map-js: 1.2.1 + dev: true + + /make-dir@3.1.0: + resolution: {integrity: sha512-g3FeP20LNwhALb/6Cz6Dd4F2ngze0jz7tbzrD2wAV+o9FeNHe4rL+yK2md0J/fiSf1sa1ADhXqi5+oVwOM/eGw==} + engines: {node: '>=8'} + dependencies: + semver: 6.3.1 + dev: true + + /make-dir@4.0.0: + resolution: {integrity: sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw==} + engines: {node: '>=10'} + dependencies: + semver: 7.6.3 + dev: true + + /map-or-similar@1.5.0: + resolution: {integrity: sha512-0aF7ZmVon1igznGI4VS30yugpduQW3y3GkcgGJOp7d8x8QrizhigUxjI/m2UojsXXto+jLAH3KSz+xOJTiORjg==} + dev: true + + /markdown-to-jsx@7.5.0(react@18.3.1): + resolution: {integrity: sha512-RrBNcMHiFPcz/iqIj0n3wclzHXjwS7mzjBNWecKKVhNTIxQepIix6Il/wZCn2Cg5Y1ow2Qi84+eJrryFRWBEWw==} + engines: {node: '>= 10'} + peerDependencies: + react: '>= 0.14.0' + dependencies: + react: 18.3.1 + dev: true + + /mdn-data@2.0.14: + resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + dev: false + + /media-typer@0.3.0: + resolution: {integrity: sha512-dq+qelQ9akHpcOl/gUVRTxVIOkAJ1wR3QAvb4RsVjS8oVoFjDGTc679wJYmUmknUF5HwMLOgb5O+a3KxfWapPQ==} + engines: {node: '>= 0.6'} + dev: true + + /memoize-one@6.0.0: + resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} + dev: false + + /memoizerific@1.11.3: + resolution: {integrity: sha512-/EuHYwAPdLtXwAwSZkh/Gutery6pD2KYd44oQLhAvQp/50mpyduZh8Q7PYHXTCJ+wuXxt7oij2LXyIJOOYFPog==} + dependencies: + map-or-similar: 1.5.0 + dev: true + + /merge-descriptors@1.0.3: + resolution: {integrity: sha512-gaNvAS7TZ897/rVaZ0nMtAyxNyi/pdbjbAwUpFQpN70GqnVfOiXpeUUMKRBmzXaSQ8DdTX4/0ms62r2K+hE6mQ==} + dev: true + + /merge-stream@2.0.0: + resolution: {integrity: sha512-abv/qOcuPfk3URPfDzmZU1LKmuw8kT+0nIHvKrKgFrwifol/doWcdA4ZqsWQ8ENrFKkd67Mfpo/LovbIUsbt3w==} + dev: true + + /merge2@1.4.1: + resolution: {integrity: sha512-8q7VEgMJW4J8tcfVPy8g09NcQwZdbwFEqhe/WZkoIzjn/3TGDwtOCYtXGxA3O8tPzpczCCDgv+P2P5y00ZJOOg==} + engines: {node: '>= 8'} + dev: true + + /methods@1.1.2: + resolution: {integrity: sha512-iclAHeNqNm68zFtnZ0e+1L2yUIdvzNoauKU4WBA3VvH/vPFieF7qfRlwUZU+DA9P9bPXIS90ulxoUoCH23sV2w==} + engines: {node: '>= 0.6'} + dev: true + + /micromatch@4.0.8: + resolution: {integrity: sha512-PXwfBhYu0hBCPw8Dn0E+WDYb7af3dSLVWKi3HGv84IdF4TyFoC0ysxFd0Goxw7nSv4T/PzEJQxsYsEiFCKo2BA==} + engines: {node: '>=8.6'} + dependencies: + braces: 3.0.3 + picomatch: 2.3.1 + dev: true + + /mime-db@1.52.0: + resolution: {integrity: sha512-sPU4uV7dYlvtWJxwwxHD0PuihVNiE7TyAbQ5SWxDCB9mUYvOgroQOwYQQOKPJ8CIbE+1ETVlOoK1UC2nU3gYvg==} + engines: {node: '>= 0.6'} + dev: true + + /mime-types@2.1.35: + resolution: {integrity: sha512-ZDY+bPm5zTTF+YpCrAU9nK0UgICYPT0QtT1NZWFv4s++TNkcgVaT0g6+4R2uI4MjQjzysHB1zxuWL50hzaeXiw==} + engines: {node: '>= 0.6'} + dependencies: + mime-db: 1.52.0 + dev: true + + /mime@1.6.0: + resolution: {integrity: sha512-x0Vn8spI+wuJ1O6S7gnbaQg8Pxh4NNHb7KSINmEWKiPE4RKOplvijn+NkmYmmRgP68mc70j2EbeTFRsrswaQeg==} + engines: {node: '>=4'} + hasBin: true + dev: true + + /mimic-fn@2.1.0: + resolution: {integrity: sha512-OqbOk5oEQeAZ8WXWydlu9HJjz9WVdEIvamMCcXmuqUYjTknH/sqsWvhQ3vgwKFRR1HpjvNBKQ37nbJgYzGqGcg==} + engines: {node: '>=6'} + dev: true + + /mimic-fn@4.0.0: + resolution: {integrity: sha512-vqiC06CuhBTUdZH+RYl8sFrL096vA45Ok5ISO6sE/Mr1jRbGH4Csnhi8f3wKVl7x8mO4Au7Ir9D3Oyv1VYMFJw==} + engines: {node: '>=12'} + dev: true + + /min-indent@1.0.1: + resolution: {integrity: sha512-I9jwMn07Sy/IwOj3zVkVik2JTvgpaykDZEigL6Rx6N9LbMywwUSMtxET+7lVoDLLd3O3IXwJwvuuns8UB/HeAg==} + engines: {node: '>=4'} + dev: true + + /minimatch@3.0.8: + resolution: {integrity: sha512-6FsRAQsxQ61mw+qP1ZzbL9Bc78x2p5OqNgNpnoAFLTrX8n5Kxph0CsnhmKKNXTWjXqU5L0pGPR7hYk+XWZr60Q==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@3.1.2: + resolution: {integrity: sha512-J7p63hRiAjw1NDEww1W7i37+ByIrOWO5XQQAzZ3VOcL0PNybwpfmV/N05zFAzwQ9USyEcX6t3UO+K5aqBQOIHw==} + dependencies: + brace-expansion: 1.1.11 + dev: true + + /minimatch@5.1.6: + resolution: {integrity: sha512-lKwV/1brpG6mBUFHtb7NUmtABCb2WZZmm2wNiOA5hAb8VdCS4B3dtMWyvcoViccwAW/COERjXLt0zP1zXUN26g==} + engines: {node: '>=10'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimatch@9.0.5: + resolution: {integrity: sha512-G6T0ZX48xgozx7587koeX9Ys2NYy6Gmv//P89sEte9V9whIapMNF4idKxnW2QtCcLiTWlb/wfCabAtAFWhhBow==} + engines: {node: '>=16 || 14 >=14.17'} + dependencies: + brace-expansion: 2.0.1 + dev: true + + /minimist@1.2.8: + resolution: {integrity: sha512-2yyAR8qBkN3YuheJanUpWC5U3bb5osDywNB8RzDVlDwDHbocAJveqqj1u8+SVD7jkWT4yvsHCpWqqWqAxb0zCA==} + dev: true + + /minipass@7.1.2: + resolution: {integrity: sha512-qOOzS1cBTWYF4BH8fVePDBOO9iptMnGUEZwNc/cMWnTV2nVLZ7VoNWEPHkYczZA0pdoA7dl6e7FL659nX9S2aw==} + engines: {node: '>=16 || 14 >=14.17'} + dev: true + + /mlly@1.7.1: + resolution: {integrity: sha512-rrVRZRELyQzrIUAVMHxP97kv+G786pHmOKzuFII8zDYahFBS7qnHh2AlYSl1GAHhaMPCz6/oHjVMcfFYgFYHgA==} + dependencies: + acorn: 8.12.1 + pathe: 1.1.2 + pkg-types: 1.2.0 + ufo: 1.5.4 + dev: true + + /moo@0.5.2: + resolution: {integrity: sha512-iSAJLHYKnX41mKcJKjqvnAN9sf0LMDTXDEvFv+ffuRR9a1MIuXLjMNL6EsnDHSkKLTWNqQQ5uo61P4EbU4NU+Q==} + dev: false + + /mrmime@2.0.0: + resolution: {integrity: sha512-eu38+hdgojoyq63s+yTpN4XMBdt5l8HhMhc4VKLO9KM5caLIBvUm4thi7fFaxyTmCKeNnXZ5pAlBwCUnhA09uw==} + engines: {node: '>=10'} + dev: true + + /ms@2.0.0: + resolution: {integrity: sha512-Tpp60P6IUJDTuOq/5Z8cdskzJujfwqfOTkrwIwj7IRISpnkJnT6SyJ4PCPnGMoFjC9ddhal5KVIYtAt97ix05A==} + dev: true + + /ms@2.1.3: + resolution: {integrity: sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA==} + + /muggle-string@0.3.1: + resolution: {integrity: sha512-ckmWDJjphvd/FvZawgygcUeQCxzvohjFO5RxTjj4eq8kw359gFF3E1brjfI+viLMxss5JrHTDRHZvu2/tuy0Qg==} + dev: true + + /nano-css@5.6.2(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + '@jridgewell/sourcemap-codec': 1.5.0 + css-tree: 1.1.3 + csstype: 3.1.3 + fastest-stable-stringify: 2.0.2 + inline-style-prefixer: 7.0.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + rtl-css-js: 1.16.1 + stacktrace-js: 2.0.2 + stylis: 4.3.4 + dev: false + + /nanoid@3.3.7: + resolution: {integrity: sha512-eSRppjcPIatRIMC1U6UngP8XFcz8MQWGQdt1MTBQ7NaAmvXDfvNxbvWV3x2y6CdEUciCSsDHDQZbhYaB8QEo2g==} + engines: {node: ^10 || ^12 || ^13.7 || ^14 || >=15.0.1} + hasBin: true + dev: true + + /nanoid@5.0.7: + resolution: {integrity: sha512-oLxFY2gd2IqnjcYyOXD8XGCftpGtZP2AbHbOkthDkvRywH5ayNtPVy9YlOPcHckXzbLTCHpkb7FB+yuxKV13pQ==} + engines: {node: ^18 || >=20} + hasBin: true + dev: false + + /nanostores@0.11.3: + resolution: {integrity: sha512-TUes3xKIX33re4QzdxwZ6tdbodjmn3tWXCEc1uokiEmo14sI1EaGYNs2k3bU2pyyGNmBqFGAVl6jAGWd06AVIg==} + engines: {node: ^18.0.0 || >=20.0.0} + dev: false + + /natural-compare@1.4.0: + resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} + dev: true + + /nearley@2.20.1: + resolution: {integrity: sha512-+Mc8UaAebFzgV+KpI5n7DasuuQCHA89dmwm7JXw3TV43ukfNQ9DnBH3Mdb2g/I4Fdxc26pwimBWvjIw0UAILSQ==} + hasBin: true + dependencies: + commander: 2.20.3 + moo: 0.5.2 + railroad-diagrams: 1.0.0 + randexp: 0.4.6 + dev: false + + /negotiator@0.6.3: + resolution: {integrity: sha512-+EUsqGPLsM+j/zdChZjsnX51g4XrHFOIXwfnCVPGlQk/k5giakcKsuxCObBRu6DSm9opw/O6slWbJdghQM4bBg==} + engines: {node: '>= 0.6'} + dev: true + + /new-github-issue-url@1.0.0: + resolution: {integrity: sha512-wa9jlUFg3v6S3ddijQiB18SY4u9eJYcUe5sHa+6SB8m1UUbtX+H/bBglxOLnhhF1zIHuhWXnKBAa8kBeKRIozQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dev: false + + /node-fetch@2.7.0: + resolution: {integrity: sha512-c4FRfUm/dbcWZ7U+1Wq0AwCyFL+3nt2bEw05wfxSz+DWpWsitgmSgYmy2dQdWyKC1694ELPqMs/YzUSNozLt8A==} + engines: {node: 4.x || >=6.0.0} + peerDependencies: + encoding: ^0.1.0 + peerDependenciesMeta: + encoding: + optional: true + dependencies: + whatwg-url: 5.0.0 + + /node-releases@2.0.18: + resolution: {integrity: sha512-d9VeXT4SJ7ZeOqGX6R5EM022wpL+eWPooLI+5UpWn2jCT1aosUQEhQP214x33Wkwx3JQMvIm+tIoVOdodFS40g==} + dev: true + + /npm-run-path@5.3.0: + resolution: {integrity: sha512-ppwTtiJZq0O/ai0z7yfudtBpWIoxM8yE6nHi1X47eFR2EWORqfbu6CnPlNsjeN683eT0qG6H/Pyf9fCcvjnnnQ==} + engines: {node: ^12.20.0 || ^14.13.1 || >=16.0.0} + dependencies: + path-key: 4.0.0 + dev: true + + /object-assign@4.1.1: + resolution: {integrity: sha512-rJgTQnkUnH1sFw8yT6VSU3zD3sWmu6sZhIseY8VX+GRu3P6F7Fu+JNDoXfklElbLJSnc3FUQHVe4cU5hj+BcUg==} + engines: {node: '>=0.10.0'} + + /object-inspect@1.13.2: + resolution: {integrity: sha512-IRZSRuzJiynemAXPYtPe5BoI/RESNYR7TYm50MC5Mqbd3Jmw5y790sErYw3V6SryFJD64b74qQQs9wn5Bg/k3g==} + engines: {node: '>= 0.4'} + dev: true + + /object-keys@1.1.1: + resolution: {integrity: sha512-NuAESUOUMrlIXOfHKzD6bpPu3tYt3xvjNdRIQ+FeT0lNb4K8WR70CaDxhuNguS2XG+GjkyMwOzsN5ZktImfhLA==} + engines: {node: '>= 0.4'} + + /object.assign@4.1.5: + resolution: {integrity: sha512-byy+U7gp+FVwmyzKPYhW2h5l3crpmGsxl7X2s8y43IgxvG4g3QZ6CffDtsNQy1WsmZpQbO+ybo0AlW7TY6DcBQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + has-symbols: 1.0.3 + object-keys: 1.1.1 + dev: true + + /object.entries@1.1.8: + resolution: {integrity: sha512-cmopxi8VwRIAw/fkijJohSfpef5PdN0pMQJN6VC/ZKvn0LIknWD8KtgY6KlQdEc4tIjcQ3HxSMmnvtzIscdaYQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /object.fromentries@2.0.8: + resolution: {integrity: sha512-k6E21FzySsSK5a21KRADBd/NGneRegFO5pLHfdQLpRDETUNJueLXs3WCzyQ3tFRDYgbq3KHGXfTbi2bs8WQ6rQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + dev: true + + /object.groupby@1.0.3: + resolution: {integrity: sha512-+Lhy3TQTuzXI5hevh8sBGqbmurHbbIjAi0Z4S63nthVLmLxfbj4T54a4CfZrXIrt9iP4mVAPYMo/v99taj3wjQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + dev: true + + /object.values@1.2.0: + resolution: {integrity: sha512-yBYjY9QX2hnRmZHAjG/f13MzmBzxzYgQhFrke06TTyKY5zSTEqkOeukBzIdVA3j3ulu8Qa3MbVFShV7T2RmGtQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /on-finished@2.4.1: + resolution: {integrity: sha512-oVlzkg3ENAhCk2zdv7IJwd/QUD4z2RxRwpkcGY8psCVcCYZNq4wYnVWALHM+brtuJjePWiYF/ClmuDr8Ch5+kg==} + engines: {node: '>= 0.8'} + dependencies: + ee-first: 1.1.1 + dev: true + + /once@1.4.0: + resolution: {integrity: sha512-lNaJgI+2Q5URQBkccEKHTQOPaXdUxnZZElQTZY0MFUAuaEqe1E+Nyvgdz/aIyNi6Z9MzO5dv1H8n58/GELp3+w==} + dependencies: + wrappy: 1.0.2 + dev: true + + /onetime@5.1.2: + resolution: {integrity: sha512-kbpaSSGJTWdAY5KPVeMOKXSrPtr8C8C7wodJbcsd51jRnmD+GZu8Y0VoU6Dm5Z4vWr0Ig/1NKuWRKf7j5aaYSg==} + engines: {node: '>=6'} + dependencies: + mimic-fn: 2.1.0 + dev: true + + /onetime@6.0.0: + resolution: {integrity: sha512-1FlR+gjXK7X+AsAHso35MnyN5KqGwJRi/31ft6x0M194ht7S+rWAvd7PHss9xSKMzE0asv1pyIHaJYq+BbacAQ==} + engines: {node: '>=12'} + dependencies: + mimic-fn: 4.0.0 + dev: true + + /open@8.4.2: + resolution: {integrity: sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ==} + engines: {node: '>=12'} + dependencies: + define-lazy-prop: 2.0.0 + is-docker: 2.2.1 + is-wsl: 2.2.0 + dev: true + + /openapi-types@12.1.3: + resolution: {integrity: sha512-N4YtSYJqghVu4iek2ZUvcN/0aqH1kRDuNqzcycDxhOUpg7GdvLa2F3DgS6yBNhInhv2r/6I0Flkn7CqL8+nIcw==} + dev: true + + /openapi-typescript@7.4.1(typescript@5.6.2): + resolution: {integrity: sha512-HrRoWveViADezHCNgQqZmPKmQ74q7nuH/yg9ursFucZaYQNUqsX38fE/V2sKBHVM+pws4tAHpuh/ext2UJ/AoQ==} + hasBin: true + peerDependencies: + typescript: ^5.x + dependencies: + '@redocly/openapi-core': 1.25.4(supports-color@9.4.0) + ansi-colors: 4.1.3 + change-case: 5.4.4 + parse-json: 8.1.0 + supports-color: 9.4.0 + typescript: 5.6.2 + yargs-parser: 21.1.1 + transitivePeerDependencies: + - encoding + dev: true + + /optionator@0.9.4: + resolution: {integrity: sha512-6IpQ7mKUxRcZNLIObR0hz7lxsapSSIYNZJwXPGeF0mTVqGKFIXj1DQcMoT22S3ROcLyY/rz0PWaWZ9ayWmad9g==} + engines: {node: '>= 0.8.0'} + dependencies: + deep-is: 0.1.4 + fast-levenshtein: 2.0.6 + levn: 0.4.1 + prelude-ls: 1.2.1 + type-check: 0.4.0 + word-wrap: 1.2.5 + dev: true + + /ora@5.4.1: + resolution: {integrity: sha512-5b6Y85tPxZZ7QytO+BQzysW31HJku27cRIlkbAXaNx+BdcVi+LlRFmVXzeF6a7JCwJpyw5c4b+YSVImQIrBpuQ==} + engines: {node: '>=10'} + dependencies: + bl: 4.1.0 + chalk: 4.1.2 + cli-cursor: 3.1.0 + cli-spinners: 2.9.2 + is-interactive: 1.0.0 + is-unicode-supported: 0.1.0 + log-symbols: 4.1.0 + strip-ansi: 6.0.1 + wcwidth: 1.0.1 + dev: true + + /overlayscrollbars-react@0.5.6(overlayscrollbars@2.10.0)(react@18.3.1): + resolution: {integrity: sha512-E5To04bL5brn9GVCZ36SnfGanxa2I2MDkWoa4Cjo5wol7l+diAgi4DBc983V7l2nOk/OLJ6Feg4kySspQEGDBw==} + peerDependencies: + overlayscrollbars: ^2.0.0 + react: '>=16.8.0' + dependencies: + overlayscrollbars: 2.10.0 + react: 18.3.1 + dev: false + + /overlayscrollbars@2.10.0: + resolution: {integrity: sha512-diNMeEafWTE0A4GJfwRpdBp2rE/BEvrhptBdBcDu8/UeytWcdCy9Td8tZWnztJeJ26f8/uHCWfPnPUC/dtgJdw==} + dev: false + + /p-limit@2.3.0: + resolution: {integrity: sha512-//88mFWSJx8lxCzwdAABTJL2MyWB12+eIY7MDL2SqLmAkeKU9qxRvWuSyTjm3FUmpBEMuFfckAIqEaVGUDxb6w==} + engines: {node: '>=6'} + dependencies: + p-try: 2.2.0 + dev: true + + /p-limit@3.1.0: + resolution: {integrity: sha512-TYOanM3wGwNGsZN2cVTYPArw454xnXj5qmWF1bEoAc4+cU/ol7GVh7odevjp1FNHduHc3KZMcFduxU5Xc6uJRQ==} + engines: {node: '>=10'} + dependencies: + yocto-queue: 0.1.0 + dev: true + + /p-limit@5.0.0: + resolution: {integrity: sha512-/Eaoq+QyLSiXQ4lyYV23f14mZRQcXnxfHrN0vCai+ak9G0pp9iEQukIIZq5NccEvwRB8PUnZT0KsOoDCINS1qQ==} + engines: {node: '>=18'} + dependencies: + yocto-queue: 1.1.1 + dev: true + + /p-locate@4.1.0: + resolution: {integrity: sha512-R79ZZ/0wAxKGu3oYMlz8jy/kbhsNrS7SKZ7PxEHBgJ5+F2mtFW2fK2cOtBh1cHYkQsbzFV7I+EoRKe6Yt0oK7A==} + engines: {node: '>=8'} + dependencies: + p-limit: 2.3.0 + dev: true + + /p-locate@5.0.0: + resolution: {integrity: sha512-LaNjtRWUBY++zB5nE/NwcaoMylSPk+S+ZHNB1TzdbMJMny6dynpAGt7X/tl/QYq3TIeE6nxHppbo2LGymrG5Pw==} + engines: {node: '>=10'} + dependencies: + p-limit: 3.1.0 + dev: true + + /p-map@4.0.0: + resolution: {integrity: sha512-/bjOqmgETBYB5BoEeGVea8dmvHb2m9GLy1E9W43yeyfP6QQCZGFNa+XRceJEuDB6zqr+gKpIAmlLebMpykw/MQ==} + engines: {node: '>=10'} + dependencies: + aggregate-error: 3.1.0 + dev: true + + /p-try@2.2.0: + resolution: {integrity: sha512-R4nPAVTAU0B9D35/Gk3uJf/7XYbQcyohSKdvAxIRSNghFl4e71hVoGnBNQz9cWaXxO2I10KTC+3jMdvvoKw6dQ==} + engines: {node: '>=6'} + dev: true + + /package-json-from-dist@1.0.1: + resolution: {integrity: sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw==} + dev: true + + /parent-module@1.0.1: + resolution: {integrity: sha512-GQ2EWRpQV8/o+Aw8YqtfZZPfNRWZYkbidE9k5rpl/hC3vtHHBfGm2Ifi6qWV+coDGkrUKZAxE3Lot5kcsRlh+g==} + engines: {node: '>=6'} + dependencies: + callsites: 3.1.0 + + /parse-json@5.2.0: + resolution: {integrity: sha512-ayCKvm/phCGxOkYRSCM82iDwct8/EonSEgCSxWxD7ve6jHggsFl4fZVQBPRNgQoKiuV/odhFrGzQXZwbifC8Rg==} + engines: {node: '>=8'} + dependencies: + '@babel/code-frame': 7.25.7 + error-ex: 1.3.2 + json-parse-even-better-errors: 2.3.1 + lines-and-columns: 1.2.4 + dev: false + + /parse-json@8.1.0: + resolution: {integrity: sha512-rum1bPifK5SSar35Z6EKZuYPJx85pkNaFrxBK3mwdfSJ1/WKbYrjoW/zTPSjRRamfmVX1ACBIdFAO0VRErW/EA==} + engines: {node: '>=18'} + dependencies: + '@babel/code-frame': 7.25.7 + index-to-position: 0.1.2 + type-fest: 4.26.1 + dev: true + + /parse-ms@4.0.0: + resolution: {integrity: sha512-TXfryirbmq34y8QBwgqCVLi+8oA3oWx2eAnSn62ITyEhEYaWRlVZ2DvMM9eZbMs/RfxPu/PK/aBLyGj4IrqMHw==} + engines: {node: '>=18'} + dev: true + + /parseurl@1.3.3: + resolution: {integrity: sha512-CiyeOxFT/JZyN5m0z9PfXw4SCBJ6Sygz1Dpl0wqjlhDEGGBP1GnsUVEL0p63hoG1fcj3fHynXi9NYO4nWOL+qQ==} + engines: {node: '>= 0.8'} + dev: true + + /path-browserify@1.0.1: + resolution: {integrity: sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g==} + dev: true + + /path-exists@4.0.0: + resolution: {integrity: sha512-ak9Qy5Q7jYb2Wwcey5Fpvg2KoAc/ZIhLSLOSBmRmygPsGwkVVt0fZa0qrtMz+m6tJTAHfZQ8FnmB4MG4LWy7/w==} + engines: {node: '>=8'} + dev: true + + /path-is-absolute@1.0.1: + resolution: {integrity: sha512-AVbw3UJ2e9bq64vSaS9Am0fje1Pa8pbGqTTsmXfaIiMpnr5DlDhfJOuLj9Sf95ZPVDAUerDfEk88MPmPe7UCQg==} + engines: {node: '>=0.10.0'} + dev: true + + /path-key@3.1.1: + resolution: {integrity: sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q==} + engines: {node: '>=8'} + dev: true + + /path-key@4.0.0: + resolution: {integrity: sha512-haREypq7xkM7ErfgIyA0z+Bj4AGKlMSdlQE2jvJo6huWD1EdkKYV+G/T4nq0YEF2vgTT8kqMFKo1uHn950r4SQ==} + engines: {node: '>=12'} + dev: true + + /path-parse@1.0.7: + resolution: {integrity: sha512-LDJzPVEEEPR+y48z93A0Ed0yXb8pAByGWo/k5YYdYgpY2/2EsOsksJrq7lOHxryrVOn1ejG6oAp8ahvOIQD8sw==} + + /path-scurry@1.11.1: + resolution: {integrity: sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA==} + engines: {node: '>=16 || 14 >=14.18'} + dependencies: + lru-cache: 10.4.3 + minipass: 7.1.2 + dev: true + + /path-to-regexp@0.1.10: + resolution: {integrity: sha512-7lf7qcQidTku0Gu3YDPc8DJ1q7OOucfa/BSsIwjuh56VU7katFvuM8hULfkwB3Fns/rsVF7PwPKVw1sl5KQS9w==} + dev: true + + /path-type@4.0.0: + resolution: {integrity: sha512-gDKb8aZMDeD/tZWs9P6+q0J9Mwkdl6xMV8TjnGP3qJVJ06bdMgkbBlLU8IdfOsIsFz2BW1rNVT3XuNEl8zPAvw==} + engines: {node: '>=8'} + + /pathe@1.1.2: + resolution: {integrity: sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ==} + dev: true + + /pathval@1.1.1: + resolution: {integrity: sha512-Dp6zGqpTdETdR63lehJYPeIOqpiNBNtc7BpWSLrOje7UaIsE5aY92r/AunQA7rsXvet3lrJ3JnZX29UPTKXyKQ==} + dev: true + + /pathval@2.0.0: + resolution: {integrity: sha512-vE7JKRyES09KiunauX7nd2Q9/L7lhok4smP9RZTDeD4MVs72Dp2qNFVz39Nz5a0FVEW0BJR6C0DYrq6unoziZA==} + engines: {node: '>= 14.16'} + dev: true + + /perfect-freehand@1.2.2: + resolution: {integrity: sha512-eh31l019WICQ03pkF3FSzHxB8n07ItqIQ++G5UV8JX0zVOXzgTGCqnRR0jJ2h9U8/2uW4W4mtGJELt9kEV0CFQ==} + dev: false + + /picocolors@1.1.0: + resolution: {integrity: sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==} + + /picomatch@2.3.1: + resolution: {integrity: sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==} + engines: {node: '>=8.6'} + dev: true + + /picomatch@4.0.2: + resolution: {integrity: sha512-M7BAV6Rlcy5u+m6oPhAPFgJTzAioX/6B0DxyvDlo9l8+T3nLKbrczg2WLUyzd45L8RqfUMyGPzekbMvX2Ldkwg==} + engines: {node: '>=12'} + dev: true + + /pkg-dir@4.2.0: + resolution: {integrity: sha512-HRDzbaKjC+AOWVXxAU/x54COGeIv9eb+6CkDSQoNTt4XyWoIJvuPsXizxu/Fr23EiekbtZwmh1IcIG/l/a10GQ==} + engines: {node: '>=8'} + dependencies: + find-up: 4.1.0 + dev: true + + /pkg-types@1.2.0: + resolution: {integrity: sha512-+ifYuSSqOQ8CqP4MbZA5hDpb97n3E8SVWdJe+Wms9kj745lmd3b7EZJiqvmLwAlmRfjrI7Hi5z3kdBJ93lFNPA==} + dependencies: + confbox: 0.1.7 + mlly: 1.7.1 + pathe: 1.1.2 + dev: true + + /pluralize@8.0.0: + resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} + engines: {node: '>=4'} + dev: true + + /polished@4.3.1: + resolution: {integrity: sha512-OBatVyC/N7SCW/FaDHrSd+vn0o5cS855TOmYi4OkdWUMSJCET/xip//ch8xGUvtr3i44X9LVyWwQlRMTN3pwSA==} + engines: {node: '>=10'} + dependencies: + '@babel/runtime': 7.25.7 + dev: true + + /possible-typed-array-names@1.0.0: + resolution: {integrity: sha512-d7Uw+eZoloe0EHDIYoe+bQ5WXnGMOpmiZFTuMWCwpjzzkL2nTjcKiAk4hh8TjnGye2TwWOk3UXucZ+3rbmBa8Q==} + engines: {node: '>= 0.4'} + dev: true + + /postcss@8.4.47: + resolution: {integrity: sha512-56rxCq7G/XfB4EkXq9Egn5GCqugWvDFjafDOThIdMBsI15iqPqR5r15TfSr1YPYeEI19YeaXMCbY6u88Y76GLQ==} + engines: {node: ^10 || ^12 || >=14} + dependencies: + nanoid: 3.3.7 + picocolors: 1.1.0 + source-map-js: 1.2.1 + dev: true + + /prelude-ls@1.2.1: + resolution: {integrity: sha512-vkcDPrRZo1QZLbn5RLGPpg/WmIQ65qoWWhcGKf/b5eplkkarX0m9z8ppCat4mlOqUsWpyNuYgO3VRyrYHSzX5g==} + engines: {node: '>= 0.8.0'} + dev: true + + /prettier@3.3.3: + resolution: {integrity: sha512-i2tDNA0O5IrMO757lfrdQZCc2jPNDVntV0m/+4whiDfWaTKfMNgR7Qz0NAeGz/nRqF4m5/6CLzbP4/liHt12Ew==} + engines: {node: '>=14'} + hasBin: true + dev: true + + /pretty-format@27.5.1: + resolution: {integrity: sha512-Qb1gy5OrP5+zDf2Bvnzdl3jsTf1qXVMazbvCoKhtKqVs4/YK4ozX4gKQJJVyNe+cajNPn0KoC0MC3FUmaHWEmQ==} + engines: {node: ^10.13.0 || ^12.13.0 || ^14.15.0 || >=15.0.0} + dependencies: + ansi-regex: 5.0.1 + ansi-styles: 5.2.0 + react-is: 17.0.2 + dev: true + + /pretty-format@29.7.0: + resolution: {integrity: sha512-Pdlw/oPxN+aXdmM9R00JVC9WVFoCLTKJvDVLgmJ+qAffBMxsV85l/Lu7sNx4zSzPyoL2euImuEwHhOXdEgNFZQ==} + engines: {node: ^14.15.0 || ^16.10.0 || >=18.0.0} + dependencies: + '@jest/schemas': 29.6.3 + ansi-styles: 5.2.0 + react-is: 18.3.1 + dev: true + + /pretty-ms@9.1.0: + resolution: {integrity: sha512-o1piW0n3tgKIKCwk2vpM/vOV13zjJzvP37Ioze54YlTHE06m4tjEbzg9WsKkvTuyYln2DHjo5pY4qrZGI0otpw==} + engines: {node: '>=18'} + dependencies: + parse-ms: 4.0.0 + dev: true + + /process@0.11.10: + resolution: {integrity: sha512-cdGef/drWFoydD1JsMzuFf8100nZl+GT+yacc2bEced5f9Rjk4z+WtFUTBu9PhOi9j/jfmBPu0mMEY4wIdAF8A==} + engines: {node: '>= 0.6.0'} + dev: true + + /prop-types@15.8.1: + resolution: {integrity: sha512-oj87CgZICdulUohogVAR7AjlC0327U4el4L6eAvOqCeudMDVU0NThNaV+b9Df4dXgSP1gXMTnPdhfe/2qDH5cg==} + dependencies: + loose-envify: 1.4.0 + object-assign: 4.1.1 + react-is: 16.13.1 + + /proxy-addr@2.0.7: + resolution: {integrity: sha512-llQsMLSUDUPT44jdrU/O37qlnifitDP+ZwrmmZcoSKyLKvtZxpyV0n2/bD/N4tBAAZ/gJEdZU7KMraoK1+XYAg==} + engines: {node: '>= 0.10'} + dependencies: + forwarded: 0.2.0 + ipaddr.js: 1.9.1 + dev: true + + /punycode@2.3.1: + resolution: {integrity: sha512-vYt7UD1U9Wg6138shLtLOvdAu+8DsC/ilFtEVHcH+wydcSpNE20AfSOduf6MkRFahL5FY7X1oU7nKVZFtfq8Fg==} + engines: {node: '>=6'} + dev: true + + /qs@6.13.0: + resolution: {integrity: sha512-+38qI9SOr8tfZ4QmJNplMUxqjbe7LKvvZgWdExBOmd+egZTtjLB67Gu0HRX3u/XOq7UU2Nx6nsjvS16Z9uwfpg==} + engines: {node: '>=0.6'} + dependencies: + side-channel: 1.0.6 + dev: true + + /query-string@9.1.0: + resolution: {integrity: sha512-t6dqMECpCkqfyv2FfwVS1xcB6lgXW/0XZSaKdsCNGYkqMO76AFiJEg4vINzoDKcZa6MS7JX+OHIjwh06K5vczw==} + engines: {node: '>=18'} + dependencies: + decode-uri-component: 0.4.1 + filter-obj: 5.1.0 + split-on-first: 3.0.0 + dev: false + + /queue-microtask@1.2.3: + resolution: {integrity: sha512-NuaNSa6flKT5JaSYQzJok04JzTL1CA6aGhv5rfLW3PgqA+M2ChpZQnAC8h8i4ZFkBS8X5RqkDBHA7r4hej3K9A==} + dev: true + + /raf-schd@4.0.3: + resolution: {integrity: sha512-tQkJl2GRWh83ui2DiPTJz9wEiMN20syf+5oKfB03yYP7ioZcJwsIK8FjrtLwH1m7C7e+Tt2yYBlrOpdT+dyeIQ==} + dev: false + + /raf-throttle@2.0.6: + resolution: {integrity: sha512-C7W6hy78A+vMmk5a/B6C5szjBHrUzWJkVyakjKCK59Uy2CcA7KhO1JUvvH32IXYFIcyJ3FMKP3ZzCc2/71I6Vg==} + dev: false + + /railroad-diagrams@1.0.0: + resolution: {integrity: sha512-cz93DjNeLY0idrCNOH6PviZGRN9GJhsdm9hpn1YCS879fj4W+x5IFJhhkRZcwVgMmFF7R82UA/7Oh+R8lLZg6A==} + dev: false + + /randexp@0.4.6: + resolution: {integrity: sha512-80WNmd9DA0tmZrw9qQa62GPPWfuXJknrmVmLcxvq4uZBdYqb1wYoKTmnlGUchvVWe0XiLupYkBoXVOxz3C8DYQ==} + engines: {node: '>=0.12'} + dependencies: + discontinuous-range: 1.0.0 + ret: 0.1.15 + dev: false + + /range-parser@1.2.1: + resolution: {integrity: sha512-Hrgsx+orqoygnmhFbKaHE6c296J+HTAQXoxEF6gNupROmmGJRoyzfG3ccAveqCBrwr/2yxQ5BVd/GTl5agOwSg==} + engines: {node: '>= 0.6'} + dev: true + + /raw-body@2.5.2: + resolution: {integrity: sha512-8zGqypfENjCIqGhgXToC8aB2r7YrBX+AQAfIPs/Mlk+BtPTztOvTS01NRW/3Eh60J+a48lt8qsCzirQ6loCVfA==} + engines: {node: '>= 0.8'} + dependencies: + bytes: 3.1.2 + http-errors: 2.0.0 + iconv-lite: 0.4.24 + unpipe: 1.0.0 + dev: true + + /react-clientside-effect@1.2.6(react@18.3.1): + resolution: {integrity: sha512-XGGGRQAKY+q25Lz9a/4EPqom7WRjz3z9R2k4jhVKA/puQFH/5Nt27vFZYql4m4NVNdUvX8PS3O7r/Zzm7cjUlg==} + peerDependencies: + react: ^15.3.0 || ^16.0.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.25.7 + react: 18.3.1 + dev: false + + /react-colorful@5.6.1(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-1exovf0uGTGyq5mXQT0zgQ80uvj2PCwvF8zY1RN9/vbJVSjSo3fsB/4L3ObbF7u70NduSiK4xu4Y6q1MHoUGEw==} + peerDependencies: + react: '>=16.8.0' + react-dom: '>=16.8.0' + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + + /react-docgen-typescript@2.2.2(typescript@5.6.2): + resolution: {integrity: sha512-tvg2ZtOpOi6QDwsb3GZhOjDkkX0h8Z2gipvTg6OVMUyoYoURhEiRNePT8NZItTVCDh39JJHnLdfCOkzoLbFnTg==} + peerDependencies: + typescript: '>= 4.3.x' + dependencies: + typescript: 5.6.2 + dev: true + + /react-docgen@7.0.3: + resolution: {integrity: sha512-i8aF1nyKInZnANZ4uZrH49qn1paRgBZ7wZiCNBMnenlPzEv0mRl+ShpTVEI6wZNl8sSc79xZkivtgLKQArcanQ==} + engines: {node: '>=16.14.0'} + dependencies: + '@babel/core': 7.25.7 + '@babel/traverse': 7.25.7 + '@babel/types': 7.25.7 + '@types/babel__core': 7.20.5 + '@types/babel__traverse': 7.20.6 + '@types/doctrine': 0.0.9 + '@types/resolve': 1.20.6 + doctrine: 3.0.0 + resolve: 1.22.8 + strip-indent: 4.0.0 + transitivePeerDependencies: + - supports-color + dev: true + + /react-dom@18.3.1(react@18.3.1): + resolution: {integrity: sha512-5m4nQKp+rZRb09LNH59GM4BxTh9251/ylbKIbpe7TpGxfJ+9kv6BLkLBXIjjspbgbnIBNqlI23tRnTWT0snUIw==} + peerDependencies: + react: ^18.3.1 + dependencies: + loose-envify: 1.4.0 + react: 18.3.1 + scheduler: 0.23.2 + + /react-dropzone@14.2.9(react@18.3.1): + resolution: {integrity: sha512-jRZsMC7h48WONsOLHcmhyn3cRWJoIPQjPApvt/sJVfnYaB3Qltn025AoRTTJaj4WdmmgmLl6tUQg1s0wOhpodQ==} + engines: {node: '>= 10.13'} + peerDependencies: + react: '>= 16.8 || 18.0.0' + dependencies: + attr-accept: 2.2.2 + file-selector: 0.6.0 + prop-types: 15.8.1 + react: 18.3.1 + dev: false + + /react-element-to-jsx-string@15.0.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-UDg4lXB6BzlobN60P8fHWVPX3Kyw8ORrTeBtClmIlGdkOOE+GYQSFvmEU5iLLpwp/6v42DINwNcwOhOLfQ//FQ==} + peerDependencies: + react: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + react-dom: ^0.14.8 || ^15.0.1 || ^16.0.0 || ^17.0.1 || ^18.0.0 + dependencies: + '@base2/pretty-print-object': 1.0.1 + is-plain-object: 5.0.0 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-is: 18.1.0 + dev: true + + /react-error-boundary@4.0.13(react@18.3.1): + resolution: {integrity: sha512-b6PwbdSv8XeOSYvjt8LpgpKrZ0yGdtZokYwkwV2wlcZbxgopHX/hgPl5VgpnoVOWd868n1hktM8Qm4b+02MiLQ==} + peerDependencies: + react: '>=16.13.1' + dependencies: + '@babel/runtime': 7.25.7 + react: 18.3.1 + dev: false + + /react-fast-compare@3.2.2: + resolution: {integrity: sha512-nsO+KSNgo1SbJqJEYRE9ERzo7YtYbou/OqjSQKxV7jcKox7+usiUVZOAC+XnDOABXggQTno0Y1CpVnuWEc1boQ==} + dev: false + + /react-focus-lock@2.13.2(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-T/7bsofxYqnod2xadvuwjGKHOoL5GH7/EIPI5UyEvaU/c2CcphvGI371opFtuY/SYdbMsNiuF4HsHQ50nA/TKQ==} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@babel/runtime': 7.25.7 + '@types/react': 18.3.11 + focus-lock: 1.3.5 + prop-types: 15.8.1 + react: 18.3.1 + react-clientside-effect: 1.2.6(react@18.3.1) + use-callback-ref: 1.3.2(@types/react@18.3.11)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.11)(react@18.3.1) + dev: false + + /react-hook-form@7.53.0(react@18.3.1): + resolution: {integrity: sha512-M1n3HhqCww6S2hxLxciEXy2oISPnAzxY7gvwVPrtlczTM/1dDadXgUxDpHMrMTblDOcm/AXtXxHwZ3jpg1mqKQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + react: ^16.8.0 || ^17 || ^18 || ^19 + dependencies: + react: 18.3.1 + dev: false + + /react-hotkeys-hook@4.5.0(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-Samb85GSgAWFQNvVt3PS90LPPGSf9mkH/r4au81ZP1yOIFayLC3QAvqTgGtJ8YEDMXtPmaVBs6NgipHO6h4Mug==} + peerDependencies: + react: '>=16.8.1' + react-dom: '>=16.8.1' + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /react-i18next@15.0.2(i18next@23.15.1)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-z0W3/RES9Idv3MmJUcf0mDNeeMOUXe+xoL0kPfQPbDoZHmni/XsIoq5zgT2MCFUiau283GuBUK578uD/mkAbLQ==} + peerDependencies: + i18next: '>= 23.2.3' + react: '>= 16.8.0' + react-dom: '*' + react-native: '*' + peerDependenciesMeta: + react-dom: + optional: true + react-native: + optional: true + dependencies: + '@babel/runtime': 7.25.7 + html-parse-stringify: 3.0.1 + i18next: 23.15.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /react-icons@5.3.0(react@18.3.1): + resolution: {integrity: sha512-DnUk8aFbTyQPSkCfF8dbX6kQjXA9DktMeJqfjrg6cK9vwQVMxmcA3BfP4QoiztVmEHtwlTgLFsPuH2NskKT6eg==} + peerDependencies: + react: '*' + dependencies: + react: 18.3.1 + dev: false + + /react-is@16.13.1: + resolution: {integrity: sha512-24e6ynE2H+OKt4kqsOvNd8kBpV65zoxbA4BVsEOB3ARVWQki/DHzaUoC5KuON/BiccDaCCTZBuOcfZs70kR8bQ==} + + /react-is@17.0.2: + resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + dev: true + + /react-is@18.1.0: + resolution: {integrity: sha512-Fl7FuabXsJnV5Q1qIOQwx/sagGF18kogb4gpfcG4gjLBWO0WDiiz1ko/ExayuxE7InyQkBLkxRFG5oxY6Uu3Kg==} + dev: true + + /react-is@18.3.1: + resolution: {integrity: sha512-/LLMVyas0ljjAtoYiPqYiL8VWXzUUdThrmU5+n20DZv+a+ClRoevUzw5JxU+Ieh5/c87ytoTBV9G1FiKfNJdmg==} + dev: true + + /react-redux@9.1.2(@types/react@18.3.11)(react@18.3.1)(redux@5.0.1): + resolution: {integrity: sha512-0OA4dhM1W48l3uzmv6B7TXPCGmokUU4p1M44DGN2/D9a1FjVPukVjER1PcPX97jIg6aUeLq1XJo1IpfbgULn0w==} + peerDependencies: + '@types/react': ^18.2.25 + react: ^18.0 + redux: ^5.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + redux: + optional: true + dependencies: + '@types/react': 18.3.11 + '@types/use-sync-external-store': 0.0.3 + react: 18.3.1 + redux: 5.0.1 + use-sync-external-store: 1.2.2(react@18.3.1) + dev: false + + /react-remove-scroll-bar@2.3.6(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-DtSYaao4mBmX+HDo5YWYdBWQwYIQQshUV/dVxFxK+KM26Wjwp1gZ6rv6OC3oujI6Bfu6Xyg3TwK533AQutsn/g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + react-style-singleton: 2.2.1(@types/react@18.3.11)(react@18.3.1) + tslib: 2.7.0 + dev: false + + /react-remove-scroll@2.5.5(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-ImKhrzJJsyXJfBZ4bzu8Bwpka14c/fQt0k+cyFp/PBhTfyDnU5hjOtM4AG/0AMyy8oKzOTR0lDgJIM7pYXI0kw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + react-remove-scroll-bar: 2.3.6(@types/react@18.3.11)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.11)(react@18.3.1) + tslib: 2.7.0 + use-callback-ref: 1.3.2(@types/react@18.3.11)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.11)(react@18.3.1) + dev: false + + /react-remove-scroll@2.6.0(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-I2U4JVEsQenxDAKaVa3VZ/JeJZe0/2DxPWL8Tj8yLKctQJQiZM52pn/GWFpSp8dftjM3pSAHVJZscAnC/y+ySQ==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + react-remove-scroll-bar: 2.3.6(@types/react@18.3.11)(react@18.3.1) + react-style-singleton: 2.2.1(@types/react@18.3.11)(react@18.3.1) + tslib: 2.7.0 + use-callback-ref: 1.3.2(@types/react@18.3.11)(react@18.3.1) + use-sidecar: 1.1.2(@types/react@18.3.11)(react@18.3.1) + dev: false + + /react-resizable-panels@2.1.4(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-kzue8lsoSBdyyd2IfXLQMMhNujOxRoGVus+63K95fQqleGxTfvgYLTzbwYMOODeAHqnkjb3WV/Ks7f5+gDYZuQ==} + peerDependencies: + react: ^16.14.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.14.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /react-select@5.8.0(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-TfjLDo58XrhP6VG5M/Mi56Us0Yt8X7xD6cDybC7yoRMUNm7BGO7qk8J0TLQOua/prb8vUOtsfnXZwfm30HGsAA==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + '@babel/runtime': 7.25.7 + '@emotion/cache': 11.13.1 + '@emotion/react': 11.13.3(@types/react@18.3.11)(react@18.3.1) + '@floating-ui/dom': 1.6.11 + '@types/react-transition-group': 4.4.11 + memoize-one: 6.0.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-transition-group: 4.4.5(react-dom@18.3.1)(react@18.3.1) + use-isomorphic-layout-effect: 1.1.2(@types/react@18.3.11)(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - supports-color + dev: false + + /react-style-singleton@2.2.1(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-ZWj0fHEMyWkHzKYUr2Bs/4zU6XLmq9HsgBURm7g5pAVfyn49DgUiNgY2d4lXRlYSiCif9YBGpQleewkcqddc7g==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + get-nonce: 1.0.1 + invariant: 2.2.4 + react: 18.3.1 + tslib: 2.7.0 + dev: false + + /react-transition-group@4.4.5(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-pZcd1MCJoiKiBR2NRxeCRg13uCXbydPnmB4EOeRrY7480qNWO8IIgQG6zlDkm6uRMsURXPuKq0GWtiM59a5Q6g==} + peerDependencies: + react: '>=16.6.0' + react-dom: '>=16.6.0' + dependencies: + '@babel/runtime': 7.25.7 + dom-helpers: 5.2.1 + loose-envify: 1.4.0 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /react-universal-interface@0.6.2(react@18.3.1)(tslib@2.7.0): + resolution: {integrity: sha512-dg8yXdcQmvgR13RIlZbTRQOoUrDciFVoSBZILwjE2LFISxZZ8loVJKAkuzswl5js8BHda79bIb2b84ehU8IjXw==} + peerDependencies: + react: '*' + tslib: '*' + dependencies: + react: 18.3.1 + tslib: 2.7.0 + dev: false + + /react-use@17.5.1(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-LG/uPEVRflLWMwi3j/sZqR00nF6JGqTTDblkXK2nzXsIvij06hXl1V/MZIlwj1OKIQUtlh1l9jK8gLsRyCQxMg==} + peerDependencies: + react: '*' + react-dom: '*' + dependencies: + '@types/js-cookie': 2.2.7 + '@xobotyi/scrollbar-width': 1.9.5 + copy-to-clipboard: 3.3.3 + fast-deep-equal: 3.1.3 + fast-shallow-equal: 1.0.0 + js-cookie: 2.2.1 + nano-css: 5.6.2(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + react-universal-interface: 0.6.2(react@18.3.1)(tslib@2.7.0) + resize-observer-polyfill: 1.5.1 + screenfull: 5.2.0 + set-harmonic-interval: 1.0.1 + throttle-debounce: 3.0.1 + ts-easing: 0.2.0 + tslib: 2.7.0 + dev: false + + /react-virtuoso@4.10.4(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-G/gprhTbK+lzMxoo/iStcZxVEGph/cIhc3WANEpt92RuMw+LiCZOmBfKoeoZOHlm/iyftTrDJhGaTCpxyucnkQ==} + engines: {node: '>=10'} + peerDependencies: + react: '>=16 || >=17 || >= 18' + react-dom: '>=16 || >=17 || >= 18' + dependencies: + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + dev: false + + /react@18.3.1: + resolution: {integrity: sha512-wS+hAgJShR0KhEvPJArfuPVN1+Hz1t0Y6n5jLrGQbkb4urgPE/0Rve+1kMB1v/oWgHgm4WIcV+i7F2pTVj+2iQ==} + engines: {node: '>=0.10.0'} + dependencies: + loose-envify: 1.4.0 + + /reactflow@11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1): + resolution: {integrity: sha512-70FOtJkUWH3BAOsN+LU9lCrKoKbtOPnz2uq0CV2PLdNSwxTXOhCbsZr50GmZ+Rtw3jx8Uv7/vBFtCGixLfd4Og==} + peerDependencies: + react: '>=17' + react-dom: '>=17' + dependencies: + '@reactflow/background': 11.3.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@reactflow/controls': 11.2.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@reactflow/core': 11.11.4(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@reactflow/minimap': 11.7.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@reactflow/node-resizer': 2.2.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + '@reactflow/node-toolbar': 1.3.14(@types/react@18.3.11)(react-dom@18.3.1)(react@18.3.1) + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + transitivePeerDependencies: + - '@types/react' + - immer + dev: false + + /readable-stream@3.6.2: + resolution: {integrity: sha512-9u/sniCrY3D5WdsERHzHE4G2YCXqoG5FTHUiCC4SIbr6XcLZBY05ya9EKjYek9O5xOAwjGq+1JdGBAS7Q9ScoA==} + engines: {node: '>= 6'} + dependencies: + inherits: 2.0.4 + string_decoder: 1.3.0 + util-deprecate: 1.0.2 + dev: true + + /recast@0.23.9: + resolution: {integrity: sha512-Hx/BGIbwj+Des3+xy5uAtAbdCyqK9y9wbBcDFDYanLS9JnMqf7OeF87HQwUimE87OEc72mr6tkKUKMBBL+hF9Q==} + engines: {node: '>= 4'} + dependencies: + ast-types: 0.16.1 + esprima: 4.0.1 + source-map: 0.6.1 + tiny-invariant: 1.3.3 + tslib: 2.7.0 + dev: true + + /redent@3.0.0: + resolution: {integrity: sha512-6tDA8g98We0zd0GvVeMT9arEOnTw9qM03L9cJXaCjrip1OO764RDBLBfrB4cwzNGDj5OA5ioymC9GkizgWJDUg==} + engines: {node: '>=8'} + dependencies: + indent-string: 4.0.0 + strip-indent: 3.0.0 + dev: true + + /redux-dynamic-middlewares@2.2.0: + resolution: {integrity: sha512-GHESQC+Y0PV98ZBoaC6br6cDOsNiM1Cu4UleGMqMWCXX03jIr3BoozYVrRkLVVAl4sC216chakMnZOu6SwNdGA==} + dev: false + + /redux-remember@5.1.0(redux@5.0.1): + resolution: {integrity: sha512-Z6/S/brpwflOsGpX8Az93eujJ5fytMcaefxDfx0iib5d0DkL804zlw/Fhh/4HzZ5nXsP67j1zPUeDNWO1rhfvA==} + peerDependencies: + redux: '>=5.0.0' + dependencies: + redux: 5.0.1 + dev: false + + /redux-thunk@3.1.0(redux@5.0.1): + resolution: {integrity: sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==} + peerDependencies: + redux: ^5.0.0 + dependencies: + redux: 5.0.1 + dev: false + + /redux-undo@1.1.0: + resolution: {integrity: sha512-zzLFh2qeF0MTIlzDhDLm9NtkfBqCllQJ3OCuIl5RKlG/ayHw6GUdIFdMhzMS9NnrnWdBX5u//ExMOHpfudGGOg==} + dev: false + + /redux@5.0.1: + resolution: {integrity: sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==} + dev: false + + /reflect.getprototypeof@1.0.6: + resolution: {integrity: sha512-fmfw4XgoDke3kdI6h4xcUz1dG8uaiv5q9gcEwLS4Pnth2kxT+GZ7YehS1JTMGBQmtV7Y4GFGbs2re2NqhdozUg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + globalthis: 1.0.4 + which-builtin-type: 1.1.4 + dev: true + + /regenerator-runtime@0.14.1: + resolution: {integrity: sha512-dYnhHh0nJoMfnkZs6GmmhFknAGRrLznOu5nc9ML+EJxGvrx6H7teuevqVqCuPcPK//3eDrrjQhehXVx9cnkGdw==} + + /regexp.prototype.flags@1.5.3: + resolution: {integrity: sha512-vqlC04+RQoFalODCbCumG2xIOvapzVMHwsyIGM/SIE8fRhFFsXeH8/QQ+s0T0kDAhKc4k30s73/0ydkHQz6HlQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-errors: 1.3.0 + set-function-name: 2.0.2 + dev: true + + /rehype-external-links@3.0.0: + resolution: {integrity: sha512-yp+e5N9V3C6bwBeAC4n796kc86M4gJCdlVhiMTxIrJG5UHDMh+PJANf9heqORJbt1nrCbDwIlAZKjANIaVBbvw==} + dependencies: + '@types/hast': 3.0.4 + '@ungap/structured-clone': 1.2.0 + hast-util-is-element: 3.0.0 + is-absolute-url: 4.0.1 + space-separated-tokens: 2.0.2 + unist-util-visit: 5.0.0 + dev: true + + /rehype-slug@6.0.0: + resolution: {integrity: sha512-lWyvf/jwu+oS5+hL5eClVd3hNdmwM1kAC0BUvEGD19pajQMIzcNUd/k9GsfQ+FfECvX+JE+e9/btsKH0EjJT6A==} + dependencies: + '@types/hast': 3.0.4 + github-slugger: 2.0.0 + hast-util-heading-rank: 3.0.0 + hast-util-to-string: 3.0.1 + unist-util-visit: 5.0.0 + dev: true + + /require-directory@2.1.1: + resolution: {integrity: sha512-fGxEI7+wsG9xrvdjsrlmL22OMTTiHRwAMroiEeMgq8gzoLC/PQr7RsRDSTLUg/bZAZtF+TVIkHc6/4RIKrui+Q==} + engines: {node: '>=0.10.0'} + dev: true + + /require-from-string@2.0.2: + resolution: {integrity: sha512-Xf0nWe6RseziFMu+Ap9biiUbmplq6S9/p+7w7YXP/JBHhrUDDUhwa+vANyubuqfZWTveU//DYVGsDG7RKL/vEw==} + engines: {node: '>=0.10.0'} + dev: true + + /requireindex@1.1.0: + resolution: {integrity: sha512-LBnkqsDE7BZKvqylbmn7lTIVdpx4K/QCduRATpO5R+wtPmky/a8pN1bO2D6wXppn1497AJF9mNjqAXr6bdl9jg==} + engines: {node: '>=0.10.5'} + dev: true + + /requireindex@1.2.0: + resolution: {integrity: sha512-L9jEkOi3ASd9PYit2cwRfyppc9NoABujTP8/5gFcbERmo5jUoAKovIC3fsF17pkTnGsrByysqX+Kxd2OTNI1ww==} + engines: {node: '>=0.10.5'} + dev: true + + /reselect@5.1.1: + resolution: {integrity: sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==} + dev: false + + /resize-observer-polyfill@1.5.1: + resolution: {integrity: sha512-LwZrotdHOo12nQuZlHEmtuXdqGoOD0OhaxopaNFxWzInpEgaLWoVuAMbTzixuosCx2nEG58ngzW3vxdWoxIgdg==} + dev: false + + /resolve-from@4.0.0: + resolution: {integrity: sha512-pb/MYmXstAkysRFx8piNI1tGFNQIFA3vkE3Gq4EuA1dF6gHp/+vgZqsCGJapvy8N3Q+4o7FwvquPJcnZ7RYy4g==} + engines: {node: '>=4'} + + /resolve@1.19.0: + resolution: {integrity: sha512-rArEXAgsBG4UgRGcynxWIWKFvh/XZCcS8UJdHhwy91zwAvCZIbcs+vAbflgBnNjYMs/i/i+/Ux6IZhML1yPvxg==} + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + dev: true + + /resolve@1.22.8: + resolution: {integrity: sha512-oKWePCxqpd6FlLvGV1VU0x7bkPmmCNolxzjMf4NczoDnQcIWrAF+cPtZn5i6n+RfD2d9i0tzpKnG6Yk168yIyw==} + hasBin: true + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + + /resolve@2.0.0-next.5: + resolution: {integrity: sha512-U7WjGVG9sH8tvjW5SmGbQuui75FiyjAX72HX15DwBBwF9dNiQZRQAg9nnPhYy+TUnE0+VcrttuvNI8oSxZcocA==} + hasBin: true + dependencies: + is-core-module: 2.15.1 + path-parse: 1.0.7 + supports-preserve-symlinks-flag: 1.0.0 + dev: true + + /restore-cursor@3.1.0: + resolution: {integrity: sha512-l+sSefzHpj5qimhFSE5a8nufZYAM3sBSVMAPtYkmC+4EH2anSGaEMXSD0izRQbu9nfyQ9y5JrVmp7E8oZrUjvA==} + engines: {node: '>=8'} + dependencies: + onetime: 5.1.2 + signal-exit: 3.0.7 + dev: true + + /ret@0.1.15: + resolution: {integrity: sha512-TTlYpa+OL+vMMNG24xSlQGEJ3B/RzEfUlLct7b5G/ytav+wPrplCpVMFuwzXbkecJrb6IYo1iFb0S9v37754mg==} + engines: {node: '>=0.12'} + dev: false + + /reusify@1.0.4: + resolution: {integrity: sha512-U9nH88a3fc/ekCF1l0/UP1IosiuIjyTh7hBvXVMHYgVcfGvt897Xguj2UOLDeI5BG2m7/uwyaLVT6fbtCwTyzw==} + engines: {iojs: '>=1.0.0', node: '>=0.10.0'} + dev: true + + /rfdc@1.4.1: + resolution: {integrity: sha512-q1b3N5QkRUWUl7iyylaaj3kOpIT0N2i9MqIEQXP73GVsN9cw3fdx8X63cEmWhJGi2PPCF23Ijp7ktmd39rawIA==} + dev: false + + /rimraf@3.0.2: + resolution: {integrity: sha512-JZkJMZkAGFFPP2YqXZXPbMlMBgsxzE8ILs4lMIX/2o0L9UBw9O/Y3o6wFw/i9YLapcUJWwqbi3kdxIPdC62TIA==} + deprecated: Rimraf versions prior to v4 are no longer supported + hasBin: true + dependencies: + glob: 7.2.3 + dev: true + + /roarr@7.21.1: + resolution: {integrity: sha512-3niqt5bXFY1InKU8HKWqqYTYjtrBaxBMnXELXCXUYgtNYGUtZM5rB46HIC430AyacL95iEniGf7RgqsesykLmQ==} + engines: {node: '>=18.0'} + dependencies: + fast-printf: 1.6.9 + safe-stable-stringify: 2.5.0 + semver-compare: 1.0.0 + dev: false + + /rollup-plugin-visualizer@5.12.0: + resolution: {integrity: sha512-8/NU9jXcHRs7Nnj07PF2o4gjxmm9lXIrZ8r175bT9dK8qoLlvKTwRMArRCMgpMGlq8CTLugRvEmyMeMXIU2pNQ==} + engines: {node: '>=14'} + hasBin: true + peerDependencies: + rollup: 2.x || 3.x || 4.x + peerDependenciesMeta: + rollup: + optional: true + dependencies: + open: 8.4.2 + picomatch: 2.3.1 + source-map: 0.7.4 + yargs: 17.7.2 + dev: true + + /rollup@2.79.2: + resolution: {integrity: sha512-fS6iqSPZDs3dr/y7Od6y5nha8dW1YnbgtsyotCVvoFGKbERG++CVRFv1meyGDE1SNItQA8BrnCw7ScdAhRJ3XQ==} + engines: {node: '>=10.0.0'} + hasBin: true + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /rollup@4.24.0: + resolution: {integrity: sha512-DOmrlGSXNk1DM0ljiQA+i+o0rSLhtii1je5wgk60j49d1jHT5YYttBv1iWOnYSTG+fZZESUOSNiAl89SIet+Cg==} + engines: {node: '>=18.0.0', npm: '>=8.0.0'} + hasBin: true + dependencies: + '@types/estree': 1.0.6 + optionalDependencies: + '@rollup/rollup-android-arm-eabi': 4.24.0 + '@rollup/rollup-android-arm64': 4.24.0 + '@rollup/rollup-darwin-arm64': 4.24.0 + '@rollup/rollup-darwin-x64': 4.24.0 + '@rollup/rollup-linux-arm-gnueabihf': 4.24.0 + '@rollup/rollup-linux-arm-musleabihf': 4.24.0 + '@rollup/rollup-linux-arm64-gnu': 4.24.0 + '@rollup/rollup-linux-arm64-musl': 4.24.0 + '@rollup/rollup-linux-powerpc64le-gnu': 4.24.0 + '@rollup/rollup-linux-riscv64-gnu': 4.24.0 + '@rollup/rollup-linux-s390x-gnu': 4.24.0 + '@rollup/rollup-linux-x64-gnu': 4.24.0 + '@rollup/rollup-linux-x64-musl': 4.24.0 + '@rollup/rollup-win32-arm64-msvc': 4.24.0 + '@rollup/rollup-win32-ia32-msvc': 4.24.0 + '@rollup/rollup-win32-x64-msvc': 4.24.0 + fsevents: 2.3.3 + dev: true + + /rtl-css-js@1.16.1: + resolution: {integrity: sha512-lRQgou1mu19e+Ya0LsTvKrVJ5TYUbqCVPAiImX3UfLTenarvPUl1QFdvu5Z3PYmHT9RCcwIfbjRQBntExyj3Zg==} + dependencies: + '@babel/runtime': 7.25.7 + dev: false + + /run-parallel@1.2.0: + resolution: {integrity: sha512-5l4VyZR86LZ/lDxZTR6jqL8AFE2S0IFLMP26AbjsLVADxHdhB/c0GUsH+y39UfCi3dzz8OlQuPmnaJOMoDHQBA==} + dependencies: + queue-microtask: 1.2.3 + dev: true + + /rxjs@7.8.1: + resolution: {integrity: sha512-AA3TVj+0A2iuIoQkWEK/tqFjBq2j+6PO6Y0zJcvzLAFhEFIO3HL0vls9hWLncZbAAbK0mar7oZ4V079I/qPMxg==} + dependencies: + tslib: 2.7.0 + dev: true + + /safe-array-concat@1.1.2: + resolution: {integrity: sha512-vj6RsCsWBCf19jIeHEfkRMw8DPiBb+DMXklQ/1SGDHOMlHdPUkZXFQ2YdplS23zESTijAcurb1aSgJA3AgMu1Q==} + engines: {node: '>=0.4'} + dependencies: + call-bind: 1.0.7 + get-intrinsic: 1.2.4 + has-symbols: 1.0.3 + isarray: 2.0.5 + dev: true + + /safe-buffer@5.2.1: + resolution: {integrity: sha512-rp3So07KcdmmKbGvgaNxQSJr7bGVSVk5S9Eq1F+ppbRo70+YeaDxkw5Dd8NPN+GD6bjnYm2VuPuCXmpuYvmCXQ==} + dev: true + + /safe-regex-test@1.0.3: + resolution: {integrity: sha512-CdASjNJPvRa7roO6Ra/gLYBTzYzzPyyBXxIMdGW3USQLyjWEls2RgW5UBTXaQVp+OrpeCK3bLem8smtmheoRuw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-regex: 1.1.4 + dev: true + + /safe-stable-stringify@2.5.0: + resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} + engines: {node: '>=10'} + dev: false + + /safer-buffer@2.1.2: + resolution: {integrity: sha512-YZo3K82SD7Riyi0E1EQPojLz7kpepnSQI9IyPbHHg1XXXevb5dJI7tpyN2ADxGcQbHG7vcyRHk0cbwqcQriUtg==} + dev: true + + /scheduler@0.23.2: + resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} + dependencies: + loose-envify: 1.4.0 + + /screenfull@5.2.0: + resolution: {integrity: sha512-9BakfsO2aUQN2K9Fdbj87RJIEZ82Q9IGim7FqM5OsebfoFC6ZHXgDq/KvniuLTPdeM8wY2o6Dj3WQ7KeQCj3cA==} + engines: {node: '>=0.10.0'} + dev: false + + /semver-compare@1.0.0: + resolution: {integrity: sha512-YM3/ITh2MJ5MtzaM429anh+x2jiLVjqILF4m4oyQB18W7Ggea7BfqdH/wGMK7dDiMghv/6WG7znWMwUDzJiXow==} + dev: false + + /semver@6.3.1: + resolution: {integrity: sha512-BR7VvDCVHO+q2xBEWskxS6DJE1qRnb7DxzUrogb71CWoSficBxYsiAGd+Kl0mmq/MprG9yArRkyrQxTO6XjMzA==} + hasBin: true + dev: true + + /semver@7.5.4: + resolution: {integrity: sha512-1bCSESV6Pv+i21Hvpxp3Dx+pSD8lIPt8uVjRrxAUt/nbswYc+tK6Y2btiULjd4+fnq15PX+nqQDC7Oft7WkwcA==} + engines: {node: '>=10'} + hasBin: true + dependencies: + lru-cache: 6.0.0 + dev: true + + /semver@7.6.3: + resolution: {integrity: sha512-oVekP1cKtI+CTDvHWYFUcMtsK/00wmAEfyqKfNdARm8u1wNVhSgaX7A8d4UuIlUI5e84iEwOhs7ZPYRmzU9U6A==} + engines: {node: '>=10'} + hasBin: true + dev: true + + /send@0.19.0: + resolution: {integrity: sha512-dW41u5VfLXu8SJh5bwRmyYUbAoSB3c9uQh6L8h/KtsFREPWpbX1lrljJo186Jc4nmci/sGUZ9a0a0J2zgfq2hw==} + engines: {node: '>= 0.8.0'} + dependencies: + debug: 2.6.9 + depd: 2.0.0 + destroy: 1.2.0 + encodeurl: 1.0.2 + escape-html: 1.0.3 + etag: 1.8.1 + fresh: 0.5.2 + http-errors: 2.0.0 + mime: 1.6.0 + ms: 2.1.3 + on-finished: 2.4.1 + range-parser: 1.2.1 + statuses: 2.0.1 + transitivePeerDependencies: + - supports-color + dev: true + + /serialize-error@11.0.3: + resolution: {integrity: sha512-2G2y++21dhj2R7iHAdd0FIzjGwuKZld+7Pl/bTU6YIkrC2ZMbVUjm+luj6A6V34Rv9XfKJDKpTWu9W4Gse1D9g==} + engines: {node: '>=14.16'} + dependencies: + type-fest: 2.19.0 + dev: false + + /serve-static@1.16.2: + resolution: {integrity: sha512-VqpjJZKadQB/PEbEwvFdO43Ax5dFBZ2UECszz8bQ7pi7wt//PWe1P6MN7eCnjsatYtBT6EuiClbjSWP2WrIoTw==} + engines: {node: '>= 0.8.0'} + dependencies: + encodeurl: 2.0.0 + escape-html: 1.0.3 + parseurl: 1.3.3 + send: 0.19.0 + transitivePeerDependencies: + - supports-color + dev: true + + /set-function-length@1.2.2: + resolution: {integrity: sha512-pgRc4hJ4/sNjWCSS9AmnS40x3bNMDTknHgL5UaMBTMyJnU90EgWh1Rz+MC9eFu4BuN/UwZjKQuY/1v3rM7HMfg==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + function-bind: 1.1.2 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-property-descriptors: 1.0.2 + dev: true + + /set-function-name@2.0.2: + resolution: {integrity: sha512-7PGFlmtwsEADb0WYyvCMa1t+yke6daIG4Wirafur5kcf+MhUnPms1UeR0CKQdTZD81yESwMHbtn+TR+dMviakQ==} + engines: {node: '>= 0.4'} + dependencies: + define-data-property: 1.1.4 + es-errors: 1.3.0 + functions-have-names: 1.2.3 + has-property-descriptors: 1.0.2 + dev: true + + /set-harmonic-interval@1.0.1: + resolution: {integrity: sha512-AhICkFV84tBP1aWqPwLZqFvAwqEoVA9kxNMniGEUvzOlm4vLmOFLiTT3UZ6bziJTy4bOVpzWGTfSCbmaayGx8g==} + engines: {node: '>=6.9'} + dev: false + + /setprototypeof@1.2.0: + resolution: {integrity: sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw==} + dev: true + + /shebang-command@2.0.0: + resolution: {integrity: sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA==} + engines: {node: '>=8'} + dependencies: + shebang-regex: 3.0.0 + dev: true + + /shebang-regex@3.0.0: + resolution: {integrity: sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A==} + engines: {node: '>=8'} + dev: true + + /shell-quote@1.8.1: + resolution: {integrity: sha512-6j1W9l1iAs/4xYBI1SYOVZyFcCis9b4KCLQ8fgAGG07QvzaRLVVRQvAy85yNmmZSjYjg4MWh4gNvlPujU/5LpA==} + dev: true + + /side-channel@1.0.6: + resolution: {integrity: sha512-fDW/EZ6Q9RiO8eFG8Hj+7u/oW+XrPTIChwCOM2+th2A6OblDtYYIpve9m+KvI9Z4C9qSEXlaGR6bTEYHReuglA==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + get-intrinsic: 1.2.4 + object-inspect: 1.13.2 + dev: true + + /siginfo@2.0.0: + resolution: {integrity: sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g==} + dev: true + + /signal-exit@3.0.7: + resolution: {integrity: sha512-wnD2ZE+l+SPC/uoS0vXeE9L1+0wuaMqKlfz9AMUo38JsyLSBWSFcHR1Rri62LZc12vLr1gb3jl7iwQhgwpAbGQ==} + dev: true + + /signal-exit@4.1.0: + resolution: {integrity: sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw==} + engines: {node: '>=14'} + dev: true + + /sirv@2.0.4: + resolution: {integrity: sha512-94Bdh3cC2PKrbgSOUqTiGPWVZeSiXfKOVZNJniWoqrWrRkB1CJzBU3NEbiTsPcYy1lDsANA/THzS+9WBiy5nfQ==} + engines: {node: '>= 10'} + dependencies: + '@polka/url': 1.0.0-next.28 + mrmime: 2.0.0 + totalist: 3.0.1 + dev: true + + /slash@3.0.0: + resolution: {integrity: sha512-g9Q1haeby36OSStwb4ntCGGGaKsaVSjQ68fBxoQcutl5fS1vuY18H3wSt3jFyFtrkx+Kz0V1G85A4MyAdDMi2Q==} + engines: {node: '>=8'} + dev: true + + /smol-toml@1.3.0: + resolution: {integrity: sha512-tWpi2TsODPScmi48b/OQZGi2lgUmBCHy6SZrhi/FdnnHiU1GwebbCfuQuxsC3nHaLwtYeJGPrDZDIeodDOc4pA==} + engines: {node: '>= 18'} + dev: true + + /socket.io-client@4.8.0: + resolution: {integrity: sha512-C0jdhD5yQahMws9alf/yvtsMGTaIDBnZ8Rb5HU56svyq0l5LIrGzIDZZD5pHQlmzxLuU91Gz+VpQMKgCTNYtkw==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7(supports-color@9.4.0) + engine.io-client: 6.6.1 + socket.io-parser: 4.2.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: false + + /socket.io-parser@4.2.4: + resolution: {integrity: sha512-/GbIKmo8ioc+NIWIhwdecY0ge+qVBSMdgxGygevmdHj24bsfgtCmcUUcQ5ZzcylGFHsN3k4HB4Cgkl96KVnuew==} + engines: {node: '>=10.0.0'} + dependencies: + '@socket.io/component-emitter': 3.1.2 + debug: 4.3.7(supports-color@9.4.0) + transitivePeerDependencies: + - supports-color + dev: false + + /source-map-js@1.2.1: + resolution: {integrity: sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA==} + engines: {node: '>=0.10.0'} + dev: true + + /source-map@0.5.6: + resolution: {integrity: sha512-MjZkVp0NHr5+TPihLcadqnlVoGIoWo4IBHptutGh9wI3ttUYvCG26HkSuDi+K6lsZ25syXJXcctwgyVCt//xqA==} + engines: {node: '>=0.10.0'} + dev: false + + /source-map@0.5.7: + resolution: {integrity: sha512-LbrmJOMUSdEVxIKvdcJzQC+nQhe8FUZQTXQy6+I75skNgn3OoQ0DZA8YnFa7gp8tqtL3KPf1kmo0R5DoApeSGQ==} + engines: {node: '>=0.10.0'} + dev: false + + /source-map@0.6.1: + resolution: {integrity: sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g==} + engines: {node: '>=0.10.0'} + + /source-map@0.7.4: + resolution: {integrity: sha512-l3BikUxvPOcn5E74dZiq5BGsTb5yEwhaTSzccU6t4sDOH8NWJCstKO5QT2CvtFoK6F0saL7p9xHAqHOlCPJygA==} + engines: {node: '>= 8'} + dev: true + + /space-separated-tokens@2.0.2: + resolution: {integrity: sha512-PEGlAwrG8yXGXRjW32fGbg66JAlOAwbObuqVoJpv/mRgoWDQfgH1wDPvtzWyUSNAXBGSk8h755YDbbcEy3SH2Q==} + dev: true + + /spawn-command@0.0.2: + resolution: {integrity: sha512-zC8zGoGkmc8J9ndvml8Xksr1Amk9qBujgbF0JAIWO7kXr43w0h/0GJNM/Vustixu+YE8N/MTrQ7N31FvHUACxQ==} + dev: true + + /split-on-first@3.0.0: + resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} + engines: {node: '>=12'} + dev: false + + /sprintf-js@1.0.3: + resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} + dev: true + + /stable-hash@0.0.4: + resolution: {integrity: sha512-LjdcbuBeLcdETCrPn9i8AYAZ1eCtu4ECAWtP7UleOiZ9LzVxRzzUZEoZ8zB24nhkQnDWyET0I+3sWokSDS3E7g==} + dev: false + + /stack-generator@2.0.10: + resolution: {integrity: sha512-mwnua/hkqM6pF4k8SnmZ2zfETsRUpWXREfA/goT8SLCV4iOFa4bzOX2nDipWAZFPTjLvQB82f5yaodMVhK0yJQ==} + dependencies: + stackframe: 1.3.4 + dev: false + + /stackback@0.0.2: + resolution: {integrity: sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw==} + dev: true + + /stackframe@1.3.4: + resolution: {integrity: sha512-oeVtt7eWQS+Na6F//S4kJ2K2VbRlS9D43mAlMyVpVWovy9o+jfgH8O9agzANzaiLjclA0oYzUXEM4PurhSUChw==} + dev: false + + /stacktrace-gps@3.1.2: + resolution: {integrity: sha512-GcUgbO4Jsqqg6RxfyTHFiPxdPqF+3LFmQhm7MgCuYQOYuWyqxo5pwRPz5d/u6/WYJdEnWfK4r+jGbyD8TSggXQ==} + dependencies: + source-map: 0.5.6 + stackframe: 1.3.4 + dev: false + + /stacktrace-js@2.0.2: + resolution: {integrity: sha512-Je5vBeY4S1r/RnLydLl0TBTi3F2qdfWmYsGvtfZgEI+SCprPppaIhQf5nGcal4gI4cGpCV/duLcAzT1np6sQqg==} + dependencies: + error-stack-parser: 2.1.4 + stack-generator: 2.0.10 + stacktrace-gps: 3.1.2 + dev: false + + /statuses@2.0.1: + resolution: {integrity: sha512-RwNA9Z/7PrK06rYLIzFMlaF+l73iwpzsqRIFgbMLbTcLD6cOao82TaWefPXQvB2fOC4AjuYSEndS7N/mTCbkdQ==} + engines: {node: '>= 0.8'} + dev: true + + /std-env@3.7.0: + resolution: {integrity: sha512-JPbdCEQLj1w5GilpiHAx3qJvFndqybBysA3qUOnznweH4QbNYUsW/ea8QzSrnh0vNsezMMw5bcVool8lM0gwzg==} + dev: true + + /storybook@8.3.4: + resolution: {integrity: sha512-nzvuK5TsEgJwcWGLGgafabBOxKn37lfJVv7ZoUVPgJIjk2mNRyJDFwYRJzUZaD37eiR/c/lQ6MoaeqlGwiXoxw==} + hasBin: true + dependencies: + '@storybook/core': 8.3.4 + transitivePeerDependencies: + - bufferutil + - supports-color + - utf-8-validate + dev: true + + /string-argv@0.3.2: + resolution: {integrity: sha512-aqD2Q0144Z+/RqG52NeHEkZauTAUWJO8c6yTftGJKO3Tja5tUgIfmIl6kExvhtxSDP7fXB6DvzkfMpCd/F3G+Q==} + engines: {node: '>=0.6.19'} + dev: true + + /string-width@4.2.3: + resolution: {integrity: sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g==} + engines: {node: '>=8'} + dependencies: + emoji-regex: 8.0.0 + is-fullwidth-code-point: 3.0.0 + strip-ansi: 6.0.1 + dev: true + + /string-width@5.1.2: + resolution: {integrity: sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA==} + engines: {node: '>=12'} + dependencies: + eastasianwidth: 0.2.0 + emoji-regex: 9.2.2 + strip-ansi: 7.1.0 + dev: true + + /string.prototype.matchall@4.0.11: + resolution: {integrity: sha512-NUdh0aDavY2og7IbBPenWqR9exH+E26Sv8e0/eTe1tltDGZL+GtBkDAnnyBtmekfK6/Dq3MkcGtzXFEd1LQrtg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-errors: 1.3.0 + es-object-atoms: 1.0.0 + get-intrinsic: 1.2.4 + gopd: 1.0.1 + has-symbols: 1.0.3 + internal-slot: 1.0.7 + regexp.prototype.flags: 1.5.3 + set-function-name: 2.0.2 + side-channel: 1.0.6 + dev: true + + /string.prototype.repeat@1.0.0: + resolution: {integrity: sha512-0u/TldDbKD8bFCQ/4f5+mNRrXwZ8hg2w7ZR8wa16e8z9XpePWl3eGEcUD0OXpEH/VJH/2G3gjUtR3ZOiBe2S/w==} + dependencies: + define-properties: 1.2.1 + es-abstract: 1.23.3 + dev: true + + /string.prototype.trim@1.2.9: + resolution: {integrity: sha512-klHuCNxiMZ8MlsOihJhJEBJAiMVqU3Z2nEXWfWnIqjN0gEFS9J9+IxKozWWtQGcgoa1WUZzLjKPTr4ZHNFTFxw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-abstract: 1.23.3 + es-object-atoms: 1.0.0 + dev: true + + /string.prototype.trimend@1.0.8: + resolution: {integrity: sha512-p73uL5VCHCO2BZZ6krwwQE3kCzM7NKmis8S//xEC6fQonchbum4eP6kR4DLEjQFO3Wnj3Fuo8NM0kOSjVdHjZQ==} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /string.prototype.trimstart@1.0.8: + resolution: {integrity: sha512-UXSH262CSZY1tfu3G3Secr6uGLCFVPMhIqHjlgCUtCCcgihYc/xKs9djMTMUOb2j1mVSeU8EU6NWc/iQKU6Gfg==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + define-properties: 1.2.1 + es-object-atoms: 1.0.0 + dev: true + + /string_decoder@1.3.0: + resolution: {integrity: sha512-hkRX8U1WjJFd8LsDJ2yQ/wWWxaopEsABU1XfkM8A+j0+85JAGppt16cr1Whg6KIbb4okU6Mql6BOj+uup/wKeA==} + dependencies: + safe-buffer: 5.2.1 + dev: true + + /strip-ansi@6.0.1: + resolution: {integrity: sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A==} + engines: {node: '>=8'} + dependencies: + ansi-regex: 5.0.1 + dev: true + + /strip-ansi@7.1.0: + resolution: {integrity: sha512-iq6eVVI64nQQTRYq2KtEg2d2uU7LElhTJwsH4YzIHZshxlgZms/wIc4VoDQTlG/IvVIrBKG06CrZnp0qv7hkcQ==} + engines: {node: '>=12'} + dependencies: + ansi-regex: 6.1.0 + dev: true + + /strip-bom@3.0.0: + resolution: {integrity: sha512-vavAMRXOgBVNF6nyEEmL3DBK19iRpDcoIwW+swQ+CbGiu7lju6t+JklA1MHweoWtadgt4ISVUsXLyDq34ddcwA==} + engines: {node: '>=4'} + dev: true + + /strip-final-newline@3.0.0: + resolution: {integrity: sha512-dOESqjYr96iWYylGObzd39EuNTa5VJxyvVAEm5Jnh7KGo75V43Hk1odPQkNDyXNmUR6k+gEiDVXnjB8HJ3crXw==} + engines: {node: '>=12'} + dev: true + + /strip-indent@3.0.0: + resolution: {integrity: sha512-laJTa3Jb+VQpaC6DseHhF7dXVqHTfJPCRDaEbid/drOhgitgYku/letMUqOXFoWV0zIIUbjpdH2t+tYj4bQMRQ==} + engines: {node: '>=8'} + dependencies: + min-indent: 1.0.1 + dev: true + + /strip-indent@4.0.0: + resolution: {integrity: sha512-mnVSV2l+Zv6BLpSD/8V87CW/y9EmmbYzGCIavsnsI6/nwn26DwffM/yztm30Z/I2DY9wdS3vXVCMnHDgZaVNoA==} + engines: {node: '>=12'} + dependencies: + min-indent: 1.0.1 + dev: true + + /strip-json-comments@3.1.1: + resolution: {integrity: sha512-6fPc+R4ihwqP6N/aIv2f1gMH8lOVtWQHoqC4yK6oSDVVocumAsfCqjkXnqiYMhmMwS/mEHLp7Vehlt3ql6lEig==} + engines: {node: '>=8'} + dev: true + + /strip-json-comments@5.0.1: + resolution: {integrity: sha512-0fk9zBqO67Nq5M/m45qHCJxylV/DhBlIOVExqgOMiCCrzrhU6tCibRXNqE3jwJLftzE9SNuZtYbpzcO+i9FiKw==} + engines: {node: '>=14.16'} + dev: true + + /strip-literal@2.1.0: + resolution: {integrity: sha512-Op+UycaUt/8FbN/Z2TWPBLge3jWrP3xj10f3fnYxf052bKuS3EKs1ZQcVGjnEMdsNVAM+plXRdmjrZ/KgG3Skw==} + dependencies: + js-tokens: 9.0.0 + dev: true + + /stylis@4.2.0: + resolution: {integrity: sha512-Orov6g6BB1sDfYgzWfTHDOxamtX1bE/zo104Dh9e6fqJ3PooipYyfJ0pUmrZO2wAvO8YbEyeFrkV91XTsGMSrw==} + dev: false + + /stylis@4.3.4: + resolution: {integrity: sha512-osIBl6BGUmSfDkyH2mB7EFvCJntXDrLhKjHTRj/rK6xLH0yuPrHULDRQzKokSOD4VoorhtKpfcfW1GAntu8now==} + dev: false + + /summary@2.1.0: + resolution: {integrity: sha512-nMIjMrd5Z2nuB2RZCKJfFMjgS3fygbeyGk9PxPPaJR1RIcyN9yn4A63Isovzm3ZtQuEkLBVgMdPup8UeLH7aQw==} + dev: true + + /supports-color@5.5.0: + resolution: {integrity: sha512-QjVjwdXIt408MIiAqCX4oUKsgU2EqAGzs2Ppkm4aQYbjm+ZEWEcW4SfFNTr4uMNZma0ey4f5lgLrkB0aX0QMow==} + engines: {node: '>=4'} + dependencies: + has-flag: 3.0.0 + + /supports-color@7.2.0: + resolution: {integrity: sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw==} + engines: {node: '>=8'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-color@8.1.1: + resolution: {integrity: sha512-MpUEN2OodtUzxvKQl72cUF7RQ5EiHsGvSsVG0ia9c5RbWGL2CI4C7EpPS8UTBIplnlzZiNuV56w+FuNxy3ty2Q==} + engines: {node: '>=10'} + dependencies: + has-flag: 4.0.0 + dev: true + + /supports-color@9.4.0: + resolution: {integrity: sha512-VL+lNrEoIXww1coLPOmiEmK/0sGigko5COxI09KzHc2VJXJsQ37UaQ+8quuxjDeA7+KnLGTWRyOXSLLR2Wb4jw==} + engines: {node: '>=12'} + + /supports-preserve-symlinks-flag@1.0.0: + resolution: {integrity: sha512-ot0WnXS9fgdkgIcePe6RHNk1WA8+muPa6cSjeR3V8K27q9BB1rTE3R1p7Hv0z1ZyAc8s6Vvv8DIyWf681MAt0w==} + engines: {node: '>= 0.4'} + + /tapable@2.2.1: + resolution: {integrity: sha512-GNzQvQTOIP6RyTfE2Qxb8ZVlNmw0n88vp1szwWRimP02mnTsx3Wtn5qRdqY9w2XduFNUgvOwhNnQsjwCp+kqaQ==} + engines: {node: '>=6'} + dev: true + + /telejson@7.2.0: + resolution: {integrity: sha512-1QTEcJkJEhc8OnStBx/ILRu5J2p0GjvWsBx56bmZRqnrkdBMUe+nX92jxV+p3dB4CP6PZCdJMQJwCggkNBMzkQ==} + dependencies: + memoizerific: 1.11.3 + dev: true + + /test-exclude@6.0.0: + resolution: {integrity: sha512-cAGWPIyOHU6zlmg88jwm7VRyXnMN7iV68OGAbYDk/Mh/xC/pzVPlQtY6ngoIH/5/tciuhGfvESU8GrHrcxD56w==} + engines: {node: '>=8'} + dependencies: + '@istanbuljs/schema': 0.1.3 + glob: 7.2.3 + minimatch: 3.1.2 + dev: true + + /text-table@0.2.0: + resolution: {integrity: sha512-N+8UisAXDGk8PFXP4HAzVR9nbfmVJ3zYLAWiTIoqC5v5isinhr+r5uaO8+7r3BMfuNIufIsA7RdpVgacC2cSpw==} + dev: true + + /throttle-debounce@3.0.1: + resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} + engines: {node: '>=10'} + dev: false + + /tiny-invariant@1.3.3: + resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} + + /tinybench@2.9.0: + resolution: {integrity: sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg==} + dev: true + + /tinypool@0.8.4: + resolution: {integrity: sha512-i11VH5gS6IFeLY3gMBQ00/MmLncVP7JLXOw1vlgkytLmJK7QnEr7NXf0LBdxfmNPAeyetukOk0bOYrJrFGjYJQ==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyrainbow@1.2.0: + resolution: {integrity: sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@2.2.1: + resolution: {integrity: sha512-KYad6Vy5VDWV4GH3fjpseMQ/XU2BhIYP7Vzd0LG44qRWm/Yt2WCOTicFdvmgo6gWaqooMQCawTtILVQJupKu7A==} + engines: {node: '>=14.0.0'} + dev: true + + /tinyspy@3.0.2: + resolution: {integrity: sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q==} + engines: {node: '>=14.0.0'} + dev: true + + /to-fast-properties@2.0.0: + resolution: {integrity: sha512-/OaKK0xYrs3DmxRYqL/yDc+FxFUVYhDlXMhRmv3z915w2HF1tnN1omB354j8VUGO/hbRzyD6Y3sA7v7GS/ceog==} + engines: {node: '>=4'} + + /to-regex-range@5.0.1: + resolution: {integrity: sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==} + engines: {node: '>=8.0'} + dependencies: + is-number: 7.0.0 + dev: true + + /toggle-selection@1.0.6: + resolution: {integrity: sha512-BiZS+C1OS8g/q2RRbJmy59xpyghNBqrr6k5L/uKBGRsTfxmu3ffiRnd8mlGPUVayg8pvfi5urfnu8TU7DVOkLQ==} + dev: false + + /toidentifier@1.0.1: + resolution: {integrity: sha512-o5sSPKEkg/DIQNmH43V0/uerLrpzVedkUh8tGNvaeXpfpuwjKenlSox/2O/BTlZUtEe+JG7s5YhEz608PlAHRA==} + engines: {node: '>=0.6'} + dev: true + + /totalist@3.0.1: + resolution: {integrity: sha512-sf4i37nQ2LBx4m3wB74y+ubopq6W/dIzXg0FDGjsYnZHVa1Da8FH853wlL2gtUhg+xJXjfk3kUZS3BRoQeoQBQ==} + engines: {node: '>=6'} + dev: true + + /tr46@0.0.3: + resolution: {integrity: sha512-N3WMsuqV66lT30CrXNbEjx4GEwlow3v6rr4mCcv6prnfwhS01rkgyFdjPNBYd9br7LpXV1+Emh01fHnq2Gdgrw==} + + /tree-kill@1.2.2: + resolution: {integrity: sha512-L0Orpi8qGpRG//Nd+H90vFB+3iHnue1zSSGmNOOCh1GLJ7rUKVwV2HvijphGQS2UmhUZewS9VgvxYIdgr+fG1A==} + hasBin: true + dev: true + + /ts-api-utils@1.3.0(typescript@5.6.2): + resolution: {integrity: sha512-UQMIo7pb8WRomKR1/+MFVLTroIvDVtMX3K6OUir8ynLyzB8Jeriont2bTAtmNPa1ekAgN7YPDyf6V+ygrdU+eQ==} + engines: {node: '>=16'} + peerDependencies: + typescript: '>=4.2.0' + dependencies: + typescript: 5.6.2 + dev: true + + /ts-dedent@2.2.0: + resolution: {integrity: sha512-q5W7tVM71e2xjHZTlgfTDoPF/SmqKG5hddq9SzR49CH2hayqRKJtQ4mtRlSxKaJlR/+9rEM+mnBHf7I2/BQcpQ==} + engines: {node: '>=6.10'} + dev: true + + /ts-easing@0.2.0: + resolution: {integrity: sha512-Z86EW+fFFh/IFB1fqQ3/+7Zpf9t2ebOAxNI/V6Wo7r5gqiqtxmgTlQ1qbqQcjLKYeSHPTsEmvlJUDg/EuL0uHQ==} + dev: false + + /ts-error@1.0.6: + resolution: {integrity: sha512-tLJxacIQUM82IR7JO1UUkKlYuUTmoY9HBJAmNWFzheSlDS5SPMcNIepejHJa4BpPQLAcbRhRf3GDJzyj6rbKvA==} + dev: false + + /tsafe@1.7.5: + resolution: {integrity: sha512-tbNyyBSbwfbilFfiuXkSOj82a6++ovgANwcoqBAcO9/REPoZMEQoE8kWPeO0dy5A2D/2Lajr8Ohue5T0ifIvLQ==} + dev: true + + /tsconfck@3.1.3(typescript@5.6.2): + resolution: {integrity: sha512-ulNZP1SVpRDesxeMLON/LtWM8HIgAJEIVpVVhBM6gsmvQ8+Rh+ZG7FWGvHh7Ah3pRABwVJWklWCr/BTZSv0xnQ==} + engines: {node: ^18 || >=20} + hasBin: true + peerDependencies: + typescript: ^5.0.0 + peerDependenciesMeta: + typescript: + optional: true + dependencies: + typescript: 5.6.2 + dev: true + + /tsconfig-paths@3.15.0: + resolution: {integrity: sha512-2Ac2RgzDe/cn48GvOe3M+o82pEFewD3UPbyoUHHdKasHwJKjds4fLXWf/Ux5kATBKN20oaFGu+jbElp1pos0mg==} + dependencies: + '@types/json5': 0.0.29 + json5: 1.0.2 + minimist: 1.2.8 + strip-bom: 3.0.0 + dev: true + + /tsconfig-paths@4.2.0: + resolution: {integrity: sha512-NoZ4roiN7LnbKn9QqE1amc9DJfzvZXxF4xDavcOWt1BPkdx+m+0gJuPM+S0vCe7zTJMYUP0R8pO2XMr+Y8oLIg==} + engines: {node: '>=6'} + dependencies: + json5: 2.2.3 + minimist: 1.2.8 + strip-bom: 3.0.0 + dev: true + + /tslib@1.14.1: + resolution: {integrity: sha512-Xni35NKzjgMrwevysHTCArtLDpPvye8zV/0E4EyYn43P7/7qvQwPh9BGkHewbMulVntbigmcT7rdX3BNo9wRJg==} + dev: true + + /tslib@2.4.0: + resolution: {integrity: sha512-d6xOpEDfsi2CZVlPQzGeux8XMwLT9hssAsaPYExaQMuYskwb+x1x7J371tWlbBdWHroy99KnVB6qIkUbs5X3UQ==} + dev: false + + /tslib@2.7.0: + resolution: {integrity: sha512-gLXCKdN1/j47AiHiOkJN69hJmcbGTHI0ImLmbYLHykhgeN0jVGola9yVjFgzCUklsZQMW55o+dW7IXv3RCXDzA==} + + /tsutils@3.21.0(typescript@5.6.2): + resolution: {integrity: sha512-mHKK3iUXL+3UF6xL5k0PEhKRUBKPBCv/+RkEOpjRWxxx27KKRBmmA60A9pgOUvMi8GKhRMPEmjBRPzs2W7O1OA==} + engines: {node: '>= 6'} + peerDependencies: + typescript: '>=2.8.0 || >= 3.2.0-dev || >= 3.3.0-dev || >= 3.4.0-dev || >= 3.5.0-dev || >= 3.6.0-dev || >= 3.6.0-beta || >= 3.7.0-dev || >= 3.7.0-beta' + dependencies: + tslib: 1.14.1 + typescript: 5.6.2 + dev: true + + /type-check@0.4.0: + resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} + engines: {node: '>= 0.8.0'} + dependencies: + prelude-ls: 1.2.1 + dev: true + + /type-detect@4.1.0: + resolution: {integrity: sha512-Acylog8/luQ8L7il+geoSxhEkazvkslg7PSNKOX59mbB9cOveP5aq9h74Y7YU8yDpJwetzQQrfIwtf4Wp4LKcw==} + engines: {node: '>=4'} + dev: true + + /type-fest@0.20.2: + resolution: {integrity: sha512-Ne+eE4r0/iWnpAxD852z3A+N0Bt5RN//NjJwRd2VFHEmrywxf5vsZlh4R6lixl6B+wz/8d+maTSAkN1FIkI3LQ==} + engines: {node: '>=10'} + dev: true + + /type-fest@2.19.0: + resolution: {integrity: sha512-RAH822pAdBgcNMAfWnCBU3CFZcfZ/i1eZjwFU/dsLKumyuuP3niueg2UAukXYF0E2AAoc82ZSSf9J0WQBinzHA==} + engines: {node: '>=12.20'} + + /type-fest@4.26.1: + resolution: {integrity: sha512-yOGpmOAL7CkKe/91I5O3gPICmJNLJ1G4zFYVAsRHg7M64biSnPtRj0WNQt++bRkjYOqjWXrhnUw1utzmVErAdg==} + engines: {node: '>=16'} + dev: true + + /type-is@1.6.18: + resolution: {integrity: sha512-TkRKr9sUTxEH8MdfuCSP7VizJyzRNMjj2J2do2Jr3Kym598JVdEksuzPQCnlFPW4ky9Q+iA+ma9BGm06XQBy8g==} + engines: {node: '>= 0.6'} + dependencies: + media-typer: 0.3.0 + mime-types: 2.1.35 + dev: true + + /typed-array-buffer@1.0.2: + resolution: {integrity: sha512-gEymJYKZtKXzzBzM4jqa9w6Q1Jjm7x2d+sh19AdsD4wqnMPDYyvwpsIc2Q/835kHuo3BEQ7CjelGhfTsoBb2MQ==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + es-errors: 1.3.0 + is-typed-array: 1.1.13 + dev: true + + /typed-array-byte-length@1.0.1: + resolution: {integrity: sha512-3iMJ9q0ao7WE9tWcaYKIptkNBuOIcZCCT0d4MRvuuH88fEoEH62IuQe0OtraD3ebQEoTRk8XCBoknUNc1Y67pw==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + dev: true + + /typed-array-byte-offset@1.0.2: + resolution: {integrity: sha512-Ous0vodHa56FviZucS2E63zkgtgrACj7omjwd/8lTEMEPFFyjfixMZ1ZXenpgCFBBt4EC1J2XsyVS2gkG0eTFA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + dev: true + + /typed-array-length@1.0.6: + resolution: {integrity: sha512-/OxDN6OtAk5KBpGb28T+HZc2M+ADtvRxXrKKbUwtsLgdoxgX13hyy7ek6bFRl5+aBs2yZzB0c4CnQfAtVypW/g==} + engines: {node: '>= 0.4'} + dependencies: + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-proto: 1.0.3 + is-typed-array: 1.1.13 + possible-typed-array-names: 1.0.0 + dev: true + + /typescript@5.4.2: + resolution: {integrity: sha512-+2/g0Fds1ERlP6JsakQQDXjZdZMM+rqpamFZJEKh4kwTIn3iDkgKtby0CeNd5ATNZ4Ry1ax15TMx0W2V+miizQ==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /typescript@5.6.2: + resolution: {integrity: sha512-NW8ByodCSNCwZeghjN3o+JX5OFH0Ojg6sadjEKY4huZ52TqbJTJnDo5+Tw98lSy63NZvi4n+ez5m2u5d4PkZyw==} + engines: {node: '>=14.17'} + hasBin: true + dev: true + + /ufo@1.5.4: + resolution: {integrity: sha512-UsUk3byDzKd04EyoZ7U4DOlxQaD14JUKQl6/P7wiX4FNvUfm3XL246n9W5AmqwW5RSFJ27NAuM0iLscAOYUiGQ==} + dev: true + + /unbox-primitive@1.0.2: + resolution: {integrity: sha512-61pPlCD9h51VoreyJ0BReideM3MDKMKnh6+V9L08331ipq6Q8OFXZYiqP6n/tbHx4s5I9uRhcye6BrbkizkBDw==} + dependencies: + call-bind: 1.0.7 + has-bigints: 1.0.2 + has-symbols: 1.0.3 + which-boxed-primitive: 1.0.2 + dev: true + + /undici-types@6.19.8: + resolution: {integrity: sha512-ve2KP6f/JnbPBFyobGHuerC9g1FYGn/F8n1LWTwNxCEzd6IfqTwUQcNXgEtmmQ6DlRrC1hrSrBnCZPokRrDHjw==} + dev: true + + /unist-util-is@6.0.0: + resolution: {integrity: sha512-2qCTHimwdxLfz+YzdGfkqNlH0tLi9xjTnHddPmJwtIG9MGsdbutfTc4P+haPD7l7Cjxf/WZj+we5qfVPvvxfYw==} + dependencies: + '@types/unist': 3.0.3 + dev: true + + /unist-util-visit-parents@6.0.1: + resolution: {integrity: sha512-L/PqWzfTP9lzzEa6CKs0k2nARxTdZduw3zyh8d2NVBnsyvHjSX4TWse388YrrQKbvI8w20fGjGlhgT96WwKykw==} + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + dev: true + + /unist-util-visit@5.0.0: + resolution: {integrity: sha512-MR04uvD+07cwl/yhVuVWAtw+3GOR/knlL55Nd/wAdblk27GCVt3lqpTivy/tkJcZoNPzTwS1Y+KMojlLDhoTzg==} + dependencies: + '@types/unist': 3.0.3 + unist-util-is: 6.0.0 + unist-util-visit-parents: 6.0.1 + dev: true + + /universalify@0.1.2: + resolution: {integrity: sha512-rBJeI5CXAlmy1pV+617WB9J63U6XcazHHF2f2dbJix4XzpUF0RS3Zbj0FGIOCAva5P/d/GBOYaACQ1w+0azUkg==} + engines: {node: '>= 4.0.0'} + dev: true + + /universalify@2.0.1: + resolution: {integrity: sha512-gptHNQghINnc/vTGIk0SOFGFNXw7JVrlRUtConJRlvaw6DuX0wO5Jeko9sWrMBhh+PsYAZ7oXAiOnf/UKogyiw==} + engines: {node: '>= 10.0.0'} + dev: true + + /unpipe@1.0.0: + resolution: {integrity: sha512-pjy2bYhSsufwWlKwPc+l3cN7+wuJlK6uz0YdJEOlQDbl6jo/YlPi4mb8agUkVC8BF7V8NuzeyPNqRksA3hztKQ==} + engines: {node: '>= 0.8'} + dev: true + + /unplugin@1.14.1: + resolution: {integrity: sha512-lBlHbfSFPToDYp9pjXlUEFVxYLaue9f9T1HC+4OHlmj+HnMDdz9oZY+erXfoCe/5V/7gKUSY2jpXPb9S7f0f/w==} + engines: {node: '>=14.0.0'} + peerDependencies: + webpack-sources: ^3 + peerDependenciesMeta: + webpack-sources: + optional: true + dependencies: + acorn: 8.12.1 + webpack-virtual-modules: 0.6.2 + dev: true + + /update-browserslist-db@1.1.1(browserslist@4.24.0): + resolution: {integrity: sha512-R8UzCaa9Az+38REPiJ1tXlImTJXlVfgHZsglwBD/k6nj76ctsH1E3q4doGrukiLQd3sGQYu56r5+lo5r94l29A==} + hasBin: true + peerDependencies: + browserslist: '>= 4.21.0' + dependencies: + browserslist: 4.24.0 + escalade: 3.2.0 + picocolors: 1.1.0 + dev: true + + /uri-js-replace@1.0.1: + resolution: {integrity: sha512-W+C9NWNLFOoBI2QWDp4UT9pv65r2w5Cx+3sTYFvtMdDBxkKt1syCqsUdSFAChbEe1uK5TfS04wt/nGwmaeIQ0g==} + dev: true + + /uri-js@4.4.1: + resolution: {integrity: sha512-7rKUyy33Q1yc98pQ1DAmLtwX109F7TIfWlW1Ydo8Wl1ii1SeHieeh0HHfPeL2fMXK6z0s8ecKs9frCuLJvndBg==} + dependencies: + punycode: 2.3.1 + dev: true + + /use-callback-ref@1.3.2(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-elOQwe6Q8gqZgDA8mrh44qRTQqpIHDcZ3hXTLjBe1i4ph8XpNJnO+aQf3NaG+lriLopI4HMx9VjQLfPQ6vhnoA==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.8.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + tslib: 2.7.0 + dev: false + + /use-debounce@10.0.3(react@18.3.1): + resolution: {integrity: sha512-DxQSI9ZKso689WM1mjgGU3ozcxU1TJElBJ3X6S4SMzMNcm2lVH0AHmyXB+K7ewjz2BSUKJTDqTcwtSMRfB89dg==} + engines: {node: '>= 16.0.0'} + peerDependencies: + react: '*' + dependencies: + react: 18.3.1 + dev: false + + /use-device-pixel-ratio@1.1.2(react@18.3.1): + resolution: {integrity: sha512-nFxV0HwLdRUt20kvIgqHYZe6PK/v4mU1X8/eLsT1ti5ck0l2ob0HDRziaJPx+YWzBo6dMm4cTac3mcyk68Gh+A==} + peerDependencies: + react: '>=16.8.0' + dependencies: + react: 18.3.1 + dev: false + + /use-isomorphic-layout-effect@1.1.2(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-49L8yCO3iGT/ZF9QttjwLF/ZD9Iwto5LnH5LmEdk/6cFmXddqi2ulF0edxTwjj+7mqvpVVGQWvbXZdn32wRSHA==} + peerDependencies: + '@types/react': '*' + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + dev: false + + /use-sidecar@1.1.2(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-epTbsLuzZ7lPClpz2TyryBfztm7m+28DlEv2ZCQ3MDr5ssiwyOwGH/e5F9CkfWjJ1t4clvI58yF822/GUkjjhw==} + engines: {node: '>=10'} + peerDependencies: + '@types/react': ^16.9.0 || ^17.0.0 || ^18.0.0 + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + peerDependenciesMeta: + '@types/react': + optional: true + dependencies: + '@types/react': 18.3.11 + detect-node-es: 1.1.0 + react: 18.3.1 + tslib: 2.7.0 + dev: false + + /use-sync-external-store@1.2.2(react@18.3.1): + resolution: {integrity: sha512-PElTlVMwpblvbNqQ82d2n6RjStvdSoNe9FG28kNfz3WiXilJm4DdNkEzRhCZuIDwY8U08WVihhGR5iRqAwfDiw==} + peerDependencies: + react: ^16.8.0 || ^17.0.0 || ^18.0.0 + dependencies: + react: 18.3.1 + dev: false + + /util-deprecate@1.0.2: + resolution: {integrity: sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==} + dev: true + + /util@0.12.5: + resolution: {integrity: sha512-kZf/K6hEIrWHI6XqOFUiiMa+79wE/D8Q+NCNAWclkyg3b4d2k7s0QGepNjiABc+aR3N1PAyHL7p6UcLY6LmrnA==} + dependencies: + inherits: 2.0.4 + is-arguments: 1.1.1 + is-generator-function: 1.0.10 + is-typed-array: 1.1.13 + which-typed-array: 1.1.15 + dev: true + + /utils-merge@1.0.1: + resolution: {integrity: sha512-pMZTvIkT1d+TFGvDOqodOclx0QWkkgi6Tdoa8gC8ffGAAqz9pzPTZWAybbsHHoED/ztMtkv/VoYTYyShUn81hA==} + engines: {node: '>= 0.4.0'} + dev: true + + /uuid@10.0.0: + resolution: {integrity: sha512-8XkAphELsDnEGrDxUOHB3RGvXz6TeuYSGEZBOjtTtPm2lwhGBjLgOzLHB63IUWfBpNucQjND6d3AOudO+H3RWQ==} + hasBin: true + dev: false + + /uuid@9.0.1: + resolution: {integrity: sha512-b+1eJOlsR9K8HJpow9Ok3fiWOWSIcIzXodvv0rQjVoOVNpWMpxf1wZNpt4y9h10odCNrqnYp1OBzRktckBe3sA==} + hasBin: true + dev: true + + /validator@13.12.0: + resolution: {integrity: sha512-c1Q0mCiPlgdTVVVIJIrBuxNicYE+t/7oKeI9MWLj3fh/uq2Pxh/3eeWbVZ4OcGW1TUf53At0njHw5SMdA3tmMg==} + engines: {node: '>= 0.10'} + dev: true + + /vary@1.1.2: + resolution: {integrity: sha512-BNGbWLfd0eUPabhkXUVm0j8uuvREyTh5ovRa/dyow/BqAbZJyC+5fU+IzQOzmAKzYqYRAISoRhdQr3eIZ/PXqg==} + engines: {node: '>= 0.8'} + dev: true + + /vite-node@1.6.0(@types/node@20.16.10): + resolution: {integrity: sha512-de6HJgzC+TFzOu0NTC4RAIsyf/DY/ibWDYQUcuEA84EMHhcefTUGkjFHKKEJhQN4A+6I0u++kr3l36ZF2d7XRw==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + dependencies: + cac: 6.7.14 + debug: 4.3.7(supports-color@9.4.0) + pathe: 1.1.2 + picocolors: 1.1.0 + vite: 5.4.8(@types/node@20.16.10) + transitivePeerDependencies: + - '@types/node' + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true + + /vite-plugin-css-injected-by-js@3.5.2(vite@5.4.8): + resolution: {integrity: sha512-2MpU/Y+SCZyWUB6ua3HbJCrgnF0KACAsmzOQt1UvRVJCGF6S8xdA3ZUhWcWdM9ivG4I5az8PnQmwwrkC2CAQrQ==} + peerDependencies: + vite: '>2.0.0-0' + dependencies: + vite: 5.4.8(@types/node@20.16.10) + dev: true + + /vite-plugin-dts@3.9.1(@types/node@20.16.10)(typescript@5.6.2)(vite@5.4.8): + resolution: {integrity: sha512-rVp2KM9Ue22NGWB8dNtWEr+KekN3rIgz1tWD050QnRGlriUCmaDwa7qA5zDEjbXg5lAXhYMSBJtx3q3hQIJZSg==} + engines: {node: ^14.18.0 || >=16.0.0} + peerDependencies: + typescript: '*' + vite: '*' + peerDependenciesMeta: + vite: + optional: true + dependencies: + '@microsoft/api-extractor': 7.43.0(@types/node@20.16.10) + '@rollup/pluginutils': 5.1.2 + '@vue/language-core': 1.8.27(typescript@5.6.2) + debug: 4.3.7(supports-color@9.4.0) + kolorist: 1.8.0 + magic-string: 0.30.11 + typescript: 5.6.2 + vite: 5.4.8(@types/node@20.16.10) + vue-tsc: 1.8.27(typescript@5.6.2) + transitivePeerDependencies: + - '@types/node' + - rollup + - supports-color + dev: true + + /vite-plugin-eslint@1.8.1(eslint@8.57.1)(vite@5.4.8): + resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==} + peerDependencies: + eslint: '>=7' + vite: '>=2' + dependencies: + '@rollup/pluginutils': 4.2.1 + '@types/eslint': 8.56.12 + eslint: 8.57.1 + rollup: 2.79.2 + vite: 5.4.8(@types/node@20.16.10) + dev: true + + /vite-tsconfig-paths@4.3.2(typescript@5.6.2)(vite@5.4.8): + resolution: {integrity: sha512-0Vd/a6po6Q+86rPlntHye7F31zA2URZMbH8M3saAZ/xR9QoGN/L21bxEGfXdWmFdNkqPpRdxFT7nmNe12e9/uA==} + peerDependencies: + vite: '*' + peerDependenciesMeta: + vite: + optional: true + dependencies: + debug: 4.3.7(supports-color@9.4.0) + globrex: 0.1.2 + tsconfck: 3.1.3(typescript@5.6.2) + vite: 5.4.8(@types/node@20.16.10) + transitivePeerDependencies: + - supports-color + - typescript + dev: true + + /vite@5.4.8(@types/node@20.16.10): + resolution: {integrity: sha512-FqrItQ4DT1NC4zCUqMB4c4AZORMKIa0m8/URVCZ77OZ/QSNeJ54bU1vrFADbDsuwfIPcgknRkmqakQcgnL4GiQ==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@types/node': ^18.0.0 || >=20.0.0 + less: '*' + lightningcss: ^1.21.0 + sass: '*' + sass-embedded: '*' + stylus: '*' + sugarss: '*' + terser: ^5.4.0 + peerDependenciesMeta: + '@types/node': + optional: true + less: + optional: true + lightningcss: + optional: true + sass: + optional: true + sass-embedded: + optional: true + stylus: + optional: true + sugarss: + optional: true + terser: + optional: true + dependencies: + '@types/node': 20.16.10 + esbuild: 0.21.5 + postcss: 8.4.47 + rollup: 4.24.0 + optionalDependencies: + fsevents: 2.3.3 + dev: true + + /vitest@1.6.0(@types/node@20.16.10)(@vitest/ui@1.6.0): + resolution: {integrity: sha512-H5r/dN06swuFnzNFhq/dnz37bPXnq8xB2xB5JOVk8K09rUtoeNN+LHWkoQ0A/i3hvbUKKcCei9KpbxqHMLhLLA==} + engines: {node: ^18.0.0 || >=20.0.0} + hasBin: true + peerDependencies: + '@edge-runtime/vm': '*' + '@types/node': ^18.0.0 || >=20.0.0 + '@vitest/browser': 1.6.0 + '@vitest/ui': 1.6.0 + happy-dom: '*' + jsdom: '*' + peerDependenciesMeta: + '@edge-runtime/vm': + optional: true + '@types/node': + optional: true + '@vitest/browser': + optional: true + '@vitest/ui': + optional: true + happy-dom: + optional: true + jsdom: + optional: true + dependencies: + '@types/node': 20.16.10 + '@vitest/expect': 1.6.0 + '@vitest/runner': 1.6.0 + '@vitest/snapshot': 1.6.0 + '@vitest/spy': 1.6.0 + '@vitest/ui': 1.6.0(vitest@1.6.0) + '@vitest/utils': 1.6.0 + acorn-walk: 8.3.4 + chai: 4.5.0 + debug: 4.3.7(supports-color@9.4.0) + execa: 8.0.1 + local-pkg: 0.5.0 + magic-string: 0.30.11 + pathe: 1.1.2 + picocolors: 1.1.0 + std-env: 3.7.0 + strip-literal: 2.1.0 + tinybench: 2.9.0 + tinypool: 0.8.4 + vite: 5.4.8(@types/node@20.16.10) + vite-node: 1.6.0(@types/node@20.16.10) + why-is-node-running: 2.3.0 + transitivePeerDependencies: + - less + - lightningcss + - sass + - sass-embedded + - stylus + - sugarss + - supports-color + - terser + dev: true + + /void-elements@3.1.0: + resolution: {integrity: sha512-Dhxzh5HZuiHQhbvTW9AMetFfBHDMYpo23Uo9btPXgdYP+3T5S+p+jgNy7spra+veYhBP2dCSgxR/i2Y02h5/6w==} + engines: {node: '>=0.10.0'} + dev: false + + /vue-template-compiler@2.7.16: + resolution: {integrity: sha512-AYbUWAJHLGGQM7+cNTELw+KsOG9nl2CnSv467WobS5Cv9uk3wFcnr1Etsz2sEIHEZvw1U+o9mRlEO6QbZvUPGQ==} + dependencies: + de-indent: 1.0.2 + he: 1.2.0 + dev: true + + /vue-tsc@1.8.27(typescript@5.6.2): + resolution: {integrity: sha512-WesKCAZCRAbmmhuGl3+VrdWItEvfoFIPXOvUJkjULi+x+6G/Dy69yO3TBRJDr9eUlmsNAwVmxsNZxvHKzbkKdg==} + hasBin: true + peerDependencies: + typescript: '*' + dependencies: + '@volar/typescript': 1.11.1 + '@vue/language-core': 1.8.27(typescript@5.6.2) + semver: 7.6.3 + typescript: 5.6.2 + dev: true + + /wcwidth@1.0.1: + resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + dependencies: + defaults: 1.0.4 + dev: true + + /webidl-conversions@3.0.1: + resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} + + /webpack-virtual-modules@0.6.2: + resolution: {integrity: sha512-66/V2i5hQanC51vBQKPH4aI8NMAcBW59FVBs+rC7eGHupMyfn34q7rZIE+ETlJ+XTevqfUhVVBgSUNSW2flEUQ==} + dev: true + + /whatwg-url@5.0.0: + resolution: {integrity: sha512-saE57nupxk6v3HY35+jzBwYa0rKSy0XR8JSxZPwgLr7ys0IBzhGviA1/TUGJLmSVqs8pb9AnvICXEuOHLprYTw==} + dependencies: + tr46: 0.0.3 + webidl-conversions: 3.0.1 + + /which-boxed-primitive@1.0.2: + resolution: {integrity: sha512-bwZdv0AKLpplFY2KZRX6TvyuN7ojjr7lwkg6ml0roIy9YeuSr7JS372qlNW18UQYzgYK9ziGcerWqZOmEn9VNg==} + dependencies: + is-bigint: 1.0.4 + is-boolean-object: 1.1.2 + is-number-object: 1.0.7 + is-string: 1.0.7 + is-symbol: 1.0.4 + dev: true + + /which-builtin-type@1.1.4: + resolution: {integrity: sha512-bppkmBSsHFmIMSl8BO9TbsyzsvGjVoppt8xUiGzwiu/bhDCGxnpOKCxgqj6GuyHE0mINMDecBFPlOm2hzY084w==} + engines: {node: '>= 0.4'} + dependencies: + function.prototype.name: 1.1.6 + has-tostringtag: 1.0.2 + is-async-function: 2.0.0 + is-date-object: 1.0.5 + is-finalizationregistry: 1.0.2 + is-generator-function: 1.0.10 + is-regex: 1.1.4 + is-weakref: 1.0.2 + isarray: 2.0.5 + which-boxed-primitive: 1.0.2 + which-collection: 1.0.2 + which-typed-array: 1.1.15 + dev: true + + /which-collection@1.0.2: + resolution: {integrity: sha512-K4jVyjnBdgvc86Y6BkaLZEN933SwYOuBFkdmBu9ZfkcAbdVbpITnDmjvZ/aQjRXQrv5EPkTnD1s39GiiqbngCw==} + engines: {node: '>= 0.4'} + dependencies: + is-map: 2.0.3 + is-set: 2.0.3 + is-weakmap: 2.0.2 + is-weakset: 2.0.3 + dev: true + + /which-typed-array@1.1.15: + resolution: {integrity: sha512-oV0jmFtUky6CXfkqehVvBP/LSWJ2sy4vWMioiENyJLePrBO/yKyV9OyJySfAKosh+RYkIl5zJCNZ8/4JncrpdA==} + engines: {node: '>= 0.4'} + dependencies: + available-typed-arrays: 1.0.7 + call-bind: 1.0.7 + for-each: 0.3.3 + gopd: 1.0.1 + has-tostringtag: 1.0.2 + dev: true + + /which@2.0.2: + resolution: {integrity: sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA==} + engines: {node: '>= 8'} + hasBin: true + dependencies: + isexe: 2.0.0 + dev: true + + /why-is-node-running@2.3.0: + resolution: {integrity: sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w==} + engines: {node: '>=8'} + hasBin: true + dependencies: + siginfo: 2.0.0 + stackback: 0.0.2 + dev: true + + /word-wrap@1.2.5: + resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} + engines: {node: '>=0.10.0'} + dev: true + + /wrap-ansi@7.0.0: + resolution: {integrity: sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q==} + engines: {node: '>=10'} + dependencies: + ansi-styles: 4.3.0 + string-width: 4.2.3 + strip-ansi: 6.0.1 + dev: true + + /wrap-ansi@8.1.0: + resolution: {integrity: sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ==} + engines: {node: '>=12'} + dependencies: + ansi-styles: 6.2.1 + string-width: 5.1.2 + strip-ansi: 7.1.0 + dev: true + + /wrappy@1.0.2: + resolution: {integrity: sha512-l4Sp/DRseor9wL6EvV2+TuQn63dMkPjZ/sp9XkghTEbV9KlPS1xUsZ3u7/IQO4wxtcFB4bgpQPRcR3QCvezPcQ==} + dev: true + + /ws@8.17.1: + resolution: {integrity: sha512-6XQFvXTkbfUOZOKKILFG1PDK2NDQs4azKQl26T0YS5CxqWLgXajbPZ+h4gZekJyRqFU8pvnbAbbs/3TgRPy+GQ==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: false + + /ws@8.18.0: + resolution: {integrity: sha512-8VbfWfHLbbwu3+N6OKsOMpBdT4kXPDDB9cJk2bJ6mh9ucxdlnNvH1e+roYkKmN9Nxw2yjz7VzeO9oOz2zJ04Pw==} + engines: {node: '>=10.0.0'} + peerDependencies: + bufferutil: ^4.0.1 + utf-8-validate: '>=5.0.2' + peerDependenciesMeta: + bufferutil: + optional: true + utf-8-validate: + optional: true + dev: true + + /xmlhttprequest-ssl@2.1.1: + resolution: {integrity: sha512-ptjR8YSJIXoA3Mbv5po7RtSYHO6mZr8s7i5VGmEk7QY2pQWyT1o0N+W1gKbOyJPUCGXGnuw0wqe8f0L6Y0ny7g==} + engines: {node: '>=0.4.0'} + dev: false + + /y18n@5.0.8: + resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==} + engines: {node: '>=10'} + dev: true + + /yallist@3.1.1: + resolution: {integrity: sha512-a4UGQaWPH59mOXUYnAG2ewncQS4i4F43Tv3JoAM+s2VDAmS9NsK8GpDMLrCHPksFT7h3K6TOoUNn2pb7RoXx4g==} + dev: true + + /yallist@4.0.0: + resolution: {integrity: sha512-3wdGidZyq5PB084XLES5TpOSRA3wjXAlIWMhum2kRcv/41Sn2emQ0dycQW4uZXLejwKvg6EsvbdlVL+FYEct7A==} + dev: true + + /yaml-ast-parser@0.0.43: + resolution: {integrity: sha512-2PTINUwsRqSd+s8XxKaJWQlUuEMHJQyEuh2edBbW8KNJz0SJPwUSD2zRWqezFEdN7IzAgeuYHFUCF7o8zRdZ0A==} + dev: true + + /yaml@1.10.2: + resolution: {integrity: sha512-r3vXyErRCYJ7wg28yvBY5VSoAF8ZvlcW9/BwUzEtUsjvX/DKs24dIkuwjtuprwJJHsbyUbLApepYTR1BN4uHrg==} + engines: {node: '>= 6'} + dev: false + + /yargs-parser@21.1.1: + resolution: {integrity: sha512-tVpsJW7DdjecAiFpbIB1e3qxIQsE6NoPc5/eTdrbbIC4h0LVsWhnoa3g+m2HclBIujHzsxZ4VJVA+GUuc2/LBw==} + engines: {node: '>=12'} + dev: true + + /yargs@17.7.2: + resolution: {integrity: sha512-7dSzzRQ++CKnNI/krKnYRV7JKKPUXMEh61soaHKg9mrWEhzFWhFnxPxGl+69cD1Ou63C13NUPCnmIcrvqCuM6w==} + engines: {node: '>=12'} + dependencies: + cliui: 8.0.1 + escalade: 3.2.0 + get-caller-file: 2.0.5 + require-directory: 2.1.1 + string-width: 4.2.3 + y18n: 5.0.8 + yargs-parser: 21.1.1 + dev: true + + /yocto-queue@0.1.0: + resolution: {integrity: sha512-rVksvsnNCdJ/ohGc6xgPwyN8eheCxsiLM8mxuE/t/mOVqJewPuO1miLpTHQiRgTKCLexL4MeAFVagts7HmNZ2Q==} + engines: {node: '>=10'} + dev: true + + /yocto-queue@1.1.1: + resolution: {integrity: sha512-b4JR1PFR10y1mKjhHY9LaGo6tmrgjit7hxVIeAmyMw3jegXR4dhYqLaQF5zMXZxY7tLpMyJeLjr1C4rLmkVe8g==} + engines: {node: '>=12.20'} + dev: true + + /z-schema@5.0.5: + resolution: {integrity: sha512-D7eujBWkLa3p2sIpJA0d1pr7es+a7m0vFAnZLlCEKq/Ij2k0MLi9Br2UPxoxdYystm5K1yeBGzub0FlYUEWj2Q==} + engines: {node: '>=8.0.0'} + hasBin: true + dependencies: + lodash.get: 4.4.2 + lodash.isequal: 4.5.0 + validator: 13.12.0 + optionalDependencies: + commander: 9.5.0 + dev: true + + /zod-validation-error@3.4.0(zod@3.23.8): + resolution: {integrity: sha512-ZOPR9SVY6Pb2qqO5XHt+MkkTRxGXb4EVtnjc9JpXUOtUB1T9Ru7mZOT361AN3MsetVe7R0a1KZshJDZdgp9miQ==} + engines: {node: '>=18.0.0'} + peerDependencies: + zod: ^3.18.0 + dependencies: + zod: 3.23.8 + + /zod@3.23.8: + resolution: {integrity: sha512-XBx9AXhXktjUqnepgTiE5flcKIYWi/rme0Eaj+5Y0lftuGBq+jyRu/md4WnuxqgP1ubdpNCsYEYPxrzVHD8d6g==} + + /zustand@4.5.5(@types/react@18.3.11)(react@18.3.1): + resolution: {integrity: sha512-+0PALYNJNgK6hldkgDq2vLrw5f6g/jCInz52n9RTpropGgeAf/ioFUCdtsjCqu4gNhW9D01rUQBROoRjdzyn2Q==} + engines: {node: '>=12.7.0'} + peerDependencies: + '@types/react': '>=16.8' + immer: '>=9.0.6' + react: '>=16.8' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + dependencies: + '@types/react': 18.3.11 + react: 18.3.1 + use-sync-external-store: 1.2.2(react@18.3.1) + dev: false diff --git a/invokeai/frontend/web/public/assets/images/commercial-license-bg.png b/invokeai/frontend/web/public/assets/images/commercial-license-bg.png new file mode 100644 index 0000000000000000000000000000000000000000..a5e8c3a002900858f22686e1ef3358a64cb1fa5f Binary files /dev/null and b/invokeai/frontend/web/public/assets/images/commercial-license-bg.png differ diff --git a/invokeai/frontend/web/public/assets/images/denoising-strength.png b/invokeai/frontend/web/public/assets/images/denoising-strength.png new file mode 100644 index 0000000000000000000000000000000000000000..b286298a5d8f6a0f45af402ec131fd24bd3ebfcf Binary files /dev/null and b/invokeai/frontend/web/public/assets/images/denoising-strength.png differ diff --git a/invokeai/frontend/web/public/assets/images/invoke-alert-favicon.svg b/invokeai/frontend/web/public/assets/images/invoke-alert-favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..d0a40b9f0118e920300f2e95178f30a35d197acd --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-alert-favicon.svg @@ -0,0 +1,5 @@ + + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-avatar-circle.svg b/invokeai/frontend/web/public/assets/images/invoke-avatar-circle.svg new file mode 100644 index 0000000000000000000000000000000000000000..73221cabf3aaddcf94f9aa97dcc414360d4f72f2 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-avatar-circle.svg @@ -0,0 +1,4 @@ + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-avatar-square.svg b/invokeai/frontend/web/public/assets/images/invoke-avatar-square.svg new file mode 100644 index 0000000000000000000000000000000000000000..1470b8cb79d87309b3567b7864ca4880341eeb4e --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-avatar-square.svg @@ -0,0 +1,4 @@ + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-favicon.png b/invokeai/frontend/web/public/assets/images/invoke-favicon.png new file mode 100644 index 0000000000000000000000000000000000000000..f9fa13c242f521043c9a61f4e487af944714d20e Binary files /dev/null and b/invokeai/frontend/web/public/assets/images/invoke-favicon.png differ diff --git a/invokeai/frontend/web/public/assets/images/invoke-favicon.svg b/invokeai/frontend/web/public/assets/images/invoke-favicon.svg new file mode 100644 index 0000000000000000000000000000000000000000..b1daa84f45bd27253f138a035e455995a588a6c5 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-favicon.svg @@ -0,0 +1,4 @@ + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-key-char-lrg.svg b/invokeai/frontend/web/public/assets/images/invoke-key-char-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..2df569b9397541497fc49e516284f5db47444e8b --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-key-char-lrg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-key-char-sml.svg b/invokeai/frontend/web/public/assets/images/invoke-key-char-sml.svg new file mode 100644 index 0000000000000000000000000000000000000000..a280e5a67bd6fbef6666d9fa1a84e810e6260829 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-key-char-sml.svg @@ -0,0 +1,4 @@ + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-key-wht-lrg.svg b/invokeai/frontend/web/public/assets/images/invoke-key-wht-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..91f210c82bff48f84b1fce49e4186d05d1516d08 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-key-wht-lrg.svg @@ -0,0 +1,4 @@ + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-key-wht-sml.svg b/invokeai/frontend/web/public/assets/images/invoke-key-wht-sml.svg new file mode 100644 index 0000000000000000000000000000000000000000..ef27d397bf87d60466ee98dd97613b6c47fc5f69 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-key-wht-sml.svg @@ -0,0 +1,4 @@ + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-symbol-char-lrg.svg b/invokeai/frontend/web/public/assets/images/invoke-symbol-char-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..067a22386b49558c4ad0972ce9d303aa03acd1d7 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-symbol-char-lrg.svg @@ -0,0 +1,3 @@ + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-symbol-char-sml.svg b/invokeai/frontend/web/public/assets/images/invoke-symbol-char-sml.svg new file mode 100644 index 0000000000000000000000000000000000000000..6ea2abfb6fdfce4902381204b6fe30faa1ea8220 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-symbol-char-sml.svg @@ -0,0 +1,3 @@ + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-symbol-wht-lrg.svg b/invokeai/frontend/web/public/assets/images/invoke-symbol-wht-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..17cfdc77da7092de2134bbdf14c88327fc228508 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-symbol-wht-lrg.svg @@ -0,0 +1,3 @@ + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-symbol-wht-sml.svg b/invokeai/frontend/web/public/assets/images/invoke-symbol-wht-sml.svg new file mode 100644 index 0000000000000000000000000000000000000000..bb2d62e21acd09544e05c9c25ed328c9668ae81d --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-symbol-wht-sml.svg @@ -0,0 +1,3 @@ + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-symbol-ylw-lrg.svg b/invokeai/frontend/web/public/assets/images/invoke-symbol-ylw-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..898f20bd6fd0b86dd712cc8b7a03d01c28831744 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-symbol-ylw-lrg.svg @@ -0,0 +1,3 @@ + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-tag-char-lrg.svg b/invokeai/frontend/web/public/assets/images/invoke-tag-char-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..9256fb87d1c9b06d48bacb6c4ca9b68326a68f20 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-tag-char-lrg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-tag-char-sml.svg b/invokeai/frontend/web/public/assets/images/invoke-tag-char-sml.svg new file mode 100644 index 0000000000000000000000000000000000000000..7d5b2846d040d106cd6a0b39c110e268682a94d7 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-tag-char-sml.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-tag-lrg.svg b/invokeai/frontend/web/public/assets/images/invoke-tag-lrg.svg new file mode 100644 index 0000000000000000000000000000000000000000..43c435cc5c62009a3fc916d13b3a05d801a2dd4b --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-tag-lrg.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-tag-sml.svg b/invokeai/frontend/web/public/assets/images/invoke-tag-sml.svg new file mode 100644 index 0000000000000000000000000000000000000000..2a31641a137b8100caaa531c23b3bbe8a139e2cd --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-tag-sml.svg @@ -0,0 +1,13 @@ + + + + + + + + + + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-wordmark-charcoal.svg b/invokeai/frontend/web/public/assets/images/invoke-wordmark-charcoal.svg new file mode 100644 index 0000000000000000000000000000000000000000..a700e0c00f42a0c3ff439d85ff0a67de6163151e --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-wordmark-charcoal.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/invokeai/frontend/web/public/assets/images/invoke-wordmark-white.svg b/invokeai/frontend/web/public/assets/images/invoke-wordmark-white.svg new file mode 100644 index 0000000000000000000000000000000000000000..9f5d216cf7cecadad36b456ebb7d702ffc05d97d --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/invoke-wordmark-white.svg @@ -0,0 +1,8 @@ + + + + + + + + diff --git a/invokeai/frontend/web/public/assets/images/mask.svg b/invokeai/frontend/web/public/assets/images/mask.svg new file mode 100644 index 0000000000000000000000000000000000000000..8cc4bee4242df905a12a4586007b198e9a7002f8 --- /dev/null +++ b/invokeai/frontend/web/public/assets/images/mask.svg @@ -0,0 +1,77 @@ + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + + diff --git a/invokeai/frontend/web/public/locales/ar.json b/invokeai/frontend/web/public/locales/ar.json new file mode 100644 index 0000000000000000000000000000000000000000..e91389144430abdfee0c477fe18083e6b1fb67e1 --- /dev/null +++ b/invokeai/frontend/web/public/locales/ar.json @@ -0,0 +1,86 @@ +{ + "common": { + "hotkeysLabel": "مفاتيح الأختصار", + "languagePickerLabel": "منتقي اللغة", + "reportBugLabel": "بلغ عن خطأ", + "settingsLabel": "إعدادات", + "img2img": "صورة إلى صورة", + "nodes": "عقد", + "upload": "رفع", + "load": "تحميل", + "back": "الى الخلف", + "statusDisconnected": "غير متصل" + }, + "gallery": { + "galleryImageSize": "حجم الصورة", + "gallerySettings": "إعدادات المعرض", + "autoSwitchNewImages": "التبديل التلقائي إلى الصور الجديدة", + "noImagesInGallery": "لا توجد صور في المعرض" + }, + "modelManager": { + "modelManager": "مدير النموذج", + "model": "نموذج", + "allModels": "جميع النماذج", + "modelUpdated": "تم تحديث النموذج", + "manual": "يدوي", + "name": "الاسم", + "description": "الوصف", + "config": "تكوين", + "repo_id": "معرف المستودع", + "width": "عرض", + "height": "ارتفاع", + "addModel": "أضف نموذج", + "availableModels": "النماذج المتاحة", + "search": "بحث", + "load": "تحميل", + "active": "نشط", + "selected": "تم التحديد", + "delete": "حذف", + "deleteModel": "حذف النموذج", + "deleteConfig": "حذف التكوين", + "deleteMsg1": "هل أنت متأكد من رغبتك في حذف إدخال النموذج هذا من استحضر الذكاء الصناعي", + "deleteMsg2": "هذا لن يحذف ملف نقطة التحكم للنموذج من القرص الخاص بك. يمكنك إعادة إضافتهم إذا كنت ترغب في ذلك." + }, + "parameters": { + "images": "الصور", + "steps": "الخطوات", + "cfgScale": "مقياس الإعداد الذاتي للجملة", + "width": "عرض", + "height": "ارتفاع", + "seed": "بذرة", + "shuffle": "تشغيل", + "noiseThreshold": "عتبة الضوضاء", + "perlinNoise": "ضجيج برلين", + "type": "نوع", + "strength": "قوة", + "upscaling": "تصغير", + "scale": "مقياس", + "imageFit": "ملائمة الصورة الأولية لحجم الخرج", + "scaleBeforeProcessing": "تحجيم قبل المعالجة", + "scaledWidth": "العرض المحجوب", + "scaledHeight": "الارتفاع المحجوب", + "infillMethod": "طريقة التعبئة", + "tileSize": "حجم البلاطة", + "copyImage": "نسخ الصورة", + "downloadImage": "تحميل الصورة", + "usePrompt": "استخدم المحث", + "useSeed": "استخدام البذور", + "useAll": "استخدام الكل", + "info": "معلومات", + "showOptionsPanel": "إظهار لوحة الخيارات" + }, + "settings": { + "models": "موديلات", + "displayInProgress": "عرض الصور المؤرشفة", + "confirmOnDelete": "تأكيد عند الحذف", + "resetWebUI": "إعادة تعيين واجهة الويب", + "resetWebUIDesc1": "إعادة تعيين واجهة الويب يعيد فقط ذاكرة التخزين المؤقت للمتصفح لصورك وإعداداتك المذكورة. لا يحذف أي صور من القرص.", + "resetWebUIDesc2": "إذا لم تظهر الصور في الصالة أو إذا كان شيء آخر غير ناجح، يرجى المحاولة إعادة تعيين قبل تقديم مشكلة على جيت هب.", + "resetComplete": "تم إعادة تعيين واجهة الويب. تحديث الصفحة لإعادة التحميل." + }, + "toast": { + "uploadFailed": "فشل التحميل", + "imageCopied": "تم نسخ الصورة", + "parametersNotSet": "لم يتم تعيين المعلمات" + } +} diff --git a/invokeai/frontend/web/public/locales/az.json b/invokeai/frontend/web/public/locales/az.json new file mode 100644 index 0000000000000000000000000000000000000000..54c65ff29163aa7fd3da70eb833462dcb74f23ba --- /dev/null +++ b/invokeai/frontend/web/public/locales/az.json @@ -0,0 +1,5 @@ +{ + "accessibility": { + "about": "Haqqında" + } +} diff --git a/invokeai/frontend/web/public/locales/bg.json b/invokeai/frontend/web/public/locales/bg.json new file mode 100644 index 0000000000000000000000000000000000000000..51070b46fe18c83efcfac84c84623ec62566c91f --- /dev/null +++ b/invokeai/frontend/web/public/locales/bg.json @@ -0,0 +1,60 @@ +{ + "accessibility": { + "menu": "Меню", + "nextImage": "Следваща снимка", + "previousImage": "Предишно изображение", + "uploadImage": "Качете изображение", + "invokeProgressBar": "Invoke лента за напредък", + "mode": "Режим" + }, + "boards": { + "addBoard": "Добавете табло", + "cancel": "Отказ", + "autoAddBoard": "Авто-добавяне на табло", + "changeBoard": "Смяна на табло", + "deleteBoard": "Изтриване на табло", + "deleteBoardAndImages": "Изтриване на табло и изображения", + "deleteBoardOnly": "Изтриване само на таблото", + "loading": "Зареждане...", + "movingImagesToBoard_one": "Преместване на {{count}} снимка към табло:", + "movingImagesToBoard_other": "Преместване на {{count}} снимки към табло:", + "selectBoard": "Изберете табло", + "uncategorized": "Некатегоризирани", + "downloadBoard": "Изтегляне на табло", + "bottomMessage": "Изтриването на това табло и изображенията в него ще нулира всички функции, които ги използват в момента.", + "deletedBoardsCannotbeRestored": "Изтритите табла не могат да бъдат възстановени", + "myBoard": "Моето табло" + }, + "accordions": { + "image": { + "title": "Изображение" + }, + "control": { + "title": "Контрол" + } + }, + "common": { + "aboutDesc": "Използвате Invoke за работа? Разгледайте:", + "ai": "ии", + "areYouSure": "Сигурен ли сте?", + "back": "Назад", + "cancel": "Отказ", + "or": "или", + "controlNet": "ControlNet", + "details": "Детайли", + "dontAskMeAgain": "Не питай повече", + "folder": "Папка", + "githubLabel": "Github", + "img2img": "Снимка към снимка", + "languagePickerLabel": "Език", + "loading": "Зареждане", + "learnMore": "Научете повече", + "modelManager": "Мениджър на модели", + "openInNewTab": "Отворете в нов таб", + "orderBy": "Подреждане по", + "communityLabel": "Общност", + "discordLabel": "Дискорд", + "error": "Грешка", + "file": "Файл" + } +} diff --git a/invokeai/frontend/web/public/locales/de.json b/invokeai/frontend/web/public/locales/de.json new file mode 100644 index 0000000000000000000000000000000000000000..ff7bda93cc49f7843cade72d4b9668392d13b053 --- /dev/null +++ b/invokeai/frontend/web/public/locales/de.json @@ -0,0 +1,1559 @@ +{ + "common": { + "languagePickerLabel": "Sprachauswahl", + "reportBugLabel": "Fehler melden", + "settingsLabel": "Einstellungen", + "img2img": "Bild zu Bild", + "nodes": "Arbeitsabläufe", + "upload": "Hochladen", + "load": "Laden", + "statusDisconnected": "Getrennt", + "cancel": "Abbrechen", + "accept": "Annehmen", + "back": "Zurück", + "hotkeysLabel": "Tastenkombinationen", + "githubLabel": "Github", + "discordLabel": "Discord", + "txt2img": "Text zu Bild", + "postprocessing": "Nachbearbeitung", + "t2iAdapter": "T2I Adapter", + "communityLabel": "Gemeinschaft", + "dontAskMeAgain": "Nicht nochmal fragen", + "areYouSure": "Bist du sicher?", + "on": "An", + "ipAdapter": "IP Adapter", + "auto": "Auto", + "controlNet": "ControlNet", + "imageFailedToLoad": "Kann Bild nicht laden", + "modelManager": "Model Manager", + "learnMore": "Mehr erfahren", + "loading": "Lade", + "random": "Zufall", + "batch": "Stapel-Manager", + "advanced": "Erweitert", + "openInNewTab": "In einem neuem Tab öffnen", + "linear": "Linear", + "checkpoint": "Checkpoint", + "inpaint": "Inpaint", + "simple": "Einfach", + "template": "Vorlage", + "outputs": "Ausgabe", + "data": "Daten", + "safetensors": "Safe-Tensors", + "outpaint": "Outpaint", + "details": "Details", + "format": "Format", + "unknown": "Unbekannt", + "folder": "Ordner", + "error": "Fehler", + "installed": "Installiert", + "ai": "KI", + "file": "Datei", + "somethingWentWrong": "Etwas ist schief gelaufen", + "copyError": "$t(gallery.copy) Fehler", + "input": "Eingabe", + "notInstalled": "Nicht $t(common.installed)", + "alpha": "Alpha", + "red": "Rot", + "green": "Grün", + "blue": "Blau", + "delete": "Löschen", + "or": "oder", + "direction": "Richtung", + "save": "Speichern", + "created": "Erstellt", + "prevPage": "Vorherige Seite", + "nextPage": "Nächste Seite", + "unknownError": "Unbekannter Fehler", + "aboutDesc": "Verwenden Sie Invoke für die Arbeit? Siehe hier:", + "localSystem": "Lokales System", + "orderBy": "Ordnen nach", + "saveAs": "Speichern als", + "updated": "Aktualisiert", + "copy": "Kopieren", + "aboutHeading": "Nutzen Sie Ihre kreative Energie", + "toResolve": "Lösen", + "add": "Hinzufügen", + "selected": "Ausgewählt", + "beta": "Beta", + "editor": "Editor", + "goTo": "Gehe zu", + "positivePrompt": "Positiv-Prompt", + "negativePrompt": "Negativ-Prompt", + "tab": "Tabulator", + "enabled": "Aktiviert", + "disabled": "Ausgeschaltet", + "dontShowMeThese": "Zeig mir diese nicht", + "apply": "Anwenden", + "edit": "Ändern", + "openInViewer": "Im Viewer öffnen", + "loadingImage": "Lade Bild", + "off": "Aus", + "view": "Anzeigen", + "placeholderSelectAModel": "Modell auswählen", + "reset": "Zurücksetzen", + "none": "Keine", + "new": "Neu", + "ok": "OK", + "close": "Schließen", + "clipboard": "Zwischenablage" + }, + "gallery": { + "galleryImageSize": "Bildgröße", + "gallerySettings": "Galerie-Einstellungen", + "autoSwitchNewImages": "Auto-Wechsel zu neuen Bildern", + "noImagesInGallery": "Keine Bilder in der Galerie", + "loading": "Lade", + "deleteImage_one": "Lösche Bild", + "deleteImage_other": "Lösche {{count}} Bilder", + "copy": "Kopieren", + "download": "Runterladen", + "featuresWillReset": "Wenn Sie dieses Bild löschen, werden diese Funktionen sofort zurückgesetzt.", + "unableToLoad": "Galerie kann nicht geladen werden", + "downloadSelection": "Auswahl herunterladen", + "currentlyInUse": "Dieses Bild wird derzeit in den folgenden Funktionen verwendet:", + "deleteImagePermanent": "Gelöschte Bilder können nicht wiederhergestellt werden.", + "autoAssignBoardOnClick": "Board per Klick automatisch zuweisen", + "noImageSelected": "Kein Bild ausgewählt", + "starImage": "Bild markieren", + "assets": "Ressourcen", + "unstarImage": "Markierung entfernen", + "image": "Bild", + "deleteSelection": "Lösche Auswahl", + "dropToUpload": "$t(gallery.drop) zum hochladen", + "dropOrUpload": "$t(gallery.drop) oder hochladen", + "drop": "Ablegen", + "bulkDownloadRequested": "Download vorbereiten", + "bulkDownloadRequestedDesc": "Dein Download wird vorbereitet. Dies kann ein paar Momente dauern.", + "bulkDownloadRequestFailed": "Problem beim Download vorbereiten", + "bulkDownloadFailed": "Download fehlgeschlagen", + "alwaysShowImageSizeBadge": "Zeige immer Bilder Größe Abzeichen", + "selectForCompare": "Zum Vergleichen auswählen", + "compareImage": "Bilder vergleichen", + "exitSearch": "Bildsuche beenden", + "newestFirst": "Neueste zuerst", + "oldestFirst": "Älteste zuerst", + "openInViewer": "Im Viewer öffnen", + "swapImages": "Bilder tauschen", + "slider": "Slider", + "showStarredImagesFirst": "Mit * markierte Bilder zuerst zeigen", + "compareHelp1": "Halten Sie Alt gedrückt, während Sie auf ein Galeriebild klicken oder die Pfeiltasten verwenden, um das Vergleichsbild zu ändern.", + "compareHelp4": "Drücken Sie Z oder Esc zum Beenden.", + "move": "Bewegen", + "exitBoardSearch": "Suchen beenden", + "searchImages": "Suche mit Metadaten", + "selectAllOnPage": "Alle auf Seite auswählen", + "showArchivedBoards": "Archivierte Boards anzeigen", + "hover": "Schweben", + "compareHelp2": "Drücken Sie M, um durch alle Vergleichsmodi zu wechseln.", + "compareHelp3": "Drücken Sie C, um die verglichenen Bilder zu wechseln.", + "gallery": "Galerie", + "sortDirection": "Sortierreihenfolge", + "sideBySide": "Nebeneinander", + "openViewer": "Viewer öffnen", + "viewerImage": "Viewer-Bild", + "exitCompare": "Vergleichen beenden", + "closeViewer": "Viewer schließen", + "selectAnImageToCompare": "Wählen Sie ein Bild zum Vergleichen", + "stretchToFit": "Strecken bis es passt", + "displayBoardSearch": "Board durchsuchen", + "displaySearch": "Bild suchen", + "go": "Los", + "jump": "Springen", + "assetsTab": "Dateien, die Sie zur Verwendung in Ihren Projekten hochgeladen haben.", + "imagesTab": "Bilder, die Sie in Invoke erstellt und gespeichert haben.", + "boardsSettings": "Ordnereinstellungen", + "imagesSettings": "Galeriebildereinstellungen" + }, + "hotkeys": { + "noHotkeysFound": "Kein Hotkey gefunden", + "searchHotkeys": "Hotkeys durchsuchen", + "clearSearch": "Suche leeren", + "canvas": { + "fitBboxToCanvas": { + "desc": "Skalierung und Positionierung der Ansicht auf Bbox-Größe.", + "title": "Bbox auf Arbeitsfläche skalieren" + }, + "selectBboxTool": { + "title": "Bbox Werkzeug", + "desc": "Bbox Werkzeug auswählen." + }, + "setFillToWhite": { + "title": "Farbe auf Weiß einstellen", + "desc": "Setzt die aktuelle Werkzeugfarbe auf weiß." + }, + "title": "Leinwand", + "selectBrushTool": { + "title": "Pinselwerkzeug", + "desc": "Wählen Sie das Pinselwerkzeug aus." + }, + "decrementToolWidth": { + "title": "Werkzeugbreite verringern", + "desc": "Verringern Sie die Breite des Pinsels oder Radiergummis, je nachdem, welches ausgewählt ist." + }, + "incrementToolWidth": { + "title": "Werkzeugbreite erhöhen", + "desc": "Vergrößern Sie die Breite des Pinsels oder Radiergummis, je nachdem, welches ausgewählt ist." + }, + "selectColorPickerTool": { + "title": "Farbwähler-Werkzeug", + "desc": "Farbwähler-Werkzeug auswählen." + }, + "selectEraserTool": { + "title": "Radiergummi-Werkzeug", + "desc": "Radiergummi-Werkzeug auswählen." + }, + "fitLayersToCanvas": { + "title": "Ebenen an die Leinwand anpassen", + "desc": "Alle sichtbaren Ebenen in der Ansicht einpassen." + }, + "filterSelected": { + "title": "Filter", + "desc": "Gewählte Ebene filtern. Nur bei \"Raster\" und Kontroll-Ebenen." + }, + "transformSelected": { + "title": "Umwandeln", + "desc": "Transformieren Sie die ausgewählte Ebene." + }, + "setZoomTo100Percent": { + "title": "Auf 100 % zoomen", + "desc": "Leinwand-Zoom auf 100 % setzen." + }, + "setZoomTo200Percent": { + "title": "Auf 200 % zoomen", + "desc": "Leinwand-Zoom auf 200 % setzen." + }, + "setZoomTo400Percent": { + "title": "Auf 400 % zoomen", + "desc": "Leinwand-Zoom auf 400 % setzen." + }, + "setZoomTo800Percent": { + "title": "Auf 800 % zoomen", + "desc": "Leinwand-Zoom auf 800 % setzen." + }, + "deleteSelected": { + "title": "Ebene löschen", + "desc": "Ausgewählte Ebene löschen." + }, + "undo": { + "title": "Rückgängig", + "desc": "Letzte Aktion rückgängig machen." + }, + "redo": { + "title": "Wiederholen", + "desc": "Letzte Aktion wiederholen." + }, + "nextEntity": { + "title": "Nächste Ebene", + "desc": "Nächste Ebene in der Liste auswählen." + }, + "resetSelected": { + "title": "Ebene zurücksetzen", + "desc": "Ausgewählte Ebene zurücksetzen. Gilt nur für Malmaske bei \"Inpaint\" und \"Regionaler Führung\"." + }, + "prevEntity": { + "title": "Vorherige Ebene", + "desc": "Vorherige Ebene in der Liste auswählen." + }, + "selectMoveTool": { + "title": "Verschieben-Werkzeug", + "desc": "Verschieben-Werkzeug auswählen." + }, + "selectRectTool": { + "title": "Rechteck-Werkzeug", + "desc": "Rechteck-Werkzeug auswählen." + }, + "selectViewTool": { + "desc": "Wählen Sie das Ansichts-Tool.", + "title": "Ansichts-Tool" + }, + "quickSwitch": { + "title": "Ebenen schnell umschalten", + "desc": "Wechseln Sie zwischen den beiden zuletzt gewählten Ebenen. Wenn eine Ebene mit einem Lesezeichen versehen ist, wird zwischen ihr und der letzten nicht markierten Ebene gewechselt." + }, + "applyFilter": { + "title": "Filter anwenden", + "desc": "Wende den ausstehenden Filter auf die ausgewählte Ebene an." + }, + "cancelFilter": { + "title": "Filter abbrechen", + "desc": "Den ausstehenden Filter abbrechen." + }, + "applyTransform": { + "desc": "Die ausstehende Transformation auf die ausgewählte Ebene anwenden.", + "title": "Transformation anwenden" + }, + "cancelTransform": { + "title": "Transformation abbrechen", + "desc": "Die ausstehende Transformation abbrechen." + } + }, + "viewer": { + "useSize": { + "desc": "Aktuelle Bildgröße als Bbox-Größe verwenden.", + "title": "Maße übernehmen" + }, + "title": "Bildbetrachter", + "toggleViewer": { + "title": "Bildbetrachter anzeigen/ausblenden", + "desc": "Zeigen oder verbergen Sie den Bildbetrachter. Nur auf der Arbeitsflächen-Registerkarte." + }, + "nextComparisonMode": { + "title": "Nächster Vergleichsmodus", + "desc": "Alle Vergleichsmodi durchlaufen." + }, + "swapImages": { + "title": "Vergleichsbilder tauschen", + "desc": "Vergleichs-Bilder tauschen." + }, + "runPostprocessing": { + "title": "Nachbearbeitung ausführen", + "desc": "Ausgewählte Nachbearbeitung/en auf aktuelles Bild anwenden." + }, + "toggleMetadata": { + "title": "Metadaten anzeigen/ausblenden", + "desc": "Zeigen oder verbergen der Metadaten des Bildes." + }, + "recallPrompts": { + "title": "Prompts abrufen", + "desc": "Rufen Sie die positiven und negativen Prompts für das aktuelle Bild ab." + }, + "recallSeed": { + "desc": "Seed für aktuelles Bild abrufen.", + "title": "Seed abrufen" + }, + "loadWorkflow": { + "title": "Lade Arbeitsablauf/Workflow", + "desc": "Laden Sie den gespeicherten Workflow des aktuellen Bildes (falls es einen hat)." + }, + "recallAll": { + "title": "Alle Metadaten abrufen", + "desc": "Alle Metadaten für das aktuelle Bild abrufen." + }, + "remix": { + "desc": "Rufen Sie alle Metadaten außer dem Seed für das aktuelle Bild ab.", + "title": "Remixen" + } + }, + "app": { + "invoke": { + "title": "Invoke", + "desc": "Stellt eine Generierung in die Warteschlange und fügt sie am Ende hinzu." + }, + "invokeFront": { + "title": "Invoke (Front)", + "desc": "Stellt eine Generierung in die Warteschlange und fügt sie am Anfang hinzu." + }, + "cancelQueueItem": { + "title": "Abbrechen", + "desc": "Aktuelles Warteschlangenelement abbrechen." + }, + "clearQueue": { + "title": "Warteschlange löschen", + "desc": "Warteschlange abbrechen und komplett löschen." + }, + "selectUpscalingTab": { + "title": "Wählen Sie die Registerkarte Hochskalieren", + "desc": "Wählt die Registerkarte Hochskalieren." + }, + "selectCanvasTab": { + "desc": "Wählt die Arbeitsflächen-Registerkarte.", + "title": "Wählen Sie die Arbeitsflächen-Registerkarte" + }, + "selectWorkflowsTab": { + "title": "Wählt die Registerkarte Arbeitsabläufe", + "desc": "Wählt die Registerkarte Arbeitsabläufe." + }, + "selectModelsTab": { + "title": "Wählt die Registerkarte Modelle", + "desc": "Wählt die Registerkarte Modelle." + }, + "selectQueueTab": { + "title": "Wählt die Registerkarte Warteschlange", + "desc": "Wählt die Registerkarte Warteschlange." + }, + "focusPrompt": { + "desc": "Bewegt den Cursor-Fokus auf den positiven Prompt.", + "title": "Fokus-Prompt" + }, + "toggleLeftPanel": { + "title": "Linkes Panel ein-/ausblenden", + "desc": "Linke Seite zeigen/verbergen." + }, + "toggleRightPanel": { + "title": "Rechte Seite umschalten", + "desc": "Rechte Seite zeigen/verbergen." + }, + "resetPanelLayout": { + "title": "Layout zurücksetzen", + "desc": "Beide Seiten auf Standard zurücksetzen." + }, + "title": "Anwendung", + "togglePanels": { + "title": "Seiten umschalten", + "desc": "Zeigen oder verbergen Sie beide Panels auf einmal." + } + }, + "hotkeys": "Tastaturbefehle", + "gallery": { + "title": "Galerie", + "selectAllOnPage": { + "title": "Alle auf der Seite auswählen", + "desc": "Alle Bilder auf der aktuellen Seite auswählen." + }, + "galleryNavRight": { + "title": "Nach rechts navigieren", + "desc": "Navigieren Sie im Galerieraster nach rechts, und wählen Sie das Bild aus. Wenn es sich um das letzte Bild in der Reihe handelt, gehen Sie zur nächsten Reihe. Wenn Sie sich beim letzten Bild der Seite befinden, gehen Sie zur nächsten Seite." + }, + "galleryNavDownAlt": { + "title": "Nach unten navigieren (Bild vergleichen)", + "desc": "Wie \"Abwärts navigieren\", wählt aber das Vergleichsbild aus und öffnet den Vergleichsmodus, falls er nicht bereits geöffnet ist." + }, + "galleryNavUp": { + "title": "Nach oben navigieren", + "desc": "Navigieren Sie im Galerieraster nach oben, und wählen Sie das Bild aus. Wenn Sie sich oben auf der Seite befinden, gehen Sie zur vorherigen Seite." + }, + "galleryNavDown": { + "title": "Nach unten navigieren", + "desc": "Navigieren Sie im Galerieraster nach unten, und wählen Sie das Bild aus. Wenn Sie sich am Ende der Seite befinden, gehen Sie zur nächsten Seite." + }, + "galleryNavLeft": { + "title": "Nach links navigieren", + "desc": "Navigieren Sie im Galerieraster nach links, und wählen Sie das Bild aus. Wenn Sie sich im ersten Bild der Reihe befinden, gehen Sie zur vorherigen Reihe. Wenn Sie sich beim ersten Bild der Seite befinden, gehen Sie zur vorherigen Seite." + }, + "galleryNavUpAlt": { + "title": "Nach oben navigieren (Bild vergleichen)", + "desc": "Wie „Nach oben navigieren“, wählt aber das Vergleichsbild aus und öffnet den Vergleichsmodus, falls er nicht bereits geöffnet ist." + }, + "galleryNavRightAlt": { + "title": "Nach rechts navigieren (Bild vergleichen)", + "desc": "Wie \"Navigieren nach rechts\", wählt aber das Vergleichsbild aus und öffnet den Vergleichsmodus, falls er nicht bereits geöffnet ist." + }, + "clearSelection": { + "title": "Auswahl aufheben", + "desc": "Aktuelle Auswahl aufheben, falls vorhanden." + }, + "galleryNavLeftAlt": { + "title": "Nach links navigieren (Bild vergleichen)", + "desc": "Wie „Nach links navigieren“, wählt aber das Vergleichsbild aus und öffnet den Vergleichsmodus, falls er nicht bereits geöffnet ist." + }, + "deleteSelection": { + "title": "Löschen", + "desc": "Alle ausgewählten Bilder löschen. Standardmäßig werden Sie aufgefordert, den Löschvorgang zu bestätigen. Wenn die Bilder derzeit in der App verwendet werden, werden Sie gewarnt." + } + }, + "workflows": { + "redo": { + "title": "Wiederholen", + "desc": "Letzte Workflow-Aktion wiederherstellen." + }, + "copySelection": { + "title": "Kopieren", + "desc": "Ausgewählte Knoten und Kanten kopieren." + }, + "title": "Arbeitsabläufe", + "addNode": { + "title": "Knoten hinzufügen", + "desc": "Öffnen Sie das \"Knoten zufügen\"-Menü." + }, + "pasteSelection": { + "title": "Einfügen", + "desc": "Kopierte Knoten und Kanten einfügen." + }, + "selectAll": { + "title": "Alles auswählen", + "desc": "Alle Knoten und Kanten auswählen." + }, + "deleteSelection": { + "title": "Löschen", + "desc": "Lösche ausgewählte Knoten und Kanten." + }, + "undo": { + "title": "Rückgängig", + "desc": "Letzte Workflow-Aktion rückgängig machen." + }, + "pasteSelectionWithEdges": { + "desc": "Kopierte Knoten, Kanten und alle mit den kopierten Knoten verbundenen Kanten einfügen.", + "title": "Einfügen mit Kanten" + } + } + }, + "modelManager": { + "modelUpdated": "Model aktualisiert", + "description": "Beschreibung", + "config": "Konfiguration", + "width": "Breite", + "height": "Höhe", + "addModel": "Modell hinzufügen", + "availableModels": "Verfügbare Modelle", + "search": "Suche", + "load": "Laden", + "active": "Aktiv", + "selected": "Ausgewählt", + "delete": "Löschen", + "deleteModel": "Model löschen", + "deleteConfig": "Konfiguration löschen", + "deleteMsg1": "Möchten Sie diesen Model-Eintrag wirklich aus InvokeAI löschen?", + "deleteMsg2": "Dadurch WIRD das Modell von der Festplatte gelöscht WENN es im InvokeAI Root Ordner liegt. Wenn es in einem anderem Ordner liegt wird das Modell NICHT von der Festplatte gelöscht.", + "convert": "Umwandeln", + "allModels": "Alle Modelle", + "alpha": "Alpha", + "convertToDiffusersHelpText2": "Bei diesem Vorgang wird Ihr Eintrag im Modell-Manager durch die Diffusor-Version desselben Modells ersetzt.", + "convertToDiffusersHelpText5": "Bitte stellen Sie sicher, dass Sie über genügend Speicherplatz verfügen. Die Modelle sind in der Regel zwischen 2 GB und 7 GB groß.", + "convertToDiffusersHelpText3": "Ihre Kontrollpunktdatei auf der Festplatte wird NICHT gelöscht oder in irgendeiner Weise verändert. Sie können Ihren Kontrollpunkt dem Modell-Manager wieder hinzufügen, wenn Sie dies wünschen.", + "convertToDiffusersHelpText4": "Dies ist ein einmaliger Vorgang. Er kann je nach den Spezifikationen Ihres Computers etwa 30-60 Sekunden dauern.", + "convertToDiffusersHelpText6": "Möchten Sie dieses Modell konvertieren?", + "modelConverted": "Modell umgewandelt", + "manual": "Manuell", + "modelManager": "Modell Manager", + "model": "Modell", + "name": "Name", + "none": "Nix", + "advanced": "Erweitert", + "convertingModelBegin": "Konvertiere Modell. Bitte warten.", + "baseModel": "Basis Modell", + "convertToDiffusers": "Konvertiere zu Diffusers", + "vae": "VAE", + "predictionType": "Vorhersagetyp", + "selectModel": "Wählen Sie Modell aus", + "repo_id": "Repo-ID", + "modelDeleted": "Modell gelöscht", + "modelUpdateFailed": "Modellaktualisierung fehlgeschlagen", + "settings": "Einstellungen", + "modelConversionFailed": "Modellkonvertierung fehlgeschlagen", + "syncModels": "Modelle synchronisieren", + "modelType": "Modelltyp", + "convertToDiffusersHelpText1": "Dieses Modell wird in das 🧨 Diffusers-Format konvertiert.", + "vaePrecision": "VAE-Präzision", + "variant": "Variante", + "modelDeleteFailed": "Modell konnte nicht gelöscht werden", + "noModelSelected": "Kein Modell ausgewählt", + "huggingFace": "HuggingFace", + "defaultSettings": "Standardeinstellungen", + "edit": "Bearbeiten", + "cancel": "Stornieren", + "defaultSettingsSaved": "Standardeinstellungen gespeichert", + "addModels": "Model hinzufügen", + "deleteModelImage": "Lösche Model Bild", + "huggingFaceRepoID": "HuggingFace Repo ID", + "huggingFacePlaceholder": "besitzer/model-name", + "modelSettings": "Modelleinstellungen", + "typePhraseHere": "Phrase hier eingeben", + "spandrelImageToImage": "Bild zu Bild (Spandrel)", + "starterModels": "Einstiegsmodelle", + "t5Encoder": "T5-Kodierer", + "uploadImage": "Bild hochladen", + "urlOrLocalPath": "URL oder lokaler Pfad", + "install": "Installieren", + "textualInversions": "Textuelle Inversionen", + "ipAdapters": "IP-Adapter", + "modelImageUpdated": "Modellbild aktualisiert", + "path": "Pfad", + "pathToConfig": "Pfad zur Konfiguration", + "scanPlaceholder": "Pfad zu einem lokalen Ordner", + "noMatchingModels": "Keine passenden Modelle", + "localOnly": "nur lokal", + "installAll": "Alles installieren", + "main": "Haupt", + "metadata": "Metadaten", + "modelImageDeleted": "Modellbild gelöscht", + "modelName": "Modellname", + "noModelsInstalled": "Keine Modelle installiert", + "source": "Quelle", + "simpleModelPlaceholder": "URL oder Pfad zu einem lokalen Datei- oder Diffusers-Ordner", + "imageEncoderModelId": "Bild Encoder Modell ID", + "installRepo": "Repo installieren", + "huggingFaceHelper": "Wenn mehrere Modelle in diesem Repo gefunden werden, werden Sie aufgefordert, eines für die Installation auszuwählen.", + "inplaceInstall": "In-place-Installation", + "modelImageDeleteFailed": "Modellbild konnte nicht gelöscht werden", + "repoVariant": "Repo Variante", + "learnMoreAboutSupportedModels": "Erfahren Sie mehr über die Modelle, die wir unterstützen", + "clipEmbed": "CLIP einbetten", + "starterModelsInModelManager": "Modelle für Ihren Start finden Sie im Modell-Manager", + "noModelsInstalledDesc1": "Installiere Modelle mit dem", + "modelImageUpdateFailed": "Modellbild-Update fehlgeschlagen", + "prune": "Bereinigen", + "loraModels": "LoRAs", + "scanFolder": "Ordner scannen", + "installQueue": "Installations-Warteschlange", + "pruneTooltip": "Abgeschlossene Importe aus Warteschlange entfernen", + "scanResults": "Ergebnisse des Scans", + "urlOrLocalPathHelper": "URLs sollten auf eine einzelne Datei deuten. Lokale Pfade können zusätzlich auch auf einen Ordner für ein einzelnes Diffusers-Modell hinweisen.", + "inplaceInstallDesc": "Installieren Sie Modelle, ohne die Dateien zu kopieren. Wenn Sie das Modell verwenden, wird es direkt von seinem Speicherort geladen. Wenn deaktiviert, werden die Dateien während der Installation in das von Invoke verwaltete Modellverzeichnis kopiert.", + "scanFolderHelper": "Der Ordner wird rekursiv nach Modellen durchsucht. Dies kann bei sehr großen Ordnern etwas dauern.", + "includesNModels": "Enthält {{n}} Modelle und deren Abhängigkeiten", + "starterBundles": "Starterpakete", + "installingXModels_one": "{{count}} Modell wird installiert", + "installingXModels_other": "{{count}} Modelle werden installiert", + "skippingXDuplicates_one": ", überspringe {{count}} Duplikat", + "skippingXDuplicates_other": ", überspringe {{count}} Duplikate", + "installingModel": "Modell wird installiert", + "loraTriggerPhrases": "LoRA-Auslösephrasen", + "installingBundle": "Bündel wird installiert", + "triggerPhrases": "Auslösephrasen", + "mainModelTriggerPhrases": "Hauptmodell-Auslösephrasen" + }, + "parameters": { + "images": "Bilder", + "steps": "Schritte", + "cfgScale": "CFG-Skala", + "width": "Breite", + "height": "Höhe", + "shuffle": "Mischen", + "noiseThreshold": "Rausch-Schwellenwert", + "perlinNoise": "Perlin-Rauschen", + "type": "Art", + "strength": "Stärke", + "upscaling": "Hochskalierung", + "scale": "Maßstab", + "imageFit": "Ausgangsbild an Ausgabegröße anpassen", + "scaleBeforeProcessing": "Skalieren vor der Verarbeitung", + "scaledWidth": "Skaliert W", + "scaledHeight": "Skaliert H", + "infillMethod": "Infill-Methode", + "tileSize": "Kachelgröße", + "downloadImage": "Bild herunterladen", + "usePrompt": "Prompt verwenden", + "useSeed": "Seed verwenden", + "useAll": "Alle verwenden", + "showOptionsPanel": "Optionsleiste zeigen", + "copyImage": "Bild kopieren", + "denoisingStrength": "Stärke der Entrauschung", + "symmetry": "Symmetrie", + "info": "Information", + "general": "Allgemein", + "aspect": "Seitenverhältnis", + "scheduler": "Planer", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (kann zu groß sein)", + "lockAspectRatio": "Seitenverhältnis sperren", + "swapDimensions": "Seitenverhältnis umkehren", + "setToOptimalSize": "Optimiere Größe für Modell", + "useSize": "Maße übernehmen", + "remixImage": "Remix des Bilds erstellen", + "imageActions": "Weitere Bildaktionen", + "invoke": { + "layer": { + "t2iAdapterIncompatibleBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, Bbox-Breite ist {{width}}", + "t2iAdapterIncompatibleScaledBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, Skalierte Bbox-Breite ist {{width}}", + "t2iAdapterIncompatibleScaledBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, Skalierte Bbox-Höhe ist {{height}}", + "t2iAdapterIncompatibleBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, Bbox-Höhe ist {{height}}" + }, + "fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), Skalierte Bbox-Breite ist {{width}}", + "fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), Skalierte Bbox-Höhe ist {{height}}", + "fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), Bbox-Breite ist {{width}}", + "fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), Bbox-Höhe ist {{height}}" + } + }, + "settings": { + "displayInProgress": "Zwischenbilder anzeigen", + "confirmOnDelete": "Bestätigen beim Löschen", + "resetWebUI": "Web-Oberfläche zurücksetzen", + "resetWebUIDesc1": "Das Zurücksetzen der Web-Oberfläche setzt nur den lokalen Cache des Browsers mit Ihren Bildern und gespeicherten Einstellungen zurück. Es werden keine Bilder von der Festplatte gelöscht.", + "resetWebUIDesc2": "Wenn die Bilder nicht in der Galerie angezeigt werden oder etwas anderes nicht funktioniert, versuchen Sie bitte, die Einstellungen zurückzusetzen, bevor Sie einen Fehler auf GitHub melden.", + "resetComplete": "Die Web-Oberfläche wurde zurückgesetzt.", + "models": "Modelle", + "clearIntermediatesDesc1": "Das Löschen der Zwischenbilder setzt Leinwand und ControlNet zurück.", + "generation": "Erzeugung", + "enableInformationalPopovers": "Info-Popouts anzeigen", + "showProgressInViewer": "Zwischenbilder im Viewer anzeigen", + "clearIntermediatesDesc3": "Ihre Bilder werden nicht gelöscht.", + "clearIntermediatesWithCount_one": "Lösche {{count}} Zwischenbilder", + "clearIntermediatesWithCount_other": "Lösche {{count}} Zwischenbilder", + "reloadingIn": "Neuladen in", + "intermediatesCleared_one": "{{count}} Zwischenbilder gelöscht", + "intermediatesCleared_other": "{{count}} Zwischenbilder gelöscht", + "enableInvisibleWatermark": "Unsichtbares Wasserzeichen aktivieren", + "general": "Allgemein", + "clearIntermediatesDisabled": "Warteschlange muss leer sein, um Zwischenbilder zu löschen", + "developer": "Entwickler", + "antialiasProgressImages": "Zwischenbilder mit Anti-Alias", + "beta": "Beta", + "ui": "Benutzeroberfläche", + "clearIntermediatesDesc2": "Zwischenbilder sind Nebenprodukte der Erstellung. Sie zu löschen macht Festplattenspeicher frei.", + "clearIntermediates": "Zwischenbilder löschen", + "intermediatesClearedFailed": "Problem beim Löschen der Zwischenbilder", + "enableNSFWChecker": "Auf unangemessene Inhalte prüfen" + }, + "toast": { + "uploadFailed": "Hochladen fehlgeschlagen", + "imageCopied": "Bild kopiert", + "parametersNotSet": "Parameter nicht zurückgerufen", + "addedToBoard": "Dem Board hinzugefügt", + "loadedWithWarnings": "Workflow mit Warnungen geladen", + "imageSaved": "Bild gespeichert", + "linkCopied": "Link kopiert", + "problemCopyingLayer": "Ebene kann nicht kopiert werden", + "problemSavingLayer": "Ebene kann nicht gespeichert werden", + "parameterSetDesc": "{{parameter}} zurückgerufen", + "imageUploaded": "Bild hochgeladen", + "problemCopyingImage": "Bild kann nicht kopiert werden", + "parameterNotSetDesc": "{{parameter}} kann nicht zurückgerufen werden", + "prunedQueue": "Warteschlange bereinigt", + "modelAddedSimple": "Modell zur Warteschlange hinzugefügt", + "parametersSet": "Parameter zurückgerufen", + "imageNotLoadedDesc": "Bild konnte nicht gefunden werden", + "setControlImage": "Als Kontrollbild festlegen", + "sentToUpscale": "An Vergrößerung gesendet", + "parameterNotSetDescWithMessage": "{{parameter}} kann nicht zurückgerufen werden: {{message}}", + "unableToLoadImageMetadata": "Bildmetadaten können nicht geladen werden", + "unableToLoadImage": "Bild kann nicht geladen werden", + "serverError": "Serverfehler", + "parameterNotSet": "Parameter nicht zurückgerufen", + "sessionRef": "Sitzung: {{sessionId}}", + "problemDownloadingImage": "Bild kann nicht heruntergeladen werden", + "parameters": "Parameter", + "parameterSet": "Parameter zurückgerufen", + "importFailed": "Import fehlgeschlagen", + "importSuccessful": "Import erfolgreich", + "setNodeField": "Als Knotenfeld festlegen", + "somethingWentWrong": "Etwas ist schief gelaufen", + "workflowLoaded": "Arbeitsablauf geladen", + "workflowDeleted": "Arbeitsablauf gelöscht", + "errorCopied": "Fehler kopiert", + "layerCopiedToClipboard": "Ebene in die Zwischenablage kopiert", + "sentToCanvas": "An Leinwand gesendet" + }, + "accessibility": { + "uploadImage": "Bild hochladen", + "previousImage": "Vorheriges Bild", + "reset": "Zurücksetzten", + "nextImage": "Nächstes Bild", + "menu": "Menü", + "invokeProgressBar": "Invoke Fortschrittsanzeige", + "mode": "Modus", + "resetUI": "$t(accessibility.reset) von UI", + "createIssue": "Ticket erstellen", + "about": "Über", + "submitSupportTicket": "Support-Ticket senden", + "toggleRightPanel": "Rechtes Bedienfeld umschalten (G)", + "toggleLeftPanel": "Linkes Bedienfeld umschalten (T)", + "uploadImages": "Bild(er) hochladen" + }, + "boards": { + "autoAddBoard": "Board automatisch erstellen", + "topMessage": "Dieser Ordner enthält Bilder, die in den folgenden Funktionen verwendet werden:", + "move": "Bewegen", + "menuItemAutoAdd": "Auto-Hinzufügen zu diesem Ordner", + "myBoard": "Meine Ordner", + "searchBoard": "Ordner durchsuchen...", + "noMatching": "Keine passenden Ordner", + "selectBoard": "Ordner wählen", + "cancel": "Abbrechen", + "addBoard": "Board hinzufügen", + "uncategorized": "Ohne Kategorie", + "downloadBoard": "Ordner runterladen", + "changeBoard": "Ordner wechseln", + "loading": "Laden...", + "clearSearch": "Suche leeren", + "bottomMessage": "Löschen des Boards und seiner Bilder setzt alle Funktionen zurück, die sie gerade verwenden.", + "deleteBoardOnly": "Nur Ordner löschen", + "deleteBoard": "Lösche Ordner", + "deleteBoardAndImages": "Lösche Ordner und Bilder", + "movingImagesToBoard_one": "Verschiebe {{count}} Bild in Ordner:", + "movingImagesToBoard_other": "Verschiebe {{count}} Bilder in Ordner:", + "selectedForAutoAdd": "Ausgewählt für Automatisches hinzufügen", + "imagesWithCount_one": "{{count}} Bild", + "imagesWithCount_other": "{{count}} Bilder", + "addPrivateBoard": "Privaten Ordner hinzufügen", + "addSharedBoard": "Geteilten Ordner hinzufügen", + "boards": "Ordner", + "unarchiveBoard": "Unarchive Ordner", + "private": "Private Ordner", + "shared": "Geteilte Ordner", + "archiveBoard": "Ordner archivieren", + "archived": "Archiviert", + "noBoards": "Kein {{boardType}} Ordner", + "hideBoards": "Ordner verstecken", + "viewBoards": "Ordner ansehen", + "deletedPrivateBoardsCannotbeRestored": "Gelöschte Boards können nicht wiederhergestellt werden. Wenn Sie „Nur Board löschen“ wählen, werden die Bilder in einen privaten, nicht kategorisierten Status für den Ersteller des Bildes versetzt.", + "assetsWithCount_one": "{{count}} in der Sammlung", + "assetsWithCount_other": "{{count}} in der Sammlung", + "deletedBoardsCannotbeRestored": "Gelöschte Ordner können nicht wiederhergestellt werden. Die Auswahl von \"Nur Ordner löschen\" verschiebt Bilder in einen unkategorisierten Zustand.", + "updateBoardError": "Fehler beim Aktualisieren des Ordners" + }, + "queue": { + "status": "Status", + "cancelTooltip": "Aufgabe abbrechen", + "queueEmpty": "Warteschlange leer", + "in_progress": "In Arbeit", + "queueFront": "Am Anfang der Warteschlange einreihen", + "completed": "Fertig", + "queueBack": "In die Warteschlange", + "clearFailed": "Probleme beim leeren der Warteschlange", + "clearSucceeded": "Warteschlange geleert", + "pause": "Pause", + "cancelSucceeded": "Auftrag abgebrochen", + "queue": "Warteschlange", + "batch": "Stapel", + "pending": "Ausstehend", + "clear": "Leeren", + "prune": "Leeren", + "total": "Gesamt", + "canceled": "Abgebrochen", + "clearTooltip": "Abbrechen und alle Aufträge leeren", + "current": "Aktuell", + "failed": "Fehler", + "cancelItem": "Abbruch Auftrag", + "next": "Nächste", + "cancel": "Abbruch", + "session": "Sitzung", + "resume": "Wieder aufnehmen", + "item": "Auftrag", + "notReady": "Warteschlange noch nicht bereit", + "clearQueueAlertDialog": "\"Die Warteschlange leeren\" stoppt den aktuellen Prozess und leert die Warteschlange komplett.", + "completedIn": "Fertig in", + "cancelBatchSucceeded": "Stapel abgebrochen", + "cancelBatch": "Stapel stoppen", + "enqueueing": "Stapel in der Warteschlange", + "cancelBatchFailed": "Problem beim Abbruch vom Stapel", + "clearQueueAlertDialog2": "Warteschlange wirklich leeren?", + "pruneSucceeded": "{{item_count}} abgeschlossene Elemente aus der Warteschlange entfernt", + "pauseSucceeded": "Prozess angehalten", + "cancelFailed": "Problem beim Abbrechen", + "pauseFailed": "Problem beim Anhalten des Prozesses", + "front": "Vorne", + "pruneTooltip": "Bereinigen Sie {{item_count}} abgeschlossene Aufträge", + "resumeFailed": "Problem beim Fortsetzen des Prozesses", + "pruneFailed": "Problem beim leeren der Warteschlange", + "pauseTooltip": "Prozess anhalten", + "back": "Ende", + "resumeSucceeded": "Prozess wird fortgesetzt", + "resumeTooltip": "Prozess wieder aufnehmen", + "time": "Zeit", + "batchQueuedDesc_one": "{{count}} Eintrag an {{direction}} der Wartschlange hinzugefügt", + "batchQueuedDesc_other": "{{count}} Einträge an {{direction}} der Wartschlange hinzugefügt", + "openQueue": "Warteschlange öffnen", + "batchFailedToQueue": "Fehler beim Einreihen in die Stapelverarbeitung", + "batchFieldValues": "Stapelverarbeitungswerte", + "batchQueued": "Stapelverarbeitung eingereiht", + "graphQueued": "Graph eingereiht", + "graphFailedToQueue": "Fehler beim Einreihen des Graphen", + "generations_one": "Generation", + "generations_other": "Generationen", + "iterations_one": "Iteration", + "iterations_other": "Iterationen", + "gallery": "Galerie", + "generation": "Erstellung", + "workflows": "Arbeitsabläufe", + "other": "Sonstige", + "origin": "Ursprung", + "destination": "Ziel", + "upscaling": "Hochskalierung", + "canvas": "Leinwand", + "prompts_one": "Prompt", + "prompts_other": "Prompts" + }, + "metadata": { + "negativePrompt": "Negativ Beschreibung", + "metadata": "Meta-Daten", + "strength": "Bild zu Bild Stärke", + "imageDetails": "Bild Details", + "model": "Modell", + "noImageDetails": "Keine Bild Details gefunden", + "cfgScale": "CFG-Skala", + "height": "Höhe", + "noMetaData": "Keine Meta-Daten gefunden", + "width": "Breite", + "createdBy": "Erstellt von", + "steps": "Schritte", + "positivePrompt": "Positiver Prompt", + "generationMode": "Generierungsmodus", + "Threshold": "Rauschen-Schwelle", + "seed": "Seed", + "vae": "VAE", + "workflow": "Workflow", + "scheduler": "Planer", + "noRecallParameters": "Es wurden keine Parameter zum Abrufen gefunden", + "recallParameters": "Parameter wiederherstellen", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "allPrompts": "Alle Prompts", + "imageDimensions": "Bilder Auslösungen", + "parameterSet": "Parameter {{parameter}} setzen", + "recallParameter": "{{label}} Abrufen", + "parsingFailed": "Parsing Fehlgeschlagen", + "canvasV2Metadata": "Leinwand", + "guidance": "Führung", + "seamlessXAxis": "Nahtlose X Achse", + "seamlessYAxis": "Nahtlose Y Achse" + }, + "popovers": { + "noiseUseCPU": { + "heading": "Nutze CPU-Rauschen", + "paragraphs": [ + "Entscheidet, ob auf der CPU oder GPU Rauschen erzeugt wird.", + "Mit aktiviertem CPU-Rauschen wird ein bestimmter Seedwert das gleiche Bild auf jeder Maschine erzeugen.", + "CPU-Rauschen einzuschalten beeinflusst nicht die Systemleistung." + ] + }, + "paramModel": { + "heading": "Modell", + "paragraphs": [ + "Modell für die Entrauschungsschritte." + ] + }, + "paramIterations": { + "heading": "Iterationen", + "paragraphs": [ + "Die Anzahl der Bilder, die erzeugt werden sollen.", + "Wenn \"Dynamische Prompts\" aktiviert ist, wird jeder einzelne Prompt so oft generiert." + ] + }, + "paramCFGScale": { + "heading": "CFG-Skala", + "paragraphs": [ + "Bestimmt, wie viel Ihr Prompt den Erzeugungsprozess beeinflusst." + ] + }, + "paramSteps": { + "heading": "Schritte", + "paragraphs": [ + "Anzahl der Schritte, die bei jeder Generierung durchgeführt werden.", + "Höhere Schrittzahlen werden in der Regel bessere Bilder ergeben, aber mehr Zeit benötigen." + ] + }, + "lora": { + "heading": "LoRA Gewichte", + "paragraphs": [ + "Höhere LoRA-Wichtungen führen zu größeren Auswirkungen auf das endgültige Bild." + ] + }, + "infillMethod": { + "heading": "Füllmethode", + "paragraphs": [ + "Infill-Methode für den ausgewählten Bereich." + ] + }, + "paramVAE": { + "heading": "VAE", + "paragraphs": [ + "Verwendetes Modell, um den KI-Ausgang in das endgültige Bild zu übersetzen." + ] + }, + "paramRatio": { + "heading": "Seitenverhältnis", + "paragraphs": [ + "Das Seitenverhältnis des erzeugten Bildes.", + "Für SD1.5-Modelle wird eine Bildgröße von 512x512 Pixel empfohlen, für SDXL-Modelle sind es 1024x1024 Pixel." + ] + }, + "paramDenoisingStrength": { + "paragraphs": [ + "Wie viel Rauschen dem Eingabebild hinzugefügt wird.", + "0 wird zu einem identischen Bild führen, während 1 zu einem völlig neuen Bild führt." + ], + "heading": "Stärke der Entrauschung" + }, + "paramVAEPrecision": { + "heading": "VAE-Präzision", + "paragraphs": [ + "Die bei der VAE-Kodierung und Dekodierung verwendete Präzision. FP16/Halbpräzision ist effizienter, aber auf Kosten kleiner Bildvariationen." + ] + }, + "paramCFGRescaleMultiplier": { + "heading": "CFG Rescale Multiplikator", + "paragraphs": [ + "Rescale-Multiplikator für die CFG-Lenkung, der für Modelle verwendet wird, die mit dem zero-terminal SNR (ztsnr) trainiert wurden. Empfohlener Wert: 0,7." + ] + }, + "scaleBeforeProcessing": { + "paragraphs": [ + "Skaliert den ausgewählten Bereich auf die Größe, die für das Modell am besten geeignet ist." + ], + "heading": "Skalieren vor der Verarbeitung" + }, + "paramSeed": { + "paragraphs": [ + "Kontrolliert das für die Erzeugung verwendete Startrauschen.", + "Deaktivieren Sie “Random Seed”, um identische Ergebnisse mit den gleichen Generierungseinstellungen zu erzeugen." + ], + "heading": "Seed" + }, + "dynamicPromptsMaxPrompts": { + "paragraphs": [ + "Beschränkt die Anzahl der Prompts, die von \"Dynamic Prompts\" generiert werden können." + ], + "heading": "Maximale Prompts" + }, + "dynamicPromptsSeedBehaviour": { + "paragraphs": [ + "Bestimmt, wie der Seed-Wert beim Erzeugen von Prompts verwendet wird.", + "Verwenden Sie dies, um schnelle Variationen eines einzigen Seeds zu erkunden.", + "Wenn Sie z. B. 5 Prompts haben, wird jedes Bild den selben Seed-Wert verwenden.", + "\"Per Bild\" wird einen einzigartigen Seed-Wert für jedes Bild verwenden. Dies bietet mehr Variationen." + ], + "heading": "Seed-Verhalten" + }, + "dynamicPrompts": { + "paragraphs": [ + "\"Dynamische Prompts\" übersetzt einen Prompt in mehrere.", + "Die Ausgangs-Syntax ist \"ein {roter|grüner|blauer} ball\". Das generiert 3 Prompts: \"ein roter ball\", \"ein grüner ball\" und \"ein blauer ball\".", + "Sie können die Syntax so oft verwenden, wie Sie in einem einzigen Prompt möchten, aber stellen Sie sicher, dass die Anzahl der Prompts zur Einstellung von \"Max Prompts\" passt." + ], + "heading": "Dynamische Prompts" + }, + "controlNetWeight": { + "paragraphs": [ + "Wie stark wird das ControlNet das generierte Bild beeinflussen wird." + ], + "heading": "Einfluss" + }, + "paramScheduler": { + "paragraphs": [ + "Verwendeter Planer währende des Generierungsprozesses.", + "Jeder Planer definiert, wie einem Bild iterativ Rauschen hinzugefügt wird, oder wie ein Sample basierend auf der Ausgabe eines Modells aktualisiert wird." + ], + "heading": "Planer" + }, + "imageFit": { + "paragraphs": [ + "Reduziert das Ausgangsbild auf die Breite und Höhe des Ausgangsbildes. Empfohlen zu aktivieren." + ] + }, + "structure": { + "paragraphs": [ + "Die Struktur steuert, wie genau sich das Ausgabebild an das Layout des Originals hält. Eine niedrige Struktur erlaubt größere Änderungen, während eine hohe Struktur die ursprüngliche Komposition und das Layout strikter beibehält." + ] + }, + "creativity": { + "paragraphs": [ + "Die Kreativität bestimmt den Grad der Freiheit, die dem Modell beim Hinzufügen von Details gewährt wird. Eine niedrige Kreativität hält sich eng an das Originalbild, während eine hohe Kreativität mehr Veränderungen zulässt. Bei der Verwendung eines Prompts erhöht eine hohe Kreativität den Einfluss des Prompts." + ] + }, + "scale": { + "paragraphs": [ + "Die Skalierung steuert die Größe des Ausgabebildes und basiert auf einem Vielfachen der Auflösung des Originalbildes. So würde z. B. eine 2-fache Hochskalierung eines 1024x1024px Bildes eine 2048x2048px große Ausgabe erzeugen." + ] + }, + "ipAdapterMethod": { + "heading": "Methode" + }, + "refinerScheduler": { + "heading": "Planer", + "paragraphs": [ + "Planer, der während der Veredelungsphase des Generierungsprozesses verwendet wird.", + "Ähnlich wie der Generierungsplaner." + ] + }, + "compositingCoherenceMode": { + "paragraphs": [ + "Verwendete Methode zur Erstellung eines kohärenten Bildes mit dem neu generierten maskierten Bereich." + ], + "heading": "Modus" + }, + "compositingCoherencePass": { + "heading": "Kohärenzdurchlauf" + }, + "controlNet": { + "heading": "ControlNet" + }, + "compositingMaskAdjustments": { + "paragraphs": [ + "Die Maske anpassen." + ], + "heading": "Maskenanpassungen" + }, + "compositingMaskBlur": { + "paragraphs": [ + "Der Unschärferadius der Maske." + ], + "heading": "Maskenunschärfe" + }, + "compositingBlurMethod": { + "paragraphs": [ + "Die auf den maskierten Bereich angewendete Unschärfemethode." + ], + "heading": "Unschärfemethode" + }, + "controlNetResizeMode": { + "heading": "Größenänderungsmodus" + }, + "paramWidth": { + "heading": "Breite", + "paragraphs": [ + "Breite des generierten Bildes. Muss ein Vielfaches von 8 sein." + ] + }, + "controlNetControlMode": { + "heading": "Kontrollmodus" + }, + "controlNetProcessor": { + "heading": "Prozessor" + }, + "patchmatchDownScaleSize": { + "heading": "Herunterskalieren" + } + }, + "invocationCache": { + "disable": "Deaktivieren", + "misses": "Cache nicht genutzt", + "hits": "Cache Treffer", + "enable": "Aktivieren", + "clear": "Leeren", + "maxCacheSize": "Maximale Cache Größe", + "cacheSize": "Cache Größe", + "useCache": "Benutze Cache", + "enableFailed": "Problem beim Aktivieren des Zwischenspeichers", + "disableFailed": "Problem bei Deaktivierung des Cache", + "enableSucceeded": "Zwischenspeicher aktiviert", + "disableSucceeded": "Invocation-Cache deaktiviert", + "clearSucceeded": "Zwischenspeicher gelöscht", + "invocationCache": "Zwischenspeicher", + "clearFailed": "Problem beim Löschen des Zwischenspeichers" + }, + "nodes": { + "addNode": "Knoten hinzufügen", + "colorCodeEdgesHelp": "Farbkodieren Sie Kanten entsprechend ihren verbundenen Feldern", + "animatedEdges": "Animierte Kanten", + "animatedEdgesHelp": "Animieren Sie ausgewählte Kanten und Kanten, die mit ausgewählten Knoten verbunden sind", + "cannotDuplicateConnection": "Es können keine doppelten Verbindungen erstellt werden", + "boolean": "Boolesche Werte", + "currentImage": "Aktuelles Bild", + "collection": "Sammlung", + "cannotConnectInputToInput": "Eingang kann nicht mit Eingang verbunden werden", + "cannotConnectOutputToOutput": "Ausgang kann nicht mit Ausgang verbunden werden", + "cannotConnectToSelf": "Es kann keine Verbindung zu sich selbst hergestellt werden", + "colorCodeEdges": "Farbkodierte Kanten", + "addNodeToolTip": "Knoten hinzufügen (Umschalt+A, Leertaste)", + "collectionFieldType": "{{name}} (Sammlung)", + "connectionWouldCreateCycle": "Verbindung würde einen Kreislauf/cycle schaffen", + "inputMayOnlyHaveOneConnection": "Eingang darf nur eine Verbindung haben", + "hideLegendNodes": "Feldtyp-Legende ausblenden", + "integer": "Ganze Zahl", + "addLinearView": "Zur linearen Ansicht hinzufügen", + "currentImageDescription": "Zeigt das aktuelle Bild im Node-Editor an", + "ipAdapter": "IP-Adapter", + "hideMinimapnodes": "Miniatur-Kartenansicht ausblenden", + "newWorkflowDesc2": "Ihr aktueller Arbeitsablauf hat ungespeicherte Änderungen.", + "problemSettingTitle": "Problem beim Einstellen des Titels", + "reloadNodeTemplates": "Knoten-Vorlagen neu laden", + "newWorkflow": "Neuer Arbeitsablauf / Workflow", + "newWorkflowDesc": "Einen neuen Arbeitsablauf erstellen?", + "noFieldsLinearview": "Keine Felder zur linearen Ansicht hinzugefügt", + "clearWorkflow": "Workflow löschen", + "clearWorkflowDesc": "Diesen Arbeitsablauf löschen und neu starten?", + "noConnectionInProgress": "Es besteht keine Verbindung", + "notes": "Anmerkungen", + "nodeVersion": "Knoten Version", + "node": "Knoten", + "nodeSearch": "Knoten suchen", + "removeLinearView": "Entfernen aus Linear View", + "nodeOutputs": "Knoten-Ausgänge", + "nodeTemplate": "Knoten-Vorlage", + "nodeType": "Knotentyp", + "noNodeSelected": "Kein Knoten gewählt", + "nodeOpacity": "Knoten-Deckkraft", + "noOutputRecorded": "Keine Ausgänge aufgezeichnet", + "notesDescription": "Anmerkungen zum Arbeitsablauf hinzufügen", + "clearWorkflowDesc2": "Ihr aktueller Arbeitsablauf hat ungespeicherte Änderungen.", + "scheduler": "Planer", + "showMinimapnodes": "MiniMap anzeigen", + "showLegendNodes": "Feldtyp-Legende anzeigen", + "executionStateCompleted": "Erledigt", + "downloadWorkflow": "Workflow JSON herunterladen", + "executionStateInProgress": "In Bearbeitung", + "snapToGridHelp": "Knoten am Gitternetz einrasten bei Bewegung", + "missingTemplate": "Ungültiger Knoten: Knoten {{node}} vom Typ {{type}} fehlt Vorlage (nicht installiert?)", + "string": "Zeichenfolge", + "fieldTypesMustMatch": "Feldtypen müssen übereinstimmen", + "fitViewportNodes": "An Ansichtsgröße anpassen", + "loadingNodes": "Lade Nodes...", + "mismatchedVersion": "Ungültiger Knoten: Knoten {{node}} vom Typ {{type}} hat keine passende Version (Update versuchen?)", + "fullyContainNodesHelp": "Nodes müssen vollständig innerhalb der Auswahlbox sein, um ausgewählt werden zu können", + "noWorkflow": "Kein Workflow", + "executionStateError": "Fehler", + "nodePack": "Knoten-Pack", + "loadWorkflow": "Lade Workflow", + "snapToGrid": "Am Gitternetz einrasten", + "unknownOutput": "Unbekannte Ausgabe: {{name}}", + "updateNode": "Knoten updaten", + "edge": "Rand / Kante", + "sourceNodeDoesNotExist": "Ungültiger Rand: Quell- / Ausgabe-Knoten {{node}} existiert nicht", + "updateAllNodes": "Update Knoten", + "allNodesUpdated": "Alle Knoten aktualisiert", + "unknownTemplate": "Unbekannte Vorlage", + "updateApp": "Update App", + "unknownInput": "Unbekannte Eingabe: {{name}}", + "unknownNodeType": "Unbekannter Knotentyp", + "float": "Kommazahlen", + "enum": "Aufzählung", + "fullyContainNodes": "Vollständig ausgewählte Nodes auswählen", + "editMode": "Im Workflow-Editor bearbeiten", + "resetToDefaultValue": "Auf Standardwert zurücksetzen", + "singleFieldType": "{{name}} (Einzeln)", + "collectionOrScalarFieldType": "{{name}} (Einzeln oder Sammlung)", + "missingFieldTemplate": "Fehlende Feldvorlage", + "missingNode": "Fehlender Aufrufknoten", + "missingInvocationTemplate": "Fehlende Aufrufvorlage", + "edit": "Bearbeiten", + "workflowAuthor": "Autor", + "graph": "Graph", + "workflowDescription": "Kurze Beschreibung", + "versionUnknown": " Version unbekannt", + "workflow": "Arbeitsablauf", + "noGraph": "Kein Graph", + "version": "Version", + "zoomInNodes": "Hineinzoomen", + "zoomOutNodes": "Herauszoomen", + "workflowName": "Name", + "unknownNode": "Unbekannter Knoten", + "workflowContact": "Kontaktdaten", + "workflowNotes": "Notizen", + "workflowTags": "Tags", + "workflowVersion": "Version", + "saveToGallery": "In Galerie speichern", + "noWorkflows": "Keine Arbeitsabläufe", + "noMatchingWorkflows": "Keine passenden Arbeitsabläufe", + "unknownErrorValidatingWorkflow": "Unbekannter Fehler beim Validieren des Arbeitsablaufes", + "inputFieldTypeParseError": "Typ des Eingabefelds {{node}}.{{field}} kann nicht analysiert werden ({{message}})", + "workflowSettings": "Arbeitsablauf Editor Einstellungen", + "unableToLoadWorkflow": "Arbeitsablauf kann nicht geladen werden", + "viewMode": "In linearen Ansicht verwenden", + "unableToValidateWorkflow": "Arbeitsablauf kann nicht validiert werden", + "outputFieldTypeParseError": "Typ des Ausgabefelds {{node}}.{{field}} kann nicht analysiert werden ({{message}})", + "unableToGetWorkflowVersion": "Version des Arbeitsablaufschemas kann nicht bestimmt werden", + "unknownFieldType": "$t(nodes.unknownField) Typ: {{type}}", + "unknownField": "Unbekanntes Feld", + "unableToUpdateNodes_one": "{{count}} Knoten kann nicht aktualisiert werden", + "unableToUpdateNodes_other": "{{count}} Knoten können nicht aktualisiert werden" + }, + "hrf": { + "enableHrf": "Korrektur für hohe Auflösungen", + "upscaleMethod": "Vergrößerungsmethode", + "metadata": { + "strength": "Auflösungs-Fix Stärke", + "enabled": "Auflösungs-Fix aktiviert", + "method": "Auflösungs-Fix Methode" + }, + "hrf": "Hohe-Auflösung-Fix" + }, + "models": { + "noMatchingModels": "Keine passenden Modelle", + "loading": "lade", + "noMatchingLoRAs": "Keine passenden LoRAs", + "noModelsAvailable": "Keine Modelle verfügbar", + "selectModel": "Wählen ein Modell aus", + "noRefinerModelsInstalled": "Keine SDXL Refiner-Modelle installiert", + "noLoRAsInstalled": "Keine LoRAs installiert", + "addLora": "LoRA hinzufügen", + "defaultVAE": "Standard VAE", + "lora": "LoRA", + "concepts": "Konzepte" + }, + "accordions": { + "generation": { + "title": "Erstellung" + }, + "image": { + "title": "Bild" + }, + "advanced": { + "title": "Erweitert", + "options": "$t(accordions.advanced.title) Optionen" + }, + "control": { + "title": "Kontrolle" + }, + "compositing": { + "coherenceTab": "Kohärenzpass", + "infillTab": "Infill", + "title": "Compositing" + } + }, + "workflows": { + "workflows": "Arbeitsabläufe", + "workflowName": "Arbeitsablauf-Name", + "saveWorkflowAs": "Arbeitsablauf speichern als", + "searchWorkflows": "Suche Arbeitsabläufe", + "newWorkflowCreated": "Neuer Arbeitsablauf erstellt", + "problemSavingWorkflow": "Problem beim Speichern des Arbeitsablaufs", + "problemLoading": "Problem beim Laden von Arbeitsabläufen", + "downloadWorkflow": "Speichern als", + "savingWorkflow": "Speichere Arbeitsablauf...", + "saveWorkflow": "Arbeitsablauf speichern", + "noWorkflows": "Keine Arbeitsabläufe", + "workflowLibrary": "Bibliothek", + "unnamedWorkflow": "Unbenannter Arbeitsablauf", + "noDescription": "Keine Beschreibung", + "clearWorkflowSearchFilter": "Suchfilter zurücksetzen", + "workflowEditorMenu": "Arbeitsablauf-Editor Menü", + "deleteWorkflow": "Arbeitsablauf löschen", + "workflowSaved": "Arbeitsablauf gespeichert", + "uploadWorkflow": "Aus Datei laden", + "openWorkflow": "Arbeitsablauf öffnen", + "saveWorkflowToProject": "Arbeitsablauf in Projekt speichern", + "workflowCleared": "Arbeitsablauf gelöscht", + "loading": "Lade Arbeitsabläufe", + "name": "Name", + "ascending": "Aufsteigend", + "defaultWorkflows": "Standard Arbeitsabläufe", + "userWorkflows": "Benutzer Arbeitsabläufe", + "projectWorkflows": "Projekt Arbeitsabläufe", + "opened": "Geöffnet", + "loadWorkflow": "Arbeitsablauf $t(common.load)", + "updated": "Aktualisiert", + "created": "Erstellt", + "descending": "Absteigend" + }, + "sdxl": { + "concatPromptStyle": "Verknüpfen von Prompt & Stil", + "scheduler": "Planer", + "steps": "Schritte" + }, + "dynamicPrompts": { + "showDynamicPrompts": "Dynamische Prompts anzeigen" + }, + "prompt": { + "noMatchingTriggers": "Keine passenden Trigger", + "addPromptTrigger": "Prompt-Trigger hinzufügen", + "compatibleEmbeddings": "Kompatible Einbettungen" + }, + "ui": { + "tabs": { + "queue": "Warteschlange", + "generation": "Erzeugung", + "gallery": "Galerie", + "models": "Modelle", + "upscaling": "Hochskalierung", + "workflows": "Arbeitsabläufe", + "canvas": "Leinwand" + } + }, + "system": { + "logNamespaces": { + "logNamespaces": "Namespaces loggen", + "models": "Modelle", + "gallery": "Galerie", + "events": "Ereignisse", + "queue": "Warteschlange", + "system": "System", + "workflows": "Arbeitsabläufe", + "generation": "Erstellung", + "metadata": "Metadaten", + "config": "Konfiguration", + "canvas": "Leinwand" + }, + "logLevel": { + "fatal": "Fatal", + "trace": "Trace", + "logLevel": "Protokollierungsstufe", + "error": "Fehler", + "info": "Infos", + "warn": "Warnung", + "debug": "Fehlerdiagnose" + }, + "enableLogging": "Protokollierung aktivieren" + }, + "whatsNew": { + "whatsNewInInvoke": "Was gibt's Neues" + }, + "stylePresets": { + "name": "Name", + "acceptedColumnsKeys": "Akzeptierte Spalten/Schlüssel:", + "noTemplates": "Keine Vorlagen", + "promptTemplatesDesc2": "Verwenden Sie die Platzhalterzeichenfolge
{{placeholder}}
, um anzugeben, wo Ihre Eingabeaufforderung in die Vorlage aufgenommen werden soll.", + "noMatchingTemplates": "Keine passenden Vorlagen", + "myTemplates": "Meine Vorlagen", + "toggleViewMode": "Ansicht umschalten", + "viewModeTooltip": "So sieht Ihr Prompt mit der aktuell ausgewählten Vorlage aus. Um Ihren Prompt zu bearbeiten, klicken Sie irgendwo in das Textfeld.", + "templateDeleted": "Promptvorlage gelöscht", + "unableToDeleteTemplate": "Promptvorlage kann nicht gelöscht werden", + "insertPlaceholder": "Platzhalter einfügen", + "type": "Typ", + "uploadImage": "Bild hochladen", + "updatePromptTemplate": "Promptvorlage aktualisieren", + "exportFailed": "CSV kann nicht generiert und heruntergeladen werden", + "viewList": "Vorlagenliste anzeigen", + "useForTemplate": "Für Promptvorlage nutzen", + "shared": "Geteilt", + "private": "Privat", + "promptTemplatesDesc1": "Promptvorlagen fügen den Prompts, die Sie in das Prompt-Feld schreiben, Text hinzu.", + "negativePrompt": "Negativ-Prompt", + "positivePromptColumn": "'prompt' oder 'positive_prompt'", + "promptTemplatesDesc3": "Wenn Sie den Platzhalter weglassen, wird die Vorlage an das Ende Ihres Prompts angehängt.", + "sharedTemplates": "Geteilte Vorlagen", + "importTemplates": "Promptvorlagen importieren (CSV/JSON)", + "flatten": "Ausgewählte Vorlage in aktuelle Eingabeaufforderung einblenden", + "searchByName": "Nach Name suchen", + "promptTemplateCleared": "Promptvorlage gelöscht", + "preview": "Vorschau", + "positivePrompt": "Positiv-Prompt", + "active": "Aktiv", + "deleteTemplate2": "Sind Sie sicher, dass Sie diese Vorlage löschen möchten? Dies kann nicht rückgängig gemacht werden.", + "deleteTemplate": "Vorlage löschen", + "copyTemplate": "Vorlage kopieren", + "editTemplate": "Vorlage bearbeiten", + "deleteImage": "Bild löschen", + "defaultTemplates": "Standardvorlagen", + "nameColumn": "'name'", + "exportDownloaded": "Export heruntergeladen" + }, + "newUserExperience": { + "gettingStartedSeries": "Wünschen Sie weitere Anleitungen? In unserer Einführungsserie finden Sie Tipps, wie Sie das Potenzial von Invoke Studio voll ausschöpfen können.", + "toGetStarted": "Um zu beginnen, geben Sie einen Prompt in das Feld ein und klicken Sie auf Invoke, um Ihr erstes Bild zu erzeugen. Sie können Ihre Bilder direkt in der Galerie speichern oder sie auf der Leinwand bearbeiten." + }, + "controlLayers": { + "pullBboxIntoLayerOk": "Bbox in die Ebene gezogen", + "saveBboxToGallery": "Bbox in Galerie speichern", + "tool": { + "bbox": "Bbox", + "brush": "Pinsel", + "eraser": "Radiergummi", + "colorPicker": "Farbwähler", + "view": "Ansicht", + "rectangle": "Rechteck", + "move": "Verschieben" + }, + "transform": { + "fitToBbox": "An Bbox anpassen", + "reset": "Zurücksetzen", + "apply": "Anwenden", + "cancel": "Abbrechen" + }, + "pullBboxIntoLayerError": "Problem, Bbox in die Ebene zu ziehen", + "pullBboxIntoLayer": "Bbox in Ebene ziehen", + "HUD": { + "bbox": "Bbox", + "scaledBbox": "Skalierte Bbox", + "entityStatus": { + "isHidden": "{{title}} ist ausgeblendet", + "isDisabled": "{{title}} ist deaktiviert", + "isLocked": "{{title}} ist gesperrt", + "isEmpty": "{{title}} ist leer" + } + }, + "fitBboxToLayers": "Bbox an Ebenen anpassen", + "pullBboxIntoReferenceImage": "Bbox ins Referenzbild ziehen", + "pullBboxIntoReferenceImageOk": "Bbox in Referenzbild gezogen", + "pullBboxIntoReferenceImageError": "Problem, Bbox ins Referenzbild zu ziehen", + "bboxOverlay": "Bbox Overlay anzeigen", + "clipToBbox": "Pinselstriche auf Bbox beschränken", + "canvasContextMenu": { + "saveBboxToGallery": "Bbox in Galerie speichern", + "bboxGroup": "Aus Bbox erstellen", + "canvasGroup": "Leinwand", + "newGlobalReferenceImage": "Neues globales Referenzbild", + "newRegionalReferenceImage": "Neues regionales Referenzbild", + "newControlLayer": "Neue Kontroll-Ebene", + "newRasterLayer": "Neue Raster-Ebene" + }, + "rectangle": "Rechteck", + "saveCanvasToGallery": "Leinwand in Galerie speichern", + "newRasterLayerError": "Problem beim Erstellen einer Raster-Ebene", + "saveLayerToAssets": "Ebene in Galerie speichern", + "deleteReferenceImage": "Referenzbild löschen", + "referenceImage": "Referenzbild", + "opacity": "Opazität", + "resetCanvas": "Leinwand zurücksetzen", + "removeBookmark": "Lesezeichen entfernen", + "rasterLayer": "Raster-Ebene", + "rasterLayers_withCount_visible": "Raster-Ebenen ({{count}})", + "controlLayers_withCount_visible": "Kontroll-Ebenen ({{count}})", + "deleteSelected": "Ausgewählte löschen", + "newRegionalReferenceImageError": "Problem beim Erstellen eines regionalen Referenzbilds", + "newControlLayerOk": "Kontroll-Ebene erstellt", + "newControlLayerError": "Problem beim Erstellen einer Kontroll-Ebene", + "newRasterLayerOk": "Raster-Layer erstellt", + "moveToFront": "Nach vorne bringen", + "copyToClipboard": "In die Zwischenablage kopieren", + "controlLayers_withCount_hidden": "Kontroll-Ebenen ({{count}} ausgeblendet)", + "clearCaches": "Cache leeren", + "controlLayer": "Kontroll-Ebene", + "rasterLayers_withCount_hidden": "Raster-Ebenen ({{count}} ausgeblendet)", + "transparency": "Transparenz", + "canvas": "Leinwand", + "global": "Global", + "regional": "Regional", + "newGlobalReferenceImageOk": "Globales Referenzbild erstellt", + "savedToGalleryError": "Fehler beim Speichern in der Galerie", + "savedToGalleryOk": "In Galerie gespeichert", + "newGlobalReferenceImageError": "Problem beim Erstellen eines globalen Referenzbilds", + "newRegionalReferenceImageOk": "Regionales Referenzbild erstellt", + "duplicate": "Duplizieren", + "regionalReferenceImage": "Regionales Referenzbild", + "globalReferenceImage": "Globales Referenzbild", + "regionIsEmpty": "Ausgewählte Region is leer", + "mergeVisible": "Sichtbare vereinen", + "mergeVisibleOk": "Sichtbare Ebenen vereinen", + "mergeVisibleError": "Fehler beim Vereinen sichtbarer Ebenen", + "clearHistory": "Verlauf leeren", + "addLayer": "Ebene hinzufügen", + "width": "Breite", + "weight": "Gewichtung", + "addReferenceImage": "$t(controlLayers.referenceImage) hinzufügen", + "addInpaintMask": "$t(controlLayers.inpaintMask) hinzufügen", + "addGlobalReferenceImage": "$t(controlLayers.globalReferenceImage) hinzufügen", + "regionalGuidance": "Regionale Führung", + "globalReferenceImages_withCount_visible": "Globale Referenzbilder ({{count}})", + "addPositivePrompt": "$t(controlLayers.prompt) hinzufügen", + "locked": "Gesperrt", + "showHUD": "HUD anzeigen", + "addNegativePrompt": "$t(controlLayers.negativePrompt) hinzufügen", + "addRasterLayer": "$t(controlLayers.rasterLayer) hinzufügen", + "addRegionalGuidance": "$t(controlLayers.regionalGuidance) hinzufügen", + "addControlLayer": "$t(controlLayers.controlLayer) hinzufügen", + "newCanvasSession": "Neue Leinwand-Sitzung", + "replaceLayer": "Ebene ersetzen", + "newGallerySession": "Neue Galerie-Sitzung", + "unlocked": "Entsperrt", + "showProgressOnCanvas": "Fortschritt auf Leinwand anzeigen", + "controlMode": { + "balanced": "Ausgewogen" + }, + "globalReferenceImages_withCount_hidden": "Globale Referenzbilder ({{count}} ausgeblendet)", + "sendToGallery": "An Galerie senden", + "stagingArea": { + "accept": "Annehmen", + "next": "Nächste", + "discardAll": "Alle verwerfen", + "discard": "Verwerfen", + "previous": "Vorherige" + }, + "regionalGuidance_withCount_visible": "Regionale Führung ({{count}})", + "regionalGuidance_withCount_hidden": "Regionale Führung ({{count}} ausgeblendet)", + "settings": { + "snapToGrid": { + "on": "Ein", + "off": "Aus", + "label": "Am Raster ausrichten" + } + }, + "layer_one": "Ebene", + "layer_other": "Ebenen", + "layer_withCount_one": "Ebene ({{count}})", + "layer_withCount_other": "Ebenen ({{count}})", + "fill": { + "fillStyle": "Füllstil", + "diagonal": "Diagonal", + "vertical": "Vertikal", + "fillColor": "Füllfarbe", + "grid": "Raster", + "solid": "Solide", + "crosshatch": "Kreuzschraffur", + "horizontal": "Horizontal" + }, + "filter": { + "apply": "Anwenden", + "reset": "Zurücksetzen", + "cancel": "Abbrechen", + "spandrel_filter": { + "label": "Bild-zu-Bild Modell", + "description": "Ein Bild-zu-Bild Modell auf der ausgewählten Ebene ausführen.", + "model": "Modell" + }, + "filters": "Filter", + "filterType": "Filtertyp", + "filter": "Filter" + } + }, + "upsell": { + "shareAccess": "Zugang teilen", + "professional": "Professionell", + "inviteTeammates": "Teamkollegen einladen", + "professionalUpsell": "Verfügbar in der Professional Edition von Invoke. Klicken Sie hier oder besuchen Sie invoke.com/pricing für weitere Details." + }, + "upscaling": { + "creativity": "Kreativität", + "structure": "Struktur", + "scale": "Maßstab" + } +} diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json new file mode 100644 index 0000000000000000000000000000000000000000..037017cdb99bc59bc170f174be1cbb26549f0da0 --- /dev/null +++ b/invokeai/frontend/web/public/locales/en.json @@ -0,0 +1,2120 @@ +{ + "accessibility": { + "about": "About", + "createIssue": "Create Issue", + "submitSupportTicket": "Submit Support Ticket", + "invokeProgressBar": "Invoke progress bar", + "menu": "Menu", + "mode": "Mode", + "nextImage": "Next Image", + "previousImage": "Previous Image", + "reset": "Reset", + "resetUI": "$t(accessibility.reset) UI", + "toggleRightPanel": "Toggle Right Panel (G)", + "toggleLeftPanel": "Toggle Left Panel (T)", + "uploadImage": "Upload Image", + "uploadImages": "Upload Image(s)" + }, + "boards": { + "addBoard": "Add Board", + "addPrivateBoard": "Add Private Board", + "addSharedBoard": "Add Shared Board", + "archiveBoard": "Archive Board", + "archived": "Archived", + "autoAddBoard": "Auto-Add Board", + "boards": "Boards", + "selectedForAutoAdd": "Selected for Auto-Add", + "bottomMessage": "Deleting this board and its images will reset any features currently using them.", + "cancel": "Cancel", + "changeBoard": "Change Board", + "clearSearch": "Clear Search", + "deleteBoard": "Delete Board", + "deleteBoardAndImages": "Delete Board and Images", + "deleteBoardOnly": "Delete Board Only", + "deletedBoardsCannotbeRestored": "Deleted boards cannot be restored. Selecting 'Delete Board Only' will move images to an uncategorized state.", + "deletedPrivateBoardsCannotbeRestored": "Deleted boards cannot be restored. Selecting 'Delete Board Only' will move images to a private uncategorized state for the image's creator.", + "hideBoards": "Hide Boards", + "loading": "Loading...", + "menuItemAutoAdd": "Auto-add to this Board", + "move": "Move", + "movingImagesToBoard_one": "Moving {{count}} image to board:", + "movingImagesToBoard_other": "Moving {{count}} images to board:", + "myBoard": "My Board", + "noBoards": "No {{boardType}} Boards", + "noMatching": "No matching Boards", + "private": "Private Boards", + "searchBoard": "Search Boards...", + "selectBoard": "Select a Board", + "shared": "Shared Boards", + "topMessage": "This board contains images used in the following features:", + "unarchiveBoard": "Unarchive Board", + "uncategorized": "Uncategorized", + "viewBoards": "View Boards", + "downloadBoard": "Download Board", + "imagesWithCount_one": "{{count}} image", + "imagesWithCount_other": "{{count}} images", + "assetsWithCount_one": "{{count}} asset", + "assetsWithCount_other": "{{count}} assets", + "updateBoardError": "Error updating board" + }, + "accordions": { + "generation": { + "title": "Generation" + }, + "image": { + "title": "Image" + }, + "advanced": { + "title": "Advanced", + "options": "$t(accordions.advanced.title) Options" + }, + "control": { + "title": "Control" + }, + "compositing": { + "title": "Compositing", + "coherenceTab": "Coherence Pass", + "infillTab": "Infill" + } + }, + "common": { + "aboutDesc": "Using Invoke for work? Check out:", + "aboutHeading": "Own Your Creative Power", + "accept": "Accept", + "apply": "Apply", + "add": "Add", + "advanced": "Advanced", + "ai": "ai", + "areYouSure": "Are you sure?", + "auto": "Auto", + "back": "Back", + "batch": "Batch Manager", + "beta": "Beta", + "cancel": "Cancel", + "close": "Close", + "copy": "Copy", + "copyError": "$t(gallery.copy) Error", + "clipboard": "Clipboard", + "on": "On", + "off": "Off", + "or": "or", + "ok": "Ok", + "checkpoint": "Checkpoint", + "communityLabel": "Community", + "controlNet": "ControlNet", + "data": "Data", + "delete": "Delete", + "details": "Details", + "direction": "Direction", + "ipAdapter": "IP Adapter", + "t2iAdapter": "T2I Adapter", + "positivePrompt": "Positive Prompt", + "negativePrompt": "Negative Prompt", + "discordLabel": "Discord", + "dontAskMeAgain": "Don't ask me again", + "dontShowMeThese": "Don't show me these", + "editor": "Editor", + "error": "Error", + "file": "File", + "folder": "Folder", + "format": "format", + "githubLabel": "Github", + "goTo": "Go to", + "hotkeysLabel": "Hotkeys", + "loadingImage": "Loading Image", + "loadingModel": "Loading Model", + "imageFailedToLoad": "Unable to Load Image", + "img2img": "Image To Image", + "inpaint": "inpaint", + "input": "Input", + "installed": "Installed", + "languagePickerLabel": "Language", + "linear": "Linear", + "load": "Load", + "loading": "Loading", + "localSystem": "Local System", + "learnMore": "Learn More", + "modelManager": "Model Manager", + "nodes": "Workflows", + "notInstalled": "Not $t(common.installed)", + "openInNewTab": "Open in New Tab", + "openInViewer": "Open in Viewer", + "orderBy": "Order By", + "outpaint": "outpaint", + "outputs": "Outputs", + "postprocessing": "Post Processing", + "random": "Random", + "reportBugLabel": "Report Bug", + "safetensors": "Safetensors", + "save": "Save", + "saveAs": "Save As", + "settingsLabel": "Settings", + "simple": "Simple", + "somethingWentWrong": "Something went wrong", + "statusDisconnected": "Disconnected", + "template": "Template", + "toResolve": "To resolve", + "txt2img": "Text To Image", + "unknown": "Unknown", + "upload": "Upload", + "updated": "Updated", + "created": "Created", + "prevPage": "Previous Page", + "nextPage": "Next Page", + "unknownError": "Unknown Error", + "red": "Red", + "green": "Green", + "blue": "Blue", + "alpha": "Alpha", + "selected": "Selected", + "tab": "Tab", + "view": "View", + "edit": "Edit", + "enabled": "Enabled", + "disabled": "Disabled", + "placeholderSelectAModel": "Select a model", + "reset": "Reset", + "none": "None", + "new": "New", + "generating": "Generating" + }, + "hrf": { + "hrf": "High Resolution Fix", + "enableHrf": "Enable High Resolution Fix", + "upscaleMethod": "Upscale Method", + "metadata": { + "enabled": "High Resolution Fix Enabled", + "strength": "High Resolution Fix Strength", + "method": "High Resolution Fix Method" + } + }, + "prompt": { + "addPromptTrigger": "Add Prompt Trigger", + "compatibleEmbeddings": "Compatible Embeddings", + "noMatchingTriggers": "No matching triggers" + }, + "queue": { + "queue": "Queue", + "queueFront": "Add to Front of Queue", + "queueBack": "Add to Queue", + "queueEmpty": "Queue Empty", + "enqueueing": "Queueing Batch", + "resume": "Resume", + "resumeTooltip": "Resume Processor", + "resumeSucceeded": "Processor Resumed", + "resumeFailed": "Problem Resuming Processor", + "pause": "Pause", + "pauseTooltip": "Pause Processor", + "pauseSucceeded": "Processor Paused", + "pauseFailed": "Problem Pausing Processor", + "cancel": "Cancel", + "cancelTooltip": "Cancel Current Item", + "cancelSucceeded": "Item Canceled", + "cancelFailed": "Problem Canceling Item", + "prune": "Prune", + "pruneTooltip": "Prune {{item_count}} Completed Items", + "pruneSucceeded": "Pruned {{item_count}} Completed Items from Queue", + "pruneFailed": "Problem Pruning Queue", + "clear": "Clear", + "clearTooltip": "Cancel and Clear All Items", + "clearSucceeded": "Queue Cleared", + "clearFailed": "Problem Clearing Queue", + "cancelBatch": "Cancel Batch", + "cancelItem": "Cancel Item", + "cancelBatchSucceeded": "Batch Canceled", + "cancelBatchFailed": "Problem Canceling Batch", + "clearQueueAlertDialog": "Clearing the queue immediately cancels any processing items and clears the queue entirely. Pending filters will be canceled.", + "clearQueueAlertDialog2": "Are you sure you want to clear the queue?", + "current": "Current", + "next": "Next", + "status": "Status", + "total": "Total", + "time": "Time", + "pending": "Pending", + "in_progress": "In Progress", + "completed": "Completed", + "failed": "Failed", + "canceled": "Canceled", + "completedIn": "Completed in", + "batch": "Batch", + "origin": "Origin", + "destination": "Destination", + "upscaling": "Upscaling", + "canvas": "Canvas", + "generation": "Generation", + "workflows": "Workflows", + "other": "Other", + "gallery": "Gallery", + "batchFieldValues": "Batch Field Values", + "item": "Item", + "session": "Session", + "notReady": "Unable to Queue", + "batchQueued": "Batch Queued", + "batchQueuedDesc_one": "Added {{count}} sessions to {{direction}} of queue", + "batchQueuedDesc_other": "Added {{count}} sessions to {{direction}} of queue", + "front": "front", + "back": "back", + "batchFailedToQueue": "Failed to Queue Batch", + "graphQueued": "Graph queued", + "graphFailedToQueue": "Failed to queue graph", + "openQueue": "Open Queue", + "prompts_one": "Prompt", + "prompts_other": "Prompts", + "iterations_one": "Iteration", + "iterations_other": "Iterations", + "generations_one": "Generation", + "generations_other": "Generations" + }, + "invocationCache": { + "invocationCache": "Invocation Cache", + "cacheSize": "Cache Size", + "maxCacheSize": "Max Cache Size", + "hits": "Cache Hits", + "misses": "Cache Misses", + "clear": "Clear", + "clearSucceeded": "Invocation Cache Cleared", + "clearFailed": "Problem Clearing Invocation Cache", + "enable": "Enable", + "enableSucceeded": "Invocation Cache Enabled", + "enableFailed": "Problem Enabling Invocation Cache", + "disable": "Disable", + "disableSucceeded": "Invocation Cache Disabled", + "disableFailed": "Problem Disabling Invocation Cache", + "useCache": "Use Cache" + }, + "gallery": { + "gallery": "Gallery", + "alwaysShowImageSizeBadge": "Always Show Image Size Badge", + "assets": "Assets", + "assetsTab": "Files you’ve uploaded for use in your projects.", + "autoAssignBoardOnClick": "Auto-Assign Board on Click", + "autoSwitchNewImages": "Auto-Switch to New Images", + "boardsSettings": "Boards Settings", + "copy": "Copy", + "currentlyInUse": "This image is currently in use in the following features:", + "drop": "Drop", + "dropOrUpload": "$t(gallery.drop) or Upload", + "dropToUpload": "$t(gallery.drop) to Upload", + "deleteImage_one": "Delete Image", + "deleteImage_other": "Delete {{count}} Images", + "deleteImagePermanent": "Deleted images cannot be restored.", + "displayBoardSearch": "Board Search", + "displaySearch": "Image Search", + "download": "Download", + "exitBoardSearch": "Exit Board Search", + "exitSearch": "Exit Image Search", + "featuresWillReset": "If you delete this image, those features will immediately be reset.", + "galleryImageSize": "Image Size", + "gallerySettings": "Gallery Settings", + "go": "Go", + "image": "image", + "imagesTab": "Images you’ve created and saved within Invoke.", + "imagesSettings": "Gallery Images Settings", + "jump": "Jump", + "loading": "Loading", + "newestFirst": "Newest First", + "oldestFirst": "Oldest First", + "sortDirection": "Sort Direction", + "showStarredImagesFirst": "Show Starred Images First", + "noImageSelected": "No Image Selected", + "noImagesInGallery": "No Images to Display", + "starImage": "Star Image", + "unstarImage": "Unstar Image", + "unableToLoad": "Unable to load Gallery", + "deleteSelection": "Delete Selection", + "downloadSelection": "Download Selection", + "bulkDownloadRequested": "Preparing Download", + "bulkDownloadRequestedDesc": "Your download request is being prepared. This may take a few moments.", + "bulkDownloadRequestFailed": "Problem Preparing Download", + "bulkDownloadFailed": "Download Failed", + "viewerImage": "Viewer Image", + "compareImage": "Compare Image", + "openInViewer": "Open in Viewer", + "searchImages": "Search by Metadata", + "selectAllOnPage": "Select All On Page", + "showArchivedBoards": "Show Archived Boards", + "selectForCompare": "Select for Compare", + "selectAnImageToCompare": "Select an Image to Compare", + "slider": "Slider", + "sideBySide": "Side-by-Side", + "hover": "Hover", + "swapImages": "Swap Images", + "stretchToFit": "Stretch to Fit", + "exitCompare": "Exit Compare", + "compareHelp1": "Hold Alt while clicking a gallery image or using the arrow keys to change the compare image.", + "compareHelp2": "Press M to cycle through comparison modes.", + "compareHelp3": "Press C to swap the compared images.", + "compareHelp4": "Press Z or Esc to exit.", + "openViewer": "Open Viewer", + "closeViewer": "Close Viewer", + "move": "Move" + }, + "hotkeys": { + "hotkeys": "Hotkeys", + "searchHotkeys": "Search Hotkeys", + "clearSearch": "Clear Search", + "noHotkeysFound": "No Hotkeys Found", + "app": { + "title": "App", + "invoke": { + "title": "Invoke", + "desc": "Queue a generation, adding it to the end of the queue." + }, + "invokeFront": { + "title": "Invoke (Front)", + "desc": "Queue a generation, adding it to the front of the queue." + }, + "cancelQueueItem": { + "title": "Cancel", + "desc": "Cancel the currently processing queue item." + }, + "clearQueue": { + "title": "Clear Queue", + "desc": "Cancel and clear all queue items." + }, + "selectCanvasTab": { + "title": "Select the Canvas Tab", + "desc": "Selects the Canvas tab." + }, + "selectUpscalingTab": { + "title": "Select the Upscaling Tab", + "desc": "Selects the Upscaling tab." + }, + "selectWorkflowsTab": { + "title": "Select the Workflows Tab", + "desc": "Selects the Workflows tab." + }, + "selectModelsTab": { + "title": "Select the Models Tab", + "desc": "Selects the Models tab." + }, + "selectQueueTab": { + "title": "Select the Queue Tab", + "desc": "Selects the Queue tab." + }, + "focusPrompt": { + "title": "Focus Prompt", + "desc": "Move cursor focus to the positive prompt." + }, + "toggleLeftPanel": { + "title": "Toggle Left Panel", + "desc": "Show or hide the left panel." + }, + "toggleRightPanel": { + "title": "Toggle Right Panel", + "desc": "Show or hide the right panel." + }, + "resetPanelLayout": { + "title": "Reset Panel Layout", + "desc": "Reset the left and right panels to their default size and layout." + }, + "togglePanels": { + "title": "Toggle Panels", + "desc": "Show or hide both left and right panels at once." + } + }, + "canvas": { + "title": "Canvas", + "selectBrushTool": { + "title": "Brush Tool", + "desc": "Select the brush tool." + }, + "selectBboxTool": { + "title": "Bbox Tool", + "desc": "Select the bounding box tool." + }, + "decrementToolWidth": { + "title": "Decrement Tool Width", + "desc": "Decrement the brush or eraser tool width, whichever is selected." + }, + "incrementToolWidth": { + "title": "Increment Tool Width", + "desc": "Increment the brush or eraser tool width, whichever is selected." + }, + "selectColorPickerTool": { + "title": "Color Picker Tool", + "desc": "Select the color picker tool." + }, + "selectEraserTool": { + "title": "Eraser Tool", + "desc": "Select the eraser tool." + }, + "selectMoveTool": { + "title": "Move Tool", + "desc": "Select the move tool." + }, + "selectRectTool": { + "title": "Rect Tool", + "desc": "Select the rect tool." + }, + "selectViewTool": { + "title": "View Tool", + "desc": "Select the view tool." + }, + "fitLayersToCanvas": { + "title": "Fit Layers to Canvas", + "desc": "Scale and position the view to fit all visible layers." + }, + "fitBboxToCanvas": { + "title": "Fit Bbox to Canvas", + "desc": "Scale and position the view to fit the bbox." + }, + "setZoomTo100Percent": { + "title": "Zoom to 100%", + "desc": "Set the canvas zoom to 100%." + }, + "setZoomTo200Percent": { + "title": "Zoom to 200%", + "desc": "Set the canvas zoom to 200%." + }, + "setZoomTo400Percent": { + "title": "Zoom to 400%", + "desc": "Set the canvas zoom to 400%." + }, + "setZoomTo800Percent": { + "title": "Zoom to 800%", + "desc": "Set the canvas zoom to 800%." + }, + "quickSwitch": { + "title": "Layer Quick Switch", + "desc": "Switch between the last two selected layers. If a layer is bookmarked, always switch between it and the last non-bookmarked layer." + }, + "deleteSelected": { + "title": "Delete Layer", + "desc": "Delete the selected layer." + }, + "resetSelected": { + "title": "Reset Layer", + "desc": "Reset the selected layer. Only applies to Inpaint Mask and Regional Guidance." + }, + "undo": { + "title": "Undo", + "desc": "Undo the last canvas action." + }, + "redo": { + "title": "Redo", + "desc": "Redo the last canvas action." + }, + "nextEntity": { + "title": "Next Layer", + "desc": "Select the next layer in the list." + }, + "prevEntity": { + "title": "Prev Layer", + "desc": "Select the previous layer in the list." + }, + "setFillToWhite": { + "title": "Set Color to White", + "desc": "Set the current tool color to white." + }, + "filterSelected": { + "title": "Filter", + "desc": "Filter the selected layer. Only applies to Raster and Control layers." + }, + "transformSelected": { + "title": "Transform", + "desc": "Transform the selected layer." + }, + "applyFilter": { + "title": "Apply Filter", + "desc": "Apply the pending filter to the selected layer." + }, + "cancelFilter": { + "title": "Cancel Filter", + "desc": "Cancel the pending filter." + }, + "applyTransform": { + "title": "Apply Transform", + "desc": "Apply the pending transform to the selected layer." + }, + "cancelTransform": { + "title": "Cancel Transform", + "desc": "Cancel the pending transform." + } + }, + "workflows": { + "title": "Workflows", + "addNode": { + "title": "Add Node", + "desc": "Open the add node menu." + }, + "copySelection": { + "title": "Copy", + "desc": "Copy selected nodes and edges." + }, + "pasteSelection": { + "title": "Paste", + "desc": "Paste copied nodes and edges." + }, + "pasteSelectionWithEdges": { + "title": "Paste with Edges", + "desc": "Paste copied nodes, edges, and all edges connected to copied nodes." + }, + "selectAll": { + "title": "Select All", + "desc": "Select all nodes and edges." + }, + "deleteSelection": { + "title": "Delete", + "desc": "Delete selected nodes and edges." + }, + "undo": { + "title": "Undo", + "desc": "Undo the last workflow action." + }, + "redo": { + "title": "Redo", + "desc": "Redo the last workflow action." + } + }, + "viewer": { + "title": "Image Viewer", + "toggleViewer": { + "title": "Show/Hide Image Viewer", + "desc": "Show or hide the image viewer. Only available on the Canvas tab." + }, + "swapImages": { + "title": "Swap Comparison Images", + "desc": "Swap the images being compared." + }, + "nextComparisonMode": { + "title": "Next Comparison Mode", + "desc": "Cycle through comparison modes." + }, + "loadWorkflow": { + "title": "Load Workflow", + "desc": "Load the current image's saved workflow (if it has one)." + }, + "recallAll": { + "title": "Recall All Metadata", + "desc": "Recall all metadata for the current image." + }, + "recallSeed": { + "title": "Recall Seed", + "desc": "Recall the seed for the current image." + }, + "recallPrompts": { + "title": "Recall Prompts", + "desc": "Recall the positive and negative prompts for the current image." + }, + "remix": { + "title": "Remix", + "desc": "Recall all metadata except for the seed for the current image." + }, + "useSize": { + "title": "Use Size", + "desc": "Use the current image's size as the bbox size." + }, + "runPostprocessing": { + "title": "Run Postprocessing", + "desc": "Run the selected postprocessing on the current image." + }, + "toggleMetadata": { + "title": "Show/Hide Metadata", + "desc": "Show or hide the current image's metadata overlay." + } + }, + "gallery": { + "title": "Gallery", + "selectAllOnPage": { + "title": "Select All On Page", + "desc": "Select all images on the current page." + }, + "clearSelection": { + "title": "Clear Selection", + "desc": "Clear the current selection, if any." + }, + "galleryNavUp": { + "title": "Navigate Up", + "desc": "Navigate up in the gallery grid, selecting that image. If at the top of the page, go to the previous page." + }, + "galleryNavRight": { + "title": "Navigate Right", + "desc": "Navigate right in the gallery grid, selecting that image. If at the last image of the row, go to the next row. If at the last image of the page, go to the next page." + }, + "galleryNavDown": { + "title": "Navigate Down", + "desc": "Navigate down in the gallery grid, selecting that image. If at the bottom of the page, go to the next page." + }, + "galleryNavLeft": { + "title": "Navigate Left", + "desc": "Navigate left in the gallery grid, selecting that image. If at the first image of the row, go to the previous row. If at the first image of the page, go to the previous page." + }, + "galleryNavUpAlt": { + "title": "Navigate Up (Compare Image)", + "desc": "Same as Navigate Up, but selects the compare image, opening compare mode if it isn't already open." + }, + "galleryNavRightAlt": { + "title": "Navigate Right (Compare Image)", + "desc": "Same as Navigate Right, but selects the compare image, opening compare mode if it isn't already open." + }, + "galleryNavDownAlt": { + "title": "Navigate Down (Compare Image)", + "desc": "Same as Navigate Down, but selects the compare image, opening compare mode if it isn't already open." + }, + "galleryNavLeftAlt": { + "title": "Navigate Left (Compare Image)", + "desc": "Same as Navigate Left, but selects the compare image, opening compare mode if it isn't already open." + }, + "deleteSelection": { + "title": "Delete", + "desc": "Delete all selected images. By default, you will be prompted to confirm deletion. If the images are currently in use in the app, you will be warned." + } + } + }, + "metadata": { + "allPrompts": "All Prompts", + "cfgScale": "CFG scale", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "createdBy": "Created By", + "generationMode": "Generation Mode", + "guidance": "Guidance", + "height": "Height", + "imageDetails": "Image Details", + "imageDimensions": "Image Dimensions", + "metadata": "Metadata", + "model": "Model", + "negativePrompt": "Negative Prompt", + "noImageDetails": "No image details found", + "noMetaData": "No metadata found", + "noRecallParameters": "No parameters to recall found", + "parameterSet": "Parameter {{parameter}} set", + "parsingFailed": "Parsing Failed", + "positivePrompt": "Positive Prompt", + "recallParameters": "Recall Parameters", + "recallParameter": "Recall {{label}}", + "scheduler": "Scheduler", + "seamlessXAxis": "Seamless X Axis", + "seamlessYAxis": "Seamless Y Axis", + "seed": "Seed", + "steps": "Steps", + "strength": "Image to image strength", + "Threshold": "Noise Threshold", + "vae": "VAE", + "width": "Width", + "workflow": "Workflow", + "canvasV2Metadata": "Canvas" + }, + "modelManager": { + "active": "active", + "addModel": "Add Model", + "addModels": "Add Models", + "advanced": "Advanced", + "allModels": "All Models", + "alpha": "Alpha", + "availableModels": "Available Models", + "baseModel": "Base Model", + "cancel": "Cancel", + "clipEmbed": "CLIP Embed", + "clipLEmbed": "CLIP-L Embed", + "clipGEmbed": "CLIP-G Embed", + "config": "Config", + "convert": "Convert", + "convertingModelBegin": "Converting Model. Please wait.", + "convertToDiffusers": "Convert To Diffusers", + "convertToDiffusersHelpText1": "This model will be converted to the 🧨 Diffusers format.", + "convertToDiffusersHelpText2": "This process will replace your Model Manager entry with the Diffusers version of the same model.", + "convertToDiffusersHelpText3": "Your checkpoint file on disk WILL be deleted if it is in InvokeAI root folder. If it is in a custom location, then it WILL NOT be deleted.", + "convertToDiffusersHelpText4": "This is a one time process only. It might take around 30s-60s depending on the specifications of your computer.", + "convertToDiffusersHelpText5": "Please make sure you have enough disk space. Models generally vary between 2GB-7GB in size.", + "convertToDiffusersHelpText6": "Do you wish to convert this model?", + "noDefaultSettings": "No default settings configured for this model. Visit the Model Manager to add default settings.", + "defaultSettings": "Default Settings", + "defaultSettingsSaved": "Default Settings Saved", + "defaultSettingsOutOfSync": "Some settings do not match the model's defaults:", + "restoreDefaultSettings": "Click to use the model's default settings.", + "usingDefaultSettings": "Using model's default settings", + "delete": "Delete", + "deleteConfig": "Delete Config", + "deleteModel": "Delete Model", + "deleteModelImage": "Delete Model Image", + "deleteMsg1": "Are you sure you want to delete this model from InvokeAI?", + "deleteMsg2": "This WILL delete the model from disk if it is in the InvokeAI root folder. If you are using a custom location, then the model WILL NOT be deleted from disk.", + "description": "Description", + "edit": "Edit", + "height": "Height", + "huggingFace": "HuggingFace", + "huggingFacePlaceholder": "owner/model-name", + "huggingFaceRepoID": "HuggingFace Repo ID", + "huggingFaceHelper": "If multiple models are found in this repo, you will be prompted to select one to install.", + "hfTokenLabel": "HuggingFace Token (Required for some models)", + "hfTokenHelperText": "A HF token is required to use some models. Click here to create or get your token.", + "hfTokenInvalid": "Invalid or Missing HF Token", + "hfForbidden": "You do not have access to this HF model", + "hfForbiddenErrorMessage": "We recommend visiting the repo page on HuggingFace.com. The owner may require acceptance of terms in order to download.", + "hfTokenInvalidErrorMessage": "Invalid or missing HuggingFace token.", + "hfTokenRequired": "You are trying to download a model that requires a valid HuggingFace Token.", + "hfTokenInvalidErrorMessage2": "Update it in the ", + "hfTokenUnableToVerify": "Unable to Verify HF Token", + "hfTokenUnableToVerifyErrorMessage": "Unable to verify HuggingFace token. This is likely due to a network error. Please try again later.", + "hfTokenSaved": "HF Token Saved", + "imageEncoderModelId": "Image Encoder Model ID", + "includesNModels": "Includes {{n}} models and their dependencies", + "installQueue": "Install Queue", + "inplaceInstall": "In-place install", + "inplaceInstallDesc": "Install models without copying the files. When using the model, it will be loaded from its this location. If disabled, the model file(s) will be copied into the Invoke-managed models directory during installation.", + "install": "Install", + "installAll": "Install All", + "installRepo": "Install Repo", + "ipAdapters": "IP Adapters", + "learnMoreAboutSupportedModels": "Learn more about the models we support", + "load": "Load", + "localOnly": "local only", + "manual": "Manual", + "loraModels": "LoRAs", + "main": "Main", + "metadata": "Metadata", + "model": "Model", + "modelConversionFailed": "Model Conversion Failed", + "modelConverted": "Model Converted", + "modelDeleted": "Model Deleted", + "modelDeleteFailed": "Failed to delete model", + "modelImageDeleted": "Model Image Deleted", + "modelImageDeleteFailed": "Model Image Delete Failed", + "modelImageUpdated": "Model Image Updated", + "modelImageUpdateFailed": "Model Image Update Failed", + "modelManager": "Model Manager", + "modelName": "Model Name", + "modelSettings": "Model Settings", + "modelType": "Model Type", + "modelUpdated": "Model Updated", + "modelUpdateFailed": "Model Update Failed", + "name": "Name", + "noModelsInstalled": "No Models Installed", + "noModelsInstalledDesc1": "Install models with the", + "noModelSelected": "No Model Selected", + "noMatchingModels": "No matching Models", + "none": "none", + "path": "Path", + "pathToConfig": "Path To Config", + "predictionType": "Prediction Type", + "prune": "Prune", + "pruneTooltip": "Prune finished imports from queue", + "repo_id": "Repo ID", + "repoVariant": "Repo Variant", + "scanFolder": "Scan Folder", + "scanFolderHelper": "The folder will be recursively scanned for models. This can take a few moments for very large folders.", + "scanPlaceholder": "Path to a local folder", + "scanResults": "Scan Results", + "search": "Search", + "selected": "Selected", + "selectModel": "Select Model", + "settings": "Settings", + "simpleModelPlaceholder": "URL or path to a local file or diffusers folder", + "source": "Source", + "spandrelImageToImage": "Image to Image (Spandrel)", + "starterBundles": "Starter Bundles", + "starterBundleHelpText": "Easily install all models needed to get started with a base model, including a main model, controlnets, IP adapters, and more. Selecting a bundle will skip any models that you already have installed.", + "starterModels": "Starter Models", + "starterModelsInModelManager": "Starter Models can be found in Model Manager", + "syncModels": "Sync Models", + "textualInversions": "Textual Inversions", + "triggerPhrases": "Trigger Phrases", + "loraTriggerPhrases": "LoRA Trigger Phrases", + "mainModelTriggerPhrases": "Main Model Trigger Phrases", + "typePhraseHere": "Type phrase here", + "t5Encoder": "T5 Encoder", + "upcastAttention": "Upcast Attention", + "uploadImage": "Upload Image", + "urlOrLocalPath": "URL or Local Path", + "urlOrLocalPathHelper": "URLs should point to a single file. Local paths can point to a single file or folder for a single diffusers model.", + "vae": "VAE", + "vaePrecision": "VAE Precision", + "variant": "Variant", + "width": "Width", + "installingBundle": "Installing Bundle", + "installingModel": "Installing Model", + "installingXModels_one": "Installing {{count}} model", + "installingXModels_other": "Installing {{count}} models", + "skippingXDuplicates_one": ", skipping {{count}} duplicate", + "skippingXDuplicates_other": ", skipping {{count}} duplicates" + }, + "models": { + "addLora": "Add LoRA", + "concepts": "Concepts", + "loading": "loading", + "noMatchingLoRAs": "No matching LoRAs", + "noMatchingModels": "No matching Models", + "noModelsAvailable": "No models available", + "lora": "LoRA", + "selectModel": "Select a Model", + "noLoRAsInstalled": "No LoRAs installed", + "noRefinerModelsInstalled": "No SDXL Refiner models installed", + "defaultVAE": "Default VAE" + }, + "nodes": { + "addNode": "Add Node", + "addNodeToolTip": "Add Node (Shift+A, Space)", + "addLinearView": "Add to Linear View", + "animatedEdges": "Animated Edges", + "animatedEdgesHelp": "Animate selected edges and edges connected to selected nodes", + "boolean": "Booleans", + "cannotConnectInputToInput": "Cannot connect input to input", + "cannotConnectOutputToOutput": "Cannot connect output to output", + "cannotConnectToSelf": "Cannot connect to self", + "cannotDuplicateConnection": "Cannot create duplicate connections", + "cannotMixAndMatchCollectionItemTypes": "Cannot mix and match collection item types", + "missingNode": "Missing invocation node", + "missingInvocationTemplate": "Missing invocation template", + "missingFieldTemplate": "Missing field template", + "nodePack": "Node pack", + "collection": "Collection", + "singleFieldType": "{{name}} (Single)", + "collectionFieldType": "{{name}} (Collection)", + "collectionOrScalarFieldType": "{{name}} (Single or Collection)", + "colorCodeEdges": "Color-Code Edges", + "colorCodeEdgesHelp": "Color-code edges according to their connected fields", + "connectionWouldCreateCycle": "Connection would create a cycle", + "currentImage": "Current Image", + "currentImageDescription": "Displays the current image in the Node Editor", + "downloadWorkflow": "Download Workflow JSON", + "edge": "Edge", + "edit": "Edit", + "editMode": "Edit in Workflow Editor", + "enum": "Enum", + "executionStateCompleted": "Completed", + "executionStateError": "Error", + "executionStateInProgress": "In Progress", + "fieldTypesMustMatch": "Field types must match", + "fitViewportNodes": "Fit View", + "float": "Float", + "fullyContainNodes": "Fully Contain Nodes to Select", + "fullyContainNodesHelp": "Nodes must be fully inside the selection box to be selected", + "showEdgeLabels": "Show Edge Labels", + "showEdgeLabelsHelp": "Show labels on edges, indicating the connected nodes", + "hideLegendNodes": "Hide Field Type Legend", + "hideMinimapnodes": "Hide MiniMap", + "inputMayOnlyHaveOneConnection": "Input may only have one connection", + "integer": "Integer", + "ipAdapter": "IP-Adapter", + "loadingNodes": "Loading Nodes...", + "loadWorkflow": "Load Workflow", + "noWorkflows": "No Workflows", + "noMatchingWorkflows": "No Matching Workflows", + "noWorkflow": "No Workflow", + "mismatchedVersion": "Invalid node: node {{node}} of type {{type}} has mismatched version (try updating?)", + "missingTemplate": "Invalid node: node {{node}} of type {{type}} missing template (not installed?)", + "sourceNodeDoesNotExist": "Invalid edge: source/output node {{node}} does not exist", + "targetNodeDoesNotExist": "Invalid edge: target/input node {{node}} does not exist", + "sourceNodeFieldDoesNotExist": "Invalid edge: source/output field {{node}}.{{field}} does not exist", + "targetNodeFieldDoesNotExist": "Invalid edge: target/input field {{node}}.{{field}} does not exist", + "deletedInvalidEdge": "Deleted invalid edge {{source}} -> {{target}}", + "noConnectionInProgress": "No connection in progress", + "node": "Node", + "nodeOutputs": "Node Outputs", + "nodeSearch": "Search for nodes", + "nodeTemplate": "Node Template", + "nodeType": "Node Type", + "noFieldsLinearview": "No fields added to Linear View", + "noFieldsViewMode": "This workflow has no selected fields to display. View the full workflow to configure values.", + "workflowHelpText": "Need Help? Check out our guide to Getting Started with Workflows.", + "noNodeSelected": "No node selected", + "nodeOpacity": "Node Opacity", + "nodeVersion": "Node Version", + "noOutputRecorded": "No outputs recorded", + "notes": "Notes", + "notesDescription": "Add notes about your workflow", + "problemSettingTitle": "Problem Setting Title", + "resetToDefaultValue": "Reset to default value", + "reloadNodeTemplates": "Reload Node Templates", + "removeLinearView": "Remove from Linear View", + "reorderLinearView": "Reorder Linear View", + "newWorkflow": "New Workflow", + "newWorkflowDesc": "Create a new workflow?", + "newWorkflowDesc2": "Your current workflow has unsaved changes.", + "clearWorkflow": "Clear Workflow", + "clearWorkflowDesc": "Clear this workflow and start a new one?", + "clearWorkflowDesc2": "Your current workflow has unsaved changes.", + "scheduler": "Scheduler", + "showLegendNodes": "Show Field Type Legend", + "showMinimapnodes": "Show MiniMap", + "snapToGrid": "Snap to Grid", + "snapToGridHelp": "Snap nodes to grid when moved", + "string": "String", + "unableToLoadWorkflow": "Unable to Load Workflow", + "unableToValidateWorkflow": "Unable to Validate Workflow", + "unknownErrorValidatingWorkflow": "Unknown error validating workflow", + "inputFieldTypeParseError": "Unable to parse type of input field {{node}}.{{field}} ({{message}})", + "outputFieldTypeParseError": "Unable to parse type of output field {{node}}.{{field}} ({{message}})", + "unableToExtractSchemaNameFromRef": "unable to extract schema name from ref", + "unsupportedArrayItemType": "unsupported array item type \"{{type}}\"", + "unsupportedAnyOfLength": "too many union members ({{count}})", + "unsupportedMismatchedUnion": "mismatched CollectionOrScalar type with base types {{firstType}} and {{secondType}}", + "unableToParseFieldType": "unable to parse field type", + "unableToExtractEnumOptions": "unable to extract enum options", + "unknownField": "Unknown field", + "unknownFieldType": "$t(nodes.unknownField) type: {{type}}", + "unknownNode": "Unknown Node", + "unknownNodeType": "Unknown node type", + "unknownTemplate": "Unknown Template", + "unknownInput": "Unknown input: {{name}}", + "unknownOutput": "Unknown output: {{name}}", + "updateNode": "Update Node", + "updateApp": "Update App", + "updateAllNodes": "Update Nodes", + "allNodesUpdated": "All Nodes Updated", + "unableToUpdateNodes_one": "Unable to update {{count}} node", + "unableToUpdateNodes_other": "Unable to update {{count}} nodes", + "validateConnections": "Validate Connections and Graph", + "validateConnectionsHelp": "Prevent invalid connections from being made, and invalid graphs from being invoked", + "viewMode": "Use in Linear View", + "unableToGetWorkflowVersion": "Unable to get workflow schema version", + "version": "Version", + "versionUnknown": " Version Unknown", + "workflow": "Workflow", + "graph": "Graph", + "noGraph": "No Graph", + "workflowAuthor": "Author", + "workflowContact": "Contact", + "workflowDescription": "Short Description", + "workflowName": "Name", + "workflowNotes": "Notes", + "workflowSettings": "Workflow Editor Settings", + "workflowTags": "Tags", + "workflowValidation": "Workflow Validation Error", + "workflowVersion": "Version", + "zoomInNodes": "Zoom In", + "zoomOutNodes": "Zoom Out", + "betaDesc": "This invocation is in beta. Until it is stable, it may have breaking changes during app updates. We plan to support this invocation long-term.", + "prototypeDesc": "This invocation is a prototype. It may have breaking changes during app updates and may be removed at any time.", + "imageAccessError": "Unable to find image {{image_name}}, resetting to default", + "boardAccessError": "Unable to find board {{board_id}}, resetting to default", + "modelAccessError": "Unable to find model {{key}}, resetting to default", + "saveToGallery": "Save To Gallery" + }, + "parameters": { + "aspect": "Aspect", + "lockAspectRatio": "Lock Aspect Ratio", + "swapDimensions": "Swap Dimensions", + "setToOptimalSize": "Optimize size for model", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (may be too small)", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (may be too large)", + "cancel": { + "cancel": "Cancel" + }, + "cfgScale": "CFG Scale", + "cfgRescaleMultiplier": "CFG Rescale Multiplier", + "clipSkip": "CLIP Skip", + "coherenceMode": "Mode", + "coherenceEdgeSize": "Edge Size", + "coherenceMinDenoise": "Min Denoise", + "controlNetControlMode": "Control Mode", + "copyImage": "Copy Image", + "denoisingStrength": "Denoising Strength", + "disabledNoRasterContent": "Disabled (No Raster Content)", + "downloadImage": "Download Image", + "general": "General", + "guidance": "Guidance", + "height": "Height", + "imageFit": "Fit Initial Image To Output Size", + "images": "Images", + "infillMethod": "Infill Method", + "infillColorValue": "Fill Color", + "info": "Info", + "invoke": { + "addingImagesTo": "Adding images to", + "invoke": "Invoke", + "missingFieldTemplate": "Missing field template", + "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} missing input", + "missingNodeTemplate": "Missing node template", + "noModelSelected": "No model selected", + "noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation", + "noFLUXVAEModelSelected": "No VAE model selected for FLUX generation", + "noCLIPEmbedModelSelected": "No CLIP Embed model selected for FLUX generation", + "fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), bbox width is {{width}}", + "fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), bbox height is {{height}}", + "fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), scaled bbox width is {{width}}", + "fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), scaled bbox height is {{height}}", + "canvasIsFiltering": "Canvas is filtering", + "canvasIsTransforming": "Canvas is transforming", + "canvasIsRasterizing": "Canvas is rasterizing", + "canvasIsCompositing": "Canvas is compositing", + "noPrompts": "No prompts generated", + "noNodesInGraph": "No nodes in graph", + "systemDisconnected": "System disconnected", + "layer": { + "controlAdapterNoModelSelected": "no Control Adapter model selected", + "controlAdapterIncompatibleBaseModel": "incompatible Control Adapter base model", + "t2iAdapterIncompatibleBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, bbox width is {{width}}", + "t2iAdapterIncompatibleBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, bbox height is {{height}}", + "t2iAdapterIncompatibleScaledBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, scaled bbox width is {{width}}", + "t2iAdapterIncompatibleScaledBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, scaled bbox height is {{height}}", + "ipAdapterNoModelSelected": "no IP adapter selected", + "ipAdapterIncompatibleBaseModel": "incompatible IP Adapter base model", + "ipAdapterNoImageSelected": "no IP Adapter image selected", + "rgNoPromptsOrIPAdapters": "no text prompts or IP Adapters", + "rgNoRegion": "no region selected" + } + }, + "maskBlur": "Mask Blur", + "negativePromptPlaceholder": "Negative Prompt", + "noiseThreshold": "Noise Threshold", + "patchmatchDownScaleSize": "Downscale", + "perlinNoise": "Perlin Noise", + "positivePromptPlaceholder": "Positive Prompt", + "recallMetadata": "Recall Metadata", + "iterations": "Iterations", + "scale": "Scale", + "scaleBeforeProcessing": "Scale Before Processing", + "scaledHeight": "Scaled H", + "scaledWidth": "Scaled W", + "scheduler": "Scheduler", + "seamlessXAxis": "Seamless X Axis", + "seamlessYAxis": "Seamless Y Axis", + "seed": "Seed", + "imageActions": "Image Actions", + "sendToCanvas": "Send To Canvas", + "sendToUpscale": "Send To Upscale", + "showOptionsPanel": "Show Side Panel (O or T)", + "shuffle": "Shuffle Seed", + "steps": "Steps", + "strength": "Strength", + "symmetry": "Symmetry", + "tileSize": "Tile Size", + "optimizedImageToImage": "Optimized Image-to-Image", + "type": "Type", + "postProcessing": "Post-Processing (Shift + U)", + "processImage": "Process Image", + "upscaling": "Upscaling", + "useAll": "Use All", + "useSize": "Use Size", + "useCpuNoise": "Use CPU Noise", + "remixImage": "Remix Image", + "usePrompt": "Use Prompt", + "useSeed": "Use Seed", + "width": "Width", + "gaussianBlur": "Gaussian Blur", + "boxBlur": "Box Blur", + "staged": "Staged" + }, + "dynamicPrompts": { + "showDynamicPrompts": "Show Dynamic Prompts", + "dynamicPrompts": "Dynamic Prompts", + "maxPrompts": "Max Prompts", + "promptsPreview": "Prompts Preview", + "seedBehaviour": { + "label": "Seed Behaviour", + "perIterationLabel": "Seed per Iteration", + "perIterationDesc": "Use a different seed for each iteration", + "perPromptLabel": "Seed per Image", + "perPromptDesc": "Use a different seed for each image" + }, + "loading": "Generating Dynamic Prompts..." + }, + "sdxl": { + "cfgScale": "CFG Scale", + "concatPromptStyle": "Linking Prompt & Style", + "freePromptStyle": "Manual Style Prompting", + "denoisingStrength": "Denoising Strength", + "loading": "Loading...", + "negAestheticScore": "Negative Aesthetic Score", + "negStylePrompt": "Negative Style Prompt", + "noModelsAvailable": "No models available", + "posAestheticScore": "Positive Aesthetic Score", + "posStylePrompt": "Positive Style Prompt", + "refiner": "Refiner", + "refinermodel": "Refiner Model", + "refinerStart": "Refiner Start", + "refinerSteps": "Refiner Steps", + "scheduler": "Scheduler", + "steps": "Steps" + }, + "settings": { + "antialiasProgressImages": "Antialias Progress Images", + "beta": "Beta", + "confirmOnDelete": "Confirm On Delete", + "confirmOnNewSession": "Confirm On New Session", + "developer": "Developer", + "displayInProgress": "Display Progress Images", + "enableInformationalPopovers": "Enable Informational Popovers", + "informationalPopoversDisabled": "Informational Popovers Disabled", + "informationalPopoversDisabledDesc": "Informational popovers have been disabled. Enable them in Settings.", + "enableModelDescriptions": "Enable Model Descriptions in Dropdowns", + "modelDescriptionsDisabled": "Model Descriptions in Dropdowns Disabled", + "modelDescriptionsDisabledDesc": "Model descriptions in dropdowns have been disabled. Enable them in Settings.", + "enableInvisibleWatermark": "Enable Invisible Watermark", + "enableNSFWChecker": "Enable NSFW Checker", + "general": "General", + "generation": "Generation", + "models": "Models", + "resetComplete": "Web UI has been reset.", + "resetWebUI": "Reset Web UI", + "resetWebUIDesc1": "Resetting the web UI only resets the browser's local cache of your images and remembered settings. It does not delete any images from disk.", + "resetWebUIDesc2": "If images aren't showing up in the gallery or something else isn't working, please try resetting before submitting an issue on GitHub.", + "showDetailedInvocationProgress": "Show Progress Details", + "showProgressInViewer": "Show Progress Images in Viewer", + "ui": "User Interface", + "clearIntermediatesDisabled": "Queue must be empty to clear intermediates", + "clearIntermediatesDesc1": "Clearing intermediates will reset your Canvas and ControlNet state.", + "clearIntermediatesDesc2": "Intermediate images are byproducts of generation, different from the result images in the gallery. Clearing intermediates will free disk space.", + "clearIntermediatesDesc3": "Your gallery images will not be deleted.", + "clearIntermediates": "Clear Intermediates", + "clearIntermediatesWithCount_one": "Clear {{count}} Intermediate", + "clearIntermediatesWithCount_other": "Clear {{count}} Intermediates", + "intermediatesCleared_one": "Cleared {{count}} Intermediate", + "intermediatesCleared_other": "Cleared {{count}} Intermediates", + "intermediatesClearedFailed": "Problem Clearing Intermediates", + "reloadingIn": "Reloading in" + }, + "toast": { + "addedToBoard": "Added to board {{name}}'s assets", + "addedToUncategorized": "Added to board $t(boards.uncategorized)'s assets", + "baseModelChanged": "Base Model Changed", + "baseModelChangedCleared_one": "Cleared or disabled {{count}} incompatible submodel", + "baseModelChangedCleared_other": "Cleared or disabled {{count}} incompatible submodels", + "canceled": "Processing Canceled", + "connected": "Connected to Server", + "imageCopied": "Image Copied", + "linkCopied": "Link Copied", + "unableToLoadImage": "Unable to Load Image", + "unableToLoadImageMetadata": "Unable to Load Image Metadata", + "unableToLoadStylePreset": "Unable to Load Style Preset", + "stylePresetLoaded": "Style Preset Loaded", + "imageNotLoadedDesc": "Could not find image", + "imageSaved": "Image Saved", + "imageSavingFailed": "Image Saving Failed", + "imageUploaded": "Image Uploaded", + "imageUploadFailed": "Image Upload Failed", + "importFailed": "Import Failed", + "importSuccessful": "Import Successful", + "invalidUpload": "Invalid Upload", + "layerCopiedToClipboard": "Layer Copied to Clipboard", + "layerSavedToAssets": "Layer Saved to Assets", + "loadedWithWarnings": "Workflow Loaded with Warnings", + "modelAddedSimple": "Model Added to Queue", + "modelImportCanceled": "Model Import Canceled", + "outOfMemoryError": "Out of Memory Error", + "outOfMemoryErrorDesc": "Your current generation settings exceed system capacity. Please adjust your settings and try again.", + "parameters": "Parameters", + "parameterSet": "Parameter Recalled", + "parameterSetDesc": "Recalled {{parameter}}", + "parameterNotSet": "Parameter Not Recalled", + "parameterNotSetDesc": "Unable to recall {{parameter}}", + "parameterNotSetDescWithMessage": "Unable to recall {{parameter}}: {{message}}", + "parametersSet": "Parameters Recalled", + "parametersNotSet": "Parameters Not Recalled", + "errorCopied": "Error Copied", + "problemCopyingImage": "Unable to Copy Image", + "problemCopyingLayer": "Unable to Copy Layer", + "problemSavingLayer": "Unable to Save Layer", + "problemDownloadingImage": "Unable to Download Image", + "prunedQueue": "Pruned Queue", + "sentToCanvas": "Sent to Canvas", + "sentToUpscale": "Sent to Upscale", + "serverError": "Server Error", + "sessionRef": "Session: {{sessionId}}", + "setControlImage": "Set as control image", + "setNodeField": "Set as node field", + "somethingWentWrong": "Something Went Wrong", + "uploadFailed": "Upload failed", + "imagesWillBeAddedTo": "Uploaded images will be added to board {{boardName}}'s assets.", + "uploadFailedInvalidUploadDesc_withCount_one": "Must be maximum of 1 PNG or JPEG image.", + "uploadFailedInvalidUploadDesc_withCount_other": "Must be maximum of {{count}} PNG or JPEG images.", + "uploadFailedInvalidUploadDesc": "Must be PNG or JPEG images.", + "workflowLoaded": "Workflow Loaded", + "problemRetrievingWorkflow": "Problem Retrieving Workflow", + "workflowDeleted": "Workflow Deleted", + "problemDeletingWorkflow": "Problem Deleting Workflow" + }, + "popovers": { + "clipSkip": { + "heading": "CLIP Skip", + "paragraphs": [ + "How many layers of the CLIP model to skip.", + "Certain models are better suited to be used with CLIP Skip." + ] + }, + "paramNegativeConditioning": { + "heading": "Negative Prompt", + "paragraphs": [ + "The generation process avoids the concepts in the negative prompt. Use this to exclude qualities or objects from the output.", + "Supports Compel syntax and embeddings." + ] + }, + "paramPositiveConditioning": { + "heading": "Positive Prompt", + "paragraphs": [ + "Guides the generation process. You may use any words or phrases.", + "Compel and Dynamic Prompts syntaxes and embeddings." + ] + }, + "paramScheduler": { + "heading": "Scheduler", + "paragraphs": [ + "Scheduler used during the generation process.", + "Each scheduler defines how to iteratively add noise to an image or how to update a sample based on a model's output." + ] + }, + "compositingMaskBlur": { + "heading": "Mask Blur", + "paragraphs": ["The blur radius of the mask."] + }, + "compositingBlurMethod": { + "heading": "Blur Method", + "paragraphs": ["The method of blur applied to the masked area."] + }, + "compositingCoherencePass": { + "heading": "Coherence Pass", + "paragraphs": ["A second round of denoising helps to composite the Inpainted/Outpainted image."] + }, + "compositingCoherenceMode": { + "heading": "Mode", + "paragraphs": ["Method used to create a coherent image with the newly generated masked area."] + }, + "compositingCoherenceEdgeSize": { + "heading": "Edge Size", + "paragraphs": ["The edge size of the coherence pass."] + }, + "compositingCoherenceMinDenoise": { + "heading": "Minimum Denoise", + "paragraphs": [ + "Minimum denoise strength for the Coherence mode", + "The minimum denoise strength for the coherence region when inpainting or outpainting" + ] + }, + "compositingMaskAdjustments": { + "heading": "Mask Adjustments", + "paragraphs": ["Adjust the mask."] + }, + "inpainting": { + "heading": "Inpainting", + "paragraphs": ["Controls which area is modified, guided by Denoising Strength."] + }, + "rasterLayer": { + "heading": "Raster Layer", + "paragraphs": ["Pixel-based content of your canvas, used during image generation."] + }, + "regionalGuidance": { + "heading": "Regional Guidance", + "paragraphs": ["Brush to guide where elements from global prompts should appear."] + }, + "regionalGuidanceAndReferenceImage": { + "heading": "Regional Guidance and Regional Reference Image", + "paragraphs": [ + "For Regional Guidance, brush to guide where elements from global prompts should appear.", + "For Regional Reference Image, brush to apply a reference image to specific areas." + ] + }, + "globalReferenceImage": { + "heading": "Global Reference Image", + "paragraphs": ["Applies a reference image to influence the entire generation."] + }, + "regionalReferenceImage": { + "heading": "Regional Reference Image", + "paragraphs": ["Brush to apply a reference image to specific areas."] + }, + "controlNet": { + "heading": "ControlNet", + "paragraphs": [ + "ControlNets provide guidance to the generation process, helping create images with controlled composition, structure, or style, depending on the model selected." + ] + }, + "controlNetBeginEnd": { + "heading": "Begin / End Step Percentage", + "paragraphs": [ + "The part of the of the denoising process that will have the Control Adapter applied.", + "Generally, Control Adapters applied at the start of the process guide composition, and Control Adapters applied at the end guide details." + ] + }, + "controlNetControlMode": { + "heading": "Control Mode", + "paragraphs": ["Lend more weight to either the prompt or ControlNet."] + }, + "controlNetProcessor": { + "heading": "Processor", + "paragraphs": [ + "Method of processing the input image to guide the generation process. Different processors will provide different effects or styles in your generated images." + ] + }, + "controlNetResizeMode": { + "heading": "Resize Mode", + "paragraphs": ["Method to fit Control Adapter's input image size to the output generation size."] + }, + "ipAdapterMethod": { + "heading": "Method", + "paragraphs": ["Method by which to apply the current IP Adapter."] + }, + "controlNetWeight": { + "heading": "Weight", + "paragraphs": [ + "Weight of the Control Adapter. Higher weight will lead to larger impacts on the final image." + ] + }, + "dynamicPrompts": { + "heading": "Dynamic Prompts", + "paragraphs": [ + "Dynamic Prompts parses a single prompt into many.", + "The basic syntax is \"a {red|green|blue} ball\". This will produce three prompts: \"a red ball\", \"a green ball\" and \"a blue ball\".", + "You can use the syntax as many times as you like in a single prompt, but be sure to keep the number of prompts generated in check with the Max Prompts setting." + ] + }, + "dynamicPromptsMaxPrompts": { + "heading": "Max Prompts", + "paragraphs": ["Limits the number of prompts that can be generated by Dynamic Prompts."] + }, + "dynamicPromptsSeedBehaviour": { + "heading": "Seed Behaviour", + "paragraphs": [ + "Controls how the seed is used when generating prompts.", + "Per Iteration will use a unique seed for each iteration. Use this to explore prompt variations on a single seed.", + "For example, if you have 5 prompts, each image will use the same seed.", + "Per Image will use a unique seed for each image. This provides more variation." + ] + }, + "imageFit": { + "heading": "Fit Initial Image to Output Size", + "paragraphs": [ + "Resizes the initial image to the width and height of the output image. Recommended to enable." + ] + }, + "infillMethod": { + "heading": "Infill Method", + "paragraphs": ["Method of infilling during the Outpainting or Inpainting process."] + }, + "lora": { + "heading": "LoRA", + "paragraphs": ["Lightweight models that are used in conjunction with base models."] + }, + "loraWeight": { + "heading": "Weight", + "paragraphs": ["Weight of the LoRA. Higher weight will lead to larger impacts on the final image."] + }, + "noiseUseCPU": { + "heading": "Use CPU Noise", + "paragraphs": [ + "Controls whether noise is generated on the CPU or GPU.", + "With CPU Noise enabled, a particular seed will produce the same image on any machine.", + "There is no performance impact to enabling CPU Noise." + ] + }, + "paramAspect": { + "heading": "Aspect", + "paragraphs": [ + "Aspect ratio of the generated image. Changing the ratio will update the Width and Height accordingly.", + "\"Optimize\" will set the Width and Height to optimal dimensions for the chosen model." + ] + }, + "paramCFGScale": { + "heading": "CFG Scale", + "paragraphs": [ + "Controls how much the prompt influences the generation process.", + "High CFG Scale values can result in over-saturation and distorted generation results. " + ] + }, + "paramGuidance": { + "heading": "Guidance", + "paragraphs": [ + "Controls how much the prompt influences the generation process.", + "High guidance values can result in over-saturation and high or low guidance may result in distorted generation results. Guidance only applies to FLUX DEV models." + ] + }, + "paramCFGRescaleMultiplier": { + "heading": "CFG Rescale Multiplier", + "paragraphs": [ + "Rescale multiplier for CFG guidance, used for models trained using zero-terminal SNR (ztsnr).", + "Suggested value of 0.7 for these models." + ] + }, + "paramDenoisingStrength": { + "heading": "Denoising Strength", + "paragraphs": [ + "Controls how much the generated image varies from the raster layer(s).", + "Lower strength stays closer to the combined visible raster layers. Higher strength relies more on the global prompt.", + "When there are no raster layers with visible content, this setting is ignored." + ] + }, + "paramHeight": { + "heading": "Height", + "paragraphs": ["Height of the generated image. Must be a multiple of 8."] + }, + "paramHrf": { + "heading": "Enable High Resolution Fix", + "paragraphs": [ + "Generate high quality images at a larger resolution than optimal for the model. Generally used to prevent duplication in the generated image." + ] + }, + "paramIterations": { + "heading": "Iterations", + "paragraphs": [ + "The number of images to generate.", + "If Dynamic Prompts is enabled, each of the prompts will be generated this many times." + ] + }, + "paramModel": { + "heading": "Model", + "paragraphs": [ + "Model used for generation. Different models are trained to specialize in producing different aesthetic results and content." + ] + }, + "paramRatio": { + "heading": "Aspect Ratio", + "paragraphs": [ + "The aspect ratio of the dimensions of the image generated.", + "An image size (in number of pixels) equivalent to 512x512 is recommended for SD1.5 models and a size equivalent to 1024x1024 is recommended for SDXL models." + ] + }, + "paramSeed": { + "heading": "Seed", + "paragraphs": [ + "Controls the starting noise used for generation.", + "Disable the \"Random\" option to produce identical results with the same generation settings." + ] + }, + "paramSteps": { + "heading": "Steps", + "paragraphs": [ + "Number of steps that will be performed in each generation.", + "Higher step counts will typically create better images but will require more generation time." + ] + }, + "paramUpscaleMethod": { + "heading": "Upscale Method", + "paragraphs": ["Method used to upscale the image for High Resolution Fix."] + }, + "paramVAE": { + "heading": "VAE", + "paragraphs": ["Model used for translating AI output into the final image."] + }, + "paramVAEPrecision": { + "heading": "VAE Precision", + "paragraphs": [ + "The precision used during VAE encoding and decoding.", + "Fp16/Half precision is more efficient, at the expense of minor image variations." + ] + }, + "paramWidth": { + "heading": "Width", + "paragraphs": ["Width of the generated image. Must be a multiple of 8."] + }, + "patchmatchDownScaleSize": { + "heading": "Downscale", + "paragraphs": [ + "How much downscaling occurs before infilling.", + "Higher downscaling will improve performance and reduce quality." + ] + }, + "refinerModel": { + "heading": "Refiner Model", + "paragraphs": [ + "Model used during the refiner portion of the generation process.", + "Similar to the Generation Model." + ] + }, + "refinerPositiveAestheticScore": { + "heading": "Positive Aesthetic Score", + "paragraphs": [ + "Weight generations to be more similar to images with a high aesthetic score, based on the training data." + ] + }, + "refinerNegativeAestheticScore": { + "heading": "Negative Aesthetic Score", + "paragraphs": [ + "Weight generations to be more similar to images with a low aesthetic score, based on the training data." + ] + }, + "refinerScheduler": { + "heading": "Scheduler", + "paragraphs": [ + "Scheduler used during the refiner portion of the generation process.", + "Similar to the Generation Scheduler." + ] + }, + "refinerStart": { + "heading": "Refiner Start", + "paragraphs": [ + "Where in the generation process the refiner will start to be used.", + "0 means the refiner will be used for the entire generation process, 0.8 means the refiner will be used for the last 20% of the generation process." + ] + }, + "refinerSteps": { + "heading": "Steps", + "paragraphs": [ + "Number of steps that will be performed during the refiner portion of the generation process.", + "Similar to the Generation Steps." + ] + }, + "refinerCfgScale": { + "heading": "CFG Scale", + "paragraphs": [ + "Controls how much the prompt influences the generation process.", + "Similar to the Generation CFG Scale." + ] + }, + "scaleBeforeProcessing": { + "heading": "Scale Before Processing", + "paragraphs": [ + "\"Auto\" scales the selected area to the size best suited for the model before the image generation process.", + "\"Manual\" allows you to choose the width and height the selected area will be scaled to before the image generation process." + ] + }, + "seamlessTilingXAxis": { + "heading": "Seamless Tiling X Axis", + "paragraphs": ["Seamlessly tile an image along the horizontal axis."] + }, + "seamlessTilingYAxis": { + "heading": "Seamless Tiling Y Axis", + "paragraphs": ["Seamlessly tile an image along the vertical axis."] + }, + "upscaleModel": { + "heading": "Upscale Model", + "paragraphs": [ + "The upscale model scales the image to the output size before details are added. Any supported upscale model may be used, but some are specialized for different kinds of images, like photos or line drawings." + ] + }, + "scale": { + "heading": "Scale", + "paragraphs": [ + "Scale controls the output image size, and is based on a multiple of the input image resolution. For example a 2x upscale on a 1024x1024 image would produce a 2048 x 2048 output." + ] + }, + "creativity": { + "heading": "Creativity", + "paragraphs": [ + "Creativity controls the amount of freedom granted to the model when adding details. Low creativity stays close to the original image, while high creativity allows for more change. When using a prompt, high creativity increases the influence of the prompt." + ] + }, + "structure": { + "heading": "Structure", + "paragraphs": [ + "Structure controls how closely the output image will keep to the layout of the original. Low structure allows major changes, while high structure strictly maintains the original composition and layout." + ] + }, + "fluxDevLicense": { + "heading": "Non-Commercial License", + "paragraphs": [ + "FLUX.1 [dev] models are licensed under the FLUX [dev] non-commercial license. To use this model type for commercial purposes in Invoke, visit our website to learn more." + ] + }, + "optimizedDenoising": { + "heading": "Optimized Image-to-Image", + "paragraphs": [ + "Enable 'Optimized Image-to-Image' for a more gradual Denoise Strength scale for image-to-image and inpainting transformations with Flux models. This setting improves the ability to control the amount of change applied to an image, but may be turned off if you prefer to use the standard Denoise Strength scale. This setting is still being tuned and is in beta status." + ] + } + }, + "workflows": { + "chooseWorkflowFromLibrary": "Choose Workflow from Library", + "defaultWorkflows": "Default Workflows", + "userWorkflows": "User Workflows", + "projectWorkflows": "Project Workflows", + "ascending": "Ascending", + "created": "Created", + "descending": "Descending", + "workflows": "Workflows", + "workflowLibrary": "Library", + "opened": "Opened", + "openWorkflow": "Open Workflow", + "updated": "Updated", + "uploadWorkflow": "Load from File", + "uploadAndSaveWorkflow": "Upload to Library", + "deleteWorkflow": "Delete Workflow", + "deleteWorkflow2": "Are you sure you want to delete this workflow? This cannot be undone.", + "unnamedWorkflow": "Unnamed Workflow", + "downloadWorkflow": "Save to File", + "saveWorkflow": "Save Workflow", + "saveWorkflowAs": "Save Workflow As", + "saveWorkflowToProject": "Save Workflow to Project", + "savingWorkflow": "Saving Workflow...", + "problemSavingWorkflow": "Problem Saving Workflow", + "workflowSaved": "Workflow Saved", + "name": "Name", + "noWorkflows": "No Workflows", + "problemLoading": "Problem Loading Workflows", + "loading": "Loading Workflows", + "noDescription": "No description", + "searchWorkflows": "Search Workflows", + "clearWorkflowSearchFilter": "Clear Workflow Search Filter", + "workflowName": "Workflow Name", + "newWorkflowCreated": "New Workflow Created", + "workflowCleared": "Workflow Cleared", + "workflowEditorMenu": "Workflow Editor Menu", + "loadFromGraph": "Load Workflow from Graph", + "convertGraph": "Convert Graph", + "loadWorkflow": "$t(common.load) Workflow", + "autoLayout": "Auto Layout", + "edit": "Edit", + "download": "Download", + "copyShareLink": "Copy Share Link", + "copyShareLinkForWorkflow": "Copy Share Link for Workflow", + "delete": "Delete" + }, + "controlLayers": { + "regional": "Regional", + "global": "Global", + "canvas": "Canvas", + "bookmark": "Bookmark for Quick Switch", + "fitBboxToLayers": "Fit Bbox To Layers", + "removeBookmark": "Remove Bookmark", + "saveCanvasToGallery": "Save Canvas to Gallery", + "saveBboxToGallery": "Save Bbox to Gallery", + "saveLayerToAssets": "Save Layer to Assets", + "cropLayerToBbox": "Crop Layer to Bbox", + "savedToGalleryOk": "Saved to Gallery", + "savedToGalleryError": "Error saving to gallery", + "newGlobalReferenceImageOk": "Created Global Reference Image", + "newGlobalReferenceImageError": "Problem Creating Global Reference Image", + "newRegionalReferenceImageOk": "Created Regional Reference Image", + "newRegionalReferenceImageError": "Problem Creating Regional Reference Image", + "newControlLayerOk": "Created Control Layer", + "newControlLayerError": "Problem Creating Control Layer", + "newRasterLayerOk": "Created Raster Layer", + "newRasterLayerError": "Problem Creating Raster Layer", + "newFromImage": "New from Image", + "pullBboxIntoLayerOk": "Bbox Pulled Into Layer", + "pullBboxIntoLayerError": "Problem Pulling BBox Into Layer", + "pullBboxIntoReferenceImageOk": "Bbox Pulled Into ReferenceImage", + "pullBboxIntoReferenceImageError": "Problem Pulling BBox Into ReferenceImage", + "regionIsEmpty": "Selected region is empty", + "mergeVisible": "Merge Visible", + "mergeDown": "Merge Down", + "mergeVisibleOk": "Merged layers", + "mergeVisibleError": "Error merging layers", + "mergingLayers": "Merging layers", + "clearHistory": "Clear History", + "bboxOverlay": "Show Bbox Overlay", + "resetCanvas": "Reset Canvas", + "clearCaches": "Clear Caches", + "recalculateRects": "Recalculate Rects", + "clipToBbox": "Clip Strokes to Bbox", + "outputOnlyMaskedRegions": "Output Only Generated Regions", + "addLayer": "Add Layer", + "duplicate": "Duplicate", + "moveToFront": "Move to Front", + "moveToBack": "Move to Back", + "moveForward": "Move Forward", + "moveBackward": "Move Backward", + "width": "Width", + "autoNegative": "Auto Negative", + "enableAutoNegative": "Enable Auto Negative", + "disableAutoNegative": "Disable Auto Negative", + "deletePrompt": "Delete Prompt", + "deleteReferenceImage": "Delete Reference Image", + "showHUD": "Show HUD", + "rectangle": "Rectangle", + "maskFill": "Mask Fill", + "addPositivePrompt": "Add $t(controlLayers.prompt)", + "addNegativePrompt": "Add $t(controlLayers.negativePrompt)", + "addReferenceImage": "Add $t(controlLayers.referenceImage)", + "addRasterLayer": "Add $t(controlLayers.rasterLayer)", + "addControlLayer": "Add $t(controlLayers.controlLayer)", + "addInpaintMask": "Add $t(controlLayers.inpaintMask)", + "addRegionalGuidance": "Add $t(controlLayers.regionalGuidance)", + "addGlobalReferenceImage": "Add $t(controlLayers.globalReferenceImage)", + "rasterLayer": "Raster Layer", + "controlLayer": "Control Layer", + "inpaintMask": "Inpaint Mask", + "regionalGuidance": "Regional Guidance", + "canvasAsRasterLayer": "$t(controlLayers.canvas) as $t(controlLayers.rasterLayer)", + "canvasAsControlLayer": "$t(controlLayers.canvas) as $t(controlLayers.controlLayer)", + "referenceImage": "Reference Image", + "regionalReferenceImage": "Regional Reference Image", + "globalReferenceImage": "Global Reference Image", + "sendingToCanvas": "Staging Generations on Canvas", + "sendingToGallery": "Sending Generations to Gallery", + "sendToGallery": "Send To Gallery", + "sendToGalleryDesc": "Pressing Invoke generates and saves a unique image to your gallery.", + "sendToCanvas": "Send To Canvas", + "newLayerFromImage": "New Layer from Image", + "newCanvasFromImage": "New Canvas from Image", + "newImg2ImgCanvasFromImage": "New Img2Img from Image", + "copyToClipboard": "Copy to Clipboard", + "sendToCanvasDesc": "Pressing Invoke stages your work in progress on the canvas.", + "viewProgressInViewer": "View progress and outputs in the Image Viewer.", + "viewProgressOnCanvas": "View progress and stage outputs on the Canvas.", + "rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)", + "rasterLayer_withCount_other": "Raster Layers", + "controlLayer_withCount_one": "$t(controlLayers.controlLayer)", + "controlLayer_withCount_other": "Control Layers", + "inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)", + "inpaintMask_withCount_other": "Inpaint Masks", + "regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)", + "regionalGuidance_withCount_other": "Regional Guidance", + "globalReferenceImage_withCount_one": "$t(controlLayers.globalReferenceImage)", + "globalReferenceImage_withCount_other": "Global Reference Images", + "opacity": "Opacity", + "regionalGuidance_withCount_hidden": "Regional Guidance ({{count}} hidden)", + "controlLayers_withCount_hidden": "Control Layers ({{count}} hidden)", + "rasterLayers_withCount_hidden": "Raster Layers ({{count}} hidden)", + "globalReferenceImages_withCount_hidden": "Global Reference Images ({{count}} hidden)", + "inpaintMasks_withCount_hidden": "Inpaint Masks ({{count}} hidden)", + "regionalGuidance_withCount_visible": "Regional Guidance ({{count}})", + "controlLayers_withCount_visible": "Control Layers ({{count}})", + "rasterLayers_withCount_visible": "Raster Layers ({{count}})", + "globalReferenceImages_withCount_visible": "Global Reference Images ({{count}})", + "inpaintMasks_withCount_visible": "Inpaint Masks ({{count}})", + "layer_one": "Layer", + "layer_other": "Layers", + "layer_withCount_one": "Layer ({{count}})", + "layer_withCount_other": "Layers ({{count}})", + "convertRasterLayerTo": "Convert $t(controlLayers.rasterLayer) To", + "convertControlLayerTo": "Convert $t(controlLayers.controlLayer) To", + "convertInpaintMaskTo": "Convert $t(controlLayers.inpaintMask) To", + "convertRegionalGuidanceTo": "Convert $t(controlLayers.regionalGuidance) To", + "copyRasterLayerTo": "Copy $t(controlLayers.rasterLayer) To", + "copyControlLayerTo": "Copy $t(controlLayers.controlLayer) To", + "copyInpaintMaskTo": "Copy $t(controlLayers.inpaintMask) To", + "copyRegionalGuidanceTo": "Copy $t(controlLayers.regionalGuidance) To", + "newRasterLayer": "New $t(controlLayers.rasterLayer)", + "newControlLayer": "New $t(controlLayers.controlLayer)", + "newInpaintMask": "New $t(controlLayers.inpaintMask)", + "newRegionalGuidance": "New $t(controlLayers.regionalGuidance)", + "transparency": "Transparency", + "enableTransparencyEffect": "Enable Transparency Effect", + "disableTransparencyEffect": "Disable Transparency Effect", + "hidingType": "Hiding {{type}}", + "showingType": "Showing {{type}}", + "dynamicGrid": "Dynamic Grid", + "logDebugInfo": "Log Debug Info", + "locked": "Locked", + "unlocked": "Unlocked", + "deleteSelected": "Delete Selected", + "stagingOnCanvas": "Staging images on", + "replaceLayer": "Replace Layer", + "pullBboxIntoLayer": "Pull Bbox into Layer", + "pullBboxIntoReferenceImage": "Pull Bbox into Reference Image", + "showProgressOnCanvas": "Show Progress on Canvas", + "prompt": "Prompt", + "negativePrompt": "Negative Prompt", + "beginEndStepPercentShort": "Begin/End %", + "weight": "Weight", + "newGallerySession": "New Gallery Session", + "newGallerySessionDesc": "This will clear the canvas and all settings except for your model selection. Generations will be sent to the gallery.", + "newCanvasSession": "New Canvas Session", + "newCanvasSessionDesc": "This will clear the canvas and all settings except for your model selection. Generations will be staged on the canvas.", + "replaceCurrent": "Replace Current", + "controlLayerEmptyState": "Upload an image, drag an image from the gallery onto this layer, or draw on the canvas to get started.", + "controlMode": { + "controlMode": "Control Mode", + "balanced": "Balanced (recommended)", + "prompt": "Prompt", + "control": "Control", + "megaControl": "Mega Control" + }, + "ipAdapterMethod": { + "ipAdapterMethod": "IP Adapter Method", + "full": "Style and Composition", + "style": "Style Only", + "composition": "Composition Only" + }, + "fill": { + "fillColor": "Fill Color", + "fillStyle": "Fill Style", + "solid": "Solid", + "grid": "Grid", + "crosshatch": "Crosshatch", + "vertical": "Vertical", + "horizontal": "Horizontal", + "diagonal": "Diagonal" + }, + "tool": { + "brush": "Brush", + "eraser": "Eraser", + "rectangle": "Rectangle", + "bbox": "Bbox", + "move": "Move", + "view": "View", + "colorPicker": "Color Picker" + }, + "filter": { + "filter": "Filter", + "filters": "Filters", + "filterType": "Filter Type", + "autoProcess": "Auto Process", + "reset": "Reset", + "process": "Process", + "apply": "Apply", + "cancel": "Cancel", + "advanced": "Advanced", + "processingLayerWith": "Processing layer with the {{type}} filter.", + "forMoreControl": "For more control, click Advanced below.", + "spandrel_filter": { + "label": "Image-to-Image Model", + "description": "Run an image-to-image model on the selected layer.", + "model": "Model", + "autoScale": "Auto Scale", + "autoScaleDesc": "The selected model will be run until the target scale is reached.", + "scale": "Target Scale" + }, + "canny_edge_detection": { + "label": "Canny Edge Detection", + "description": "Generates an edge map from the selected layer using the Canny edge detection algorithm.", + "low_threshold": "Low Threshold", + "high_threshold": "High Threshold" + }, + "color_map": { + "label": "Color Map", + "description": "Create a color map from the selected layer.", + "tile_size": "Tile Size" + }, + "content_shuffle": { + "label": "Content Shuffle", + "description": "Shuffles the content of the selected layer, similar to a 'liquify' effect.", + "scale_factor": "Scale Factor" + }, + "depth_anything_depth_estimation": { + "label": "Depth Anything", + "description": "Generates a depth map from the selected layer using a Depth Anything model.", + "model_size": "Model Size", + "model_size_small": "Small", + "model_size_small_v2": "Small v2", + "model_size_base": "Base", + "model_size_large": "Large" + }, + "dw_openpose_detection": { + "label": "DW Openpose Detection", + "description": "Detects human poses in the selected layer using the DW Openpose model.", + "draw_hands": "Draw Hands", + "draw_face": "Draw Face", + "draw_body": "Draw Body" + }, + "hed_edge_detection": { + "label": "HED Edge Detection", + "description": "Generates an edge map from the selected layer using the HED edge detection model.", + "scribble": "Scribble" + }, + "lineart_anime_edge_detection": { + "label": "Lineart Anime Edge Detection", + "description": "Generates an edge map from the selected layer using the Lineart Anime edge detection model." + }, + "lineart_edge_detection": { + "label": "Lineart Edge Detection", + "description": "Generates an edge map from the selected layer using the Lineart edge detection model.", + "coarse": "Coarse" + }, + "mediapipe_face_detection": { + "label": "MediaPipe Face Detection", + "description": "Detects faces in the selected layer using the MediaPipe face detection model.", + "max_faces": "Max Faces", + "min_confidence": "Min Confidence" + }, + "mlsd_detection": { + "label": "Line Segment Detection", + "description": "Generates a line segment map from the selected layer using the MLSD line segment detection model.", + "score_threshold": "Score Threshold", + "distance_threshold": "Distance Threshold" + }, + "normal_map": { + "label": "Normal Map", + "description": "Generates a normal map from the selected layer." + }, + "pidi_edge_detection": { + "label": "PiDiNet Edge Detection", + "description": "Generates an edge map from the selected layer using the PiDiNet edge detection model.", + "scribble": "Scribble", + "quantize_edges": "Quantize Edges" + } + }, + "transform": { + "transform": "Transform", + "fitToBbox": "Fit to Bbox", + "fitMode": "Fit Mode", + "fitModeContain": "Contain", + "fitModeCover": "Cover", + "fitModeFill": "Fill", + "reset": "Reset", + "apply": "Apply", + "cancel": "Cancel" + }, + "selectObject": { + "selectObject": "Select Object", + "pointType": "Point Type", + "invertSelection": "Invert Selection", + "include": "Include", + "exclude": "Exclude", + "neutral": "Neutral", + "apply": "Apply", + "reset": "Reset", + "saveAs": "Save As", + "cancel": "Cancel", + "process": "Process", + "help1": "Select a single target object. Add Include and Exclude points to indicate which parts of the layer are part of the target object.", + "help2": "Start with one Include point within the target object. Add more points to refine the selection. Fewer points typically produce better results.", + "help3": "Invert the selection to select everything except the target object.", + "clickToAdd": "Click on the layer to add a point", + "dragToMove": "Drag a point to move it", + "clickToRemove": "Click on a point to remove it" + }, + "settings": { + "snapToGrid": { + "label": "Snap to Grid", + "on": "On", + "off": "Off" + }, + "preserveMask": { + "label": "Preserve Masked Region", + "alert": "Preserving Masked Region" + }, + "isolatedStagingPreview": "Isolated Staging Preview", + "isolatedPreview": "Isolated Preview", + "isolatedLayerPreview": "Isolated Layer Preview", + "isolatedLayerPreviewDesc": "Whether to show only this layer when performing operations like filtering or transforming.", + "invertBrushSizeScrollDirection": "Invert Scroll for Brush Size", + "pressureSensitivity": "Pressure Sensitivity" + }, + "HUD": { + "bbox": "Bbox", + "scaledBbox": "Scaled Bbox", + "entityStatus": { + "isFiltering": "{{title}} is filtering", + "isTransforming": "{{title}} is transforming", + "isLocked": "{{title}} is locked", + "isHidden": "{{title}} is hidden", + "isDisabled": "{{title}} is disabled", + "isEmpty": "{{title}} is empty" + } + }, + "canvasContextMenu": { + "canvasGroup": "Canvas", + "saveToGalleryGroup": "Save To Gallery", + "saveCanvasToGallery": "Save Canvas To Gallery", + "saveBboxToGallery": "Save Bbox To Gallery", + "bboxGroup": "Create From Bbox", + "newGlobalReferenceImage": "New Global Reference Image", + "newRegionalReferenceImage": "New Regional Reference Image", + "newControlLayer": "New Control Layer", + "newRasterLayer": "New Raster Layer", + "newInpaintMask": "New Inpaint Mask", + "newRegionalGuidance": "New Regional Guidance", + "cropCanvasToBbox": "Crop Canvas to Bbox" + }, + "stagingArea": { + "accept": "Accept", + "discardAll": "Discard All", + "discard": "Discard", + "previous": "Previous", + "next": "Next", + "saveToGallery": "Save To Gallery", + "showResultsOn": "Showing Results", + "showResultsOff": "Hiding Results" + } + }, + "upscaling": { + "upscale": "Upscale", + "creativity": "Creativity", + "exceedsMaxSize": "Upscale settings exceed max size limit", + "exceedsMaxSizeDetails": "Max upscale limit is {{maxUpscaleDimension}}x{{maxUpscaleDimension}} pixels. Please try a smaller image or decrease your scale selection.", + "structure": "Structure", + "upscaleModel": "Upscale Model", + "postProcessingModel": "Post-Processing Model", + "scale": "Scale", + "postProcessingMissingModelWarning": "Visit the Model Manager to install a post-processing (image to image) model.", + "missingModelsWarning": "Visit the Model Manager to install the required models:", + "mainModelDesc": "Main model (SD1.5 or SDXL architecture)", + "tileControlNetModelDesc": "Tile ControlNet model for the chosen main model architecture", + "upscaleModelDesc": "Upscale (image to image) model", + "missingUpscaleInitialImage": "Missing initial image for upscaling", + "missingUpscaleModel": "Missing upscale model", + "missingTileControlNetModel": "No valid tile ControlNet models installed", + "incompatibleBaseModel": "Unsupported main model architecture for upscaling", + "incompatibleBaseModelDesc": "Upscaling is supported for SD1.5 and SDXL architecture models only. Change the main model to enable upscaling." + }, + "stylePresets": { + "active": "Active", + "choosePromptTemplate": "Choose Prompt Template", + "clearTemplateSelection": "Clear Template Selection", + "copyTemplate": "Copy Template", + "createPromptTemplate": "Create Prompt Template", + "defaultTemplates": "Default Templates", + "deleteImage": "Delete Image", + "deleteTemplate": "Delete Template", + "deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.", + "exportPromptTemplates": "Export My Prompt Templates (CSV)", + "editTemplate": "Edit Template", + "exportDownloaded": "Export Downloaded", + "exportFailed": "Unable to generate and download CSV", + "flatten": "Flatten selected template into current prompt", + "importTemplates": "Import Prompt Templates (CSV/JSON)", + "acceptedColumnsKeys": "Accepted columns/keys:", + "nameColumn": "'name'", + "positivePromptColumn": "'prompt' or 'positive_prompt'", + "negativePromptColumn": "'negative_prompt'", + "insertPlaceholder": "Insert placeholder", + "myTemplates": "My Templates", + "name": "Name", + "negativePrompt": "Negative Prompt", + "noTemplates": "No templates", + "noMatchingTemplates": "No matching templates", + "promptTemplatesDesc1": "Prompt templates add text to the prompts you write in the prompt box.", + "promptTemplatesDesc2": "Use the placeholder string
{{placeholder}}
to specify where your prompt should be included in the template.", + "promptTemplatesDesc3": "If you omit the placeholder, the template will be appended to the end of your prompt.", + "positivePrompt": "Positive Prompt", + "preview": "Preview", + "private": "Private", + "promptTemplateCleared": "Prompt Template Cleared", + "searchByName": "Search by name", + "shared": "Shared", + "sharedTemplates": "Shared Templates", + "templateDeleted": "Prompt template deleted", + "toggleViewMode": "Toggle View Mode", + "type": "Type", + "unableToDeleteTemplate": "Unable to delete prompt template", + "updatePromptTemplate": "Update Prompt Template", + "uploadImage": "Upload Image", + "useForTemplate": "Use For Prompt Template", + "viewList": "View Template List", + "viewModeTooltip": "This is how your prompt will look with your currently selected template. To edit your prompt, click anywhere in the text box." + }, + "upsell": { + "inviteTeammates": "Invite Teammates", + "professional": "Professional", + "professionalUpsell": "Available in Invoke's Professional Edition. Click here or visit invoke.com/pricing for more details.", + "shareAccess": "Share Access" + }, + "ui": { + "tabs": { + "generation": "Generation", + "canvas": "Canvas", + "workflows": "Workflows", + "workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)", + "models": "Models", + "modelsTab": "$t(ui.tabs.models) $t(common.tab)", + "queue": "Queue", + "upscaling": "Upscaling", + "upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)", + "gallery": "Gallery" + } + }, + "system": { + "enableLogging": "Enable Logging", + "logLevel": { + "logLevel": "Log Level", + "trace": "Trace", + "debug": "Debug", + "info": "Info", + "warn": "Warn", + "error": "Error", + "fatal": "Fatal" + }, + "logNamespaces": { + "logNamespaces": "Log Namespaces", + "gallery": "Gallery", + "models": "Models", + "config": "Config", + "canvas": "Canvas", + "generation": "Generation", + "workflows": "Workflows", + "system": "System", + "events": "Events", + "queue": "Queue", + "metadata": "Metadata" + } + }, + "newUserExperience": { + "toGetStartedLocal": "To get started, make sure to download or import models needed to run Invoke. Then, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.", + "toGetStarted": "To get started, enter a prompt in the box and click Invoke to generate your first image. Select a prompt template to improve results. You can choose to save your images directly to the Gallery or edit them to the Canvas.", + "gettingStartedSeries": "Want more guidance? Check out our Getting Started Series for tips on unlocking the full potential of the Invoke Studio.", + "downloadStarterModels": "Download Starter Models", + "importModels": "Import Models", + "noModelsInstalled": "It looks like you don't have any models installed" + }, + "whatsNew": { + "whatsNewInInvoke": "What's New in Invoke", + "items": [ + "SD 3.5: Support for SD 3.5 Medium and Large.", + "Canvas: Streamlined Control Layer processing and improved default Control settings." + ], + "readReleaseNotes": "Read Release Notes", + "watchRecentReleaseVideos": "Watch Recent Release Videos", + "watchUiUpdatesOverview": "Watch UI Updates Overview" + } +} diff --git a/invokeai/frontend/web/public/locales/es.json b/invokeai/frontend/web/public/locales/es.json new file mode 100644 index 0000000000000000000000000000000000000000..846b975288385c6368d1a5dfa5841f89e7182772 --- /dev/null +++ b/invokeai/frontend/web/public/locales/es.json @@ -0,0 +1,340 @@ +{ + "common": { + "hotkeysLabel": "Atajos de teclado", + "languagePickerLabel": "Selector de idioma", + "reportBugLabel": "Reportar errores", + "settingsLabel": "Ajustes", + "img2img": "Imagen a Imagen", + "nodes": "Flujos de trabajo", + "upload": "Subir imagen", + "load": "Cargar", + "statusDisconnected": "Desconectado", + "githubLabel": "Github", + "discordLabel": "Discord", + "back": "Atrás", + "loading": "Cargando", + "postprocessing": "Postprocesado", + "txt2img": "De texto a imagen", + "accept": "Aceptar", + "cancel": "Cancelar", + "linear": "Lineal", + "random": "Aleatorio", + "openInNewTab": "Abrir en una nueva pestaña", + "dontAskMeAgain": "No me preguntes de nuevo", + "areYouSure": "¿Estas seguro?", + "batch": "Administrador de lotes", + "modelManager": "Administrador de modelos", + "communityLabel": "Comunidad", + "direction": "Dirección", + "ai": "Ia", + "add": "Añadir", + "auto": "Automático", + "copyError": "Error $t(gallery.copy)", + "details": "Detalles", + "or": "o", + "checkpoint": "Punto de control", + "controlNet": "ControlNet", + "aboutHeading": "Sea dueño de su poder creativo", + "advanced": "Avanzado", + "data": "Fecha", + "delete": "Borrar", + "copy": "Copiar", + "beta": "Beta", + "on": "En", + "aboutDesc": "¿Utilizas Invoke para trabajar? Mira aquí:", + "installed": "Instalado", + "green": "Verde", + "editor": "Editor", + "orderBy": "Ordenar por", + "file": "Archivo", + "goTo": "Ir a", + "imageFailedToLoad": "No se puede cargar la imagen", + "saveAs": "Guardar Como", + "somethingWentWrong": "Algo salió mal", + "nextPage": "Página Siguiente", + "selected": "Seleccionado", + "tab": "Tabulador", + "positivePrompt": "Prompt Positivo", + "negativePrompt": "Prompt Negativo", + "error": "Error", + "format": "formato", + "unknown": "Desconocido", + "input": "Entrada", + "template": "Plantilla", + "prevPage": "Página Anterior", + "red": "Rojo", + "alpha": "Transparencia", + "outputs": "Salidas", + "learnMore": "Aprende más", + "enabled": "Activado", + "disabled": "Desactivado", + "folder": "Carpeta", + "updated": "Actualizado", + "created": "Creado", + "save": "Guardar", + "unknownError": "Error Desconocido", + "blue": "Azul" + }, + "gallery": { + "galleryImageSize": "Tamaño de la imagen", + "gallerySettings": "Ajustes de la galería", + "autoSwitchNewImages": "Auto seleccionar Imágenes nuevas", + "noImagesInGallery": "No hay imágenes para mostrar", + "deleteImage_one": "Eliminar Imagen", + "deleteImage_many": "Eliminar {{count}} Imágenes", + "deleteImage_other": "Eliminar {{count}} Imágenes", + "deleteImagePermanent": "Las imágenes eliminadas no se pueden restaurar.", + "assets": "Activos", + "autoAssignBoardOnClick": "Asignación automática de tableros al hacer clic" + }, + "modelManager": { + "modelManager": "Gestor de Modelos", + "model": "Modelo", + "modelUpdated": "Modelo actualizado", + "manual": "Manual", + "name": "Nombre", + "description": "Descripción", + "config": "Configurar", + "width": "Ancho", + "height": "Alto", + "addModel": "Añadir Modelo", + "availableModels": "Modelos disponibles", + "search": "Búsqueda", + "load": "Cargar", + "active": "activo", + "selected": "Seleccionado", + "delete": "Eliminar", + "deleteModel": "Eliminar Modelo", + "deleteConfig": "Eliminar Configuración", + "deleteMsg1": "¿Estás seguro de que deseas eliminar este modelo de InvokeAI?", + "deleteMsg2": "Esto eliminará el modelo del disco si está en la carpeta raíz de InvokeAI. Si está utilizando una ubicación personalizada, el modelo NO se eliminará del disco.", + "convertToDiffusersHelpText4": "Este proceso se realiza una sola vez. Puede tardar entre 30 y 60 segundos dependiendo de las especificaciones de tu ordenador.", + "convert": "Convertir", + "convertToDiffusers": "Convertir en difusores", + "convertToDiffusersHelpText1": "Este modelo se convertirá al formato 🧨 Difusores.", + "convertToDiffusersHelpText2": "Este proceso sustituirá su entrada del Gestor de Modelos por la versión de Difusores del mismo modelo.", + "convertToDiffusersHelpText3": "Tu archivo del punto de control en el disco se eliminará si está en la carpeta raíz de InvokeAI. Si está en una ubicación personalizada, NO se eliminará.", + "convertToDiffusersHelpText5": "Por favor, asegúrate de tener suficiente espacio en el disco. Los modelos generalmente varían entre 2 GB y 7 GB de tamaño.", + "convertToDiffusersHelpText6": "¿Desea transformar este modelo?", + "modelConverted": "Modelo adaptado", + "alpha": "Alfa", + "allModels": "Todos los modelos", + "repo_id": "Identificador del repositorio", + "none": "ninguno", + "vae": "VAE", + "variant": "Variante", + "baseModel": "Modelo básico", + "modelConversionFailed": "Conversión al modelo fallida", + "selectModel": "Seleccionar un modelo", + "modelUpdateFailed": "Error al actualizar el modelo", + "convertingModelBegin": "Convirtiendo el modelo. Por favor, espere.", + "modelDeleted": "Modelo eliminado", + "modelDeleteFailed": "Error al borrar el modelo", + "settings": "Ajustes", + "syncModels": "Sincronizar las plantillas" + }, + "parameters": { + "images": "Imágenes", + "steps": "Pasos", + "cfgScale": "Escala CFG", + "width": "Ancho", + "height": "Alto", + "seed": "Semilla", + "shuffle": "Semilla aleatoria", + "noiseThreshold": "Umbral de Ruido", + "perlinNoise": "Ruido Perlin", + "type": "Tipo", + "strength": "Fuerza", + "upscaling": "Aumento de resolución", + "scale": "Escala", + "imageFit": "Ajuste tamaño de imagen inicial al tamaño objetivo", + "scaleBeforeProcessing": "Redimensionar antes de procesar", + "scaledWidth": "Ancho escalado", + "scaledHeight": "Alto escalado", + "infillMethod": "Método de relleno", + "tileSize": "Tamaño del mosaico", + "downloadImage": "Descargar imagen", + "usePrompt": "Usar Entrada", + "useSeed": "Usar Semilla", + "useAll": "Usar Todo", + "info": "Información", + "showOptionsPanel": "Mostrar panel lateral (O o T)", + "symmetry": "Simetría", + "copyImage": "Copiar la imagen", + "general": "General", + "denoisingStrength": "Intensidad de la eliminación del ruido", + "seamlessXAxis": "Eje x", + "seamlessYAxis": "Eje y", + "scheduler": "Programador", + "positivePromptPlaceholder": "Prompt Positivo", + "negativePromptPlaceholder": "Prompt Negativo", + "controlNetControlMode": "Modo de control", + "clipSkip": "Omitir el CLIP", + "maskBlur": "Desenfoque de máscara", + "patchmatchDownScaleSize": "Reducir a escala", + "coherenceMode": "Modo" + }, + "settings": { + "models": "Modelos", + "displayInProgress": "Mostrar las imágenes del progreso", + "confirmOnDelete": "Confirmar antes de eliminar", + "resetWebUI": "Restablecer interfaz web", + "resetWebUIDesc1": "Al restablecer la interfaz web, solo se restablece la caché local del navegador de sus imágenes y la configuración guardada. No se elimina ninguna imagen de su disco duro.", + "resetWebUIDesc2": "Si las imágenes no se muestran en la galería o algo más no funciona, intente restablecer antes de reportar un incidente en GitHub.", + "resetComplete": "Se ha restablecido la interfaz web.", + "general": "General", + "developer": "Desarrollador", + "antialiasProgressImages": "Imágenes del progreso de Antialias", + "showProgressInViewer": "Mostrar las imágenes del progreso en el visor", + "ui": "Interfaz del usuario", + "generation": "Generación", + "beta": "Beta", + "reloadingIn": "Recargando en", + "intermediatesClearedFailed": "Error limpiando los intermediarios", + "intermediatesCleared_one": "Borrado {{count}} intermediario", + "intermediatesCleared_many": "Borrados {{count}} intermediarios", + "intermediatesCleared_other": "Borrados {{count}} intermediarios" + }, + "toast": { + "uploadFailed": "Error al subir archivo", + "imageCopied": "Imágen copiada", + "parametersNotSet": "Parámetros no recuperados", + "serverError": "Error en el servidor", + "canceled": "Procesando la cancelación", + "connected": "Conectado al servidor", + "uploadFailedInvalidUploadDesc": "Deben ser imágenes PNG o JPEG.", + "parameterSet": "Parámetro recuperado", + "parameterNotSet": "Parámetro no recuperado", + "problemCopyingImage": "No se puede copiar la imagen", + "errorCopied": "Error al copiar", + "baseModelChanged": "Modelo base cambiado", + "addedToBoard": "Se agregó a los activos del tablero {{name}}", + "baseModelChangedCleared_one": "Borrado o desactivado {{count}} submodelo incompatible", + "baseModelChangedCleared_many": "Borrados o desactivados {{count}} submodelos incompatibles", + "baseModelChangedCleared_other": "Borrados o desactivados {{count}} submodelos incompatibles" + }, + "accessibility": { + "invokeProgressBar": "Activar la barra de progreso", + "reset": "Reiniciar", + "uploadImage": "Cargar imagen", + "previousImage": "Imagen anterior", + "nextImage": "Siguiente imagen", + "menu": "Menú", + "about": "Acerca de", + "createIssue": "Crear un problema", + "resetUI": "Interfaz de usuario $t(accessibility.reset)", + "mode": "Modo", + "submitSupportTicket": "Enviar Ticket de Soporte", + "toggleRightPanel": "Activar o desactivar el panel derecho (G)", + "toggleLeftPanel": "Activar o desactivar el panel izquierdo (T)" + }, + "nodes": { + "zoomInNodes": "Acercar", + "hideMinimapnodes": "Ocultar el minimapa", + "fitViewportNodes": "Ajustar la vista", + "zoomOutNodes": "Alejar", + "hideLegendNodes": "Ocultar la leyenda del tipo de campo", + "showLegendNodes": "Mostrar la leyenda del tipo de campo", + "showMinimapnodes": "Mostrar el minimapa", + "reloadNodeTemplates": "Recargar las plantillas de nodos", + "loadWorkflow": "Cargar el flujo de trabajo", + "downloadWorkflow": "Descargar el flujo de trabajo en un archivo JSON" + }, + "boards": { + "autoAddBoard": "Agregar panel automáticamente", + "changeBoard": "Cambiar el panel", + "clearSearch": "Borrar la búsqueda", + "deleteBoard": "Borrar el panel", + "selectBoard": "Seleccionar un panel", + "uncategorized": "Sin categoría", + "cancel": "Cancelar", + "addBoard": "Agregar un panel", + "movingImagesToBoard_one": "Moviendo {{count}} imagen al panel:", + "movingImagesToBoard_many": "Moviendo {{count}} imágenes al panel:", + "movingImagesToBoard_other": "Moviendo {{count}} imágenes al panel:", + "bottomMessage": "Al eliminar este panel y las imágenes que contiene, se restablecerán las funciones que los estén utilizando actualmente.", + "deleteBoardAndImages": "Borrar el panel y las imágenes", + "loading": "Cargando...", + "deletedBoardsCannotbeRestored": "Los paneles eliminados no se pueden restaurar. Al Seleccionar 'Borrar Solo el Panel' transferirá las imágenes a un estado sin categorizar.", + "move": "Mover", + "menuItemAutoAdd": "Agregar automáticamente a este panel", + "searchBoard": "Buscando paneles…", + "topMessage": "Este panel contiene imágenes utilizadas en las siguientes funciones:", + "downloadBoard": "Descargar panel", + "deleteBoardOnly": "Borrar solo el panel", + "myBoard": "Mi panel", + "noMatching": "No hay paneles que coincidan", + "imagesWithCount_one": "{{count}} imagen", + "imagesWithCount_many": "{{count}} imágenes", + "imagesWithCount_other": "{{count}} imágenes", + "assetsWithCount_one": "{{count}} activo", + "assetsWithCount_many": "{{count}} activos", + "assetsWithCount_other": "{{count}} activos", + "hideBoards": "Ocultar Paneles", + "addPrivateBoard": "Agregar un tablero privado", + "addSharedBoard": "Agregar Panel Compartido", + "boards": "Paneles", + "archiveBoard": "Archivar Panel", + "archived": "Archivado", + "selectedForAutoAdd": "Seleccionado para agregar automáticamente", + "unarchiveBoard": "Desarchivar el tablero", + "noBoards": "No hay tableros {{boardType}}", + "shared": "Carpetas compartidas", + "deletedPrivateBoardsCannotbeRestored": "Los tableros eliminados no se pueden restaurar. Al elegir \"Eliminar solo tablero\", las imágenes se colocan en un estado privado y sin categoría para el creador de la imagen." + }, + "accordions": { + "compositing": { + "title": "Composición", + "infillTab": "Relleno" + }, + "generation": { + "title": "Generación" + }, + "image": { + "title": "Imagen" + }, + "control": { + "title": "Control" + }, + "advanced": { + "options": "$t(accordions.advanced.title) opciones", + "title": "Avanzado" + } + }, + "ui": { + "tabs": { + "canvas": "Lienzo", + "generation": "Generación", + "queue": "Cola", + "workflows": "Flujos de trabajo", + "models": "Modelos", + "modelsTab": "$t(ui.tabs.models) $t(common.tab)", + "workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)" + } + }, + "queue": { + "back": "Atrás", + "front": "Delante", + "batchQueuedDesc_one": "Se agregó {{count}} sesión a {{direction}} la cola", + "batchQueuedDesc_many": "Se agregaron {{count}} sesiones a {{direction}} la cola", + "batchQueuedDesc_other": "Se agregaron {{count}} sesiones a {{direction}} la cola" + }, + "upsell": { + "inviteTeammates": "Invitar compañeros de equipo", + "shareAccess": "Compartir acceso", + "professionalUpsell": "Disponible en la edición profesional de Invoke. Haga clic aquí o visite invoke.com/pricing para obtener más detalles." + }, + "controlLayers": { + "layer_one": "Capa", + "layer_many": "Capas", + "layer_other": "Capas", + "layer_withCount_one": "({{count}}) capa", + "layer_withCount_many": "({{count}}) capas", + "layer_withCount_other": "({{count}}) capas" + }, + "whatsNew": { + "readReleaseNotes": "Leer las notas de la versión", + "watchRecentReleaseVideos": "Ver videos de lanzamientos recientes", + "watchUiUpdatesOverview": "Descripción general de las actualizaciones de la interfaz de usuario de Watch" + } +} diff --git a/invokeai/frontend/web/public/locales/fi.json b/invokeai/frontend/web/public/locales/fi.json new file mode 100644 index 0000000000000000000000000000000000000000..9fc63ef44a0cb9267d91f17a945582f31aae7fde --- /dev/null +++ b/invokeai/frontend/web/public/locales/fi.json @@ -0,0 +1,34 @@ +{ + "accessibility": { + "reset": "Resetoi", + "uploadImage": "Lataa kuva", + "invokeProgressBar": "Invoken edistymispalkki", + "nextImage": "Seuraava kuva", + "previousImage": "Edellinen kuva" + }, + "common": { + "languagePickerLabel": "Kielen valinta", + "hotkeysLabel": "Pikanäppäimet", + "reportBugLabel": "Raportoi Bugista", + "settingsLabel": "Asetukset", + "githubLabel": "Github", + "discordLabel": "Discord", + "upload": "Lataa", + "img2img": "Kuva kuvaksi", + "nodes": "Solmut", + "postprocessing": "Jälkikäsitellään", + "cancel": "Peruuta", + "accept": "Hyväksy", + "load": "Lataa", + "back": "Takaisin", + "statusDisconnected": "Yhteys katkaistu", + "loading": "Ladataan", + "txt2img": "Teksti kuvaksi" + }, + "gallery": { + "galleryImageSize": "Kuvan koko", + "gallerySettings": "Gallerian asetukset", + "autoSwitchNewImages": "Vaihda uusiin kuviin automaattisesti", + "noImagesInGallery": "Ei kuvia galleriassa" + } +} diff --git a/invokeai/frontend/web/public/locales/fr.json b/invokeai/frontend/web/public/locales/fr.json new file mode 100644 index 0000000000000000000000000000000000000000..565a492afad842715de9a880b4d39b1789fc006b --- /dev/null +++ b/invokeai/frontend/web/public/locales/fr.json @@ -0,0 +1,2153 @@ +{ + "common": { + "hotkeysLabel": "Raccourcis clavier", + "languagePickerLabel": "Langue", + "reportBugLabel": "Signaler un bug", + "settingsLabel": "Paramètres", + "img2img": "Image vers Image", + "nodes": "Workflows", + "upload": "Importer", + "load": "Charger", + "back": "Retour", + "statusDisconnected": "Hors ligne", + "discordLabel": "Discord", + "githubLabel": "Github", + "accept": "Accepter", + "cancel": "Annuler", + "loading": "Chargement", + "txt2img": "Texte vers Image", + "postprocessing": "Post-Traitement", + "file": "Fichier", + "orderBy": "Trier par", + "add": "Ajouter", + "dontAskMeAgain": "Ne plus me demander", + "outputs": "Sorties", + "unknown": "Inconnu", + "editor": "Éditeur", + "error": "Erreur", + "installed": "Installé", + "format": "format", + "goTo": "Aller à", + "input": "Entrée", + "linear": "Linéaire", + "localSystem": "Système local", + "learnMore": "En savoir plus", + "modelManager": "Gestionnaire de modèle", + "notInstalled": "Non $t(common.installed)", + "openInNewTab": "Ouvrir dans un nouvel onglet", + "somethingWentWrong": "Une erreur s'est produite", + "created": "Créé", + "tab": "Onglet", + "folder": "Dossier", + "imageFailedToLoad": "Impossible de charger l'Image", + "prevPage": "Page précédente", + "nextPage": "Page suivante", + "selected": "Sélectionné", + "save": "Enregistrer", + "updated": "Mis à jour", + "random": "Aléatoire", + "unknownError": "Erreur inconnue", + "red": "Rouge", + "green": "Vert", + "delete": "Supprimer", + "simple": "Simple", + "template": "Template", + "advanced": "Avancé", + "copy": "Copier", + "saveAs": "Enregistrer sous", + "blue": "Bleu", + "alpha": "Alpha", + "enabled": "Activé", + "disabled": "Désactivé", + "direction": "Direction", + "aboutHeading": "Possédez Votre Pouvoir Créatif", + "ai": "ia", + "safetensors": "Safetensors", + "apply": "Appliquer", + "communityLabel": "Communauté", + "loadingImage": "Chargement de l'Image", + "view": "Visualisateur", + "beta": "Beta", + "on": "Activé", + "batch": "Gestionaire de Lots", + "outpaint": "Extension", + "openInViewer": "Ouvrir dans le Visualisateur", + "edit": "Édition", + "off": "Désactivé", + "areYouSure": "Êtes-vous sûr ?", + "data": "Donnée", + "details": "Détails", + "placeholderSelectAModel": "Séléctionner un modèle", + "reset": "Réinitialiser", + "none": "Aucun", + "new": "Nouveau", + "dontShowMeThese": "Ne pas me montrer ceci", + "auto": "Auto", + "or": "ou", + "checkpoint": "Point de sauvegarde", + "ipAdapter": "IP Adapter", + "t2iAdapter": "T2I Adapter", + "inpaint": "Retouche", + "toResolve": "À résoudre", + "aboutDesc": "Utilisez vous Invoke pour le travail ? Consultez :", + "copyError": "$t(gallery.copy) Erreur", + "controlNet": "ControlNet", + "positivePrompt": "Prompt Positif", + "negativePrompt": "Prompt Négatif", + "ok": "Ok", + "close": "Fermer", + "clipboard": "Presse-papier" + }, + "gallery": { + "galleryImageSize": "Taille de l'image", + "gallerySettings": "Paramètres de la galerie", + "autoSwitchNewImages": "Basculer automatiquement vers de nouvelles images", + "noImagesInGallery": "Aucune image à afficher", + "bulkDownloadRequestedDesc": "Votre demande de téléchargement est en cours de traitement. Cela peut prendre quelques instants.", + "deleteSelection": "Supprimer la sélection", + "selectAllOnPage": "Séléctionner toute la page", + "unableToLoad": "Impossible de charger la Galerie", + "featuresWillReset": "Si vous supprimez cette image, ces fonctionnalités vont être réinitialisés.", + "loading": "Chargement", + "sortDirection": "Direction de tri", + "sideBySide": "Côte-à-Côte", + "hover": "Au passage de la souris", + "assets": "Ressources", + "alwaysShowImageSizeBadge": "Toujours montrer le badge de taille de l'Image", + "gallery": "Galerie", + "bulkDownloadRequestFailed": "Problème lors de la préparation du téléchargement", + "copy": "Copier", + "autoAssignBoardOnClick": "Assigner automatiquement une Planche lors du clic", + "dropToUpload": "$t(gallery.drop) pour Importer", + "dropOrUpload": "$t(gallery.drop) ou Importer", + "oldestFirst": "Plus Ancien en premier", + "deleteImagePermanent": "Les Images supprimées ne peuvent pas être restorées.", + "displaySearch": "Recherche d'Image", + "exitBoardSearch": "Sortir de la recherche de Planche", + "go": "Aller", + "newestFirst": "Plus Récents en permier", + "showStarredImagesFirst": "Monter les Images partagées en premier", + "bulkDownloadFailed": "Téléchargement échoué", + "bulkDownloadRequested": "Préparation du téléchargement", + "compareImage": "Comparer l'Image", + "openInViewer": "Ouvrir dans le Visualiseur", + "showArchivedBoards": "Montrer les Planches archivées", + "selectForCompare": "Séléctionner pour comparaison", + "selectAnImageToCompare": "Séléctionner une Image à comparer", + "exitCompare": "Sortir de la comparaison", + "compareHelp2": "Appuyez sur M pour faire défiler les modes de comparaison.", + "swapImages": "Échanger les Images", + "move": "Déplacer", + "compareHelp1": "Maintenir Alt lors du clic d'une image dans la galerie ou en utilisant les flèches du clavier pour changer l'Image à comparer.", + "compareHelp3": "Appuyer sur C pour échanger les images à comparer.", + "image": "image", + "openViewer": "Ouvrir le Visualisateur", + "closeViewer": "Fermer le Visualisateur", + "currentlyInUse": "Cette image est actuellement utilisée dans ces fonctionalités :", + "jump": "Sauter", + "starImage": "Marquer l'Image", + "download": "Téléchargement", + "deleteImage_one": "Supprimer l'Image", + "deleteImage_many": "Supprimer {{count}} Images", + "deleteImage_other": "Supprimer {{count}} Images", + "displayBoardSearch": "Recherche dans la Planche", + "searchImages": "Chercher par Métadonnées", + "slider": "Curseur", + "stretchToFit": "Étirer pour remplir", + "compareHelp4": "Appuyer sur Z ou Esc pour sortir.", + "drop": "Déposer", + "noImageSelected": "Pas d'Image séléctionnée", + "downloadSelection": "Télécharger la sélection", + "exitSearch": "Sortir de la recherche d'Image", + "unstarImage": "Retirer le marquage de l'Image", + "viewerImage": "Visualisation de l'Image", + "imagesSettings": "Paramètres des images de la galerie", + "assetsTab": "Fichiers que vous avez importés pour vos projets.", + "imagesTab": "Images que vous avez créées et enregistrées dans Invoke.", + "boardsSettings": "Paramètres des planches" + }, + "modelManager": { + "modelManager": "Gestionnaire de modèle", + "model": "Modèle", + "allModels": "Tous les modèles", + "modelUpdated": "Modèle mis à jour", + "manual": "Manuel", + "name": "Nom", + "description": "Description", + "config": "Config", + "repo_id": "ID de dépôt", + "width": "Largeur", + "height": "Hauteur", + "addModel": "Ajouter un modèle", + "availableModels": "Modèles disponibles", + "search": "Rechercher", + "load": "Charger", + "active": "actif", + "selected": "Sélectionné", + "delete": "Supprimer", + "deleteModel": "Supprimer le modèle", + "deleteConfig": "Supprimer la configuration", + "deleteMsg1": "Voulez-vous vraiment supprimer ce modèle de InvokeAI ?", + "deleteMsg2": "Cela SUPPRIMERA le modèle du disque s'il se trouve dans le dossier racine d'InvokeAI. Si vous utilisez un emplacement personnalisé, le modèle NE SERA PAS supprimé du disque.", + "convert": "Convertir", + "convertToDiffusersHelpText2": "Ce processus remplacera votre entrée dans le gestionaire de modèles par la version Diffusers du même modèle.", + "convertToDiffusersHelpText1": "Ce modèle sera converti au format 🧨 Diffusers.", + "huggingFaceHelper": "Si plusieurs modèles sont trouvés dans ce dépôt, vous serez invité à en sélectionner un à installer.", + "convertToDiffusers": "Convertir en Diffusers", + "convertToDiffusersHelpText5": "Veuillez vous assurer que vous disposez de suffisamment d'espace disque. La taille des modèles varient généralement entre 2 Go et 7 Go.", + "convertToDiffusersHelpText4": "C'est un processus executé une unique fois. Cela peut prendre environ 30 à 60 secondes en fonction des spécifications de votre ordinateur.", + "alpha": "Alpha", + "modelConverted": "Modèle Converti", + "convertToDiffusersHelpText3": "Votre fichier de point de contrôle sur le disque SERA supprimé s'il se trouve dans le dossier racine d'InvokeAI. S'il est dans un emplacement personnalisé, alors il NE SERA PAS supprimé.", + "convertToDiffusersHelpText6": "Souhaitez-vous convertir ce modèle ?", + "modelConversionFailed": "Échec de la conversion du modèle", + "none": "aucun", + "selectModel": "Sélectionner le modèle", + "modelDeleted": "Modèle supprimé", + "vae": "VAE", + "baseModel": "Modèle de Base", + "convertingModelBegin": "Conversion du modèle. Veuillez patienter.", + "modelDeleteFailed": "Échec de la suppression du modèle", + "modelUpdateFailed": "Échec de la mise à jour du modèle", + "variant": "Variante", + "syncModels": "Synchroniser les Modèles", + "settings": "Paramètres", + "predictionType": "Type de Prédiction", + "advanced": "Avancé", + "modelType": "Type de modèle", + "vaePrecision": "Précision VAE", + "noModelSelected": "Aucun modèle sélectionné", + "typePhraseHere": "Écrire une phrase ici", + "cancel": "Annuler", + "defaultSettingsSaved": "Paramètres par défaut enregistrés", + "imageEncoderModelId": "ID du modèle d'encodeur d'image", + "path": "Chemin sur le disque", + "repoVariant": "Variante de dépôt", + "scanResults": "Résultats de l'analyse", + "starterModels": "Modèles de démarrage", + "huggingFace": "HuggingFace", + "metadata": "Métadonnées", + "scanFolder": "Scanner le dossier", + "inplaceInstallDesc": "Installez les modèles sans copier les fichiers. Lors de l'utilisation du modèle, il sera chargé depuis cet emplacement. Si cette option est désactivée, le(s) fichier(s) du modèle seront copiés dans le répertoire des modèles géré par Invoke lors de l'installation.", + "ipAdapters": "Adaptateurs IP", + "installQueue": "File d'attente d'installation", + "modelImageDeleteFailed": "Échec de la suppression de l'image du modèle", + "modelName": "Nom du modèle", + "triggerPhrases": "Phrases de déclenchement", + "defaultSettings": "Paramètres par défaut", + "simpleModelPlaceholder": "URL ou chemin vers un fichier local ou un dossier de diffuseurs", + "textualInversions": "Inversions textuelles", + "inplaceInstall": "Installation sur place", + "huggingFacePlaceholder": "propriétaire/nom-modèle", + "installRepo": "Installer le dépôt", + "noModelsInstalled": "Aucun modèle installé", + "urlOrLocalPath": "URL ou chemin local", + "prune": "Vider", + "uploadImage": "Importer une image", + "addModels": "Ajouter des modèles", + "install": "Installer", + "localOnly": "local uniquement", + "source": "Source", + "installAll": "Installer tout", + "deleteModelImage": "Supprimer l'image du modèle", + "huggingFaceRepoID": "ID de dépôt HuggingFace", + "loraModels": "LoRAs", + "main": "Principal", + "urlOrLocalPathHelper": "Les URL doivent pointer vers un seul fichier. Les chemins locaux peuvent pointer vers un seul fichier ou un dossier pour un seul modèle de diffuseurs.", + "modelImageUpdateFailed": "Mise à jour de l'image du modèle échouée", + "loraTriggerPhrases": "Phrases de déclenchement LoRA", + "mainModelTriggerPhrases": "Phrases de déclenchement du modèle principal", + "scanPlaceholder": "Chemin vers un dossier local", + "modelImageDeleted": "Image du modèle supprimée", + "upcastAttention": "Augmenter l'Attention", + "noMatchingModels": "Aucun modèle correspondant", + "noModelsInstalledDesc1": "Installer des modèles avec le", + "modelSettings": "Paramètres du modèle", + "edit": "Modifier", + "pruneTooltip": "Vider les importations terminées de la file d'attente", + "pathToConfig": "Chemin vers la configuration", + "modelImageUpdated": "Image du modèle mise à jour", + "scanFolderHelper": "Le dossier sera analysé de manière récursive à la recherche de modèles. Cela peut prendre quelques instants pour des dossiers très volumineux.", + "clipEmbed": "Intégration CLIP", + "spandrelImageToImage": "Image vers Image (Spandrel)", + "starterModelsInModelManager": "Les modèles de démarrage peuvent être trouvés dans le gestionnaire de modèles", + "t5Encoder": "Encodeur T5", + "learnMoreAboutSupportedModels": "En savoir plus sur les modèles que nous prenons en charge", + "includesNModels": "Contient {{n}} modèles et leurs dépendances", + "starterBundles": "Packs de démarrages", + "starterBundleHelpText": "Installe facilement tous les modèles nécessaire pour démarrer avec un modèle de base, incluant un modèle principal, ControlNets, IP Adapters et plus encore. Choisir un pack igniorera tous les modèles déjà installés.", + "installingXModels_one": "En cours d'installation de {{count}} modèle", + "installingXModels_many": "En cours d'installation de {{count}} modèles", + "installingXModels_other": "En cours d'installation de {{count}} modèles", + "skippingXDuplicates_one": ", en ignorant {{count}} doublon", + "skippingXDuplicates_many": ", en ignorant {{count}} doublons", + "skippingXDuplicates_other": ", en ignorant {{count}} doublons", + "installingModel": "Modèle en cours d'installation", + "installingBundle": "Pack en cours d'installation", + "noDefaultSettings": "Aucun paramètre par défaut configuré pour ce modèle. Visitez le Gestionnaire de Modèles pour ajouter des paramètres par défaut.", + "usingDefaultSettings": "Utilisation des paramètres par défaut du modèle", + "defaultSettingsOutOfSync": "Certain paramètres ne correspondent pas aux valeurs par défaut du modèle :", + "restoreDefaultSettings": "Cliquez pour utiliser les paramètres par défaut du modèle." + }, + "parameters": { + "images": "Images", + "steps": "Étapes", + "cfgScale": "Échelle CFG", + "width": "Largeur", + "height": "Hauteur", + "seed": "Graine", + "shuffle": "Nouvelle graine", + "noiseThreshold": "Seuil de Bruit", + "perlinNoise": "Bruit de Perlin", + "type": "Type", + "strength": "Force", + "upscaling": "Agrandissement", + "scale": "Échelle", + "imageFit": "Ajuster Image Initiale à la Taille de Sortie", + "scaleBeforeProcessing": "Échelle Avant Traitement", + "scaledWidth": "Larg. Échelle", + "scaledHeight": "Haut. Échelle", + "infillMethod": "Méthode de Remplissage", + "tileSize": "Taille des Tuiles", + "copyImage": "Copier Image", + "downloadImage": "Télécharger Image", + "usePrompt": "Utiliser la suggestion", + "useSeed": "Utiliser la graine", + "useAll": "Tout utiliser", + "info": "Info", + "showOptionsPanel": "Afficher le panneau latéral (O ou T)", + "invoke": { + "layer": { + "rgNoPromptsOrIPAdapters": "aucun prompts ou IP Adapters", + "t2iAdapterIncompatibleScaledBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, la largeur de la bounding box mise à l'échelle est {{width}}", + "t2iAdapterIncompatibleScaledBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, la hauteur de la bounding box mise à l'échelle est {{height}}", + "ipAdapterNoModelSelected": "aucun IP adapter sélectionné", + "ipAdapterNoImageSelected": "aucune image d'IP adapter sélectionnée", + "controlAdapterIncompatibleBaseModel": "modèle de base de Control Adapter incompatible", + "t2iAdapterIncompatibleBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, la hauteur de la bounding box est {{height}}", + "t2iAdapterIncompatibleBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, la largeur de la bounding box est {{width}}", + "ipAdapterIncompatibleBaseModel": "modèle de base d'IP adapter incompatible", + "rgNoRegion": "aucune zone sélectionnée", + "controlAdapterNoModelSelected": "aucun modèle de Control Adapter sélectionné" + }, + "noPrompts": "Aucun prompts généré", + "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} entrée manquante", + "missingFieldTemplate": "Modèle de champ manquant", + "invoke": "Invoke", + "addingImagesTo": "Ajouter des images à", + "missingNodeTemplate": "Modèle de nœud manquant", + "noModelSelected": "Aucun modèle sélectionné", + "noNodesInGraph": "Aucun nœud dans le graphique", + "systemDisconnected": "Système déconnecté", + "fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la hauteur de la bounding box est {{height}}", + "fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la hauteur de la bounding box est {{height}}", + "noFLUXVAEModelSelected": "Aucun modèle VAE sélectionné pour la génération FLUX", + "canvasIsTransforming": "La Toile se transforme", + "canvasIsRasterizing": "La Toile se rastérise", + "noCLIPEmbedModelSelected": "Aucun modèle CLIP Embed sélectionné pour la génération FLUX", + "canvasIsFiltering": "La Toile est en train de filtrer", + "fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la largeur de la bounding box est {{width}}", + "noT5EncoderModelSelected": "Aucun modèle T5 Encoder sélectionné pour la génération FLUX", + "fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), la largeur de la bounding box mise à l'échelle est {{width}}", + "canvasIsCompositing": "La toile est en train de composer" + }, + "negativePromptPlaceholder": "Prompt Négatif", + "positivePromptPlaceholder": "Prompt Positif", + "general": "Général", + "symmetry": "Symétrie", + "denoisingStrength": "Force de débruitage", + "scheduler": "Planificateur", + "clipSkip": "CLIP Skip", + "seamlessXAxis": "Axe X sans jointure", + "seamlessYAxis": "Axe Y sans jointure", + "controlNetControlMode": "Mode de Contrôle", + "patchmatchDownScaleSize": "Réduire", + "coherenceMode": "Mode", + "maskBlur": "Flou de masque", + "iterations": "Itérations", + "cancel": { + "cancel": "Annuler" + }, + "useCpuNoise": "Utiliser le bruit du CPU", + "imageActions": "Actions d'image", + "setToOptimalSize": "Optimiser la taille pour le modèle", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (peut être trop petit)", + "swapDimensions": "Échanger les dimensions", + "aspect": "Aspect", + "cfgRescaleMultiplier": "Multiplicateur de mise à l'échelle CFG", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (peut être trop grand)", + "useSize": "Utiliser la taille", + "remixImage": "Remixer l'image", + "lockAspectRatio": "Verrouiller le rapport hauteur/largeur", + "coherenceEdgeSize": "Taille du bord", + "infillColorValue": "Couleur de remplissage", + "coherenceMinDenoise": "Débruitage minimum", + "sendToCanvas": "Envoyer à la Toile", + "gaussianBlur": "Flou gaussien", + "boxBlur": "Flou de boîte", + "staged": "Mis en attente", + "optimizedImageToImage": "Image vers Image Optimisé", + "sendToUpscale": "Envoyer à Agrandir", + "guidance": "Guidage", + "postProcessing": "Post-traitement (Maj + U)", + "processImage": "Traiter l'image" + }, + "settings": { + "models": "Modèles", + "displayInProgress": "Afficher les images progressivement", + "confirmOnDelete": "Confirmer la suppression", + "resetWebUI": "Réinitialiser l'interface Web", + "resetWebUIDesc1": "Réinitialiser l'interface Web ne réinitialise que le cache local du navigateur de vos images et de vos paramètres enregistrés. Cela n'efface pas les images du disque.", + "resetWebUIDesc2": "Si les images ne s'affichent pas dans la galerie ou si quelque chose d'autre ne fonctionne pas, veuillez essayer de réinitialiser avant de soumettre une demande sur GitHub.", + "resetComplete": "L'interface Web a été réinitialisée.", + "general": "Général", + "showProgressInViewer": "Afficher les images progressivement dans le Visualiseur", + "antialiasProgressImages": "Anti Alisasing des Images progressives", + "beta": "Bêta", + "generation": "Génération", + "ui": "Interface Utilisateur", + "developer": "Développeur", + "enableNSFWChecker": "Activer le vérificateur NSFW", + "clearIntermediatesDesc2": "Les images intermédiaires sont des sous-produits de la génération, différentes des images de résultat dans la galerie. La suppression des intermédiaires libérera de l'espace disque.", + "clearIntermediatesDisabled": "La file d'attente doit être vide pour effacer les intermédiaires", + "reloadingIn": "Rechargement dans", + "intermediatesClearedFailed": "Problème de suppression des intermédiaires", + "clearIntermediates": "Effacer les intermédiaires", + "enableInvisibleWatermark": "Activer le Filigrane Invisible", + "clearIntermediatesDesc1": "Effacer les intermédiaires réinitialisera votre Toile et votre ControlNet.", + "enableInformationalPopovers": "Activer les infobulles d'information", + "intermediatesCleared_one": "Effacé {{count}} Intermédiaire", + "intermediatesCleared_many": "Effacé {{count}} Intermédiaires", + "intermediatesCleared_other": "Effacé {{count}} Intermédiaires", + "clearIntermediatesDesc3": "Vos images de galerie ne seront pas supprimées.", + "clearIntermediatesWithCount_one": "Effacé {{count}} Intermédiaire", + "clearIntermediatesWithCount_many": "Effacé {{count}} Intermédiaires", + "clearIntermediatesWithCount_other": "Effacé {{count}} Intermédiaires", + "informationalPopoversDisabled": "Pop-ups d'information désactivés", + "informationalPopoversDisabledDesc": "Les pop-ups d'information ont été désactivés. Activez-les dans les paramètres.", + "confirmOnNewSession": "Confirmer lors d'une nouvelle session", + "modelDescriptionsDisabledDesc": "Les descriptions des modèles dans les menus déroulants ont été désactivées. Activez-les dans les paramètres.", + "enableModelDescriptions": "Activer les descriptions de modèle dans les menus déroulants", + "modelDescriptionsDisabled": "Descriptions de modèle dans les menus déroulants désactivés" + }, + "toast": { + "uploadFailed": "Importation échouée", + "imageCopied": "Image copiée", + "parametersNotSet": "Paramètres non rappelés", + "serverError": "Erreur du serveur", + "uploadFailedInvalidUploadDesc": "Doit être des images au format PNG ou JPEG.", + "problemCopyingImage": "Impossible de copier l'image", + "parameterSet": "Paramètre Rappelé", + "parameterNotSet": "Paramètre non Rappelé", + "canceled": "Traitement annulé", + "addedToBoard": "Ajouté aux ressources de la planche {{name}}", + "workflowLoaded": "Workflow chargé", + "connected": "Connecté au serveur", + "setNodeField": "Définir comme champ de nœud", + "imageUploadFailed": "Échec de l'importation de l'image", + "loadedWithWarnings": "Workflow chargé avec des avertissements", + "imageUploaded": "Image importée", + "modelAddedSimple": "Modèle ajouté à la file d'attente", + "setControlImage": "Définir comme image de contrôle", + "workflowDeleted": "Workflow supprimé", + "baseModelChangedCleared_one": "Effacé ou désactivé {{count}} sous-modèle incompatible", + "baseModelChangedCleared_many": "Effacé ou désactivé {{count}} sous-modèles incompatibles", + "baseModelChangedCleared_other": "Effacé ou désactivé {{count}} sous-modèles incompatibles", + "invalidUpload": "Importation invalide", + "problemDownloadingImage": "Impossible de télécharger l'image", + "problemRetrievingWorkflow": "Problème de récupération du Workflow", + "problemDeletingWorkflow": "Problème de suppression du Workflow", + "prunedQueue": "File d'attente vidée", + "parameters": "Paramètres", + "modelImportCanceled": "Importation du modèle annulée", + "sentToCanvas": "Envoyé à la Toile", + "sentToUpscale": "Envoyé à l'Agrandissement", + "unableToLoadImage": "Impossible de charger l'image", + "unableToLoadImageMetadata": "Impossible de charger les métadonnées de l'image", + "errorCopied": "Erreur copiée", + "parametersSet": "Paramètres rappelés", + "somethingWentWrong": "Quelque chose a échoué", + "imageSaved": "Image enregistrée", + "unableToLoadStylePreset": "Impossible de charger le préréglage de style", + "stylePresetLoaded": "Préréglage de style chargé", + "parameterNotSetDescWithMessage": "Impossible de rappeler {{parameter}} : {{message}}", + "importFailed": "Importation échouée", + "imageSavingFailed": "Échec de l'enregistrement de l'image", + "importSuccessful": "Importation réussie", + "outOfMemoryError": "Erreur de mémoire insuffisante", + "sessionRef": "Session : {{sessionId}}", + "outOfMemoryErrorDesc": "Vos paramètres de génération actuels dépassent la capacité du système. Veuillez ajuster vos paramètres et réessayer.", + "parameterSetDesc": "Rappelé {{parameter}}", + "parameterNotSetDesc": "Impossible de rappeler {{parameter}}", + "layerCopiedToClipboard": "Calque copié dans le presse-papiers", + "layerSavedToAssets": "Calque enregistré dans les ressources", + "problemCopyingLayer": "Impossible de copier la couche", + "baseModelChanged": "Modèle de base changé", + "problemSavingLayer": "Impossible d'enregistrer la couche", + "imageNotLoadedDesc": "Image introuvable", + "linkCopied": "Lien copié", + "imagesWillBeAddedTo": "Les images Importées seront ajoutées au ressources de la Planche {{boardName}}.", + "uploadFailedInvalidUploadDesc_withCount_one": "Doit être au maximum une image PNG ou JPEG.", + "uploadFailedInvalidUploadDesc_withCount_many": "Doit être au maximum {{count}} images PNG ou JPEG.", + "uploadFailedInvalidUploadDesc_withCount_other": "Doit être au maximum {{count}} images PNG ou JPEG.", + "addedToUncategorized": "Ajouté aux ressources de la planche $t(boards.uncategorized)" + }, + "accessibility": { + "uploadImage": "Importer une image", + "reset": "Réinitialiser", + "nextImage": "Image suivante", + "previousImage": "Image précédente", + "invokeProgressBar": "Barre de Progression Invoke", + "menu": "Menu", + "about": "À propos", + "mode": "Mode", + "createIssue": "Créer un ticket", + "submitSupportTicket": "Envoyer un ticket de support", + "resetUI": "$t(accessibility.reset) l'Interface Utilisateur", + "toggleRightPanel": "Afficher/Masquer le panneau de droite (G)", + "toggleLeftPanel": "Afficher/Masquer le panneau de gauche (T)", + "uploadImages": "Importer Image(s)" + }, + "boards": { + "move": "Déplacer", + "cancel": "Annuler", + "loading": "Chargement…", + "archived": "Archivé", + "clearSearch": "Effacer la recherche", + "imagesWithCount_one": "{{count}} image", + "imagesWithCount_many": "{{count}} images", + "imagesWithCount_other": "{{count}} images", + "bottomMessage": "Supprimer cette planche et ses images va réinitialiser toutes les fonctionnalités les utilisant.", + "deleteBoardAndImages": "Supprimer la Planche et les Images", + "deleteBoardOnly": "Supprimer la Planche uniquement", + "assetsWithCount_one": "{{count}} ressource", + "assetsWithCount_many": "{{count}} ressources", + "assetsWithCount_other": "{{count}} ressources", + "selectedForAutoAdd": "Séléctioné pour Ajout Automatique", + "noMatching": "Pas de Planches correspondantes", + "myBoard": "Ma Planche", + "menuItemAutoAdd": "Ajouter automatiquement à cette Planche", + "changeBoard": "Changer de Planche", + "movingImagesToBoard_one": "Déplacer {{count}} image à cette planche :", + "movingImagesToBoard_many": "Déplacer {{count}} images à cette planche :", + "movingImagesToBoard_other": "Déplacer {{count}} image à cette planche :", + "viewBoards": "Voir les Planches", + "hideBoards": "Cacher les Planches", + "noBoards": "Pas de Planches {{boardType}}", + "shared": "Planches Partagées", + "searchBoard": "Chercher les Planches...", + "addSharedBoard": "Créer une Planche Partagée", + "addPrivateBoard": "Créer une Planche Privée", + "boards": "Planches", + "deletedPrivateBoardsCannotbeRestored": "Les planches supprimées ne peuvent pas être restaurées. Séléctionner 'Supprimer la planche uniquement' placera les images dans un état non catégorisé pour le créateur des images.", + "uncategorized": "Non catégorisé", + "downloadBoard": "Télécharger la Planche", + "private": "Planches Privées", + "deleteBoard": "Supprimer la Planche", + "autoAddBoard": "Création de Planche Automatique", + "addBoard": "Créer une Planche", + "topMessage": "Cette planche contient des images utilisée dans ces fonctionnalités :", + "selectBoard": "Séléctionner une Planche", + "archiveBoard": "Archiver la Planche", + "unarchiveBoard": "Déarchiver la Planche", + "deletedBoardsCannotbeRestored": "Les planches supprimées ne peuvent pas être restaurées. Séléctionner 'Supprimer la planche uniquement' placera les images dans un état non catégorisé.", + "updateBoardError": "Erreur de mise à jour de la planche" + }, + "accordions": { + "advanced": { + "title": "Avancé", + "options": "Options $t(accordions.advanced.title)" + }, + "image": { + "title": "Image" + }, + "compositing": { + "title": "Composition", + "coherenceTab": "Passe de Cohérence", + "infillTab": "Remplissage" + }, + "generation": { + "title": "Génération" + }, + "control": { + "title": "Controle" + } + }, + "queue": { + "clear": "Effacer", + "failed": "Échec", + "session": "Session", + "queueEmpty": "File d'attente vide", + "next": "Suivant", + "queue": "File d'attente", + "clearSucceeded": "File d'attente effacée", + "total": "Total", + "pending": "En attente", + "in_progress": "En cours", + "time": "Heure", + "status": "État", + "openQueue": "Ouvrir la file d'attente", + "queueFront": "Ajouter en premier", + "cancel": "Annuler", + "canceled": "Annulé", + "clearQueueAlertDialog2": "Voulez-vous vraiment effacer la file d'attente ?", + "queueBack": "Ajouter à la file d'attente", + "completed": "Terminé", + "pauseSucceeded": "Traitement intérompu", + "cancelBatchFailed": "Problème lors de l'annulation du Lot", + "resumeTooltip": "Reprendre le traitement", + "resumeFailed": "Problème lors de la reprise du traitement", + "cancelItem": "Annuler l'élément", + "pruneSucceeded": "Purgé {{item_count}} éléments complété de la file d'attente", + "cancelTooltip": "Annuler l'élément actuel", + "current": "Actuel", + "pause": "Pause", + "clearTooltip": "Annuler et Effacer tous les éléments", + "pauseFailed": "Problème lors de l'intéruption du traitement", + "cancelBatch": "Annuler le Lot", + "pauseTooltip": "Intérrompre le traitement", + "prune": "Purger", + "pruneFailed": "Problème lors du Purgeage de la file d'attente", + "clearQueueAlertDialog": "Effacer la file d'attente immédiatement annule tous les éléments en cours de traitement et efface entièrement la file d'attente. Les filtres en attente seront également annulés.", + "pruneTooltip": "Purger {{item_count}} élémentscomplétés", + "cancelSucceeded": "Élément annulé", + "cancelFailed": "Problème lors de l'annulation de l'élément", + "clearFailed": "Problème lors de l'Effacement de la file d'attente", + "cancelBatchSucceeded": "Lot Annulé", + "resume": "Reprendre", + "resumeSucceeded": "Traitement repris", + "enqueueing": "Ajout du Lot à la file d'attente", + "origin": "Origine", + "destination": "Destination", + "batch": "Lot", + "completedIn": "Complété en", + "upscaling": "Agrandissement", + "canvas": "Toile", + "batchQueuedDesc_one": "Ajouté {{count}} session à {{direction}} de la file d'attente", + "batchQueuedDesc_many": "Ajouté {{count}} sessions à {{direction}} de la file d'attente", + "batchQueuedDesc_other": "Ajouté {{count}} sessions à {{direction}} de la file d'attente", + "prompts_one": "Prompt", + "prompts_many": "Prompts", + "prompts_other": "Prompts", + "batchQueued": "Lot ajouté à la file d'attente", + "gallery": "Galerie", + "notReady": "Impossible d'ajouter à la file d'attente", + "batchFieldValues": "Valeurs Champ Lot", + "front": "début", + "graphQueued": "Graph ajouté à la file d'attente", + "other": "Autre", + "generation": "Génération", + "workflows": "Workflows", + "batchFailedToQueue": "Impossible d'ajouter le Lot dans à la file d'attente", + "graphFailedToQueue": "Impossible d'ajouter le graph à la file d'attente", + "item": "Élément", + "generations_one": "Génération", + "generations_many": "Générations", + "generations_other": "Générations", + "iterations_one": "Itération", + "iterations_many": "Itérations", + "iterations_other": "Itérations", + "back": "fin" + }, + "prompt": { + "noMatchingTriggers": "Pas de déclancheurs correspondants", + "addPromptTrigger": "Ajouter un déclencheur de Prompt", + "compatibleEmbeddings": "Embeddings Compatibles" + }, + "hrf": { + "upscaleMethod": "Méthode d'Agrandissement", + "metadata": { + "enabled": "Correction Haute Résolution Activée", + "strength": "Force de la Correction Haute Résolution", + "method": "Méthode de la Correction Haute Résolution" + }, + "enableHrf": "Activer la Correction Haute Résolution", + "hrf": "Correction Haute Résolution" + }, + "invocationCache": { + "clear": "Vider", + "useCache": "Utiliser le Cache", + "invocationCache": "Cache des Invocations", + "enableFailed": "Problème lors de l'activation du Cache d'Invocation", + "enable": "Activer", + "enableSucceeded": "Cache d'Invocation Activé", + "clearSucceeded": "Cache d'Invocation vidé", + "disable": "Désactiver", + "disableSucceeded": "Cache d'Invocation désactivé", + "maxCacheSize": "Taille du Cache maximum", + "misses": "Non trouvé dans le Cache", + "clearFailed": "Problème lors du vidage du Cache d'Invocation", + "cacheSize": "Taille du Cache", + "hits": "Trouvé dans le Cache", + "disableFailed": "Problème lors de la désactivation du Cache d'Invocation" + }, + "hotkeys": { + "hotkeys": "Raccourci clavier", + "viewer": { + "recallPrompts": { + "desc": "Rappeler le prompt positif et négatif pour l'image actuelle.", + "title": "Rappeler les Prompts" + }, + "nextComparisonMode": { + "desc": "Faire défiler les modes de comparaison.", + "title": "Mode de comparaison suivant" + }, + "runPostprocessing": { + "title": "Exécuter le post-traitement", + "desc": "Exécute le post-traitement sélectionné sur l'image actuelle." + }, + "toggleViewer": { + "title": "Afficher/Masquer le visualiseur d'images", + "desc": "Afficher ou masquer le visualiseur d'images. Disponible uniquement dans l'onglet Toile." + }, + "swapImages": { + "title": "Échanger les images de comparaison", + "desc": "Échange les images comparées." + }, + "title": "Visualiseur d'images", + "recallAll": { + "title": "Rappeler toutes les métadonnées", + "desc": "Rappelle toutes les métadonnées pour l'image actuelle." + }, + "loadWorkflow": { + "title": "Ouvrir un Workflow", + "desc": "Charge le workflow enregistré lié à l'image actuelle (s'il en a un)." + }, + "recallSeed": { + "desc": "Rappelle la graine pour l'image actuelle.", + "title": "Rappeler la graine" + }, + "useSize": { + "title": "Utiliser la taille", + "desc": "Utilisez la taille de l'image actuelle comme taille de la bounding box." + }, + "toggleMetadata": { + "title": "Afficher/Masquer les métadonnées", + "desc": "Affiche ou masque la superposition des métadonnées de l'image actuelle." + }, + "remix": { + "title": "Remixer", + "desc": "Rappelle toutes les métadonnées sauf la graine pour l'image actuelle." + } + }, + "searchHotkeys": "Recherche raccourci clavier", + "app": { + "selectQueueTab": { + "desc": "Selectionne l'onglet de file d'attente.", + "title": "Sélectionner l'onglet File d'Attente" + }, + "title": "Application", + "invoke": { + "title": "Invoke", + "desc": "Ajouter une génération à la fin de la file d'attente." + }, + "invokeFront": { + "title": "Invoke (Front)", + "desc": "Ajouter une génération au début de la file d'attente." + }, + "cancelQueueItem": { + "title": "Annuler", + "desc": "Annuler l'élément en cours de traitement dans la file d'attente." + }, + "clearQueue": { + "title": "Vider la file d'attente", + "desc": "Annuler et retirer tous les éléments de la file d'attente." + }, + "selectCanvasTab": { + "title": "Séléctionner l'onglet Toile", + "desc": "Séléctionne l'onglet Toile." + }, + "selectUpscalingTab": { + "title": "Séléctionner l'onglet Agrandissement", + "desc": "Séléctionne l'onglet Agrandissement." + }, + "selectWorkflowsTab": { + "desc": "Sélectionne l'onglet Workflows.", + "title": "Sélectionner l'onglet Workflows" + }, + "togglePanels": { + "desc": "Affiche ou masque les panneaux gauche et droit en même temps.", + "title": "Afficher/Masquer les panneaux" + }, + "selectModelsTab": { + "desc": "Sélectionne l'onglet Modèles.", + "title": "Sélectionner l'onglet Modèles" + }, + "focusPrompt": { + "title": "Selectionne le Prompt", + "desc": "Déplace le focus du curseur sur le prompt positif." + }, + "toggleLeftPanel": { + "title": "Afficher/Masquer le panneau de gauche", + "desc": "Affiche ou masque le panneau de gauche." + }, + "resetPanelLayout": { + "desc": "Réinitialise les panneaux gauche et droit à leur taille et disposition par défaut.", + "title": "Reinitialiser l'organisation des panneau" + }, + "toggleRightPanel": { + "title": "Afficher/Masquer le panneau de droite", + "desc": "Affiche ou masque le panneau de droite." + } + }, + "canvas": { + "title": "Toile", + "selectBrushTool": { + "title": "Outil Pinceau", + "desc": "Sélectionne l'outil pinceau." + }, + "incrementToolWidth": { + "title": "Augmenter largeur de l'outil", + "desc": "Augmente la largeur du pinceau ou de la gomme, en fonction de la sélection." + }, + "selectColorPickerTool": { + "title": "Outil Pipette", + "desc": "Sélectionne l'outil pipette pour la sélection de couleur." + }, + "selectEraserTool": { + "title": "Outil Gomme", + "desc": "Sélectionne l'outil gomme." + }, + "selectMoveTool": { + "title": "Outil Déplacer", + "desc": "Sélectionne l'outil déplacer." + }, + "selectRectTool": { + "title": "Outil Rectangle", + "desc": "Sélectionne l'outil rectangle." + }, + "selectViewTool": { + "title": "Outil Visualisation", + "desc": "Sélectionne l'outil visualisation." + }, + "selectBboxTool": { + "title": "Outil Cadre de délimitation", + "desc": "Sélectionne l'outil cadre de délimitation." + }, + "fitLayersToCanvas": { + "title": "Adapte les Couches à la Toile", + "desc": "Mettre à l'échelle et positionner la vue pour l'adapter à tous les couches visibles." + }, + "fitBboxToCanvas": { + "desc": "Ajuster l'échelle et la position de la vue pour s'adapter au cadre de délimitation.", + "title": "Ajuster le cadre de délimitation à la Toile" + }, + "decrementToolWidth": { + "title": "Réduire largeur de l'outil", + "desc": "Réduit la largeur du pinceau ou de la gomme, en fonction de la sélection." + }, + "setZoomTo800Percent": { + "title": "Zoomer à 800 %", + "desc": "Définit le zoom de la toile à 800 %." + }, + "setZoomTo400Percent": { + "desc": "Définit le zoom de la toile à 400 %.", + "title": "Zoomer à 400 %" + }, + "setFillToWhite": { + "title": "Définir la couleur sur blanc", + "desc": "Définir la couleur de l'outil actuel sur blanc." + }, + "transformSelected": { + "title": "Transformer", + "desc": "Transforme la couche sélectionnée." + }, + "quickSwitch": { + "title": "Commutateur rapide de couche", + "desc": "Alterner entre les deux dernières couches sélectionnées. Si une couche est marquée, alternez toujours entre celle-ci et la dernière couche non marquée." + }, + "setZoomTo200Percent": { + "desc": "Définit le zoom de la toile à 200 %.", + "title": "Zoomer à 200 %" + }, + "filterSelected": { + "title": "Filtrer", + "desc": "Filtre la couche sélectionnée. S'applique uniquement aux couches de rastérisation et de contrôle." + }, + "setZoomTo100Percent": { + "title": "Zoomer à 100 %", + "desc": "Définir le zoom de la toile à 100 %." + }, + "cancelTransform": { + "desc": "Annule la transformation en attente.", + "title": "Annuler la transformation" + }, + "applyTransform": { + "desc": "Applique la transformation en attente à la couche sélectionnée.", + "title": "Appliquer la transformation" + }, + "cancelFilter": { + "title": "Annuler le filtre", + "desc": "Annule le filtre en attente." + }, + "applyFilter": { + "title": "Appliquer le filtre", + "desc": "Applique le filtre en attente à la couche sélectionnée." + }, + "deleteSelected": { + "title": "Supprimer la couche", + "desc": "Supprime la couche sélectionnée." + }, + "resetSelected": { + "title": "Réinitialiser la couche", + "desc": "Réinitialiser la couche sélectionnée. S'applique uniquement au masque de retouche et au guidage régional." + }, + "undo": { + "title": "Annuler", + "desc": "Annule la dernière action sur la toile." + }, + "nextEntity": { + "desc": "Sélectionne la couche suivante dans la liste.", + "title": "Couche suivante" + }, + "redo": { + "title": "Rétablir", + "desc": "Rétablir la dernière action sur la toile." + }, + "prevEntity": { + "title": "Couche Précédente", + "desc": "Sélectionne la couche précédente dans la liste." + } + }, + "clearSearch": "Annuler la recherche", + "noHotkeysFound": "Aucun raccourci clavier trouvé", + "gallery": { + "deleteSelection": { + "desc": "Supprime toutes les images séléctionnées. Par défault une confirmation vous sera demandée. Si les images sont actuellement utilisées dans l'application vous serez mis en garde.", + "title": "Supprimer" + }, + "galleryNavRightAlt": { + "title": "Naviguer à droite (Comparaison d'Image)", + "desc": "Identique à Naviguer à droite, mais sélectionne l'image de comparaison, ouvrant le mode de comparaison s'il n'est pas déjà ouvert." + }, + "galleryNavUpAlt": { + "desc": "Identique à \"Naviguer vers le haut\", mais sélectionne l'image de comparaison, ouvrant le mode de comparaison s'il n'est pas déjà ouvert.", + "title": "Naviguer vers le haut (Comparaison d'Image)" + }, + "galleryNavDownAlt": { + "title": "Naviguer vers le bas (Comparaison d'Image)", + "desc": "Identique à Naviguer vers le bas, mais sélectionne l'image de comparaison, ouvrant le mode de comparaison s'il n'est pas déjà ouvert." + }, + "galleryNavRight": { + "title": "Naviguer à droite", + "desc": "Navigue vers la droite dans la grille de la galerie, en sélectionnant cette image. Si vous êtes à la dernière image de la ligne, passez à la ligne suivante. Si vous êtes à la dernière image de la page, passe à la page suivante." + }, + "selectAllOnPage": { + "desc": "Sélectionne toutes les images sur la page actuelle.", + "title": "Sélectionner tout sur la page" + }, + "clearSelection": { + "title": "Effacer la sélection", + "desc": "Efface la sélection actuelle, le cas échéant." + }, + "galleryNavLeft": { + "title": "Naviguer à gauche", + "desc": "Navigue vers la gauche dans la grille de la galerie, en sélectionnant cette image. Si vous êtes à la première image de la ligne, allez à la ligne précédente. Si vous êtes à la première image de la page, va à la page précédente." + }, + "galleryNavDown": { + "desc": "Navigue vers le bas dans la grille de la galerie, en sélectionnant cette image. Si vous êtes en bas de la page, va à la page suivante.", + "title": "Naviguer vers le bas" + }, + "galleryNavLeftAlt": { + "title": "Naviguer à gauche (Comparaison d'Image)", + "desc": "Identique à Naviguer à gauche, mais sélectionne l'image de comparaison, ouvrant le mode de comparaison s'il n'est pas déjà ouvert." + }, + "title": "Galerie", + "galleryNavUp": { + "title": "Naviguer vers le haut", + "desc": "Navigue vers le haut dans la grille de la galerie, en sélectionnant cette image. Si vous êtes en haut de la page, va à la page précédente." + } + }, + "workflows": { + "selectAll": { + "title": "Sélectionner tout", + "desc": "Sélectionne tous les nœuds et connexions." + }, + "deleteSelection": { + "title": "Supprimer", + "desc": "Supprime les nœuds et les connexions sélectionnés." + }, + "undo": { + "title": "Annuler", + "desc": "Annule la dernière action de workflow." + }, + "redo": { + "title": "Rétablir", + "desc": "Rétablit la dernière action de workflow." + }, + "addNode": { + "desc": "Ouvre le menu d'ajout de nœud.", + "title": "Ajouter un nœud" + }, + "pasteSelectionWithEdges": { + "title": "Coller avec connections", + "desc": "Colle les nœuds copiés, les arêtes et toutes les connections des nœuds copiés." + }, + "copySelection": { + "desc": "Copie les nœuds et les connections sélectionnés.", + "title": "Copier" + }, + "pasteSelection": { + "desc": "Colle les nœuds et les connections copiés.", + "title": "Coller" + }, + "title": "Workflows" + } + }, + "popovers": { + "paramPositiveConditioning": { + "paragraphs": [ + "Guide le processus de génération. Vous pouvez utiliser n'importe quels mots ou phrases.", + "Prend en charge les syntaxes et les embeddings de Compel et des Prompts dynamiques." + ], + "heading": "Prompt Positif" + }, + "paramNegativeConditioning": { + "paragraphs": [ + "Le processus de génération évite les concepts dans le prompt négatif. Utilisez cela pour exclure des qualités ou des objets du résultat.", + "Prend en charge la syntaxe et les embeddings de Compel." + ], + "heading": "Prompt Négatif" + }, + "paramVAEPrecision": { + "heading": "Précision du VAE", + "paragraphs": [ + "La précision utilisée lors de l'encodage et du décodage VAE.", + "La pr'ecision Fp16/Half est plus efficace, au détriment de légères variations d'image." + ] + }, + "controlNetWeight": { + "heading": "Poids", + "paragraphs": [ + "Poids du Control Adapter. Un poids plus élevé aura un impact plus important sur l'image finale." + ] + }, + "compositingMaskAdjustments": { + "heading": "Ajustements de masque", + "paragraphs": [ + "Ajuste le masque." + ] + }, + "infillMethod": { + "heading": "Méthode de Remplissage", + "paragraphs": [ + "Méthode de remplissage lors du processus d'Outpainting ou d'Inpainting." + ] + }, + "clipSkip": { + "paragraphs": [ + "Combien de couches du modèle CLIP faut-il ignorer.", + "Certains modèles sont mieux adaptés à une utilisation avec CLIP Skip." + ], + "heading": "CLIP Skip" + }, + "paramScheduler": { + "heading": "Planificateur", + "paragraphs": [ + "Planificateur utilisé pendant le processus de génération.", + "Chaque planificateur définit comment ajouter de manière itérative du bruit à une image ou comment mettre à jour un échantillon en fonction de la sortie d'un modèle." + ] + }, + "controlNet": { + "paragraphs": [ + "Les ControlNets fournissent des indications au processus de génération, aidant à créer des images avec une composition, une structure ou un style contrôlés, en fonction du modèle sélectionné." + ], + "heading": "ControlNet" + }, + "paramSteps": { + "heading": "Étapes", + "paragraphs": [ + "Nombre d'étapes qui seront effectuées à chaque génération.", + "Des nombres d'étapes plus élevés créeront généralement de meilleures images, mais nécessiteront plus de temps de génération." + ] + }, + "controlNetBeginEnd": { + "heading": "Pourcentage de début / de fin d'étape", + "paragraphs": [ + "La partie du processus de débruitage à laquelle le Control Adapter sera appliqué.", + "En général, les Control Adapter appliqués au début du processus guident la composition, tandis que les Control Adapter appliqués à la fin guident les détails." + ] + }, + "controlNetControlMode": { + "paragraphs": [ + "Accordez plus de poids soit au prompt, soit au ControlNet." + ], + "heading": "Mode de Contrôle" + }, + "dynamicPromptsSeedBehaviour": { + "heading": "Comportement de la graine", + "paragraphs": [ + "Contrôle l'utilisation de la graine lors de la génération des prompts.", + "Une graine unique pour chaque itération. Utilisez ceci pour explorer les variations de prompt sur une seule graine.", + "Par exemple, si vous avez 5 prompts, chaque image utilisera la même graine.", + "Par image utilisera une graine unique pour chaque image. Cela offre plus de variation." + ] + }, + "paramVAE": { + "heading": "VAE", + "paragraphs": [ + "Modèle utilisé pour convertir la sortie de l'IA en l'image finale." + ] + }, + "compositingCoherenceMode": { + "heading": "Mode", + "paragraphs": [ + "Méthode utilisée pour créer une image cohérente avec la zone masquée nouvellement générée." + ] + }, + "paramIterations": { + "heading": "Itérations", + "paragraphs": [ + "Le nombre d'images à générer.", + "Si les prompts dynamiques sont activées, chaque prompt sera généré autant de fois." + ] + }, + "dynamicPrompts": { + "paragraphs": [ + "Les Prompts dynamiques divisent un seul prompt en plusieurs.", + "La syntaxe de base est \"une balle {rouge|verte|bleue}\". Cela produira trois prompts : \"une balle rouge\", \"une balle verte\" et \"une balle bleue\".", + "Vous pouvez utiliser la syntaxe autant de fois que vous le souhaitez dans un seul prompt, mais veillez à garder le nombre de prompts générées sous contrôle avec le paramètre Max Prompts." + ], + "heading": "Prompts Dynamiques" + }, + "paramModel": { + "heading": "Modèle", + "paragraphs": [ + "Modèle utilisé pour la génération. Différents modèles sont entraînés pour se spécialiser dans la production de résultats esthétiques et de contenus variés." + ] + }, + "compositingCoherencePass": { + "heading": "Passe de cohérence", + "paragraphs": [ + "Un deuxième tour de débruitage aide à composer l'image remplie/étendue." + ] + }, + "paramRatio": { + "heading": "Rapport hauteur/largeur", + "paragraphs": [ + "Le rapport hauteur/largeur de l'image générée.", + "Une taille d'image (en nombre de pixels) équivalente à 512x512 est recommandée pour les modèles SD1.5 et une taille équivalente à 1024x1024 est recommandée pour les modèles SDXL." + ] + }, + "paramSeed": { + "heading": "Graine", + "paragraphs": [ + "Contrôle le bruit de départ utilisé pour la génération.", + "Désactivez l'option \"Aléatoire\" pour produire des résultats identiques avec les mêmes paramètres de génération." + ] + }, + "scaleBeforeProcessing": { + "heading": "Échelle avant traitement", + "paragraphs": [ + "\"Auto\" ajuste la zone sélectionnée à la taille la mieux adaptée au modèle avant le processus de génération d'image.", + "\"Manuel\" vous permet de choisir la largeur et la hauteur auxquelles la zone sélectionnée sera redimensionnée avant le processus de génération d'image." + ] + }, + "compositingBlurMethod": { + "heading": "Méthode de flou", + "paragraphs": [ + "La méthode de flou appliquée à la zone masquée." + ] + }, + "controlNetResizeMode": { + "heading": "Mode de Redimensionnement", + "paragraphs": [ + "Méthode pour adapter la taille de l'image d'entrée du Control Adapter à la taille de l'image générée." + ] + }, + "dynamicPromptsMaxPrompts": { + "heading": "Max Prompts", + "paragraphs": [ + "Limite le nombre de prompts pouvant être générés par les Prompts Dynamiques." + ] + }, + "paramDenoisingStrength": { + "heading": "Force de débruitage", + "paragraphs": [ + "Intensité du bruit ajouté à l'image d'entrée.", + "0 produira une image identique, tandis que 1 produira une image complètement différente." + ] + }, + "lora": { + "heading": "LoRA", + "paragraphs": [ + "Modèles légers utilisés en conjonction avec des modèles de base." + ] + }, + "noiseUseCPU": { + "heading": "Utiliser le bruit du CPU", + "paragraphs": [ + "Contrôle si le bruit est généré sur le CPU ou le GPU.", + "Avec le bruit du CPU activé, une graine particulière produira la même image sur n'importe quelle machine.", + "Il n'y a aucun impact sur les performances à activer le bruit du CPU." + ] + }, + "paramCFGScale": { + "heading": "Échelle CFG", + "paragraphs": [ + "Contrôle de l'influence du prompt sur le processus de génération.", + "Des valeurs élevées de l'échelle CFG peuvent entraîner une saturation excessive et des distortions. " + ] + }, + "loraWeight": { + "heading": "Poids", + "paragraphs": [ + "Poids du LoRA. Un poids plus élevé aura un impact plus important sur l'image finale." + ] + }, + "imageFit": { + "heading": "Ajuster l'image initiale à la taille de sortie", + "paragraphs": [ + "Redimensionne l'image initiale à la largeur et à la hauteur de l'image de sortie. Il est recommandé de l'activer." + ] + }, + "paramCFGRescaleMultiplier": { + "heading": "Multiplicateur de mise à l'échelle CFG", + "paragraphs": [ + "Multiplicateur de mise à l'échelle pour le guidage CFG, utilisé pour les modèles entraînés en utilisant le zero-terminal SNR (ztsnr).", + "Une valeur de 0.7 est suggérée pour ces modèles." + ] + }, + "controlNetProcessor": { + "heading": "Processeur", + "paragraphs": [ + "Méthode de traitement de l'image d'entrée pour guider le processus de génération. Différents processeurs fourniront différents effets ou styles dans vos images générées." + ] + }, + "paramUpscaleMethod": { + "paragraphs": [ + "Méthode utilisée pour améliorer l'image pour la correction de haute résolution." + ], + "heading": "Méthode d'agrandissement" + }, + "refinerModel": { + "heading": "Modèle de Raffinage", + "paragraphs": [ + "Modèle utilisé pendant la partie raffinage du processus de génération.", + "Similaire au Modèle de Génération." + ] + }, + "paramWidth": { + "paragraphs": [ + "Largeur de l'image générée. Doit être un multiple de 8." + ], + "heading": "Largeur" + }, + "paramHeight": { + "heading": "Hauteur", + "paragraphs": [ + "Hauteur de l'image générée. Doit être un multiple de 8." + ] + }, + "paramHrf": { + "heading": "Activer la correction haute résolution", + "paragraphs": [ + "Générez des images de haute qualité à une résolution plus grande que celle qui est optimale pour le modèle. Cela est généralement utilisé pour prévenir la duplication dans l'image générée." + ] + }, + "patchmatchDownScaleSize": { + "paragraphs": [ + "Intensité du sous-échantillonage qui se produit avant le remplissage.", + "Un sous-échantillonage plus élevé améliorera les performances et réduira la qualité." + ], + "heading": "Sous-échantillonage" + }, + "paramAspect": { + "paragraphs": [ + "Rapport hauteur/largeur de l'image générée. Changer le rapport mettra à jour la largeur et la hauteur en conséquence.", + "\"Optimiser\" définira la largeur et la hauteur aux dimensions optimales pour le modèle choisi." + ], + "heading": "Aspect" + }, + "refinerScheduler": { + "heading": "Planificateur", + "paragraphs": [ + "Planificateur utilisé pendant la partie de raffinage du processus de génération.", + "Semblable au Planificateur de Génération." + ] + }, + "refinerPositiveAestheticScore": { + "paragraphs": [ + "Ajoute un biais envers les générations pour qu'elles soient plus similaires aux images ayant un score esthétique élevé, en fonction des données d'entraînement." + ], + "heading": "Score Esthétique Positif" + }, + "refinerNegativeAestheticScore": { + "heading": "Score Esthétique Négatif", + "paragraphs": [ + "Ajoute un biais envers les générations pour qu'elles soient plus similaires aux images ayant un faible score esthétique, en fonction des données d'entraînement." + ] + }, + "seamlessTilingYAxis": { + "paragraphs": [ + "Concaténer une image sans bord le long de l'axe vertical." + ], + "heading": "Concaténation sans bord axe Y" + }, + "compositingCoherenceMinDenoise": { + "paragraphs": [ + "Force de débruitage minimale pour le mode de cohérence", + "La force minimale de débruitage pour la région de cohérence lors de l'inpainting ou de l'outpainting" + ], + "heading": "Débruitage minimum" + }, + "refinerStart": { + "paragraphs": [ + "À quel moment du processus de génération le raffineur commencera-t-il à être utilisé.", + "0 signifie que le raffineur sera utilisé pour l'ensemble du processus de génération, 0,8 signifie que le raffineur sera utilisé pour les 20 % restants du processus de génération." + ], + "heading": "Démarrer le raffineur" + }, + "compositingMaskBlur": { + "heading": "Flou de masque", + "paragraphs": [ + "Le rayon de flou du masque." + ] + }, + "refinerSteps": { + "paragraphs": [ + "Nombre d'étapes qui seront effectuées pendant la partie de raffinage du processus de génération.", + "Similaire aux Étapes de Génération." + ], + "heading": "Étapes" + }, + "refinerCfgScale": { + "paragraphs": [ + "Contrôle dans quelle mesure le prompt influence le processus de génération.", + "Similaire à l'échelle de génération CFG." + ], + "heading": "Échelle CFG" + }, + "compositingCoherenceEdgeSize": { + "paragraphs": [ + "La taille de bord du passage de cohérence." + ], + "heading": "Taille de bord" + }, + "seamlessTilingXAxis": { + "heading": "Concaténation sans bord axe X", + "paragraphs": [ + "Concaténer une image de manière fluide le long de l'axe horizontal." + ] + }, + "creativity": { + "paragraphs": [ + "La créativité contrôle la quantité de liberté accordée au modèle lors de l'ajout de détails. Une faible créativité reste proche de l'image originale, tandis qu'une forte créativité permet plus de changements. Lors de l'utilisation d'un prompt, une forte créativité augmente l'influence du prompt." + ], + "heading": "Créativité" + }, + "structure": { + "heading": "Structure", + "paragraphs": [ + "La structure contrôle à quel point l'image de sortie respectera la mise en page de l'originale. Une faible structure permet des changements majeurs, tandis qu'une forte structure maintient strictement la composition et la mise en page d'origine." + ] + }, + "fluxDevLicense": { + "heading": "Licence non commerciale", + "paragraphs": [ + "Les modèles FLUX.1 [dev] sont sous licence non commerciale FLUX [dev]. Pour utiliser ce type de modèle à des fins commerciales dans Invoke, visitez notre site web pour en savoir plus." + ] + }, + "optimizedDenoising": { + "heading": "Image vers Image Optimisé", + "paragraphs": [ + "Activez « Image-vers-image optimisé » pour une échelle de force de débruitage plus progressive pour les transformations image-vers-image et d'inpainting avec les modèles Flux. Ce paramètre améliore la capacité à contrôler la quantité de changement appliquée à une image, mais peut être désactivé si vous préférez utiliser l'échelle de force de débruitage standard. Ce paramètre est encore en cours d'ajustement et est en bêta." + ] + }, + "upscaleModel": { + "paragraphs": [ + "Le modèle d'agrandissement redimensionne l'image à la taille de sortie avant que les détails ne soient ajoutés. Tout modèle d'agrandissement pris en charge peut être utilisé, mais certains sont spécialisés pour différents types d'images, comme les photos ou les dessins." + ], + "heading": "Modèle d'agrandissement" + }, + "ipAdapterMethod": { + "heading": "Méthode", + "paragraphs": [ + "Méthode pour appliquer l'adaptateur IP actuel." + ] + }, + "scale": { + "heading": "Échelle", + "paragraphs": [ + "L'échelle contrôle la taille de l'image de sortie et est basée sur un multiple de la résolution de l'image d'entrée. Par exemple, un agrandissement 2x sur une image de 1024x1024 produirait une sortie de 2048 x 2048." + ] + }, + "paramGuidance": { + "paragraphs": [ + "Contrôle de l'influence du prompt sur le processus de génération.", + "Des valeurs de guidage élevées peuvent entraîner une saturation excessive, et un guidage élevé ou faible peut entraîner des résultats de génération déformés. Le guidage ne s'applique qu'aux modèles FLUX DEV." + ], + "heading": "Guidage" + }, + "globalReferenceImage": { + "heading": "Image de Référence Globale", + "paragraphs": [ + "Applique une image de référence pour influencer l'ensemble de la génération." + ] + }, + "regionalReferenceImage": { + "heading": "Image de Référence Régionale", + "paragraphs": [ + "Pinceau pour appliquer une image de référence à des zones spécifiques." + ] + }, + "inpainting": { + "heading": "Inpainting", + "paragraphs": [ + "Contrôle la zone qui est modifiée, guidé par la force de débruitage." + ] + }, + "regionalGuidance": { + "heading": "Guide Régional", + "paragraphs": [ + "Pinceau pour guider l'emplacement des éléments provenant des prompts globaux." + ] + }, + "regionalGuidanceAndReferenceImage": { + "heading": "Guide régional et image de référence régionale", + "paragraphs": [ + "Pour le Guide Régional, utilisez le pinceau pour indiquer où les éléments des prompts globaux doivent apparaître.", + "Pour l'image de référence régionale, pinceau pour appliquer une image de référence à des zones spécifiques." + ] + }, + "rasterLayer": { + "heading": "Couche Rastérisation", + "paragraphs": [ + "Contenu basé sur les pixels de votre toile, utilisé lors de la génération d'images." + ] + } + }, + "dynamicPrompts": { + "seedBehaviour": { + "label": "Comportement de la graine", + "perPromptDesc": "Utiliser une graine différente pour chaque image", + "perIterationLabel": "Graine par Itération", + "perIterationDesc": "Utiliser une graine différente pour chaque itération", + "perPromptLabel": "Graine par Image" + }, + "maxPrompts": "Nombre maximum de Prompts", + "showDynamicPrompts": "Afficher les Prompts dynamiques", + "dynamicPrompts": "Prompts Dynamiques", + "promptsPreview": "Prévisualisation des Prompts", + "loading": "Génération des Pompts Dynamiques..." + }, + "metadata": { + "positivePrompt": "Prompt Positif", + "allPrompts": "Tous les Prompts", + "negativePrompt": "Prompt Négatif", + "metadata": "Métadonné", + "scheduler": "Planificateur", + "imageDetails": "Détails de l'Image", + "seed": "Graine", + "workflow": "Workflow", + "width": "Largeur", + "Threshold": "Seuil de bruit", + "noMetaData": "Aucune métadonnée trouvée", + "model": "Modèle", + "noImageDetails": "Aucun détail d'image trouvé", + "steps": "Étapes", + "cfgScale": "Échelle CFG", + "generationMode": "Mode Génération", + "height": "Hauteur", + "createdBy": "Créé par", + "strength": "Force d'image à image", + "vae": "VAE", + "noRecallParameters": "Aucun paramètres à rappeler trouvé", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "recallParameters": "Rappeler les paramètres", + "imageDimensions": "Dimensions de l'image", + "parameterSet": "Paramètre {{parameter}} défini", + "parsingFailed": "L'analyse a échoué", + "recallParameter": "Rappeler {{label}}", + "canvasV2Metadata": "Toile", + "guidance": "Guide" + }, + "sdxl": { + "freePromptStyle": "Écriture de Prompt manuelle", + "concatPromptStyle": "Lier Prompt & Style", + "negStylePrompt": "Style Prompt Négatif", + "posStylePrompt": "Style Prompt Positif", + "refinerStart": "Démarrer le Refiner", + "denoisingStrength": "Force de débruitage", + "steps": "Étapes", + "refinermodel": "Modèle de Refiner", + "scheduler": "Planificateur", + "cfgScale": "Échelle CFG", + "noModelsAvailable": "Aucun modèle disponible", + "posAestheticScore": "Score esthétique positif", + "loading": "Chargement...", + "negAestheticScore": "Score esthétique négatif", + "refiner": "Refiner", + "refinerSteps": "Étapes de raffinage" + }, + "nodes": { + "showMinimapnodes": "Afficher la MiniCarte", + "fitViewportNodes": "Ajuster la Vue", + "hideLegendNodes": "Masquer la légende du type de champ", + "showLegendNodes": "Afficher la légende du type de champ", + "hideMinimapnodes": "Masquer MiniCarte", + "zoomOutNodes": "Dézoomer", + "zoomInNodes": "Zoomer", + "downloadWorkflow": "Exporter le Workflow au format JSON", + "loadWorkflow": "Charger un Workflow", + "reloadNodeTemplates": "Recharger les modèles de nœuds", + "animatedEdges": "Connexions animées", + "cannotConnectToSelf": "Impossible de se connecter à soi-même", + "edge": "Connexion", + "workflowAuthor": "Auteur", + "enum": "Énumération", + "integer": "Entier", + "inputMayOnlyHaveOneConnection": "L'entrée ne peut avoir qu'une seule connexion", + "noNodeSelected": "Aucun nœud sélectionné", + "nodeOpacity": "Opacité du nœud", + "workflowDescription": "Courte description", + "executionStateError": "Erreur", + "version": "Version", + "boolean": "Booléens", + "executionStateCompleted": "Terminé", + "colorCodeEdges": "Code de couleur des connexions", + "colorCodeEdgesHelp": "Code couleur des connexions en fonction de leurs champs connectés", + "currentImage": "Image actuelle", + "noFieldsLinearview": "Aucun champ ajouté à la vue linéaire", + "float": "Flottant", + "mismatchedVersion": "Nœud invalide : le nœud {{node}} de type {{type}} a une version incompatible (essayez de mettre à jour ?)", + "missingTemplate": "Nœud invalide : le nœud {{node}} de type {{type}} modèle manquant (non installé ?)", + "noWorkflow": "Pas de Workflow", + "validateConnectionsHelp": "Prévenir la création de connexions invalides et l'invocation de graphes invalides", + "workflowSettings": "Paramètres de l'Éditeur de Workflow", + "workflowValidation": "Erreur de validation du Workflow", + "executionStateInProgress": "En cours", + "node": "Noeud", + "scheduler": "Planificateur", + "notes": "Notes", + "notesDescription": "Ajouter des notes sur votre workflow", + "unableToLoadWorkflow": "Impossible de charger le Workflow", + "addNode": "Ajouter un nœud", + "problemSettingTitle": "Problème lors de définition du Titre", + "connectionWouldCreateCycle": "La connexion créerait un cycle", + "currentImageDescription": "Affiche l'image actuelle dans l'éditeur de nœuds", + "versionUnknown": " Version inconnue", + "cannotConnectInputToInput": "Impossible de connecter l'entrée à l'entrée", + "addNodeToolTip": "Ajouter un nœud (Shift+A, Espace)", + "fullyContainNodesHelp": "Les nœuds doivent être entièrement à l'intérieur de la zone de sélection pour être sélectionnés", + "cannotConnectOutputToOutput": "Impossible de connecter la sortie à la sortie", + "loadingNodes": "Chargement des nœuds...", + "unknownField": "Champ inconnu", + "workflowNotes": "Notes", + "workflowTags": "Tags", + "animatedEdgesHelp": "Animer les connexions sélectionnées et les connexions associées aux nœuds sélectionnés", + "nodeTemplate": "Modèle de nœud", + "fieldTypesMustMatch": "Les types de champs doivent correspondre", + "fullyContainNodes": "Contient complètement les nœuds à sélectionner", + "nodeSearch": "Rechercher des nœuds", + "collection": "Collection", + "noOutputRecorded": "Aucun résultat enregistré", + "removeLinearView": "Retirer de la vue linéaire", + "snapToGrid": "Aligner sur la grille", + "workflow": "Workflow", + "updateApp": "Mettre à jour l'application", + "updateNode": "Mettre à jour le nœud", + "nodeOutputs": "Sorties de nœud", + "noConnectionInProgress": "Aucune connexion en cours", + "nodeType": "Type de nœud", + "workflowContact": "Contact", + "unknownTemplate": "Modèle inconnu", + "unknownNode": "Nœud inconnu", + "workflowVersion": "Version", + "string": "Chaîne de caractères", + "workflowName": "Nom", + "snapToGridHelp": "Aligner les nœuds sur la grille lors du déplacement", + "unableToValidateWorkflow": "Impossible de valider le Workflow", + "validateConnections": "Valider les connexions et le graphique", + "unableToUpdateNodes_one": "Impossible de mettre à jour {{count}} nœud", + "unableToUpdateNodes_many": "Impossible de mettre à jour {{count}} nœuds", + "unableToUpdateNodes_other": "Impossible de mettre à jour {{count}} nœuds", + "cannotDuplicateConnection": "Impossible de créer des connexions en double", + "resetToDefaultValue": "Réinitialiser à la valeur par défaut", + "unknownNodeType": "Type de nœud inconnu", + "unknownInput": "Entrée inconnue : {{name}}", + "prototypeDesc": "Cette invocation est un prototype. Elle peut subir des modifications majeures lors des mises à jour de l'application et peut être supprimée à tout moment.", + "nodePack": "Paquet de nœuds", + "sourceNodeDoesNotExist": "Connexion invalide : le nœud source/de sortie {{node}} n'existe pas", + "sourceNodeFieldDoesNotExist": "Connexion invalide : {{node}}.{{field}} n'existe pas", + "unableToGetWorkflowVersion": "Impossible d'obtenir la version du schéma du Workflow", + "newWorkflowDesc2": "Votre workflow actuel comporte des modifications non enregistrées.", + "deletedInvalidEdge": "Connexion invalide supprimé {{source}} -> {{target}}", + "targetNodeDoesNotExist": "Connexion invalide : le nœud cible/entrée {{node}} n'existe pas", + "targetNodeFieldDoesNotExist": "Connexion invalide : le champ {{node}}.{{field}} n'existe pas", + "nodeVersion": "Version du noeud", + "clearWorkflowDesc2": "Votre workflow actuel comporte des modifications non enregistrées.", + "clearWorkflow": "Effacer le Workflow", + "clearWorkflowDesc": "Effacer ce workflow et en commencer un nouveau ?", + "unsupportedArrayItemType": "type d'élément de tableau non pris en charge \"{{type}}\"", + "addLinearView": "Ajouter à la vue linéaire", + "collectionOrScalarFieldType": "{{name}} (Unique ou Collection)", + "unableToExtractEnumOptions": "impossible d'extraire les options d'énumération", + "unsupportedAnyOfLength": "trop de membres dans l'union ({{count}})", + "ipAdapter": "IP-Adapter", + "viewMode": "Utiliser en vue linéaire", + "collectionFieldType": "{{name}} (Collection)", + "newWorkflow": "Nouveau Workflow", + "reorderLinearView": "Réorganiser la vue linéaire", + "unknownOutput": "Sortie inconnue : {{name}}", + "outputFieldTypeParseError": "Impossible d'analyser le type du champ de sortie {{node}}.{{field}} ({{message}})", + "unsupportedMismatchedUnion": "type CollectionOrScalar non concordant avec les types de base {{firstType}} et {{secondType}}", + "unableToParseFieldType": "impossible d'analyser le type de champ", + "betaDesc": "Cette invocation est en version bêta. Tant qu'elle n'est pas stable, elle peut avoir des changements majeurs lors des mises à jour de l'application. Nous prévoyons de soutenir cette invocation à long terme.", + "unknownFieldType": "$t(nodes.unknownField) type : {{type}}", + "inputFieldTypeParseError": "Impossible d'analyser le type du champ d'entrée {{node}}.{{field}} ({{message}})", + "unableToExtractSchemaNameFromRef": "impossible d'extraire le nom du schéma à partir de la référence", + "editMode": "Modifier dans l'éditeur de Workflow", + "unknownErrorValidatingWorkflow": "Erreur inconnue lors de la validation du Workflow", + "updateAllNodes": "Mettre à jour les nœuds", + "allNodesUpdated": "Tous les nœuds mis à jour", + "newWorkflowDesc": "Créer un nouveau workflow ?", + "edit": "Modifier", + "noFieldsViewMode": "Ce workflow n'a aucun champ sélectionné à afficher. Consultez le workflow complet pour configurer les valeurs.", + "graph": "Graph", + "modelAccessError": "Impossible de trouver le modèle {{key}}, réinitialisation aux paramètres par défaut", + "showEdgeLabelsHelp": "Afficher le nom sur les connections, indiquant les nœuds connectés", + "showEdgeLabels": "Afficher le nom des connections", + "cannotMixAndMatchCollectionItemTypes": "Impossible de mélanger et d'associer des types d'éléments de collection", + "noGraph": "Pas de graphique", + "saveToGallery": "Enregistrer dans la galerie", + "missingFieldTemplate": "Modèle de champ manquant", + "missingNode": "Noeud d'invocation manquant", + "singleFieldType": "{{name}} (Unique)", + "missingInvocationTemplate": "Modèle d'invocation manquant", + "imageAccessError": "Impossible de trouver l'image {{image_name}}, réinitialisation à la valeur par défaut", + "boardAccessError": "Impossible de trouver la planche {{board_id}}, réinitialisation à la valeur par défaut", + "workflowHelpText": "Besoin d'aide ? Consultez notre guide sur Comment commencer avec les Workflows.", + "noWorkflows": "Aucun Workflows", + "noMatchingWorkflows": "Aucun Workflows correspondant" + }, + "models": { + "noMatchingModels": "Aucun modèle correspondant", + "noModelsAvailable": "Aucun modèle disponible", + "loading": "chargement", + "selectModel": "Sélectionner un modèle", + "noMatchingLoRAs": "Aucun LoRA correspondant", + "lora": "LoRA", + "noRefinerModelsInstalled": "Aucun modèle SDXL Refiner installé", + "noLoRAsInstalled": "Aucun LoRA installé", + "addLora": "Ajouter LoRA", + "defaultVAE": "VAE par défaut", + "concepts": "Concepts" + }, + "workflows": { + "workflowLibrary": "Bibliothèque", + "loading": "Chargement des Workflows", + "searchWorkflows": "Chercher des Workflows", + "workflowCleared": "Workflow effacé", + "noDescription": "Aucune description", + "deleteWorkflow": "Supprimer le Workflow", + "openWorkflow": "Ouvrir le Workflow", + "uploadWorkflow": "Charger à partir d'un fichier", + "workflowName": "Nom du Workflow", + "unnamedWorkflow": "Workflow sans nom", + "saveWorkflowAs": "Enregistrer le Workflow sous", + "workflows": "Workflows", + "savingWorkflow": "Enregistrement du Workflow...", + "saveWorkflowToProject": "Enregistrer le Workflow dans le projet", + "downloadWorkflow": "Enregistrer dans le fichier", + "saveWorkflow": "Enregistrer le Workflow", + "problemSavingWorkflow": "Problème de sauvegarde du Workflow", + "workflowEditorMenu": "Menu de l'Éditeur de Workflow", + "newWorkflowCreated": "Nouveau Workflow créé", + "clearWorkflowSearchFilter": "Réinitialiser le filtre de recherche de Workflow", + "problemLoading": "Problème de chargement des Workflows", + "workflowSaved": "Workflow enregistré", + "noWorkflows": "Pas de Workflows", + "ascending": "Ascendant", + "loadFromGraph": "Charger le Workflow à partir du graphique", + "descending": "Descendant", + "created": "Créé", + "updated": "Mis à jour", + "loadWorkflow": "$t(common.load) Workflow", + "convertGraph": "Convertir le graphique", + "opened": "Ouvert", + "name": "Nom", + "autoLayout": "Mise en page automatique", + "defaultWorkflows": "Workflows par défaut", + "userWorkflows": "Workflows de l'utilisateur", + "projectWorkflows": "Workflows du projet", + "copyShareLink": "Copier le lien de partage", + "chooseWorkflowFromLibrary": "Choisir le Workflow dans la Bibliothèque", + "uploadAndSaveWorkflow": "Importer dans la bibliothèque", + "edit": "Modifer", + "deleteWorkflow2": "Êtes-vous sûr de vouloir supprimer ce Workflow ? Cette action ne peut pas être annulé.", + "download": "Télécharger", + "copyShareLinkForWorkflow": "Copier le lien de partage pour le Workflow", + "delete": "Supprimer" + }, + "whatsNew": { + "whatsNewInInvoke": "Quoi de neuf dans Invoke" + }, + "ui": { + "tabs": { + "queue": "File d'attente", + "canvas": "Toile", + "upscaling": "Agrandissement", + "gallery": "Galerie", + "upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)", + "generation": "Génération", + "workflows": "Workflows", + "workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)", + "models": "Modèles", + "modelsTab": "$t(ui.tabs.models) $t(common.tab)" + } + }, + "controlLayers": { + "newLayerFromImage": "Nouvelle couche à partir de l'image", + "sendToGalleryDesc": "Appuyer sur Invoker génère et enregistre une image unique dans votre galerie.", + "sendToCanvas": "Envoyer vers la Toile", + "globalReferenceImage": "Image de référence globale", + "newCanvasFromImage": "Nouvelle Toile à partir de l'image", + "deleteSelected": "Supprimer la sélection", + "unlocked": "Déverrouillé", + "filter": { + "mediapipe_face_detection": { + "description": "Détecte les visages dans la couche sélectionnée en utilisant le modèle de détection de visages MediaPipe.", + "label": "Détection de visage MediaPipe", + "min_confidence": "Confiance minimale", + "max_faces": "Max Visages" + }, + "lineart_edge_detection": { + "coarse": "Grossier", + "description": "Génère une carte des contours à partir de la couche sélectionnée en utilisant le modèle de détection de contours Lineart.", + "label": "Détection de contours Lineart" + }, + "mlsd_detection": { + "score_threshold": "Seuil de score", + "label": "Détection de segments", + "description": "Génère une carte de segments de ligne à partir de la couche sélectionnée en utilisant le modèle de détection de segments MLSD.", + "distance_threshold": "Seuil de distance" + }, + "normal_map": { + "label": "Carte normale", + "description": "Génère une carte normale à partir de la couche sélectionnée." + }, + "pidi_edge_detection": { + "quantize_edges": "Quantifier les contours", + "scribble": "Esquisse", + "description": "Génère une carte des contours à partir de la couche sélectionnée en utilisant le modèle de détection de contours PiDiNet.", + "label": "Détection de contours PiDiNet" + }, + "filter": "Filtre", + "filters": "Filtres", + "filterType": "Type de filtre", + "reset": "Réinitialiser", + "spandrel_filter": { + "label": "Modèle Image-vers-Image", + "model": "Modèle", + "autoScale": "Échelle automatique", + "description": "Exécute un modèle d'image vers image sur le calque sélectionné.", + "autoScaleDesc": "Le modèle sélectionné sera exécuté jusqu'à ce que l'échelle cible soit atteinte.", + "scale": "Échelle cible" + }, + "canny_edge_detection": { + "label": "Détection des contours de Canny", + "low_threshold": "Seuil Inférieur", + "high_threshold": "Seuil Supérieur", + "description": "Génère une carte des contours à partir de la couche sélectionnée en utilisant l'algorithme de détection de contours de Canny." + }, + "color_map": { + "label": "Carte de couleurs", + "description": "Créer une carte des couleurs à partir de la couche sélectionnée.", + "tile_size": "Taille de tuile" + }, + "content_shuffle": { + "label": "Mélanger le contenu", + "scale_factor": "Facteur d'échelle", + "description": "Mélange le contenu de la couche sélectionnée, similaire à un effet de 'liquéfaction'." + }, + "depth_anything_depth_estimation": { + "model_size": "Taille du modèle", + "model_size_small_v2": "Petit v2", + "label": "Depth Anything", + "model_size_large": "Grand", + "model_size_base": "Base", + "model_size_small": "Petit", + "description": "Génère une carte de profondeur à partir de la couche sélectionnée en utilisant un modèle Depth Anything." + }, + "dw_openpose_detection": { + "draw_hands": "Dessiner les mains", + "label": "Détection DW OpenPose", + "description": "Détecte les poses humaines dans la couche sélectionnée en utilisant le modèle DW Openpose.", + "draw_face": "Dessiner le visage", + "draw_body": "Dessiner le corps" + }, + "hed_edge_detection": { + "scribble": "Esquisse", + "description": "Génère une carte des contours à partir de la couche sélectionnée en utilisant le modèle de détection de contours HED.", + "label": "Détection de contours HED" + }, + "autoProcess": "Traiter automatiquement", + "lineart_anime_edge_detection": { + "label": "Détection de contours Lineart Anime", + "description": "Génère une carte des contours à partir de la couche sélectionnée en utilisant le modèle de détection de contours Lineart Anime." + }, + "process": "Traiter", + "apply": "Appliquer", + "cancel": "Annuler" + }, + "canvasContextMenu": { + "saveToGalleryGroup": "Enregistrer dans la galerie", + "saveCanvasToGallery": "Enregistrer la Toile dans la galerie", + "newRasterLayer": "Nouveau couche de rastérisation", + "canvasGroup": "Toile", + "cropCanvasToBbox": "Rogner la toile à la bounding box", + "saveBboxToGallery": "Enregistrer la bounding box dans la galerie", + "bboxGroup": "Créer à partir de la bounding box", + "newRegionalReferenceImage": "Nouvelle image de référence régionale", + "newGlobalReferenceImage": "Nouvelle image de référence globale", + "newControlLayer": "Nouveau couche de contrôle", + "newInpaintMask": "Nouveau Masque Inpaint", + "newRegionalGuidance": "Nouveau Guide Régional" + }, + "bookmark": "Marque-page pour Changement Rapide", + "saveLayerToAssets": "Enregistrer la couche dans les ressources", + "stagingOnCanvas": "Mise en attente des images sur", + "enableTransparencyEffect": "Activer l'effet de transparence", + "hidingType": "Masquer {{type}}", + "settings": { + "snapToGrid": { + "off": "Désactivé", + "on": "Activé", + "label": "Aligner sur la grille" + }, + "invertBrushSizeScrollDirection": "Inverser le défilement pour la taille du pinceau", + "pressureSensitivity": "Sensibilité à la pression", + "preserveMask": { + "label": "Préserver la zone masquée", + "alert": "Préserver la zone masquée" + }, + "isolatedPreview": "Aperçu Isolé", + "isolatedStagingPreview": "Aperçu de l'attente isolé", + "isolatedLayerPreview": "Aperçu de la couche isolée", + "isolatedLayerPreviewDesc": "Pour afficher uniquement cette couche lors de l'exécution d'opérations telles que le filtrage ou la transformation." + }, + "transparency": "Transparence", + "moveBackward": "Reculer", + "rectangle": "Rectangle", + "saveCanvasToGallery": "Enregistrer la Toile dans la galerie", + "saveBboxToGallery": "Enregistrer la Bounding Box dans la Galerie", + "mergeVisible": "Fusionner visible", + "recalculateRects": "Recalculer les rectangles", + "clipToBbox": "Couper les traits à la bounding box", + "disableAutoNegative": "Désactiver l'Auto Négatif", + "addNegativePrompt": "Ajouter $t(controlLayers.negativePrompt)", + "addRegionalGuidance": "Ajouter $t(controlLayers.regionalGuidance)", + "controlLayers_withCount_hidden": "Control Layers ({{count}} cachées)", + "rasterLayers_withCount_hidden": "Couche de Rastérisation ({{count}} cachées)", + "regionalGuidance_withCount_hidden": "Guidage Régional ({{count}} caché)", + "rasterLayers_withCount_visible": "Couche de Rastérisation ({{count}})", + "inpaintMasks_withCount_visible": "Masques de remplissage ({{count}})", + "layer_one": "Couche", + "layer_many": "Couches", + "layer_other": "Couches", + "dynamicGrid": "Grille dynamique", + "logDebugInfo": "Journaliser les informations de débogage", + "locked": "Verrouillé", + "fill": { + "fillColor": "Couleur de remplissage", + "horizontal": "Horizontal", + "diagonal": "Diagonale", + "crosshatch": "Hachures", + "solid": "Solide", + "grid": "Grille", + "fillStyle": "Style de remplissage", + "vertical": "Vertical" + }, + "tool": { + "brush": "Pinceau", + "colorPicker": "Pipette", + "eraser": "Gomme", + "rectangle": "Rectangle", + "bbox": "Bounding Box", + "move": "Déplacer", + "view": "Vue" + }, + "transform": { + "fitToBbox": "Ajuster à la bounding box", + "reset": "Réinitialiser", + "apply": "Appliquer", + "cancel": "Annuler", + "transform": "Transformer", + "fitMode": "Mode Ajusté", + "fitModeContain": "Contenir", + "fitModeCover": "Couvrir", + "fitModeFill": "Remplir" + }, + "rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)", + "rasterLayer_withCount_many": "Rastériser les couches", + "rasterLayer_withCount_other": "Rastériser les couches", + "stagingArea": { + "discard": "Jeter", + "discardAll": "Tout jeter", + "showResultsOn": "Afficher les résultats", + "showResultsOff": "Masquer les résultats", + "accept": "Accepter", + "previous": "Précédent", + "next": "Suivant", + "saveToGallery": "Enregistrer dans la galerie" + }, + "viewProgressOnCanvas": "Voir les progrès et les sorties de la scène sur la Toile.", + "sendToCanvasDesc": "Appuyer sur Invoker met en attente votre travail en cours sur la toile.", + "mergeVisibleError": "Erreur lors de la fusion des calques visibles", + "mergeVisibleOk": "Couches visibles fusionnées", + "clearHistory": "Effacer l'historique", + "addLayer": "Ajouter une couche", + "clearCaches": "Vider les caches", + "duplicate": "Dupliquer", + "enableAutoNegative": "Activer l'Auto Négatif", + "showHUD": "Afficher HUD", + "sendToGallery": "Envoyer à la galerie", + "sendingToGallery": "Envoi des générations à la galerie", + "disableTransparencyEffect": "Désactiver l'effet de transparence", + "HUD": { + "entityStatus": { + "isHidden": "{{title}} est caché", + "isDisabled": "{{title}} est désactivé", + "isLocked": "{{title}} est verrouillé", + "isTransforming": "{{title}} est en train de se transformer", + "isFiltering": "{{title}} est en train de filtrer", + "isEmpty": "{{title}} est vide" + }, + "bbox": "Bounding Box", + "scaledBbox": "Bounding Box redimensionné" + }, + "opacity": "Opacité", + "savedToGalleryError": "Erreur lors de l'enregistrement dans la galerie", + "addInpaintMask": "Ajouter $t(controlLayers.inpaintMask)", + "newCanvasSessionDesc": "Cela effacera la toile et tous les paramètres, sauf votre sélection de modèle. Les générations seront mises en attente sur la toile.", + "canvas": "Toile", + "savedToGalleryOk": "Enregistré dans la galerie", + "addPositivePrompt": "Ajouter $t(controlLayers.prompt)", + "showProgressOnCanvas": "Afficher la progression sur la Toile", + "newGallerySession": "Nouvelle session de galerie", + "newCanvasSession": "Nouvelle session de toile", + "showingType": "Afficher {{type}}", + "viewProgressInViewer": "Voir les progrès et les résultats dans le Visionneur d'images.", + "deletePrompt": "Supprimer le prompt", + "addControlLayer": "Ajouter $t(controlLayers.controlLayer)", + "global": "Global", + "newGlobalReferenceImageOk": "Image de référence globale créée", + "regional": "Régional", + "newRegionalReferenceImageError": "Problème de création d'image de référence régionale", + "newControlLayerError": "Problème de création de la couche de contrôle", + "newRasterLayerOk": "Couche de Rastérisation créée", + "newControlLayerOk": "Couche de contrôle créée", + "newGlobalReferenceImageError": "Problème de création d'image de référence globale", + "newRegionalReferenceImageOk": "Image de référence régionale créée", + "newRasterLayerError": "Problème de création de couche de rastérisation", + "negativePrompt": "Prompt négatif", + "weight": "Poids", + "globalReferenceImages_withCount_hidden": "Images de référence globales ({{count}} cachées)", + "inpaintMasks_withCount_hidden": "Masques de remplissage ({{count}} cachés)", + "regionalGuidance_withCount_visible": "Guidage Régional ({{count}})", + "globalReferenceImage_withCount_one": "$t(controlLayers.globalReferenceImage)", + "globalReferenceImage_withCount_many": "Images de référence globales", + "globalReferenceImage_withCount_other": "Images de référence globales", + "layer_withCount_one": "Couche {{count}}", + "layer_withCount_many": "Couches {{count}}", + "layer_withCount_other": "Couches {{count}}", + "globalReferenceImages_withCount_visible": "Images de référence globales ({{count}})", + "controlMode": { + "controlMode": "Mode de contrôle", + "balanced": "Équilibré", + "prompt": "Prompt", + "control": "Contrôle", + "megaControl": "Méga Contrôle" + }, + "replaceLayer": "Remplacer la couche", + "pullBboxIntoLayer": "Tirer la bounding box dans la couche", + "pullBboxIntoReferenceImage": "Insérer la Bounding Box dans l'image de référence", + "prompt": "Prompt", + "beginEndStepPercentShort": "Début/Fin %", + "ipAdapterMethod": { + "ipAdapterMethod": "Méthode d'IP Adapter", + "full": "Complet", + "style": "Style uniquement", + "composition": "Composition uniquement" + }, + "fitBboxToLayers": "Ajuster la bounding box aux calques", + "regionIsEmpty": "La zone sélectionnée est vide", + "controlLayers_withCount_visible": "Couches de contrôle ({{count}})", + "cropLayerToBbox": "Rogner la couche selon la bounding box", + "sendingToCanvas": "Mise en attente des Générations sur la Toile", + "copyToClipboard": "Copier dans le presse-papiers", + "regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)", + "regionalGuidance_withCount_many": "Guidage Régional", + "regionalGuidance_withCount_other": "Guidage Régional", + "newGallerySessionDesc": "Cela effacera la toile et tous les paramètres, sauf votre sélection de modèle. Les générations seront envoyées à la galerie.", + "inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)", + "inpaintMask_withCount_many": "Remplir les masques", + "inpaintMask_withCount_other": "Remplir les masques", + "newImg2ImgCanvasFromImage": "Nouvelle Img2Img à partir de l'image", + "resetCanvas": "Réinitialiser la Toile", + "bboxOverlay": "Afficher la superposition des Bounding Box", + "moveToFront": "Déplacer vers le permier plan", + "moveToBack": "Déplacer vers l'arrière plan", + "moveForward": "Avancer", + "width": "Largeur", + "outputOnlyMaskedRegions": "Retourner uniquement les régions masquées", + "autoNegative": "Négatif automatique", + "maskFill": "Remplissage de masque", + "addRasterLayer": "Ajouter $t(controlLayers.rasterLayer)", + "rasterLayer": "Rastériser la Couche", + "controlLayer": "Control Layer", + "inpaintMask": "Masque de remplissage", + "deleteReferenceImage": "Supprimer l'image de référence", + "addReferenceImage": "Ajouter $t(controlLayers.referenceImage)", + "addGlobalReferenceImage": "Ajouter $t(controlLayers.globalReferenceImage)", + "removeBookmark": "Supprimer le marque-page", + "regionalGuidance": "Guide régional", + "regionalReferenceImage": "Image de référence régionale", + "pullBboxIntoLayerOk": "Bounding Box insérée dans la couche", + "pullBboxIntoReferenceImageError": "Problème de l'insertion de la Bounding Box dans l'image de référence", + "referenceImage": "Image de référence", + "pullBboxIntoLayerError": "Problème d'insertion de la bounding box dans la couche", + "pullBboxIntoReferenceImageOk": "Bounding Box insérée dans l'Image de référence", + "controlLayer_withCount_one": "$t(controlLayers.controlLayer)", + "controlLayer_withCount_many": "Controler les couches", + "controlLayer_withCount_other": "Controler les couches", + "copyInpaintMaskTo": "Copier $t(controlLayers.inpaintMask) vers", + "copyRegionalGuidanceTo": "Copier $t(controlLayers.regionalGuidance) vers", + "convertRasterLayerTo": "Convertir $t(controlLayers.rasterLayer) vers", + "selectObject": { + "selectObject": "Sélectionner l'objet", + "clickToAdd": "Cliquez sur la couche pour ajouter un point", + "apply": "Appliquer", + "cancel": "Annuler", + "dragToMove": "Faites glisser un point pour le déplacer", + "clickToRemove": "Cliquez sur un point pour le supprimer", + "include": "Inclure", + "invertSelection": "Sélection Inversée", + "saveAs": "Enregistrer sous", + "neutral": "Neutre", + "pointType": "Type de point", + "exclude": "Exclure", + "process": "Traiter", + "reset": "Réinitialiser", + "help1": "Sélectionnez un seul objet cible. Ajoutez des points Inclure et Exclure pour indiquer quelles parties de la couche font partie de l'objet cible.", + "help2": "Commencez par un point Inclure au sein de l'objet cible. Ajoutez d'autres points pour affiner la sélection. Moins de points produisent généralement de meilleurs résultats.", + "help3": "Inversez la sélection pour sélectionner tout sauf l'objet cible." + }, + "canvasAsControlLayer": "$t(controlLayers.canvas) en tant que $t(controlLayers.controlLayer)", + "convertRegionalGuidanceTo": "Convertir $t(controlLayers.regionalGuidance) vers", + "copyRasterLayerTo": "Copier $t(controlLayers.rasterLayer) vers", + "newControlLayer": "Nouveau $t(controlLayers.controlLayer)", + "newRegionalGuidance": "Nouveau $t(controlLayers.regionalGuidance)", + "replaceCurrent": "Remplacer Actuel", + "convertControlLayerTo": "Convertir $t(controlLayers.controlLayer) vers", + "convertInpaintMaskTo": "Convertir $t(controlLayers.inpaintMask) vers", + "copyControlLayerTo": "Copier $t(controlLayers.controlLayer) vers", + "newInpaintMask": "Nouveau $t(controlLayers.inpaintMask)", + "newRasterLayer": "Nouveau $t(controlLayers.rasterLayer)", + "canvasAsRasterLayer": "$t(controlLayers.canvas) en tant que $t(controlLayers.rasterLayer)" + }, + "upscaling": { + "exceedsMaxSizeDetails": "La limite maximale d'agrandissement est de {{maxUpscaleDimension}}x{{maxUpscaleDimension}} pixels. Veuillez essayer une image plus petite ou réduire votre sélection d'échelle.", + "upscale": "Agrandissement", + "exceedsMaxSize": "Les paramètres d'agrandissement dépassent la limite de taille maximale", + "structure": "Structure", + "creativity": "Créativité", + "upscaleModel": "Modèle d'Agrandissement", + "tileControlNetModelDesc": "Modèle ControlNet pour l'architecture principale choisie", + "upscaleModelDesc": "Modèle d'agrandissement (image vers image)", + "missingModelsWarning": "Visitez le Gestionnaire de Modèles pour installer les modèles requis :", + "postProcessingMissingModelWarning": "Visitez le Gestionnaire de Modèles pour installer un modèle de post-traitement (image vers image).", + "scale": "Échelle", + "mainModelDesc": "Modèle principal (architecture SD1.5 ou SDXL)", + "postProcessingModel": "Modèle de post-traitement", + "missingUpscaleModel": "Modèle d'agrandissement manquant", + "missingUpscaleInitialImage": "Image initiale manquante pour l'agrandissement", + "missingTileControlNetModel": "Aucun modèle ControlNet valide installé" + }, + "stylePresets": { + "deleteTemplate": "Supprimer le template", + "editTemplate": "Modifier le template", + "exportFailed": "Impossible de générer et de télécharger le CSV", + "name": "Nom", + "acceptedColumnsKeys": "Colonnes/clés acceptées :", + "promptTemplatesDesc1": "Les templates de prompt ajoutent du texte aux prompts que vous écrivez dans la zone de saisie.", + "private": "Privé", + "searchByName": "Rechercher par nom", + "viewList": "Afficher la liste des templates", + "noTemplates": "Aucun templates", + "insertPlaceholder": "Insérer un placeholder", + "defaultTemplates": "Template pré-défini", + "deleteImage": "Supprimer l'image", + "createPromptTemplate": "Créer un template de prompt", + "negativePrompt": "Prompt négatif", + "promptTemplatesDesc3": "Si vous omettez le placeholder, le template sera ajouté à la fin de votre prompt.", + "positivePrompt": "Prompt positif", + "choosePromptTemplate": "Choisir un template de prompt", + "toggleViewMode": "Basculer le mode d'affichage", + "updatePromptTemplate": "Mettre à jour le template de prompt", + "flatten": "Intégrer le template sélectionné dans le prompt actuel", + "myTemplates": "Mes Templates", + "type": "Type", + "exportDownloaded": "Exportation téléchargée", + "clearTemplateSelection": "Supprimer la sélection de template", + "promptTemplateCleared": "Template de prompt effacé", + "templateDeleted": "Template de prompt supprimé", + "exportPromptTemplates": "Exporter mes templates de prompt (CSV)", + "nameColumn": "'nom'", + "positivePromptColumn": "\"prompt\" ou \"prompt_positif\"", + "useForTemplate": "Utiliser pour le template de prompt", + "uploadImage": "Importer une image", + "importTemplates": "Importer des templates de prompt (CSV/JSON)", + "negativePromptColumn": "'prompt_négatif'", + "deleteTemplate2": "Êtes-vous sûr de vouloir supprimer ce template ? Cette action ne peut pas être annulée.", + "preview": "Aperçu", + "shared": "Partagé", + "noMatchingTemplates": "Aucun templates correspondant", + "sharedTemplates": "Template partagés", + "unableToDeleteTemplate": "Impossible de supprimer le template de prompt", + "active": "Actif", + "copyTemplate": "Copier le template", + "viewModeTooltip": "Voici à quoi ressemblera votre prompt avec le template actuellement sélectionné. Pour modifier votre prompt, cliquez n'importe où dans la zone de texte.", + "promptTemplatesDesc2": "Utilisez la chaîne de remplacement
{{placeholder}}
pour spécifier où votre prompt doit être inclus dans le template." + }, + "system": { + "logNamespaces": { + "config": "Configuration", + "canvas": "Toile", + "generation": "Génération", + "workflows": "Workflows", + "system": "Système", + "models": "Modèles", + "logNamespaces": "Journalisation des espaces de noms", + "queue": "File d'attente", + "events": "Événements", + "metadata": "Métadonnées", + "gallery": "Galerie" + }, + "logLevel": { + "trace": "Trace", + "logLevel": "Niveau de journalisation", + "debug": "Debug", + "error": "Erreur", + "info": "Info", + "warn": "Alerte", + "fatal": "Fatal" + }, + "enableLogging": "Activer la journalisation" + }, + "newUserExperience": { + "toGetStarted": "Pour commencer, saisissez un prompt dans la boîte et cliquez sur Invoke pour générer votre première image. Sélectionnez un template de prompt pour améliorer les résultats. Vous pouvez choisir de sauvegarder vos images directement dans la Galerie ou de les modifier sur la Toile.", + "gettingStartedSeries": "Vous souhaitez plus de conseils ? Consultez notre Série de démarrage pour des astuces sur l'exploitation du plein potentiel de l'Invoke Studio.", + "noModelsInstalled": "Il semble qu'aucun modèle ne soit installé", + "downloadStarterModels": "Télécharger les modèles de démarrage", + "importModels": "Importer des Modèles", + "toGetStartedLocal": "Pour commencer, assurez-vous de télécharger ou d'importer des modèles nécessaires pour exécuter Invoke. Ensuite, saisissez le prompt dans la boîte et cliquez sur Invoke pour générer votre première image. Sélectionnez un template de prompt pour améliorer les résultats. Vous pouvez choisir de sauvegarder vos images directement sur Galerie ou les modifier sur la Toile." + }, + "upsell": { + "shareAccess": "Partager l'accès", + "inviteTeammates": "Inviter des collègues", + "professionalUpsell": "Disponible dans l'édition professionnelle d'Invoke. Cliquez ici ou visitez invoke.com/pricing pour plus de détails.", + "professional": "Professionnel" + } +} diff --git a/invokeai/frontend/web/public/locales/he.json b/invokeai/frontend/web/public/locales/he.json new file mode 100644 index 0000000000000000000000000000000000000000..476551169f94a564a35def98ba06fb20b0dae76d --- /dev/null +++ b/invokeai/frontend/web/public/locales/he.json @@ -0,0 +1,101 @@ +{ + "modelManager": { + "height": "גובה", + "load": "טען", + "search": "חיפוש", + "allModels": "כל המודלים", + "modelUpdated": "מודל עודכן", + "manual": "ידני", + "name": "שם", + "description": "תיאור", + "config": "תצורה", + "repo_id": "מזהה מאגר", + "width": "רוחב", + "addModel": "הוסף מודל", + "active": "פעיל", + "selected": "נבחר", + "deleteModel": "מחיקת מודל", + "deleteConfig": "מחיקת תצורה", + "convertToDiffusersHelpText5": "אנא ודא/י שיש לך מספיק מקום בדיסק. גדלי מודלים בדרך כלל הינם בין 4GB-7GB.", + "convertToDiffusersHelpText1": "מודל זה יומר לפורמט 🧨 המפזרים.", + "convertToDiffusersHelpText2": "תהליך זה יחליף את הרשומה של מנהל המודלים שלך בגרסת המפזרים של אותו המודל.", + "convertToDiffusersHelpText6": "האם ברצונך להמיר מודל זה?", + "modelConverted": "מודל הומר", + "alpha": "אלפא", + "modelManager": "מנהל המודלים", + "model": "מודל", + "availableModels": "מודלים זמינים", + "delete": "מחיקה", + "deleteMsg1": "האם אתה בטוח שברצונך למחוק רשומת מודל זו מ- InvokeAI?", + "deleteMsg2": "פעולה זו לא תמחק את קובץ נקודת הביקורת מהדיסק שלך. ניתן לקרוא אותם מחדש במידת הצורך.", + "convertToDiffusers": "המרה למפזרים", + "convert": "המרה", + "convertToDiffusersHelpText3": "קובץ נקודת הביקורת שלך בדיסק לא יימחק או ישונה בכל מקרה. אתה יכול להוסיף את נקודת הביקורת שלך למנהל המודלים שוב אם תרצה בכך.", + "convertToDiffusersHelpText4": "זהו תהליך חד פעמי בלבד. התהליך עשוי לקחת בסביבות 30-60 שניות, תלוי במפרט המחשב שלך." + }, + "common": { + "languagePickerLabel": "בחירת שפה", + "githubLabel": "גיטהאב", + "discordLabel": "דיסקורד", + "settingsLabel": "הגדרות", + "img2img": "תמונה לתמונה", + "nodes": "צמתים", + "statusDisconnected": "מנותק", + "hotkeysLabel": "מקשים חמים", + "reportBugLabel": "דווח באג", + "upload": "העלאה", + "load": "טעינה", + "back": "אחורה" + }, + "gallery": { + "galleryImageSize": "גודל תמונה", + "gallerySettings": "הגדרות גלריה", + "autoSwitchNewImages": "החלף אוטומטית לתמונות חדשות", + "noImagesInGallery": "אין תמונות בגלריה" + }, + "parameters": { + "images": "תמונות", + "steps": "צעדים", + "cfgScale": "סולם CFG", + "width": "רוחב", + "height": "גובה", + "seed": "זרע", + "type": "סוג", + "strength": "חוזק", + "denoisingStrength": "חוזק מנטרל הרעש", + "scaleBeforeProcessing": "שנה קנה מידה לפני עיבוד", + "scaledWidth": "קנה מידה לאחר שינוי W", + "scaledHeight": "קנה מידה לאחר שינוי H", + "infillMethod": "שיטת מילוי", + "tileSize": "גודל אריח", + "symmetry": "סימטריה", + "copyImage": "העתקת תמונה", + "downloadImage": "הורדת תמונה", + "usePrompt": "שימוש בבקשה", + "useSeed": "שימוש בזרע", + "useAll": "שימוש בהכל", + "info": "פרטים", + "showOptionsPanel": "הצג חלונית אפשרויות", + "shuffle": "ערבוב", + "noiseThreshold": "סף רעש", + "perlinNoise": "רעש פרלין", + "imageFit": "התאמת תמונה ראשונית לגודל הפלט", + "general": "כללי", + "upscaling": "מגדיל את קנה מידה", + "scale": "סולם" + }, + "settings": { + "models": "מודלים", + "displayInProgress": "הצגת תמונות בתהליך", + "confirmOnDelete": "אישור בעת המחיקה", + "resetWebUI": "איפוס ממשק משתמש", + "resetWebUIDesc1": "איפוס ממשק המשתמש האינטרנטי מאפס רק את המטמון המקומי של הדפדפן של התמונות וההגדרות שנשמרו. זה לא מוחק תמונות מהדיסק.", + "resetComplete": "ממשק המשתמש אופס. יש לבצע רענון דף בכדי לטעון אותו מחדש.", + "resetWebUIDesc2": "אם תמונות לא מופיעות בגלריה או שמשהו אחר לא עובד, נא לנסות איפוס /או אתחול לפני שליחת תקלה ב-GitHub." + }, + "toast": { + "uploadFailed": "העלאה נכשלה", + "imageCopied": "התמונה הועתקה", + "parametersNotSet": "פרמטרים לא הוגדרו" + } +} diff --git a/invokeai/frontend/web/public/locales/hu.json b/invokeai/frontend/web/public/locales/hu.json new file mode 100644 index 0000000000000000000000000000000000000000..2624fa03fd848af384d7d29dfa202eff6f23f84e --- /dev/null +++ b/invokeai/frontend/web/public/locales/hu.json @@ -0,0 +1,34 @@ +{ + "accessibility": { + "mode": "Mód", + "uploadImage": "Fénykép feltöltése", + "nextImage": "Következő kép", + "previousImage": "Előző kép", + "menu": "Menü" + }, + "boards": { + "cancel": "Mégsem", + "loading": "Betöltés..." + }, + "accordions": { + "image": { + "title": "Kép" + } + }, + "common": { + "accept": "Elfogad", + "ai": "ai", + "back": "Vissza", + "cancel": "Mégsem", + "or": "vagy", + "details": "Részletek", + "error": "Hiba", + "file": "Fájl", + "githubLabel": "Github", + "hotkeysLabel": "Gyorsbillentyűk", + "delete": "Törlés", + "data": "Adat", + "discordLabel": "Discord", + "folder": "Mappa" + } +} diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json new file mode 100644 index 0000000000000000000000000000000000000000..3ea2b33ee10eedb18dabec4427243c0ace84be2f --- /dev/null +++ b/invokeai/frontend/web/public/locales/it.json @@ -0,0 +1,2179 @@ +{ + "common": { + "hotkeysLabel": "Tasti di scelta rapida", + "languagePickerLabel": "Lingua", + "reportBugLabel": "Segnala un errore", + "settingsLabel": "Impostazioni", + "img2img": "Immagine a Immagine", + "nodes": "Flussi di lavoro", + "upload": "Caricamento", + "load": "Carica", + "back": "Indietro", + "statusDisconnected": "Disconnesso", + "githubLabel": "GitHub", + "discordLabel": "Discord", + "loading": "Caricamento in corso", + "postprocessing": "Post Elaborazione", + "txt2img": "Testo a Immagine", + "accept": "Accetta", + "cancel": "Annulla", + "linear": "Lineare", + "random": "Casuale", + "openInNewTab": "Apri in una nuova scheda", + "areYouSure": "Sei sicuro?", + "dontAskMeAgain": "Non chiedermelo più", + "batch": "Gestione Lotto", + "modelManager": "Gestione Modelli", + "communityLabel": "Comunità", + "advanced": "Avanzate", + "imageFailedToLoad": "Impossibile caricare l'immagine", + "learnMore": "Per saperne di più", + "ipAdapter": "Adattatore IP", + "t2iAdapter": "Adattatore T2I", + "controlNet": "ControlNet", + "auto": "Automatico", + "simple": "Semplice", + "details": "Dettagli", + "format": "Formato", + "unknown": "Sconosciuto", + "folder": "Cartella", + "error": "Errore", + "installed": "Installato", + "template": "Schema", + "outputs": "Risultati", + "data": "Dati", + "somethingWentWrong": "Qualcosa è andato storto", + "copyError": "Errore $t(gallery.copy)", + "input": "Ingresso", + "notInstalled": "Non $t(common.installed)", + "unknownError": "Errore sconosciuto", + "updated": "Aggiornato", + "save": "Salva", + "created": "Creato", + "prevPage": "Pagina precedente", + "delete": "Elimina", + "orderBy": "Ordina per", + "nextPage": "Pagina successiva", + "saveAs": "Salva come", + "direction": "Direzione", + "or": "o", + "red": "Rosso", + "aboutHeading": "Possiedi il tuo potere creativo", + "aboutDesc": "Utilizzi Invoke per lavoro? Guarda qui:", + "localSystem": "Sistema locale", + "green": "Verde", + "blue": "Blu", + "alpha": "Alfa", + "copy": "Copia", + "on": "Acceso", + "checkpoint": "Checkpoint", + "safetensors": "Safetensors", + "ai": "ia", + "file": "File", + "toResolve": "Da risolvere", + "add": "Aggiungi", + "beta": "Beta", + "positivePrompt": "Prompt positivo", + "negativePrompt": "Prompt negativo", + "selected": "Selezionato", + "goTo": "Vai a", + "editor": "Editor", + "tab": "Scheda", + "enabled": "Abilitato", + "disabled": "Disabilitato", + "dontShowMeThese": "Non mostrare più", + "openInViewer": "Apri nel visualizzatore", + "apply": "Applica", + "loadingImage": "Caricamento immagine", + "off": "Spento", + "edit": "Modifica", + "placeholderSelectAModel": "Seleziona un modello", + "reset": "Reimposta", + "none": "Niente", + "new": "Nuovo", + "view": "Vista", + "close": "Chiudi", + "clipboard": "Appunti", + "ok": "Ok", + "generating": "Generazione" + }, + "gallery": { + "galleryImageSize": "Dimensione dell'immagine", + "gallerySettings": "Impostazioni della galleria", + "autoSwitchNewImages": "Passaggio automatico a nuove immagini", + "noImagesInGallery": "Nessuna immagine da visualizzare", + "deleteImage_one": "Elimina l'immagine", + "deleteImage_many": "Elimina {{count}} immagini", + "deleteImage_other": "Elimina {{count}} immagini", + "deleteImagePermanent": "Le immagini eliminate non possono essere ripristinate.", + "assets": "Risorse", + "autoAssignBoardOnClick": "Assegna automaticamente la bacheca al clic", + "featuresWillReset": "Se elimini questa immagine, quelle funzionalità verranno immediatamente ripristinate.", + "loading": "Caricamento in corso", + "unableToLoad": "Impossibile caricare la Galleria", + "currentlyInUse": "Questa immagine è attualmente utilizzata nelle seguenti funzionalità:", + "copy": "Copia", + "download": "Scarica", + "downloadSelection": "Scarica gli elementi selezionati", + "noImageSelected": "Nessuna immagine selezionata", + "deleteSelection": "Elimina la selezione", + "image": "immagine", + "drop": "Rilascia", + "unstarImage": "Rimuovi contrassegno immagine", + "dropOrUpload": "$t(gallery.drop) o carica", + "starImage": "Contrassegna l'immagine", + "dropToUpload": "$t(gallery.drop) per aggiornare", + "bulkDownloadRequested": "Preparazione del download", + "bulkDownloadRequestedDesc": "La tua richiesta di download è in preparazione. L'operazione potrebbe richiedere alcuni istanti.", + "bulkDownloadRequestFailed": "Problema durante la preparazione del download", + "bulkDownloadFailed": "Scaricamento fallito", + "alwaysShowImageSizeBadge": "Mostra sempre le dimensioni dell'immagine", + "openInViewer": "Apri nel visualizzatore", + "selectForCompare": "Seleziona per il confronto", + "selectAnImageToCompare": "Seleziona un'immagine da confrontare", + "slider": "Cursore", + "sideBySide": "Fianco a Fianco", + "compareImage": "Immagine di confronto", + "viewerImage": "Immagine visualizzata", + "hover": "Al passaggio del mouse", + "swapImages": "Scambia le immagini", + "stretchToFit": "Scala per adattare", + "exitCompare": "Esci dal confronto", + "compareHelp1": "Tieni premuto Alt mentre fai clic su un'immagine della galleria o usi i tasti freccia per cambiare l'immagine di confronto.", + "compareHelp2": "Premi M per scorrere le modalità di confronto.", + "compareHelp3": "Premi C per scambiare le immagini confrontate.", + "compareHelp4": "Premi Z o Esc per uscire.", + "newestFirst": "Prima i più nuovi", + "oldestFirst": "Prima i più vecchi", + "sortDirection": "Direzione dell'ordinamento", + "showStarredImagesFirst": "Mostra prima le immagini contrassegnate", + "showArchivedBoards": "Mostra le bacheche archiviate", + "searchImages": "Ricerca per metadati", + "displayBoardSearch": "Ricerca nella Bacheca", + "displaySearch": "Ricerca immagine", + "selectAllOnPage": "Seleziona tutto nella pagina", + "exitBoardSearch": "Esci da Ricerca bacheca", + "exitSearch": "Esci dalla ricerca immagini", + "go": "Vai", + "jump": "Salta", + "move": "Sposta", + "gallery": "Galleria", + "openViewer": "Apri visualizzatore", + "closeViewer": "Chiudi visualizzatore", + "imagesTab": "Immagini create e salvate in Invoke.", + "assetsTab": "File che hai caricato per usarli nei tuoi progetti.", + "boardsSettings": "Impostazioni Bacheche", + "imagesSettings": "Impostazioni Immagini Galleria" + }, + "hotkeys": { + "searchHotkeys": "Cerca tasti di scelta rapida", + "noHotkeysFound": "Nessun tasto di scelta rapida trovato", + "clearSearch": "Cancella ricerca", + "app": { + "selectCanvasTab": { + "title": "Seleziona la scheda Tela", + "desc": "Seleziona la scheda Tela." + }, + "title": "Applicazione", + "invoke": { + "desc": "Metti in coda una generazione, aggiungendola alla fine della coda." + }, + "invokeFront": { + "title": "Invoke (Fronte)", + "desc": "Metti in coda una generazione, aggiungendola all'inizio della coda." + }, + "cancelQueueItem": { + "desc": "Annulla l'elemento della coda in elaborazione.", + "title": "Annulla" + }, + "clearQueue": { + "title": "Cancella la coda", + "desc": "Annulla e cancella tutti gli elementi in coda." + }, + "selectUpscalingTab": { + "title": "Seleziona la scheda Amplia", + "desc": "Seleziona la scheda Amplia." + }, + "selectModelsTab": { + "title": "Seleziona la scheda Modelli", + "desc": "Seleziona la scheda Modelli." + }, + "selectQueueTab": { + "title": "Seleziona la scheda della Coda", + "desc": "Seleziona la scheda della Coda." + }, + "selectWorkflowsTab": { + "desc": "Seleziona la scheda dei Flussi di lavoro.", + "title": "Seleziona la scheda dei Flussi di lavoro" + }, + "focusPrompt": { + "title": "Seleziona il Prompt", + "desc": "Sposta il cursore sul prompt positivo." + }, + "toggleLeftPanel": { + "title": "Attiva/disattiva il pannello sinistro", + "desc": "Attiva/disattiva il pannello sinistro." + }, + "toggleRightPanel": { + "title": "Attiva/disattiva il pannello destro", + "desc": "Attiva/disattiva il pannello destro." + }, + "resetPanelLayout": { + "title": "Ripristina il layout del pannello", + "desc": "Ripristina le dimensioni e il layout predefiniti dei pannelli sinistro e destro." + }, + "togglePanels": { + "title": "Attiva/disattiva i pannelli", + "desc": "Mostra o nascondi contemporaneamente i pannelli sinistro e destro." + } + }, + "hotkeys": "Tasti di scelta rapida", + "canvas": { + "transformSelected": { + "desc": "Trasforma il livello selezionato.", + "title": "Trasforma" + }, + "fitBboxToCanvas": { + "desc": "Scala e posiziona la vista per adattarla al riquadro di delimitazione.", + "title": "Adatta il riquadro di delimitazione alla tela" + }, + "redo": { + "title": "Ripeti", + "desc": "Ripeti l'ultima azione sulla tela." + }, + "selectBrushTool": { + "title": "Strumento pennello", + "desc": "Seleziona lo strumento pennello." + }, + "selectBboxTool": { + "title": "Strumento di selezione riquadro", + "desc": "Seleziona lo strumento riquadro di delimitazione." + }, + "decrementToolWidth": { + "title": "Diminuisci la larghezza dello strumento", + "desc": "Diminuisce la larghezza dello strumento pennello o gomma, a seconda di quello selezionato." + }, + "incrementToolWidth": { + "title": "Aumenta la larghezza dello strumento", + "desc": "Aumenta la larghezza dello strumento pennello o gomma, a seconda di quello selezionato." + }, + "selectColorPickerTool": { + "title": "Strumento di selezione del colore", + "desc": "Seleziona lo strumento di selezione del colore." + }, + "resetSelected": { + "title": "Reimposta il Livello", + "desc": "Reimposta il livello selezionato. Si applica solo alla Maschera Inpaint e alla Guida Regionale." + }, + "undo": { + "title": "Annulla", + "desc": "Annulla l'ultima azione sulla tela." + }, + "nextEntity": { + "title": "Livello successivo", + "desc": "Seleziona il livello successivo nell'elenco." + }, + "filterSelected": { + "title": "Filtro", + "desc": "Filtra il livello selezionato. Applicabile solo ai livelli Raster e Controllo." + }, + "setZoomTo100Percent": { + "title": "Zoom al 100%", + "desc": "Imposta l'ingrandimento della tela al 100%." + }, + "setZoomTo200Percent": { + "title": "Zoom al 200%", + "desc": "Imposta l'ingrandimento della tela al 200%." + }, + "setZoomTo400Percent": { + "title": "Zoom al 400%", + "desc": "Imposta l'ingrandimento della tela al 400%." + }, + "setZoomTo800Percent": { + "title": "Zoom al 800%", + "desc": "Imposta l'ingrandimento della tela al 800%." + }, + "quickSwitch": { + "title": "Cambio rapido livello", + "desc": "Passa tra gli ultimi due livelli selezionati. Se un livello è aggiunto ai segnalibri, passa sempre tra questo e l'ultimo livello non aggiunto ai segnalibri." + }, + "deleteSelected": { + "title": "Elimina livello", + "desc": "Elimina il livello selezionato." + }, + "prevEntity": { + "title": "Livello precedente", + "desc": "Seleziona il livello precedente nell'elenco." + }, + "setFillToWhite": { + "title": "Imposta il colore su bianco", + "desc": "Imposta il colore dello strumento corrente su bianco." + }, + "title": "Tela", + "selectMoveTool": { + "title": "Strumento Sposta", + "desc": "Seleziona lo strumento sposta." + }, + "fitLayersToCanvas": { + "desc": "Scala e posiziona la vista per adattarla a tutti i livelli visibili.", + "title": "Adatta i livelli alla tela" + }, + "selectEraserTool": { + "title": "Strumento gomma", + "desc": "Selezionare lo strumento gomma." + }, + "selectRectTool": { + "title": "Strumento Rettangolo", + "desc": "Seleziona lo strumento rettangolo." + }, + "selectViewTool": { + "title": "Strumento Visualizza", + "desc": "Seleziona lo strumento Visualizza." + }, + "applyFilter": { + "title": "Applica filtro", + "desc": "Applica il filtro in sospeso al livello selezionato." + }, + "cancelFilter": { + "title": "Annulla filtro", + "desc": "Annulla il filtro in sospeso." + }, + "cancelTransform": { + "desc": "Annulla la trasformazione in sospeso.", + "title": "Annulla Trasforma" + }, + "applyTransform": { + "title": "Applica trasformazione", + "desc": "Applica la trasformazione in sospeso al livello selezionato." + } + }, + "workflows": { + "addNode": { + "title": "Aggiungi nodo", + "desc": "Apri il menu aggiungi nodo." + }, + "pasteSelectionWithEdges": { + "title": "Incolla con collegamenti", + "desc": "Incolla i nodi copiati, i collegamenti e tutti i collegamenti connessi ai nodi copiati." + }, + "copySelection": { + "title": "Copia", + "desc": "Copia i nodi ed i collegamenti selezionati." + }, + "pasteSelection": { + "title": "Incolla", + "desc": "Incolla i nodi ed i collegamenti copiati." + }, + "deleteSelection": { + "title": "Elimina", + "desc": "Elimina i nodi ed i collegamenti selezionati." + }, + "redo": { + "title": "Ripeti", + "desc": "Ripeti l'ultima azione del flusso di lavoro." + }, + "selectAll": { + "desc": "Seleziona tutti i nodi ed i collegamenti.", + "title": "Seleziona tutto" + }, + "undo": { + "desc": "Annulla l'ultima azione del flusso di lavoro.", + "title": "Annulla" + }, + "title": "Flussi di lavoro" + }, + "viewer": { + "nextComparisonMode": { + "title": "Modalità di confronto successiva", + "desc": "Scorri le modalità di confronto." + }, + "recallPrompts": { + "title": "Richiama i Prompt", + "desc": "Richiama i prompt positivo e negativo per l'immagine corrente." + }, + "remix": { + "title": "Remixa", + "desc": "Richiama tutti i metadati, ad eccezione del seme, per l'immagine corrente." + }, + "useSize": { + "desc": "Utilizza la dimensione dell'immagine corrente come dimensione del riquadro di delimitazione.", + "title": "Usa Dimensioni" + }, + "runPostprocessing": { + "title": "Esegui Post-elaborazione", + "desc": "Esegue la post-elaborazione selezionata sull'immagine corrente." + }, + "title": "Visualizzatore immagini", + "toggleViewer": { + "title": "Mostra/Nascondi visualizzatore immagini", + "desc": "Mostra o nascondi il visualizzatore di immagini. Disponibile solo nella scheda Tela." + }, + "loadWorkflow": { + "title": "Carica Flusso di lavoro", + "desc": "Carica il flusso di lavoro salvato dell'immagine corrente (se presente)." + }, + "recallAll": { + "title": "Richiama tutti i metadati", + "desc": "Richiama tutti i metadati dell'immagine corrente." + }, + "swapImages": { + "title": "Scambia le immagini di confronto", + "desc": "Scambia le immagini da confrontare." + }, + "recallSeed": { + "title": "Richiama il seme", + "desc": "Richiama il seme per l'immagine corrente." + }, + "toggleMetadata": { + "title": "Mostra/Nascondi metadati", + "desc": "Mostra o nasconde la sovrapposizione dei metadati dell'immagine corrente." + } + }, + "gallery": { + "selectAllOnPage": { + "desc": "Seleziona tutte le immagini nella pagina corrente.", + "title": "Seleziona tutto nella pagina" + }, + "galleryNavUp": { + "desc": "Naviga verso l'alto nella griglia della galleria, selezionando quell'immagine. Se sei in cima alla pagina, andrai alla pagina precedente.", + "title": "Naviga verso l'alto" + }, + "galleryNavRight": { + "title": "Naviga a destra", + "desc": "Naviga a destra nella griglia della galleria, selezionando quell'immagine. Se sei all'ultima immagine della riga, andrai alla riga successiva. Se sei all'ultima immagine della pagina, andrai alla pagina successiva." + }, + "galleryNavLeftAlt": { + "desc": "Uguale a Naviga a sinistra, ma seleziona l'immagine da confrontare, aprendo la modalità di confronto se non è già aperta.", + "title": "Naviga a sinistra (Confronta immagine)" + }, + "deleteSelection": { + "title": "Elimina", + "desc": "Elimina tutte le immagini selezionate. Per impostazione predefinita, ti verrà chiesto di confermare l'eliminazione. Se le immagini sono attualmente in uso nell'applicazione, verrai avvisato." + }, + "clearSelection": { + "title": "Cancella selezione", + "desc": "Cancella la selezione corrente, se presente." + }, + "galleryNavRightAlt": { + "desc": "Uguale a Naviga a destra, ma seleziona l'immagine da confrontare, aprendo la modalità di confronto se non è già aperta.", + "title": "Naviga a destra (Confronta immagine)" + }, + "galleryNavDownAlt": { + "title": "Naviga in basso (Confronta immagine)", + "desc": "Uguale a Naviga in basso, ma seleziona l'immagine da confrontare, aprendo la modalità di confronto se non è già aperta." + }, + "title": "Galleria", + "galleryNavDown": { + "desc": "Naviga verso il basso nella griglia della galleria, selezionando quell'immagine. Se sei in fondo alla pagina, andrai alla pagina successiva.", + "title": "Naviga in basso" + }, + "galleryNavLeft": { + "title": "Naviga a sinistra", + "desc": "Naviga a sinistra nella griglia della galleria, selezionando quell'immagine. Se sei alla prima immagine della riga, andrai alla riga precedente. Se sei alla prima immagine della pagina, andrai alla pagina precedente." + }, + "galleryNavUpAlt": { + "desc": "Uguale a Naviga verso l'alto, ma seleziona l'immagine da confrontare, aprendo la modalità di confronto se non è già aperta.", + "title": "Naviga verso l'alto (Confronta immagine)" + } + } + }, + "modelManager": { + "modelManager": "Gestione Modelli", + "model": "Modello", + "allModels": "Tutti i modelli", + "modelUpdated": "Modello aggiornato", + "manual": "Manuale", + "name": "Nome", + "description": "Descrizione", + "config": "Configurazione", + "repo_id": "Repo ID", + "width": "Larghezza", + "height": "Altezza", + "addModel": "Aggiungi modello", + "availableModels": "Modelli disponibili", + "search": "Ricerca", + "load": "Carica", + "active": "attivo", + "selected": "Selezionato", + "delete": "Elimina", + "deleteModel": "Elimina modello", + "deleteConfig": "Elimina configurazione", + "deleteMsg1": "Sei sicuro di voler eliminare questo modello da InvokeAI?", + "deleteMsg2": "Questo eliminerà il modello dal disco se si trova nella cartella principale di InvokeAI. Se invece utilizzi una cartella personalizzata, il modello NON verrà eliminato dal disco.", + "convert": "Converti", + "convertToDiffusers": "Converti in Diffusori", + "convertToDiffusersHelpText2": "Questo processo sostituirà la voce in Gestione Modelli con la versione Diffusori dello stesso modello.", + "convertToDiffusersHelpText4": "Questo è un processo una tantum. Potrebbero essere necessari circa 30-60 secondi a seconda delle specifiche del tuo computer.", + "convertToDiffusersHelpText5": "Assicurati di avere spazio su disco sufficiente. I modelli generalmente variano tra 2 GB e 7 GB in dimensione.", + "convertToDiffusersHelpText6": "Vuoi convertire questo modello?", + "modelConverted": "Modello convertito", + "alpha": "Alpha", + "convertToDiffusersHelpText1": "Questo modello verrà convertito nel formato 🧨 Diffusori.", + "convertToDiffusersHelpText3": "Il file del modello su disco verrà eliminato se si trova nella cartella principale di InvokeAI. Se si trova invece in una posizione personalizzata, NON verrà eliminato.", + "none": "nessuno", + "variant": "Variante", + "baseModel": "Modello Base", + "vae": "VAE", + "modelUpdateFailed": "Aggiornamento del modello non riuscito", + "modelConversionFailed": "Conversione del modello non riuscita", + "selectModel": "Seleziona Modello", + "modelDeleted": "Modello eliminato", + "modelDeleteFailed": "Impossibile eliminare il modello", + "convertingModelBegin": "Conversione del modello. Attendere prego.", + "settings": "Impostazioni", + "syncModels": "Sincronizza modelli", + "predictionType": "Tipo di previsione", + "advanced": "Avanzate", + "modelType": "Tipo di modello", + "vaePrecision": "Precisione VAE", + "noModelSelected": "Nessun modello selezionato", + "modelName": "Nome del modello", + "modelSettings": "Impostazioni del modello", + "addModels": "Aggiungi modelli", + "cancel": "Annulla", + "edit": "Modifica", + "imageEncoderModelId": "ID modello codificatore di immagini", + "path": "Percorso", + "prune": "Elimina", + "pruneTooltip": "Elimina dalla coda le importazioni completate", + "repoVariant": "Variante del repository", + "scanFolder": "Scansione cartella", + "scanResults": "Risultati della scansione", + "source": "Sorgente", + "upcastAttention": "Eleva l'attenzione", + "typePhraseHere": "Digita la frase qui", + "defaultSettingsSaved": "Impostazioni predefinite salvate", + "defaultSettings": "Impostazioni predefinite", + "metadata": "Metadati", + "triggerPhrases": "Frasi Trigger", + "deleteModelImage": "Elimina l'immagine del modello", + "localOnly": "solo locale", + "modelImageDeleted": "Immagine del modello eliminata", + "modelImageDeleteFailed": "Eliminazione dell'immagine del modello non riuscita", + "modelImageUpdated": "Immagine del modello aggiornata", + "modelImageUpdateFailed": "Aggiornamento dell'immagine del modello non riuscito", + "pathToConfig": "Percorso file di configurazione", + "uploadImage": "Carica immagine", + "loraTriggerPhrases": "Frasi Trigger LoRA", + "mainModelTriggerPhrases": "Frasi Trigger del modello principale", + "inplaceInstall": "Installazione sul posto", + "inplaceInstallDesc": "Installa i modelli senza copiare i file. Quando si utilizza il modello, verrà caricato da questa posizione. Se disabilitato, i file del modello verranno copiati nella directory dei modelli gestiti da Invoke durante l'installazione.", + "installQueue": "Coda di installazione", + "install": "Installa", + "installRepo": "Installa Repository", + "huggingFacePlaceholder": "proprietario/nome-modello", + "huggingFaceHelper": "Se in questo repository vengono trovati più modelli, ti verrà richiesto di selezionarne uno da installare.", + "installAll": "Installa tutto", + "scanFolderHelper": "La cartella verrà sottoposta a scansione ricorsiva per i modelli. L'operazione può richiedere un po' di tempo per cartelle molto grandi.", + "scanPlaceholder": "Percorso di una cartella locale", + "simpleModelPlaceholder": "URL o percorso di un file locale o di una cartella diffusori", + "urlOrLocalPath": "URL o percorso locale", + "urlOrLocalPathHelper": "Gli URL dovrebbero puntare a un singolo file. I percorsi locali possono puntare a un singolo file o cartella per un singolo modello di diffusore.", + "loraModels": "LoRA", + "starterModels": "Modelli iniziali", + "textualInversions": "Inversioni Testuali", + "noModelsInstalled": "Nessun modello installato", + "main": "Principali", + "noModelsInstalledDesc1": "Installa i modelli con", + "ipAdapters": "Adattatori IP", + "noMatchingModels": "Nessun modello corrispondente", + "starterModelsInModelManager": "I modelli iniziali possono essere trovati in Gestione Modelli", + "spandrelImageToImage": "Immagine a immagine (Spandrel)", + "learnMoreAboutSupportedModels": "Scopri di più sui modelli che supportiamo", + "starterBundles": "Pacchetti per iniziare", + "installingBundle": "Installazione del pacchetto", + "skippingXDuplicates_one": ", saltando {{count}} duplicato", + "skippingXDuplicates_many": ", saltando {{count}} duplicati", + "skippingXDuplicates_other": ", saltando {{count}} duplicati", + "installingModel": "Installazione del modello", + "installingXModels_one": "Installazione di {{count}} modello", + "installingXModels_many": "Installazione di {{count}} modelli", + "installingXModels_other": "Installazione di {{count}} modelli", + "includesNModels": "Include {{n}} modelli e le loro dipendenze", + "starterBundleHelpText": "Installa facilmente tutti i modelli necessari per iniziare con un modello base, tra cui un modello principale, controlnet, adattatori IP e altro. Selezionando un pacchetto salterai tutti i modelli che hai già installato.", + "noDefaultSettings": "Nessuna impostazione predefinita configurata per questo modello. Visita Gestione Modelli per aggiungere impostazioni predefinite.", + "defaultSettingsOutOfSync": "Alcune impostazioni non corrispondono a quelle predefinite del modello:", + "restoreDefaultSettings": "Fare clic per utilizzare le impostazioni predefinite del modello.", + "usingDefaultSettings": "Utilizzo delle impostazioni predefinite del modello", + "huggingFace": "HuggingFace", + "huggingFaceRepoID": "HuggingFace Repository ID", + "clipEmbed": "CLIP Embed", + "t5Encoder": "T5 Encoder", + "hfTokenInvalidErrorMessage": "Gettone HuggingFace non valido o mancante.", + "hfTokenRequired": "Stai tentando di scaricare un modello che richiede un gettone HuggingFace valido.", + "hfTokenUnableToVerifyErrorMessage": "Impossibile verificare il gettone HuggingFace. Ciò è probabilmente dovuto a un errore di rete. Riprova più tardi.", + "hfTokenHelperText": "Per utilizzare alcuni modelli è necessario un gettone HF. Fai clic qui per creare o ottenere il tuo gettone.", + "hfTokenInvalid": "Gettone HF non valido o mancante", + "hfTokenUnableToVerify": "Impossibile verificare il gettone HF", + "hfTokenSaved": "Gettone HF salvato", + "hfForbidden": "Non hai accesso a questo modello HF", + "hfTokenLabel": "Gettone HuggingFace (richiesto per alcuni modelli)", + "hfForbiddenErrorMessage": "Consigliamo di visitare la pagina del repository su HuggingFace.com. Il proprietario potrebbe richiedere l'accettazione dei termini per poter effettuare il download.", + "hfTokenInvalidErrorMessage2": "Aggiornalo in " + }, + "parameters": { + "images": "Immagini", + "steps": "Passi", + "cfgScale": "Scala CFG", + "width": "Larghezza", + "height": "Altezza", + "seed": "Seme", + "shuffle": "Mescola il seme", + "noiseThreshold": "Soglia del rumore", + "perlinNoise": "Rumore Perlin", + "type": "Tipo", + "strength": "Forza", + "upscaling": "Amplia", + "scale": "Scala", + "imageFit": "Adatta l'immagine iniziale alle dimensioni di output", + "scaleBeforeProcessing": "Scala prima dell'elaborazione", + "scaledWidth": "Larghezza scalata", + "scaledHeight": "Altezza scalata", + "infillMethod": "Metodo di riempimento", + "tileSize": "Dimensione piastrella", + "downloadImage": "Scarica l'immagine", + "usePrompt": "Usa Prompt", + "useSeed": "Usa Seme", + "useAll": "Usa Tutto", + "info": "Informazioni", + "showOptionsPanel": "Mostra il pannello laterale (O o T)", + "general": "Generale", + "denoisingStrength": "Forza di riduzione del rumore", + "copyImage": "Copia immagine", + "cancel": { + "cancel": "Annulla" + }, + "symmetry": "Simmetria", + "seamlessXAxis": "Asse X senza giunte", + "seamlessYAxis": "Asse Y senza giunte", + "scheduler": "Campionatore", + "positivePromptPlaceholder": "Prompt Positivo", + "negativePromptPlaceholder": "Prompt Negativo", + "controlNetControlMode": "Modalità di controllo", + "clipSkip": "CLIP Skip", + "maskBlur": "Sfoc. maschera", + "patchmatchDownScaleSize": "Ridimensiona", + "coherenceMode": "Modalità", + "invoke": { + "noNodesInGraph": "Nessun nodo nel grafico", + "noModelSelected": "Nessun modello selezionato", + "noPrompts": "Nessun prompt generato", + "addingImagesTo": "Aggiungi immagini a", + "systemDisconnected": "Sistema disconnesso", + "missingNodeTemplate": "Modello di nodo mancante", + "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} ingresso mancante", + "missingFieldTemplate": "Modello di campo mancante", + "layer": { + "controlAdapterNoModelSelected": "Nessun modello di adattatore di controllo selezionato", + "controlAdapterIncompatibleBaseModel": "Il modello base dell'adattatore di controllo non è compatibile", + "ipAdapterNoModelSelected": "Nessun adattatore IP selezionato", + "ipAdapterIncompatibleBaseModel": "Il modello base dell'adattatore IP non è compatibile", + "ipAdapterNoImageSelected": "Nessuna immagine dell'adattatore IP selezionata", + "rgNoPromptsOrIPAdapters": "Nessun prompt o adattatore IP", + "rgNoRegion": "Nessuna regione selezionata", + "t2iAdapterIncompatibleBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, larghezza riquadro è {{width}}", + "t2iAdapterIncompatibleBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, altezza riquadro è {{height}}", + "t2iAdapterIncompatibleScaledBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, larghezza del riquadro scalato {{width}}", + "t2iAdapterIncompatibleScaledBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, altezza del riquadro scalato {{height}}" + }, + "fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), altezza riquadro è {{height}}", + "fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), larghezza riquadro è {{width}}", + "fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), larghezza del riquadro scalato è {{width}}", + "fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), altezza del riquadro scalato è {{height}}", + "noT5EncoderModelSelected": "Nessun modello di encoder T5 selezionato per la generazione con FLUX", + "noCLIPEmbedModelSelected": "Nessun modello CLIP Embed selezionato per la generazione con FLUX", + "noFLUXVAEModelSelected": "Nessun modello VAE selezionato per la generazione con FLUX", + "canvasIsTransforming": "La tela sta trasformando", + "canvasIsRasterizing": "La tela sta rasterizzando", + "canvasIsCompositing": "La tela è in fase di composizione", + "canvasIsFiltering": "La tela sta filtrando" + }, + "useCpuNoise": "Usa la CPU per generare rumore", + "iterations": "Iterazioni", + "imageActions": "Azioni Immagine", + "cfgRescaleMultiplier": "Moltiplicatore riscala CFG", + "useSize": "Usa Dimensioni", + "setToOptimalSize": "Ottimizza le dimensioni per il modello", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (potrebbe essere troppo piccolo)", + "lockAspectRatio": "Blocca proporzioni", + "swapDimensions": "Scambia dimensioni", + "aspect": "Aspetto", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (potrebbe essere troppo grande)", + "remixImage": "Remixa l'immagine", + "coherenceEdgeSize": "Dim. bordo", + "infillColorValue": "Colore di riempimento", + "processImage": "Elabora Immagine", + "sendToUpscale": "Invia a Amplia", + "postProcessing": "Post-elaborazione (Shift + U)", + "guidance": "Guida", + "gaussianBlur": "Sfocatura Gaussiana", + "boxBlur": "Sfocatura Box", + "staged": "Maschera espansa", + "optimizedImageToImage": "Immagine-a-immagine ottimizzata", + "sendToCanvas": "Invia alla Tela", + "coherenceMinDenoise": "Min rid. rumore", + "recallMetadata": "Richiama i metadati", + "disabledNoRasterContent": "Disabilitato (nessun contenuto Raster)" + }, + "settings": { + "models": "Modelli", + "displayInProgress": "Visualizza le immagini di avanzamento", + "confirmOnDelete": "Conferma l'eliminazione", + "resetWebUI": "Reimposta l'interfaccia utente Web", + "resetWebUIDesc1": "Il ripristino dell'interfaccia utente Web reimposta solo la cache locale del browser delle immagini e le impostazioni memorizzate. Non cancella alcuna immagine dal disco.", + "resetWebUIDesc2": "Se le immagini non vengono visualizzate nella galleria o qualcos'altro non funziona, prova a reimpostare prima di segnalare un problema su GitHub.", + "resetComplete": "L'interfaccia utente Web è stata reimpostata.", + "general": "Generale", + "developer": "Sviluppatore", + "antialiasProgressImages": "Anti aliasing delle immagini di avanzamento", + "showProgressInViewer": "Mostra le immagini di avanzamento nel visualizzatore", + "generation": "Generazione", + "ui": "Interfaccia Utente", + "beta": "Beta", + "clearIntermediates": "Cancella le immagini intermedie", + "clearIntermediatesDesc3": "Le immagini della galleria non verranno eliminate.", + "clearIntermediatesDesc2": "Le immagini intermedie sono sottoprodotti della generazione, diversi dalle immagini risultanti nella galleria. La cancellazione degli intermedi libererà spazio su disco.", + "intermediatesCleared_one": "Cancellata {{count}} immagine intermedia", + "intermediatesCleared_many": "Cancellate {{count}} immagini intermedie", + "intermediatesCleared_other": "Cancellate {{count}} immagini intermedie", + "clearIntermediatesDesc1": "La cancellazione delle immagini intermedie ripristinerà lo stato della Tela e degli Adattatori di Controllo.", + "intermediatesClearedFailed": "Problema con la cancellazione delle immagini intermedie", + "clearIntermediatesWithCount_one": "Cancella {{count}} immagine intermedia", + "clearIntermediatesWithCount_many": "Cancella {{count}} immagini intermedie", + "clearIntermediatesWithCount_other": "Cancella {{count}} immagini intermedie", + "clearIntermediatesDisabled": "La coda deve essere vuota per cancellare le immagini intermedie", + "enableNSFWChecker": "Abilita controllo NSFW", + "enableInvisibleWatermark": "Abilita filigrana invisibile", + "enableInformationalPopovers": "Abilita testo informativo a comparsa", + "reloadingIn": "Ricaricando in", + "informationalPopoversDisabled": "Testo informativo a comparsa disabilitato", + "informationalPopoversDisabledDesc": "I testi informativi a comparsa sono disabilitati. Attivali nelle impostazioni.", + "confirmOnNewSession": "Conferma su nuova sessione", + "enableModelDescriptions": "Abilita le descrizioni dei modelli nei menu a discesa", + "modelDescriptionsDisabled": "Descrizioni dei modelli nei menu a discesa disabilitate", + "modelDescriptionsDisabledDesc": "Le descrizioni dei modelli nei menu a discesa sono state disabilitate. Abilitale nelle Impostazioni.", + "showDetailedInvocationProgress": "Mostra dettagli avanzamento" + }, + "toast": { + "uploadFailed": "Caricamento fallito", + "imageCopied": "Immagine copiata", + "parametersNotSet": "Parametri non richiamati", + "serverError": "Errore del Server", + "connected": "Connesso al server", + "canceled": "Elaborazione annullata", + "uploadFailedInvalidUploadDesc": "Devono essere immagini PNG o JPEG.", + "parameterSet": "Parametro richiamato", + "parameterNotSet": "Parametro non richiamato", + "problemCopyingImage": "Impossibile copiare l'immagine", + "baseModelChangedCleared_one": "Cancellato o disabilitato {{count}} sottomodello incompatibile", + "baseModelChangedCleared_many": "Cancellati o disabilitati {{count}} sottomodelli incompatibili", + "baseModelChangedCleared_other": "Cancellati o disabilitati {{count}} sottomodelli incompatibili", + "loadedWithWarnings": "Flusso di lavoro caricato con avvisi", + "imageUploaded": "Immagine caricata", + "addedToBoard": "Aggiunto alle risorse della bacheca {{name}}", + "modelAddedSimple": "Modello aggiunto alla Coda", + "imageUploadFailed": "Caricamento immagine non riuscito", + "setControlImage": "Imposta come immagine di controllo", + "setNodeField": "Imposta come campo nodo", + "workflowLoaded": "Flusso di lavoro caricato", + "invalidUpload": "Caricamento non valido", + "problemDeletingWorkflow": "Problema durante l'eliminazione del flusso di lavoro", + "workflowDeleted": "Flusso di lavoro eliminato", + "problemRetrievingWorkflow": "Problema nel recupero del flusso di lavoro", + "problemDownloadingImage": "Impossibile scaricare l'immagine", + "prunedQueue": "Coda ripulita", + "modelImportCanceled": "Importazione del modello annullata", + "parameters": "Parametri", + "parameterSetDesc": "{{parameter}} richiamato", + "parameterNotSetDesc": "Impossibile richiamare {{parameter}}", + "parameterNotSetDescWithMessage": "Impossibile richiamare {{parameter}}: {{message}}", + "parametersSet": "Parametri richiamati", + "errorCopied": "Errore copiato", + "outOfMemoryError": "Errore di memoria esaurita", + "baseModelChanged": "Modello base modificato", + "sessionRef": "Sessione: {{sessionId}}", + "somethingWentWrong": "Qualcosa è andato storto", + "outOfMemoryErrorDesc": "Le impostazioni della generazione attuale superano la capacità del sistema. Modifica le impostazioni e riprova.", + "importFailed": "Importazione non riuscita", + "importSuccessful": "Importazione riuscita", + "layerSavedToAssets": "Livello salvato nelle risorse", + "problemSavingLayer": "Impossibile salvare il livello", + "unableToLoadImage": "Impossibile caricare l'immagine", + "problemCopyingLayer": "Impossibile copiare il livello", + "sentToCanvas": "Inviato alla Tela", + "sentToUpscale": "Inviato a Amplia", + "unableToLoadStylePreset": "Impossibile caricare lo stile predefinito", + "stylePresetLoaded": "Stile predefinito caricato", + "unableToLoadImageMetadata": "Impossibile caricare i metadati dell'immagine", + "imageSaved": "Immagine salvata", + "imageSavingFailed": "Salvataggio dell'immagine non riuscito", + "layerCopiedToClipboard": "Livello copiato negli appunti", + "imageNotLoadedDesc": "Impossibile trovare l'immagine", + "linkCopied": "Collegamento copiato", + "addedToUncategorized": "Aggiunto alle risorse della bacheca $t(boards.uncategorized)", + "imagesWillBeAddedTo": "Le immagini caricate verranno aggiunte alle risorse della bacheca {{boardName}}.", + "uploadFailedInvalidUploadDesc_withCount_one": "Devi caricare al massimo 1 immagine PNG o JPEG.", + "uploadFailedInvalidUploadDesc_withCount_many": "Devi caricare al massimo {{count}} immagini PNG o JPEG.", + "uploadFailedInvalidUploadDesc_withCount_other": "Devi caricare al massimo {{count}} immagini PNG o JPEG." + }, + "accessibility": { + "invokeProgressBar": "Barra di avanzamento generazione", + "uploadImage": "Carica immagine", + "previousImage": "Immagine precedente", + "nextImage": "Immagine successiva", + "reset": "Reimposta", + "menu": "Menu", + "mode": "Modalità", + "resetUI": "$t(accessibility.reset) l'Interfaccia Utente", + "createIssue": "Segnala un problema", + "about": "Informazioni", + "submitSupportTicket": "Invia ticket di supporto", + "toggleLeftPanel": "Attiva/disattiva il pannello sinistro (T)", + "toggleRightPanel": "Attiva/disattiva il pannello destro (G)", + "uploadImages": "Carica immagine(i)" + }, + "nodes": { + "zoomOutNodes": "Rimpicciolire", + "hideLegendNodes": "Nascondi la legenda del tipo di campo", + "showLegendNodes": "Mostra legenda del tipo di campo", + "hideMinimapnodes": "Nascondi minimappa", + "showMinimapnodes": "Mostra minimappa", + "zoomInNodes": "Ingrandire", + "fitViewportNodes": "Adatta vista", + "reloadNodeTemplates": "Ricarica i modelli di nodo", + "loadWorkflow": "Importa flusso di lavoro JSON", + "downloadWorkflow": "Esporta flusso di lavoro JSON", + "scheduler": "Campionatore", + "addNode": "Aggiungi nodo", + "animatedEdgesHelp": "Anima i bordi selezionati e i bordi collegati ai nodi selezionati", + "executionStateInProgress": "In corso", + "executionStateError": "Errore", + "executionStateCompleted": "Completato", + "addNodeToolTip": "Aggiungi nodo (Shift+A, Space)", + "colorCodeEdgesHelp": "Bordi con codice colore in base ai campi collegati", + "animatedEdges": "Bordi animati", + "snapToGrid": "Aggancia alla griglia", + "validateConnections": "Convalida connessioni e grafico", + "validateConnectionsHelp": "Impedisce che vengano effettuate connessioni non valide e che vengano \"invocati\" grafici non validi", + "fullyContainNodesHelp": "I nodi devono essere completamente all'interno della casella di selezione per essere selezionati", + "fullyContainNodes": "Contenere completamente i nodi da selezionare", + "snapToGridHelp": "Aggancia i nodi alla griglia quando vengono spostati", + "workflowSettings": "Impostazioni Editor del flusso di lavoro", + "colorCodeEdges": "Bordi con codice colore", + "noOutputRecorded": "Nessun output registrato", + "noFieldsLinearview": "Nessun campo aggiunto alla vista lineare", + "removeLinearView": "Rimuovi dalla vista lineare", + "workflowDescription": "Breve descrizione", + "workflowContact": "Contatto", + "workflowVersion": "Versione", + "workflow": "Flusso di lavoro", + "noWorkflow": "Nessun flusso di lavoro", + "workflowTags": "Tag", + "workflowValidation": "Errore di convalida del flusso di lavoro", + "workflowAuthor": "Autore", + "workflowName": "Nome", + "workflowNotes": "Note", + "versionUnknown": " Versione sconosciuta", + "unableToValidateWorkflow": "Impossibile convalidare il flusso di lavoro", + "updateApp": "Aggiorna Applicazione", + "unableToLoadWorkflow": "Impossibile caricare il flusso di lavoro", + "updateNode": "Aggiorna nodo", + "version": "Versione", + "notes": "Note", + "problemSettingTitle": "Problema nell'impostazione del titolo", + "unknownTemplate": "Modello sconosciuto", + "nodeType": "Tipo di nodo", + "notesDescription": "Aggiunge note sul tuo flusso di lavoro", + "unknownField": "Campo sconosciuto", + "unknownNode": "Nodo sconosciuto", + "missingTemplate": "Nodo non valido: nodo {{node}} di tipo {{type}} modello mancante (non installato?)", + "noNodeSelected": "Nessun nodo selezionato", + "nodeTemplate": "Modello di nodo", + "nodeOpacity": "Opacità del nodo", + "nodeSearch": "Cerca nodi", + "nodeOutputs": "Uscite del nodo", + "noConnectionInProgress": "Nessuna connessione in corso", + "cannotDuplicateConnection": "Impossibile creare connessioni duplicate", + "boolean": "Booleani", + "node": "Nodo", + "collection": "Raccolta", + "cannotConnectInputToInput": "Impossibile collegare Input a Input", + "cannotConnectOutputToOutput": "Impossibile collegare Output ad Output", + "cannotConnectToSelf": "Impossibile connettersi a se stesso", + "mismatchedVersion": "Nodo non valido: il nodo {{node}} di tipo {{type}} ha una versione non corrispondente (provare ad aggiornare?)", + "loadingNodes": "Caricamento nodi...", + "enum": "Enumeratore", + "float": "In virgola mobile", + "currentImageDescription": "Visualizza l'immagine corrente nell'editor dei nodi", + "fieldTypesMustMatch": "I tipi di campo devono corrispondere", + "edge": "Collegamento", + "currentImage": "Immagine corrente", + "integer": "Numero Intero", + "inputMayOnlyHaveOneConnection": "L'ingresso può avere solo una connessione", + "ipAdapter": "Adattatore IP", + "string": "Stringa", + "connectionWouldCreateCycle": "La connessione creerebbe un ciclo", + "updateAllNodes": "Aggiorna i nodi", + "unableToUpdateNodes_one": "Impossibile aggiornare {{count}} nodo", + "unableToUpdateNodes_many": "Impossibile aggiornare {{count}} nodi", + "unableToUpdateNodes_other": "Impossibile aggiornare {{count}} nodi", + "addLinearView": "Aggiungi alla vista Lineare", + "unknownErrorValidatingWorkflow": "Errore sconosciuto durante la convalida del flusso di lavoro", + "collectionFieldType": "{{name}} (Raccolta)", + "collectionOrScalarFieldType": "{{name}} (Singola o Raccolta)", + "nodeVersion": "Versione Nodo", + "inputFieldTypeParseError": "Impossibile analizzare il tipo di campo di input {{node}}.{{field}} ({{message}})", + "unsupportedArrayItemType": "Tipo di elemento dell'array non supportato \"{{type}}\"", + "targetNodeFieldDoesNotExist": "Connessione non valida: il campo di destinazione/input {{node}}.{{field}} non esiste", + "unsupportedMismatchedUnion": "tipo CollectionOrScalar non corrispondente con tipi di base {{firstType}} e {{secondType}}", + "allNodesUpdated": "Tutti i nodi sono aggiornati", + "sourceNodeDoesNotExist": "Connessione non valida: il nodo di origine/output {{node}} non esiste", + "unableToExtractEnumOptions": "Impossibile estrarre le opzioni enum", + "unableToParseFieldType": "Impossibile analizzare il tipo di campo", + "outputFieldTypeParseError": "Impossibile analizzare il tipo di campo di output {{node}}.{{field}} ({{message}})", + "sourceNodeFieldDoesNotExist": "Connessione non valida: il campo di origine/output {{node}}.{{field}} non esiste", + "unableToGetWorkflowVersion": "Impossibile ottenere la versione dello schema del flusso di lavoro", + "nodePack": "Pacchetto di nodi", + "unableToExtractSchemaNameFromRef": "Impossibile estrarre il nome dello schema dal riferimento", + "unknownOutput": "Output sconosciuto: {{name}}", + "unknownNodeType": "Tipo di nodo sconosciuto", + "targetNodeDoesNotExist": "Connessione non valida: il nodo di destinazione/input {{node}} non esiste", + "unknownFieldType": "$t(nodes.unknownField) tipo: {{type}}", + "deletedInvalidEdge": "Eliminata connessione non valida {{source}} -> {{target}}", + "unknownInput": "Input sconosciuto: {{name}}", + "prototypeDesc": "Questa invocazione è un prototipo. Potrebbe subire modifiche sostanziali durante gli aggiornamenti dell'app e potrebbe essere rimossa in qualsiasi momento.", + "betaDesc": "Questa invocazione è in versione beta. Fino a quando non sarà stabile, potrebbe subire modifiche importanti durante gli aggiornamenti dell'app. Abbiamo intenzione di supportare questa invocazione a lungo termine.", + "newWorkflow": "Nuovo flusso di lavoro", + "newWorkflowDesc": "Creare un nuovo flusso di lavoro?", + "newWorkflowDesc2": "Il flusso di lavoro attuale presenta modifiche non salvate.", + "unsupportedAnyOfLength": "unione di troppi elementi ({{count}})", + "clearWorkflowDesc": "Cancellare questo flusso di lavoro e avviarne uno nuovo?", + "clearWorkflow": "Cancella il flusso di lavoro", + "clearWorkflowDesc2": "Il tuo flusso di lavoro attuale presenta modifiche non salvate.", + "viewMode": "Usa la vista lineare", + "reorderLinearView": "Riordina la vista lineare", + "editMode": "Modifica nell'editor del flusso di lavoro", + "resetToDefaultValue": "Ripristina il valore predefinito", + "noFieldsViewMode": "Questo flusso di lavoro non ha campi selezionati da visualizzare. Visualizza il flusso di lavoro completo per configurare i valori.", + "edit": "Modifica", + "graph": "Grafico", + "showEdgeLabelsHelp": "Mostra etichette sui collegamenti, che indicano i nodi collegati", + "showEdgeLabels": "Mostra le etichette del collegamento", + "cannotMixAndMatchCollectionItemTypes": "Impossibile combinare e abbinare i tipi di elementi della raccolta", + "noGraph": "Nessun grafico", + "missingNode": "Nodo di invocazione mancante", + "missingInvocationTemplate": "Modello di invocazione mancante", + "missingFieldTemplate": "Modello di campo mancante", + "singleFieldType": "{{name}} (Singola)", + "imageAccessError": "Impossibile trovare l'immagine {{image_name}}, ripristino ai valori predefiniti", + "boardAccessError": "Impossibile trovare la bacheca {{board_id}}, ripristino ai valori predefiniti", + "modelAccessError": "Impossibile trovare il modello {{key}}, ripristino ai valori predefiniti", + "saveToGallery": "Salva nella Galleria", + "noMatchingWorkflows": "Nessun flusso di lavoro corrispondente", + "noWorkflows": "Nessun flusso di lavoro", + "workflowHelpText": "Hai bisogno di aiuto? Consulta la nostra guida Introduzione ai flussi di lavoro." + }, + "boards": { + "autoAddBoard": "Aggiungi automaticamente bacheca", + "menuItemAutoAdd": "Aggiungi automaticamente a questa bacheca", + "cancel": "Annulla", + "addBoard": "Aggiungi Bacheca", + "bottomMessage": "L'eliminazione di questa bacheca e delle sue immagini ripristinerà tutte le funzionalità che le stanno attualmente utilizzando.", + "changeBoard": "Cambia Bacheca", + "loading": "Caricamento in corso ...", + "clearSearch": "Cancella Ricerca", + "topMessage": "Questa bacheca contiene immagini utilizzate nelle seguenti funzionalità:", + "move": "Sposta", + "myBoard": "Bacheca", + "searchBoard": "Cerca bacheche ...", + "noMatching": "Nessuna bacheca corrispondente", + "selectBoard": "Seleziona una bacheca", + "uncategorized": "Non categorizzato", + "downloadBoard": "Scarica la bacheca", + "deleteBoardOnly": "solo la Bacheca", + "deleteBoard": "Elimina Bacheca", + "deleteBoardAndImages": "Bacheca e Immagini", + "deletedBoardsCannotbeRestored": "Le bacheche eliminate non possono essere ripristinate. Selezionando \"Elimina solo bacheca\" le immagini verranno spostate nella bacheca \"Non categorizzato\".", + "movingImagesToBoard_one": "Spostare {{count}} immagine nella bacheca:", + "movingImagesToBoard_many": "Spostare {{count}} immagini nella bacheca:", + "movingImagesToBoard_other": "Spostare {{count}} immagini nella bacheca:", + "imagesWithCount_one": "{{count}} immagine", + "imagesWithCount_many": "{{count}} immagini", + "imagesWithCount_other": "{{count}} immagini", + "assetsWithCount_one": "{{count}} risorsa", + "assetsWithCount_many": "{{count}} risorse", + "assetsWithCount_other": "{{count}} risorse", + "archiveBoard": "Archivia la bacheca", + "archived": "Archiviato", + "unarchiveBoard": "Annulla l'archiviazione della bacheca", + "selectedForAutoAdd": "Selezionato per l'aggiunta automatica", + "addSharedBoard": "Aggiungi una Bacheca Condivisa", + "boards": "Bacheche", + "private": "Bacheche private", + "shared": "Bacheche condivise", + "addPrivateBoard": "Aggiungi una Bacheca Privata", + "noBoards": "Nessuna bacheca {{boardType}}", + "hideBoards": "Nascondi bacheche", + "viewBoards": "Visualizza bacheche", + "deletedPrivateBoardsCannotbeRestored": "Le bacheche cancellate non possono essere ripristinate. Selezionando 'Cancella solo bacheca', le immagini verranno spostate nella bacheca \"Non categorizzato\" privata dell'autore dell'immagine.", + "updateBoardError": "Errore durante l'aggiornamento della bacheca" + }, + "queue": { + "queueFront": "Aggiungi all'inizio della coda", + "queueBack": "Aggiungi alla coda", + "queue": "Coda", + "status": "Stato", + "pruneSucceeded": "Rimossi {{item_count}} elementi completati dalla coda", + "cancelTooltip": "Annulla l'elemento corrente", + "queueEmpty": "Coda vuota", + "pauseSucceeded": "Elaborazione sospesa", + "in_progress": "In corso", + "notReady": "Impossibile mettere in coda", + "batchFailedToQueue": "Impossibile mettere in coda il lotto", + "completed": "Completati", + "cancelFailed": "Problema durante l'annullamento dell'elemento", + "batchQueued": "Lotto aggiunto alla coda", + "pauseFailed": "Problema durante la sospensione dell'elaborazione", + "clearFailed": "Problema nella cancellazione della coda", + "front": "inizio", + "clearSucceeded": "Coda cancellata", + "pause": "Sospendi", + "pruneTooltip": "Rimuovi {{item_count}} elementi completati", + "cancelSucceeded": "Elemento annullato", + "batchQueuedDesc_one": "Aggiunta {{count}} sessione a {{direction}} della coda", + "batchQueuedDesc_many": "Aggiunte {{count}} sessioni a {{direction}} della coda", + "batchQueuedDesc_other": "Aggiunte {{count}} sessioni a {{direction}} della coda", + "graphQueued": "Grafico in coda", + "batch": "Lotto", + "clearQueueAlertDialog": "Lo svuotamento della coda annulla immediatamente tutti gli elementi in elaborazione e cancella completamente la coda. I filtri in sospeso verranno annullati.", + "pending": "In attesa", + "completedIn": "Completato in", + "resumeFailed": "Problema nel riavvio dell'elaborazione", + "clear": "Cancella", + "prune": "Rimuovi", + "total": "Totale", + "canceled": "Annullati", + "pruneFailed": "Problema nel rimuovere la coda", + "cancelBatchSucceeded": "Lotto annullato", + "clearTooltip": "Annulla e cancella tutti gli elementi", + "current": "Attuale", + "pauseTooltip": "Sospendi l'elaborazione", + "failed": "Falliti", + "cancelItem": "Annulla l'elemento", + "next": "Prossimo", + "cancelBatch": "Annulla lotto", + "back": "fine", + "cancel": "Annulla", + "session": "Sessione", + "resumeSucceeded": "Elaborazione ripresa", + "enqueueing": "Lotto in coda", + "resumeTooltip": "Riprendi l'elaborazione", + "resume": "Riprendi", + "cancelBatchFailed": "Problema durante l'annullamento del lotto", + "clearQueueAlertDialog2": "Sei sicuro di voler cancellare la coda?", + "item": "Elemento", + "graphFailedToQueue": "Impossibile mettere in coda il grafico", + "batchFieldValues": "Valori Campi Lotto", + "time": "Tempo", + "openQueue": "Apri coda", + "iterations_one": "Iterazione", + "iterations_many": "Iterazioni", + "iterations_other": "Iterazioni", + "prompts_one": "Prompt", + "prompts_many": "Prompt", + "prompts_other": "Prompt", + "generations_one": "Generazione", + "generations_many": "Generazioni", + "generations_other": "Generazioni", + "origin": "Origine", + "destination": "Destinazione", + "upscaling": "Ampliamento", + "canvas": "Tela", + "workflows": "Flussi di lavoro", + "generation": "Generazione", + "other": "Altro", + "gallery": "Galleria" + }, + "models": { + "noMatchingModels": "Nessun modello corrispondente", + "loading": "caricamento", + "noMatchingLoRAs": "Nessun LoRA corrispondente", + "noModelsAvailable": "Nessun modello disponibile", + "selectModel": "Seleziona un modello", + "noRefinerModelsInstalled": "Nessun modello affinatore SDXL installato", + "noLoRAsInstalled": "Nessun LoRA installato", + "addLora": "Aggiungi LoRA", + "defaultVAE": "VAE predefinito", + "concepts": "Concetti", + "lora": "LoRA" + }, + "invocationCache": { + "disable": "Disabilita", + "misses": "Non trovati in cache", + "enableFailed": "Problema nell'abilitazione della cache delle invocazioni", + "invocationCache": "Cache delle invocazioni", + "clearSucceeded": "Cache delle invocazioni svuotata", + "enableSucceeded": "Cache delle invocazioni abilitata", + "clearFailed": "Problema durante lo svuotamento della cache delle invocazioni", + "hits": "Trovati in cache", + "disableSucceeded": "Cache delle invocazioni disabilitata", + "disableFailed": "Problema durante la disabilitazione della cache delle invocazioni", + "enable": "Abilita", + "clear": "Svuota", + "maxCacheSize": "Dimensione max cache", + "cacheSize": "Dimensione cache", + "useCache": "Usa Cache" + }, + "dynamicPrompts": { + "seedBehaviour": { + "perPromptDesc": "Utilizza un seme diverso per ogni immagine", + "perIterationLabel": "Per iterazione", + "perIterationDesc": "Utilizza un seme diverso per ogni iterazione", + "perPromptLabel": "Per immagine", + "label": "Comportamento del seme" + }, + "maxPrompts": "Numero massimo di prompt", + "dynamicPrompts": "Prompt dinamici", + "promptsPreview": "Anteprima dei prompt", + "showDynamicPrompts": "Mostra prompt dinamici", + "loading": "Generazione prompt dinamici..." + }, + "popovers": { + "paramScheduler": { + "paragraphs": [ + "Il campionatore utilizzato durante il processo di generazione.", + "Ciascun campionatore definisce come aggiungere in modo iterativo il rumore a un'immagine o come aggiornare un campione in base all'output di un modello." + ], + "heading": "Campionatore" + }, + "compositingMaskAdjustments": { + "heading": "Regolazioni della maschera", + "paragraphs": [ + "Regola la maschera." + ] + }, + "compositingCoherenceMode": { + "heading": "Modalità", + "paragraphs": [ + "Metodo utilizzato per creare un'immagine coerente con l'area mascherata appena generata." + ] + }, + "clipSkip": { + "paragraphs": [ + "Scegli quanti livelli del modello CLIP saltare.", + "Alcuni modelli funzionano meglio con determinate impostazioni di CLIP Skip." + ], + "heading": "CLIP Skip" + }, + "compositingCoherencePass": { + "heading": "Passaggio di Coerenza", + "paragraphs": [ + "Un secondo ciclo di riduzione del rumore aiuta a comporre l'immagine Inpaint/Outpaint." + ] + }, + "paramNegativeConditioning": { + "paragraphs": [ + "Il processo di generazione evita i concetti nel prompt negativo. Utilizzatelo per escludere qualità o oggetti dall'output.", + "Supporta la sintassi e gli incorporamenti di Compel." + ], + "heading": "Prompt negativo" + }, + "compositingBlurMethod": { + "heading": "Metodo di sfocatura", + "paragraphs": [ + "Il metodo di sfocatura applicato all'area mascherata." + ] + }, + "paramPositiveConditioning": { + "heading": "Prompt positivo", + "paragraphs": [ + "Guida il processo di generazione. Puoi usare qualsiasi parola o frase.", + "Supporta sintassi e incorporamenti di Compel e Prompt Dinamici." + ] + }, + "controlNetBeginEnd": { + "heading": "Percentuale passi Inizio / Fine", + "paragraphs": [ + "La parte del processo di rimozione del rumore in cui verrà applicato l'adattatore di controllo.", + "In genere, gli adattatori di controllo applicati all'inizio del processo guidano la composizione, mentre quelli applicati alla fine guidano i dettagli." + ] + }, + "noiseUseCPU": { + "paragraphs": [ + "Controlla se viene generato rumore sulla CPU o sulla GPU.", + "Con il rumore della CPU abilitato, un seme particolare produrrà la stessa immagine su qualsiasi macchina.", + "Non vi è alcun impatto sulle prestazioni nell'abilitare il rumore della CPU." + ], + "heading": "Usa la CPU per generare rumore" + }, + "scaleBeforeProcessing": { + "paragraphs": [ + "\"Auto\" scala l'area selezionata alla dimensione più adatta al modello prima del processo di generazione dell'immagine.", + "\"Manuale\" consente di scegliere la larghezza e l'altezza a cui verrà ridimensionata l'area selezionata prima del processo di generazione dell'immagine." + ], + "heading": "Scala prima dell'elaborazione" + }, + "paramRatio": { + "heading": "Proporzioni", + "paragraphs": [ + "Le proporzioni delle dimensioni dell'immagine generata.", + "Per i modelli SD1.5 si consiglia una dimensione dell'immagine (in numero di pixel) equivalente a 512x512 mentre per i modelli SDXL si consiglia una dimensione equivalente a 1024x1024." + ] + }, + "dynamicPrompts": { + "paragraphs": [ + "Prompt Dinamici crea molte variazioni a partire da un singolo prompt.", + "La sintassi di base è \"a {red|green|blue} ball\". Ciò produrrà tre prompt: \"a red ball\", \"a green ball\" e \"a blue ball\".", + "Puoi utilizzare la sintassi quante volte vuoi in un singolo prompt, ma assicurati di tenere sotto controllo il numero di prompt generati con l'impostazione \"Numero massimo di prompt\"." + ], + "heading": "Prompt Dinamici" + }, + "paramVAE": { + "paragraphs": [ + "Modello utilizzato per tradurre l'output dell'intelligenza artificiale nell'immagine finale." + ], + "heading": "VAE" + }, + "paramIterations": { + "paragraphs": [ + "Il numero di immagini da generare.", + "Se i prompt dinamici sono abilitati, ciascuno dei prompt verrà generato questo numero di volte." + ], + "heading": "Iterazioni" + }, + "paramVAEPrecision": { + "heading": "Precisione VAE", + "paragraphs": [ + "La precisione utilizzata durante la codifica e decodifica VAE.", + "Fp16/Mezza precisione è più efficiente, a scapito di minori variazioni dell'immagine." + ] + }, + "paramSeed": { + "paragraphs": [ + "Controlla il rumore iniziale utilizzato per la generazione.", + "Disabilita l'opzione \"Casuale\" per produrre risultati identici con le stesse impostazioni di generazione." + ], + "heading": "Seme" + }, + "controlNetResizeMode": { + "heading": "Modalità ridimensionamento", + "paragraphs": [ + "Metodo per adattare le dimensioni dell'immagine di controllo dell'adattatore alle dimensioni di generazione." + ] + }, + "dynamicPromptsSeedBehaviour": { + "paragraphs": [ + "Controlla il modo in cui viene utilizzato il seme durante la generazione dei prompt.", + "Per iterazione utilizzerà un seme univoco per ogni iterazione. Usalo per esplorare variazioni del prompt su un singolo seme.", + "Ad esempio, se hai 5 prompt, ogni immagine utilizzerà lo stesso seme.", + "Per immagine utilizzerà un seme univoco per ogni immagine. Ciò fornisce più variazione." + ], + "heading": "Comportamento del seme" + }, + "paramModel": { + "heading": "Modello", + "paragraphs": [ + "Modello utilizzato per la generazione. Diversi modelli vengono addestrati per specializzarsi nella produzione di risultati e contenuti estetici diversi." + ] + }, + "paramDenoisingStrength": { + "paragraphs": [ + "Controlla la differenza tra l'immagine generata e il/i livello/i raster.", + "Una forza inferiore rimane più vicina ai livelli raster visibili combinati. Una forza superiore si basa maggiormente sul prompt globale.", + "Se non sono presenti livelli raster con contenuto visibile, questa impostazione viene ignorata." + ], + "heading": "Forza di riduzione del rumore" + }, + "dynamicPromptsMaxPrompts": { + "heading": "Numero massimo di prompt", + "paragraphs": [ + "Limita il numero di prompt che possono essere generati da Prompt Dinamici." + ] + }, + "infillMethod": { + "paragraphs": [ + "Metodo di riempimento durante il processo di Outpaint o Inpaint." + ], + "heading": "Metodo di riempimento" + }, + "controlNetWeight": { + "heading": "Peso", + "paragraphs": [ + "Peso dell'adattatore di controllo. Un peso maggiore porterà a impatti maggiori sull'immagine finale." + ] + }, + "paramCFGScale": { + "heading": "Scala CFG", + "paragraphs": [ + "Controlla quanto il prompt influenza il processo di generazione.", + "Valori elevati della scala CFG possono provocare una saturazione eccessiva e distorsioni nei risultati della generazione. " + ] + }, + "controlNetControlMode": { + "paragraphs": [ + "Attribuisce più peso al prompt oppure a ControlNet." + ], + "heading": "Modalità di controllo" + }, + "paramSteps": { + "heading": "Passi", + "paragraphs": [ + "Numero di passi che verranno eseguiti in ogni generazione.", + "Un numero di passi più elevato generalmente creerà immagini migliori ma richiederà più tempo di generazione." + ] + }, + "lora": { + "heading": "LoRA", + "paragraphs": [ + "Modelli concettuali utilizzati insieme ai modelli di base." + ] + }, + "controlNet": { + "paragraphs": [ + "ControlNet fornisce una guida al processo di generazione, aiutando a creare immagini con composizione, struttura o stile controllati, a seconda del modello selezionato." + ], + "heading": "ControlNet" + }, + "paramCFGRescaleMultiplier": { + "heading": "Moltiplicatore di riscala CFG", + "paragraphs": [ + "Moltiplicatore di riscala per la guida CFG, utilizzato per modelli addestrati utilizzando SNR a terminale zero (ztsnr).", + "Valore suggerito di 0.7 per questi modelli." + ] + }, + "controlNetProcessor": { + "heading": "Processore", + "paragraphs": [ + "Metodo di elaborazione dell'immagine di input per guidare il processo di generazione. Processori diversi forniranno effetti o stili diversi nelle immagini generate." + ] + }, + "imageFit": { + "heading": "Adatta l'immagine iniziale alle dimensioni di output", + "paragraphs": [ + "Ridimensiona l'immagine iniziale in base alla larghezza e all'altezza dell'immagine di output. Si consiglia di abilitarlo." + ] + }, + "loraWeight": { + "heading": "Peso", + "paragraphs": [ + "Peso del LoRA. Un peso maggiore comporterà un impatto maggiore sull'immagine finale." + ] + }, + "paramAspect": { + "heading": "Aspetto", + "paragraphs": [ + "Proporzioni dell'immagine generata. La modifica del rapporto aggiornerà di conseguenza la larghezza e l'altezza.", + "\"Ottimizza\" imposterà la larghezza e l'altezza alle dimensioni ottimali per il modello scelto." + ] + }, + "paramHeight": { + "heading": "Altezza", + "paragraphs": [ + "Altezza dell'immagine generata. Deve essere un multiplo di 8." + ] + }, + "paramHrf": { + "heading": "Abilita correzione alta risoluzione", + "paragraphs": [ + "Genera immagini di alta qualità con una risoluzione maggiore di quella ottimale per il modello. Generalmente utilizzato per impedire la duplicazione nell'immagine generata." + ] + }, + "paramUpscaleMethod": { + "heading": "Metodo di ampliamento", + "paragraphs": [ + "Metodo utilizzato per ampliare l'immagine per la correzione ad alta risoluzione." + ] + }, + "patchmatchDownScaleSize": { + "heading": "Ridimensiona", + "paragraphs": [ + "Quanto ridimensionamento avviene prima del riempimento.", + "Un ridimensionamento più elevato migliorerà le prestazioni e ridurrà la qualità." + ] + }, + "paramWidth": { + "paragraphs": [ + "Larghezza dell'immagine generata. Deve essere un multiplo di 8." + ], + "heading": "Larghezza" + }, + "refinerModel": { + "heading": "Modello Affinatore", + "paragraphs": [ + "Modello utilizzato durante la parte di affinamento del processo di generazione.", + "Simile al modello di generazione." + ] + }, + "refinerNegativeAestheticScore": { + "paragraphs": [ + "Valuta le generazioni in modo che siano più simili alle immagini con un punteggio estetico basso, in base ai dati di addestramento." + ], + "heading": "Punteggio estetico negativo" + }, + "refinerScheduler": { + "paragraphs": [ + "Campionatore utilizzato durante la parte di affinamento del processo di generazione.", + "Simile al campionatore di generazione." + ], + "heading": "Campionatore" + }, + "refinerStart": { + "heading": "Inizio affinamento", + "paragraphs": [ + "A che punto nel processo di generazione inizierà ad essere utilizzato l'affinatore.", + "0 significa che l'affinatore verrà utilizzato per l'intero processo di generazione, 0.8 significa che l'affinatore verrà utilizzato per l'ultimo 20% del processo di generazione." + ] + }, + "refinerSteps": { + "heading": "Passi", + "paragraphs": [ + "Numero di passi che verranno eseguiti durante la parte di affinamento del processo di generazione.", + "Simile ai passi di generazione." + ] + }, + "refinerCfgScale": { + "heading": "Scala CFG", + "paragraphs": [ + "Controlla quanto il prompt influenza il processo di generazione.", + "Simile alla scala CFG di generazione." + ] + }, + "seamlessTilingXAxis": { + "heading": "Piastrella senza giunte sull'asse X", + "paragraphs": [ + "Affianca senza soluzione di continuità un'immagine lungo l'asse orizzontale." + ] + }, + "seamlessTilingYAxis": { + "heading": "Piastrella senza giunte sull'asse Y", + "paragraphs": [ + "Affianca senza soluzione di continuità un'immagine lungo l'asse verticale." + ] + }, + "refinerPositiveAestheticScore": { + "heading": "Punteggio estetico positivo", + "paragraphs": [ + "Valuta le generazioni in modo che siano più simili alle immagini con un punteggio estetico elevato, in base ai dati di addestramento." + ] + }, + "compositingCoherenceMinDenoise": { + "heading": "Livello minimo di riduzione del rumore", + "paragraphs": [ + "Intensità minima di riduzione rumore per la modalità di Coerenza", + "L'intensità minima di riduzione del rumore per la regione di coerenza durante l'inpaint o l'outpaint" + ] + }, + "compositingMaskBlur": { + "paragraphs": [ + "Il raggio di sfocatura della maschera." + ], + "heading": "Sfocatura maschera" + }, + "compositingCoherenceEdgeSize": { + "heading": "Dimensione del bordo", + "paragraphs": [ + "La dimensione del bordo del passaggio di coerenza." + ] + }, + "ipAdapterMethod": { + "heading": "Metodo", + "paragraphs": [ + "Metodo con cui applicare l'adattatore IP corrente." + ] + }, + "scale": { + "heading": "Scala", + "paragraphs": [ + "La scala controlla la dimensione dell'immagine di uscita e si basa su un multiplo della risoluzione dell'immagine di ingresso. Ad esempio, un ampliamento 2x su un'immagine 1024x1024 produrrebbe in uscita a 2048x2048." + ] + }, + "upscaleModel": { + "paragraphs": [ + "Il modello di ampliamento, scala l'immagine alle dimensioni di uscita prima di aggiungere i dettagli. È possibile utilizzare qualsiasi modello di ampliamento supportato, ma alcuni sono specializzati per diversi tipi di immagini, come foto o disegni al tratto." + ], + "heading": "Modello di ampliamento" + }, + "creativity": { + "heading": "Creatività", + "paragraphs": [ + "La creatività controlla quanta libertà è concessa al modello quando si aggiungono dettagli. Una creatività bassa rimane vicina all'immagine originale, mentre una creatività alta consente più cambiamenti. Quando si usa un prompt, una creatività alta aumenta l'influenza del prompt." + ] + }, + "structure": { + "heading": "Struttura", + "paragraphs": [ + "La struttura determina quanto l'immagine finale rispecchierà il layout dell'originale. Una struttura bassa permette cambiamenti significativi, mentre una struttura alta conserva la composizione e il layout originali." + ] + }, + "fluxDevLicense": { + "heading": "Licenza non commerciale", + "paragraphs": [ + "I modelli FLUX.1 [dev] sono concessi in licenza con la licenza non commerciale FLUX [dev]. Per utilizzare questo tipo di modello per scopi commerciali in Invoke, visita il nostro sito Web per saperne di più." + ] + }, + "optimizedDenoising": { + "heading": "Immagine-a-immagine ottimizzata", + "paragraphs": [ + "Abilita 'Immagine-a-immagine ottimizzata' per una scala di riduzione del rumore più graduale per le trasformazioni da immagine a immagine e di inpaint con modelli Flux. Questa impostazione migliora la capacità di controllare la quantità di modifica applicata a un'immagine, ma può essere disattivata se preferisci usare la scala di riduzione rumore standard. Questa impostazione è ancora in fase di messa a punto ed è in stato beta." + ] + }, + "paramGuidance": { + "heading": "Guida", + "paragraphs": [ + "Controlla quanto il prompt influenza il processo di generazione.", + "Valori di guida elevati possono causare sovrasaturazione e una guida elevata o bassa può causare risultati di generazione distorti. La guida si applica solo ai modelli FLUX DEV." + ] + }, + "regionalReferenceImage": { + "paragraphs": [ + "Pennello per applicare un'immagine di riferimento ad aree specifiche." + ], + "heading": "Immagine di riferimento Regionale" + }, + "rasterLayer": { + "paragraphs": [ + "Contenuto basato sui pixel della tua tela, utilizzato durante la generazione dell'immagine." + ], + "heading": "Livello Raster" + }, + "regionalGuidance": { + "heading": "Guida Regionale", + "paragraphs": [ + "Pennello per guidare la posizione in cui devono apparire gli elementi dei prompt globali." + ] + }, + "regionalGuidanceAndReferenceImage": { + "heading": "Guida regionale e immagine di riferimento regionale", + "paragraphs": [ + "Per la Guida Regionale, utilizzare il pennello per indicare dove devono apparire gli elementi dei prompt globali.", + "Per l'immagine di riferimento regionale, utilizzare il pennello per applicare un'immagine di riferimento ad aree specifiche." + ] + }, + "globalReferenceImage": { + "heading": "Immagine di riferimento Globale", + "paragraphs": [ + "Applica un'immagine di riferimento per influenzare l'intera generazione." + ] + }, + "inpainting": { + "paragraphs": [ + "Controlla quale area viene modificata, in base all'intensità di riduzione del rumore." + ] + } + }, + "sdxl": { + "scheduler": "Campionatore", + "noModelsAvailable": "Nessun modello disponibile", + "denoisingStrength": "Forza di riduzione del rumore", + "concatPromptStyle": "Collega Prompt & Stile", + "loading": "Caricamento...", + "steps": "Passi", + "refinerStart": "Inizio Affinamento", + "cfgScale": "Scala CFG", + "negStylePrompt": "Prompt Stile negativo", + "refiner": "Affinatore", + "negAestheticScore": "Punteggio estetico negativo", + "refinermodel": "Modello Affinatore", + "posAestheticScore": "Punteggio estetico positivo", + "posStylePrompt": "Prompt Stile positivo", + "freePromptStyle": "Prompt di stile manuale", + "refinerSteps": "Passi Affinamento" + }, + "metadata": { + "positivePrompt": "Prompt positivo", + "negativePrompt": "Prompt negativo", + "generationMode": "Modalità generazione", + "Threshold": "Livello di soglia del rumore", + "metadata": "Metadati", + "strength": "Forza Immagine a Immagine", + "seed": "Seme", + "imageDetails": "Dettagli dell'immagine", + "model": "Modello", + "noImageDetails": "Nessun dettaglio dell'immagine trovato", + "cfgScale": "Scala CFG", + "height": "Altezza", + "noMetaData": "Nessun metadato trovato", + "width": "Larghezza", + "createdBy": "Creato da", + "workflow": "Flusso di lavoro", + "steps": "Passi", + "scheduler": "Campionatore", + "recallParameters": "Richiama i parametri", + "noRecallParameters": "Nessun parametro da richiamare trovato", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "allPrompts": "Tutti i prompt", + "imageDimensions": "Dimensioni dell'immagine", + "parameterSet": "Parametro {{parameter}} impostato", + "parsingFailed": "Analisi non riuscita", + "recallParameter": "Richiama {{label}}", + "canvasV2Metadata": "Tela", + "guidance": "Guida", + "seamlessXAxis": "Asse X senza giunte", + "seamlessYAxis": "Asse Y senza giunte", + "vae": "VAE" + }, + "hrf": { + "enableHrf": "Abilita Correzione Alta Risoluzione", + "upscaleMethod": "Metodo di ampliamento", + "metadata": { + "strength": "Forza della Correzione Alta Risoluzione", + "enabled": "Correzione Alta Risoluzione Abilitata", + "method": "Metodo della Correzione Alta Risoluzione" + }, + "hrf": "Correzione Alta Risoluzione" + }, + "workflows": { + "saveWorkflowAs": "Salva flusso di lavoro come", + "workflowEditorMenu": "Menu dell'editor del flusso di lavoro", + "workflowName": "Nome del flusso di lavoro", + "saveWorkflow": "Salva flusso di lavoro", + "openWorkflow": "Apri flusso di lavoro", + "clearWorkflowSearchFilter": "Cancella il filtro di ricerca del flusso di lavoro", + "workflowLibrary": "Libreria", + "workflowSaved": "Flusso di lavoro salvato", + "unnamedWorkflow": "Flusso di lavoro senza nome", + "savingWorkflow": "Salvataggio del flusso di lavoro...", + "problemLoading": "Problema durante il caricamento dei flussi di lavoro", + "loading": "Caricamento dei flussi di lavoro", + "searchWorkflows": "Cerca flussi di lavoro", + "problemSavingWorkflow": "Problema durante il salvataggio del flusso di lavoro", + "deleteWorkflow": "Elimina flusso di lavoro", + "workflows": "Flussi di lavoro", + "noDescription": "Nessuna descrizione", + "newWorkflowCreated": "Nuovo flusso di lavoro creato", + "downloadWorkflow": "Salva su file", + "uploadWorkflow": "Carica da file", + "noWorkflows": "Nessun flusso di lavoro", + "workflowCleared": "Flusso di lavoro cancellato", + "saveWorkflowToProject": "Salva flusso di lavoro nel progetto", + "descending": "Discendente", + "created": "Creato", + "ascending": "Ascendente", + "name": "Nome", + "updated": "Aggiornato", + "opened": "Aperto", + "convertGraph": "Converti grafico", + "loadWorkflow": "$t(common.load) Flusso di lavoro", + "autoLayout": "Disposizione automatica", + "loadFromGraph": "Carica il flusso di lavoro dal grafico", + "userWorkflows": "Flussi di lavoro utente", + "projectWorkflows": "Flussi di lavoro del progetto", + "defaultWorkflows": "Flussi di lavoro predefiniti", + "uploadAndSaveWorkflow": "Carica nella libreria", + "chooseWorkflowFromLibrary": "Scegli il flusso di lavoro dalla libreria", + "deleteWorkflow2": "Vuoi davvero eliminare questo flusso di lavoro? Questa operazione non può essere annullata.", + "edit": "Modifica", + "download": "Scarica", + "copyShareLink": "Copia Condividi Link", + "copyShareLinkForWorkflow": "Copia Condividi Link del Flusso di lavoro", + "delete": "Elimina" + }, + "accordions": { + "compositing": { + "infillTab": "Riempimento", + "coherenceTab": "Passaggio di coerenza", + "title": "Composizione" + }, + "control": { + "title": "Controllo" + }, + "generation": { + "title": "Generazione" + }, + "advanced": { + "title": "Avanzate", + "options": "Opzioni $t(accordions.advanced.title)" + }, + "image": { + "title": "Immagine" + } + }, + "prompt": { + "compatibleEmbeddings": "Incorporamenti compatibili", + "addPromptTrigger": "Aggiungi Trigger nel prompt", + "noMatchingTriggers": "Nessun Trigger corrispondente" + }, + "controlLayers": { + "addLayer": "Aggiungi Livello", + "moveToFront": "Sposta in primo piano", + "moveToBack": "Sposta in fondo", + "moveForward": "Sposta avanti", + "moveBackward": "Sposta indietro", + "autoNegative": "Auto Negativo", + "deletePrompt": "Cancella il prompt", + "rectangle": "Rettangolo", + "addPositivePrompt": "Aggiungi $t(controlLayers.prompt)", + "addNegativePrompt": "Aggiungi $t(controlLayers.negativePrompt)", + "regionalGuidance": "Guida regionale", + "opacity": "Opacità", + "mergeVisible": "Fondi il visibile", + "mergeVisibleOk": "Livelli uniti", + "deleteReferenceImage": "Elimina l'immagine di riferimento", + "referenceImage": "Immagine di riferimento", + "fitBboxToLayers": "Adatta il riquadro di delimitazione ai livelli", + "mergeVisibleError": "Errore durante l'unione dei livelli", + "regionalReferenceImage": "Immagine di riferimento Regionale", + "newLayerFromImage": "Nuovo livello da immagine", + "newCanvasFromImage": "Nuova tela da immagine", + "globalReferenceImage": "Immagine di riferimento Globale", + "copyToClipboard": "Copia negli appunti", + "sendingToCanvas": "Effettua le generazioni nella Tela", + "clearHistory": "Cancella la cronologia", + "inpaintMask": "Maschera Inpaint", + "sendToGallery": "Invia alla Galleria", + "controlLayer": "Livello di Controllo", + "rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)", + "rasterLayer_withCount_many": "Livelli Raster", + "rasterLayer_withCount_other": "Livelli Raster", + "controlLayer_withCount_one": "$t(controlLayers.controlLayer)", + "controlLayer_withCount_many": "Livelli di controllo", + "controlLayer_withCount_other": "Livelli di controllo", + "clipToBbox": "Ritaglia i tratti al riquadro", + "duplicate": "Duplica", + "width": "Larghezza", + "addControlLayer": "Aggiungi $t(controlLayers.controlLayer)", + "addInpaintMask": "Aggiungi $t(controlLayers.inpaintMask)", + "addRegionalGuidance": "Aggiungi $t(controlLayers.regionalGuidance)", + "sendToCanvasDesc": "Premendo Invoke il lavoro in corso viene visualizzato sulla tela.", + "addRasterLayer": "Aggiungi $t(controlLayers.rasterLayer)", + "clearCaches": "Svuota le cache", + "regionIsEmpty": "La regione selezionata è vuota", + "recalculateRects": "Ricalcola rettangoli", + "removeBookmark": "Rimuovi segnalibro", + "saveCanvasToGallery": "Salva la tela nella Galleria", + "regional": "Regionale", + "global": "Globale", + "canvas": "Tela", + "bookmark": "Segnalibro per cambio rapido", + "newRegionalReferenceImageOk": "Immagine di riferimento regionale creata", + "newRegionalReferenceImageError": "Problema nella creazione dell'immagine di riferimento regionale", + "newControlLayerOk": "Livello di controllo creato", + "bboxOverlay": "Mostra sovrapposizione riquadro", + "resetCanvas": "Reimposta la tela", + "outputOnlyMaskedRegions": "In uscita solo le regioni generate", + "enableAutoNegative": "Abilita Auto Negativo", + "disableAutoNegative": "Disabilita Auto Negativo", + "showHUD": "Mostra HUD", + "maskFill": "Riempimento maschera", + "addReferenceImage": "Aggiungi $t(controlLayers.referenceImage)", + "addGlobalReferenceImage": "Aggiungi $t(controlLayers.globalReferenceImage)", + "sendingToGallery": "Inviare generazioni alla Galleria", + "sendToGalleryDesc": "Premendo Invoke viene generata e salvata un'immagine unica nella tua galleria.", + "sendToCanvas": "Invia alla Tela", + "viewProgressInViewer": "Visualizza i progressi e i risultati nel Visualizzatore immagini.", + "viewProgressOnCanvas": "Visualizza i progressi e i risultati nella Tela.", + "saveBboxToGallery": "Salva il riquadro di delimitazione nella Galleria", + "cropLayerToBbox": "Ritaglia il livello al riquadro di delimitazione", + "savedToGalleryError": "Errore durante il salvataggio nella galleria", + "rasterLayer": "Livello Raster", + "regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)", + "regionalGuidance_withCount_many": "Guide regionali", + "regionalGuidance_withCount_other": "Guide regionali", + "inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)", + "inpaintMask_withCount_many": "Maschere Inpaint", + "inpaintMask_withCount_other": "Maschere Inpaint", + "savedToGalleryOk": "Salvato nella Galleria", + "newGlobalReferenceImageOk": "Immagine di riferimento globale creata", + "newGlobalReferenceImageError": "Problema nella creazione dell'immagine di riferimento globale", + "newControlLayerError": "Problema nella creazione del livello di controllo", + "newRasterLayerOk": "Livello raster creato", + "newRasterLayerError": "Problema nella creazione del livello raster", + "saveLayerToAssets": "Salva il livello nelle Risorse", + "pullBboxIntoLayerError": "Problema nel caricare il riquadro nel livello", + "pullBboxIntoReferenceImageOk": "Contenuto del riquadro inserito nell'immagine di riferimento", + "pullBboxIntoLayerOk": "Riquadro caricato nel livello", + "pullBboxIntoReferenceImageError": "Problema nell'inserimento del contenuto del riquadro nell'immagine di riferimento", + "globalReferenceImage_withCount_one": "$t(controlLayers.globalReferenceImage)", + "globalReferenceImage_withCount_many": "Immagini di riferimento Globali", + "globalReferenceImage_withCount_other": "Immagini di riferimento Globali", + "controlMode": { + "balanced": "Bilanciato (consigliato)", + "controlMode": "Modalità di controllo", + "prompt": "Prompt", + "control": "Controllo", + "megaControl": "Mega Controllo" + }, + "negativePrompt": "Prompt Negativo", + "prompt": "Prompt Positivo", + "beginEndStepPercentShort": "Inizio/Fine %", + "stagingOnCanvas": "Genera immagini nella", + "ipAdapterMethod": { + "full": "Stile e Composizione", + "style": "Solo Stile", + "composition": "Solo Composizione", + "ipAdapterMethod": "Metodo Adattatore IP" + }, + "showingType": "Mostra {{type}}", + "dynamicGrid": "Griglia dinamica", + "tool": { + "view": "Muovi", + "colorPicker": "Selettore Colore", + "rectangle": "Rettangolo", + "bbox": "Riquadro di delimitazione", + "move": "Sposta", + "brush": "Pennello", + "eraser": "Cancellino" + }, + "filter": { + "apply": "Applica", + "reset": "Reimposta", + "process": "Elabora", + "cancel": "Annulla", + "autoProcess": "Processo automatico", + "filterType": "Tipo Filtro", + "filter": "Filtro", + "filters": "Filtri", + "mlsd_detection": { + "score_threshold": "Soglia di punteggio", + "distance_threshold": "Soglia di distanza", + "description": "Genera una mappa dei segmenti di linea dal livello selezionato utilizzando il modello di rilevamento dei segmenti di linea MLSD.", + "label": "Rilevamento segmenti di linea" + }, + "content_shuffle": { + "label": "Mescola contenuto", + "scale_factor": "Fattore di scala", + "description": "Mescola il contenuto del livello selezionato, in modo simile all'effetto \"liquefa\"." + }, + "mediapipe_face_detection": { + "min_confidence": "Confidenza minima", + "label": "Rilevamento del volto MediaPipe", + "max_faces": "Max volti", + "description": "Rileva i volti nel livello selezionato utilizzando il modello di rilevamento dei volti MediaPipe." + }, + "dw_openpose_detection": { + "draw_face": "Disegna il volto", + "description": "Rileva le pose umane nel livello selezionato utilizzando il modello DW Openpose.", + "label": "Rilevamento DW Openpose", + "draw_hands": "Disegna le mani", + "draw_body": "Disegna il corpo" + }, + "normal_map": { + "description": "Genera una mappa delle normali dal livello selezionato.", + "label": "Mappa delle normali" + }, + "lineart_edge_detection": { + "label": "Rilevamento bordi Lineart", + "coarse": "Grossolano", + "description": "Genera una mappa dei bordi dal livello selezionato utilizzando il modello di rilevamento dei bordi Lineart." + }, + "depth_anything_depth_estimation": { + "model_size_small": "Piccolo", + "model_size_small_v2": "Piccolo v2", + "model_size": "Dimensioni modello", + "model_size_large": "Grande", + "model_size_base": "Base", + "description": "Genera una mappa di profondità dal livello selezionato utilizzando un modello Depth Anything." + }, + "color_map": { + "label": "Mappa colore", + "description": "Crea una mappa dei colori dal livello selezionato.", + "tile_size": "Dimens. Piastrella" + }, + "canny_edge_detection": { + "high_threshold": "Soglia superiore", + "low_threshold": "Soglia inferiore", + "description": "Genera una mappa dei bordi dal livello selezionato utilizzando l'algoritmo di rilevamento dei bordi Canny.", + "label": "Rilevamento bordi Canny" + }, + "spandrel_filter": { + "scale": "Scala di destinazione", + "autoScaleDesc": "Il modello selezionato verrà eseguito fino al raggiungimento della scala di destinazione.", + "description": "Esegue un modello immagine-a-immagine sul livello selezionato.", + "label": "Modello Immagine-a-Immagine", + "model": "Modello", + "autoScale": "Auto Scala" + }, + "pidi_edge_detection": { + "quantize_edges": "Quantizza i bordi", + "scribble": "Scarabocchio", + "description": "Genera una mappa dei bordi dal livello selezionato utilizzando il modello di rilevamento dei bordi PiDiNet.", + "label": "Rilevamento bordi PiDiNet" + }, + "hed_edge_detection": { + "label": "Rilevamento bordi HED", + "description": "Genera una mappa dei bordi dal livello selezionato utilizzando il modello di rilevamento dei bordi HED.", + "scribble": "Scarabocchio" + }, + "lineart_anime_edge_detection": { + "description": "Genera una mappa dei bordi dal livello selezionato utilizzando il modello di rilevamento dei bordi Lineart Anime.", + "label": "Rilevamento bordi Lineart Anime" + }, + "forMoreControl": "Per un maggiore controllo, fare clic su Avanzate qui sotto.", + "advanced": "Avanzate", + "processingLayerWith": "Elaborazione del livello con il filtro {{type}}." + }, + "controlLayers_withCount_hidden": "Livelli di controllo ({{count}} nascosti)", + "regionalGuidance_withCount_hidden": "Guida regionale ({{count}} nascosti)", + "fill": { + "grid": "Griglia", + "crosshatch": "Tratteggio incrociato", + "fillColor": "Colore di riempimento", + "fillStyle": "Stile riempimento", + "solid": "Solido", + "vertical": "Verticale", + "horizontal": "Orizzontale", + "diagonal": "Diagonale" + }, + "rasterLayers_withCount_hidden": "Livelli raster ({{count}} nascosti)", + "inpaintMasks_withCount_hidden": "Maschere Inpaint ({{count}} nascoste)", + "regionalGuidance_withCount_visible": "Guide regionali ({{count}})", + "locked": "Bloccato", + "hidingType": "Nascondere {{type}}", + "logDebugInfo": "Registro Info Debug", + "inpaintMasks_withCount_visible": "Maschere Inpaint ({{count}})", + "layer_one": "Livello", + "layer_many": "Livelli", + "layer_other": "Livelli", + "disableTransparencyEffect": "Disabilita l'effetto trasparenza", + "controlLayers_withCount_visible": "Livelli di controllo ({{count}})", + "transparency": "Trasparenza", + "newCanvasSessionDesc": "Questo cancellerà la tela e tutte le impostazioni, eccetto la selezione del modello. Le generazioni saranno effettuate sulla tela.", + "rasterLayers_withCount_visible": "Livelli raster ({{count}})", + "globalReferenceImages_withCount_visible": "Immagini di riferimento Globali ({{count}})", + "globalReferenceImages_withCount_hidden": "Immagini di riferimento globali ({{count}} nascoste)", + "layer_withCount_one": "Livello ({{count}})", + "layer_withCount_many": "Livelli ({{count}})", + "layer_withCount_other": "Livelli ({{count}})", + "unlocked": "Sbloccato", + "enableTransparencyEffect": "Abilita l'effetto trasparenza", + "replaceLayer": "Sostituisci livello", + "pullBboxIntoLayer": "Carica l'immagine delimitata nel riquadro", + "pullBboxIntoReferenceImage": "Carica l'immagine delimitata nel riquadro", + "showProgressOnCanvas": "Mostra i progressi sulla Tela", + "weight": "Peso", + "newGallerySession": "Nuova sessione Galleria", + "newGallerySessionDesc": "Questo cancellerà la tela e tutte le impostazioni, eccetto la selezione del modello. Le generazioni saranno inviate alla galleria.", + "newCanvasSession": "Nuova sessione Tela", + "deleteSelected": "Elimina selezione", + "settings": { + "isolatedStagingPreview": "Anteprima di generazione isolata", + "isolatedPreview": "Anteprima isolata", + "invertBrushSizeScrollDirection": "Inverti scorrimento per dimensione pennello", + "snapToGrid": { + "label": "Aggancia alla griglia", + "on": "Acceso", + "off": "Spento" + }, + "pressureSensitivity": "Sensibilità alla pressione", + "preserveMask": { + "alert": "Preservare la regione mascherata", + "label": "Preserva la regione mascherata" + }, + "isolatedLayerPreview": "Anteprima livello isolato", + "isolatedLayerPreviewDesc": "Se visualizzare solo questo livello quando si eseguono operazioni come il filtraggio o la trasformazione." + }, + "transform": { + "reset": "Reimposta", + "fitToBbox": "Adatta al Riquadro", + "transform": "Trasforma", + "apply": "Applica", + "cancel": "Annulla", + "fitMode": "Adattamento", + "fitModeContain": "Contieni", + "fitModeFill": "Riempi", + "fitModeCover": "Copri" + }, + "stagingArea": { + "next": "Successiva", + "discard": "Scarta", + "discardAll": "Scarta tutto", + "accept": "Accetta", + "saveToGallery": "Salva nella Galleria", + "previous": "Precedente", + "showResultsOn": "Risultati visualizzati", + "showResultsOff": "Risultati nascosti" + }, + "HUD": { + "bbox": "Riquadro di delimitazione", + "entityStatus": { + "isHidden": "{{title}} è nascosto", + "isLocked": "{{title}} è bloccato", + "isTransforming": "{{title}} sta trasformando", + "isFiltering": "{{title}} sta filtrando", + "isEmpty": "{{title}} è vuoto", + "isDisabled": "{{title}} è disabilitato" + }, + "scaledBbox": "Riquadro scalato" + }, + "canvasContextMenu": { + "newControlLayer": "Nuovo Livello di Controllo", + "newRegionalReferenceImage": "Nuova immagine di riferimento Regionale", + "newGlobalReferenceImage": "Nuova immagine di riferimento Globale", + "bboxGroup": "Crea dal riquadro di delimitazione", + "saveBboxToGallery": "Salva il riquadro nella Galleria", + "cropCanvasToBbox": "Ritaglia la Tela al riquadro", + "canvasGroup": "Tela", + "newRasterLayer": "Nuovo Livello Raster", + "saveCanvasToGallery": "Salva la Tela nella Galleria", + "saveToGalleryGroup": "Salva nella Galleria", + "newInpaintMask": "Nuova maschera Inpaint", + "newRegionalGuidance": "Nuova Guida Regionale" + }, + "newImg2ImgCanvasFromImage": "Nuova Immagine da immagine", + "copyRasterLayerTo": "Copia $t(controlLayers.rasterLayer) in", + "copyControlLayerTo": "Copia $t(controlLayers.controlLayer) in", + "copyInpaintMaskTo": "Copia $t(controlLayers.inpaintMask) in", + "selectObject": { + "dragToMove": "Trascina un punto per spostarlo", + "clickToAdd": "Fare clic sul livello per aggiungere un punto", + "clickToRemove": "Clicca su un punto per rimuoverlo", + "help3": "Inverte la selezione per selezionare tutto tranne l'oggetto di destinazione.", + "pointType": "Tipo punto", + "apply": "Applica", + "reset": "Reimposta", + "cancel": "Annulla", + "selectObject": "Seleziona oggetto", + "invertSelection": "Inverti selezione", + "exclude": "Escludi", + "include": "Includi", + "neutral": "Neutro", + "saveAs": "Salva come", + "process": "Elabora", + "help1": "Seleziona un singolo oggetto di destinazione. Aggiungi i punti Includi e Escludi per indicare quali parti del livello fanno parte dell'oggetto di destinazione.", + "help2": "Inizia con un punto Include all'interno dell'oggetto di destinazione. Aggiungi altri punti per perfezionare la selezione. Meno punti in genere producono risultati migliori." + }, + "convertControlLayerTo": "Converti $t(controlLayers.controlLayer) in", + "newRasterLayer": "Nuovo $t(controlLayers.rasterLayer)", + "newRegionalGuidance": "Nuova $t(controlLayers.regionalGuidance)", + "canvasAsRasterLayer": "$t(controlLayers.canvas) come $t(controlLayers.rasterLayer)", + "canvasAsControlLayer": "$t(controlLayers.canvas) come $t(controlLayers.controlLayer)", + "convertInpaintMaskTo": "Converti $t(controlLayers.inpaintMask) in", + "copyRegionalGuidanceTo": "Copia $t(controlLayers.regionalGuidance) in", + "convertRasterLayerTo": "Converti $t(controlLayers.rasterLayer) in", + "convertRegionalGuidanceTo": "Converti $t(controlLayers.regionalGuidance) in", + "newControlLayer": "Nuovo $t(controlLayers.controlLayer)", + "newInpaintMask": "Nuova $t(controlLayers.inpaintMask)", + "replaceCurrent": "Sostituisci corrente", + "mergeDown": "Unire in basso", + "newFromImage": "Nuovo da Immagine", + "mergingLayers": "Unione dei livelli", + "controlLayerEmptyState": "Carica un'immagine, trascina un'immagine dalla galleria su questo livello oppure disegna sulla tela per iniziare." + }, + "ui": { + "tabs": { + "generation": "Generazione", + "canvas": "Tela", + "workflows": "Flussi di lavoro", + "workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)", + "models": "Modelli", + "modelsTab": "$t(ui.tabs.models) $t(common.tab)", + "queue": "Coda", + "upscaling": "Amplia", + "upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)", + "gallery": "Galleria" + } + }, + "upscaling": { + "creativity": "Creatività", + "structure": "Struttura", + "upscaleModel": "Modello di ampliamento", + "scale": "Scala", + "missingModelsWarning": "Visita Gestione modelli per installare i modelli richiesti:", + "mainModelDesc": "Modello principale (architettura SD1.5 o SDXL)", + "tileControlNetModelDesc": "Modello Tile ControlNet per l'architettura del modello principale scelto", + "upscaleModelDesc": "Modello per l'ampliamento (immagine a immagine)", + "missingUpscaleInitialImage": "Immagine iniziale mancante per l'ampliamento", + "missingUpscaleModel": "Modello per l’ampliamento mancante", + "missingTileControlNetModel": "Nessun modello ControlNet Tile valido installato", + "postProcessingModel": "Modello di post-elaborazione", + "postProcessingMissingModelWarning": "Visita Gestione modelli per installare un modello di post-elaborazione (da immagine a immagine).", + "exceedsMaxSize": "Le impostazioni di ampliamento superano il limite massimo delle dimensioni", + "exceedsMaxSizeDetails": "Il limite massimo di ampliamento è {{maxUpscaleDimension}}x{{maxUpscaleDimension}} pixel. Prova un'immagine più piccola o diminuisci la scala selezionata.", + "upscale": "Amplia", + "incompatibleBaseModel": "Architettura del modello principale non supportata per l'ampliamento", + "incompatibleBaseModelDesc": "L'ampliamento è supportato solo per i modelli di architettura SD1.5 e SDXL. Cambia il modello principale per abilitare l'ampliamento." + }, + "upsell": { + "inviteTeammates": "Invita collaboratori", + "shareAccess": "Condividi l'accesso", + "professional": "Professionale", + "professionalUpsell": "Disponibile nell'edizione Professional di Invoke. Fai clic qui o visita invoke.com/pricing per ulteriori dettagli." + }, + "stylePresets": { + "active": "Attivo", + "choosePromptTemplate": "Scegli un modello di prompt", + "clearTemplateSelection": "Cancella selezione modello", + "copyTemplate": "Copia modello", + "createPromptTemplate": "Crea modello di prompt", + "defaultTemplates": "Modelli predefiniti", + "deleteImage": "Elimina immagine", + "deleteTemplate": "Elimina modello", + "editTemplate": "Modifica modello", + "flatten": "Unisci il modello selezionato al prompt corrente", + "insertPlaceholder": "Inserisci segnaposto", + "myTemplates": "I miei modelli", + "name": "Nome", + "negativePrompt": "Prompt Negativo", + "noMatchingTemplates": "Nessun modello corrispondente", + "promptTemplatesDesc1": "I modelli di prompt aggiungono testo ai prompt che scrivi nelle caselle dei prompt.", + "promptTemplatesDesc3": "Se si omette il segnaposto, il modello verrà aggiunto alla fine del prompt.", + "positivePrompt": "Prompt Positivo", + "preview": "Anteprima", + "private": "Privato", + "searchByName": "Cerca per nome", + "shared": "Condiviso", + "sharedTemplates": "Modelli condivisi", + "templateDeleted": "Modello di prompt eliminato", + "toggleViewMode": "Attiva/disattiva visualizzazione", + "uploadImage": "Carica immagine", + "useForTemplate": "Usa per modello di prompt", + "viewList": "Visualizza l'elenco dei modelli", + "viewModeTooltip": "Ecco come apparirà il tuo prompt con il modello attualmente selezionato. Per modificare il tuo prompt, clicca in un punto qualsiasi della casella di testo.", + "deleteTemplate2": "Vuoi davvero eliminare questo modello? Questa operazione non può essere annullata.", + "unableToDeleteTemplate": "Impossibile eliminare il modello di prompt", + "updatePromptTemplate": "Aggiorna il modello di prompt", + "type": "Tipo", + "promptTemplatesDesc2": "Utilizza la stringa segnaposto
{{placeholder}}
per specificare dove inserire il tuo prompt nel modello.", + "importTemplates": "Importa modelli di prompt (CSV/JSON)", + "exportDownloaded": "Esportazione completata", + "exportFailed": "Impossibile generare e scaricare il file CSV", + "exportPromptTemplates": "Esporta i miei modelli di prompt (CSV)", + "positivePromptColumn": "'prompt' o 'positive_prompt'", + "noTemplates": "Nessun modello", + "acceptedColumnsKeys": "Colonne/chiavi accettate:", + "promptTemplateCleared": "Modello di prompt cancellato" + }, + "newUserExperience": { + "gettingStartedSeries": "Desideri maggiori informazioni? Consulta la nostra Getting Started Series per suggerimenti su come sfruttare appieno il potenziale di Invoke Studio.", + "toGetStarted": "Per iniziare, inserisci un prompt nella casella e fai clic su Invoke per generare la tua prima immagine. Seleziona un modello di prompt per migliorare i risultati. Puoi scegliere di salvare le tue immagini direttamente nella Galleria o modificarle nella Tela.", + "importModels": "Importa modelli", + "downloadStarterModels": "Scarica i modelli per iniziare", + "noModelsInstalled": "Sembra che tu non abbia installato alcun modello", + "toGetStartedLocal": "Per iniziare, assicurati di scaricare o importare i modelli necessari per eseguire Invoke. Quindi, inserisci un prompt nella casella e fai clic su Invoke per generare la tua prima immagine. Seleziona un modello di prompt per migliorare i risultati. Puoi scegliere di salvare le tue immagini direttamente nella Galleria o modificarle nella Tela." + }, + "whatsNew": { + "whatsNewInInvoke": "Novità in Invoke", + "readReleaseNotes": "Leggi le note di rilascio", + "watchRecentReleaseVideos": "Guarda i video su questa versione", + "watchUiUpdatesOverview": "Guarda le novità dell'interfaccia", + "items": [ + "SD 3.5: supporto per SD 3.5 Medium e Large.", + "Tela: elaborazione semplificata del livello di controllo e impostazioni di controllo predefinite migliorate." + ] + }, + "system": { + "logLevel": { + "info": "Info", + "warn": "Avviso", + "fatal": "Fatale", + "error": "Errore", + "debug": "Debug", + "trace": "Traccia", + "logLevel": "Livello di registro" + }, + "logNamespaces": { + "workflows": "Flussi di lavoro", + "generation": "Generazione", + "canvas": "Tela", + "config": "Configurazione", + "models": "Modelli", + "gallery": "Galleria", + "queue": "Coda", + "events": "Eventi", + "system": "Sistema", + "metadata": "Metadati", + "logNamespaces": "Elementi del registro" + }, + "enableLogging": "Abilita la registrazione" + } +} diff --git a/invokeai/frontend/web/public/locales/ja.json b/invokeai/frontend/web/public/locales/ja.json new file mode 100644 index 0000000000000000000000000000000000000000..c0cdd4e692b050b1c7cefa7be8cf9a881c110fa4 --- /dev/null +++ b/invokeai/frontend/web/public/locales/ja.json @@ -0,0 +1,690 @@ +{ + "common": { + "languagePickerLabel": "言語", + "reportBugLabel": "バグ報告", + "settingsLabel": "設定", + "upload": "アップロード", + "load": "ロード", + "back": "戻る", + "statusDisconnected": "切断済", + "cancel": "キャンセル", + "accept": "同意", + "img2img": "img2img", + "loading": "ロード中", + "githubLabel": "Github", + "hotkeysLabel": "ホットキー", + "discordLabel": "Discord", + "nodes": "ワークフロー", + "txt2img": "txt2img", + "postprocessing": "ポストプロセス", + "t2iAdapter": "T2I アダプター", + "communityLabel": "コミュニティ", + "dontAskMeAgain": "次回から確認しない", + "areYouSure": "本当によろしいですか?", + "on": "オン", + "ipAdapter": "IPアダプター", + "auto": "自動", + "openInNewTab": "新しいタブで開く", + "controlNet": "コントロールネット", + "linear": "リニア", + "imageFailedToLoad": "画像が読み込めません", + "modelManager": "モデルマネージャー", + "learnMore": "もっと学ぶ", + "random": "ランダム", + "batch": "バッチマネージャー", + "advanced": "高度な設定", + "created": "作成済", + "green": "緑", + "blue": "青", + "alpha": "アルファ", + "outpaint": "アウトペイント", + "unknown": "不明", + "updated": "更新済", + "add": "追加", + "ai": "AI", + "copyError": "$t(gallery.copy) エラー", + "data": "データ", + "template": "テンプレート", + "red": "赤", + "or": "または", + "checkpoint": "チェックポイント", + "direction": "方向", + "simple": "シンプル", + "save": "保存", + "saveAs": "名前をつけて保存", + "somethingWentWrong": "何かの問題が発生しました", + "details": "詳細", + "inpaint": "インペイント", + "delete": "削除", + "nextPage": "次のページ", + "copy": "コピー", + "error": "エラー", + "file": "ファイル", + "folder": "フォルダ", + "input": "インプット", + "format": "形式", + "installed": "インストール済み", + "localSystem": "ローカルシステム", + "outputs": "アウトプット", + "prevPage": "前のページ", + "unknownError": "未知のエラー", + "orderBy": "並び順:", + "enabled": "有効", + "notInstalled": "未インストール", + "positivePrompt": "ポジティブプロンプト", + "negativePrompt": "ネガティブプロンプト", + "selected": "選択済み", + "aboutDesc": "Invokeを業務で利用する場合はマークしてください:", + "beta": "ベータ", + "disabled": "無効", + "editor": "エディタ", + "safetensors": "Safetensors", + "tab": "タブ", + "toResolve": "解決方法", + "openInViewer": "ビューアで開く", + "placeholderSelectAModel": "モデルを選択", + "clipboard": "クリップボード", + "apply": "適用", + "loadingImage": "画像をロード中", + "off": "オフ", + "view": "ビュー", + "edit": "編集", + "ok": "OK", + "reset": "リセット", + "none": "なし", + "new": "新規", + "close": "閉じる" + }, + "gallery": { + "galleryImageSize": "画像のサイズ", + "gallerySettings": "ギャラリーの設定", + "noImagesInGallery": "表示する画像がありません", + "autoSwitchNewImages": "新しい画像に自動切替", + "copy": "コピー", + "image": "画像", + "autoAssignBoardOnClick": "クリックしたボードに自動追加", + "featuresWillReset": "この画像を削除すると、これらの機能は即座にリセットされます。", + "unstarImage": "スターを外す", + "loading": "ロード中", + "assets": "アセット", + "currentlyInUse": "この画像は現在下記の機能を使用しています:", + "drop": "ドロップ", + "dropOrUpload": "$t(gallery.drop) またはアップロード", + "deleteImage_other": "画像を削除", + "deleteImagePermanent": "削除された画像は復元できません。", + "download": "ダウンロード", + "unableToLoad": "ギャラリーをロードできません", + "bulkDownloadRequested": "ダウンロード準備中", + "bulkDownloadRequestedDesc": "ダウンロードの準備中です。しばらくお待ちください。", + "bulkDownloadRequestFailed": "ダウンロード準備中に問題が発生", + "bulkDownloadFailed": "ダウンロード失敗", + "alwaysShowImageSizeBadge": "画像サイズバッジを常に表示", + "dropToUpload": "$t(gallery.drop) してアップロード", + "noImageSelected": "画像が選択されていません", + "deleteSelection": "選択中のものを削除", + "downloadSelection": "選択中のものをダウンロード", + "starImage": "スターをつける", + "viewerImage": "閲覧画像", + "compareImage": "比較画像", + "openInViewer": "ビューアで開く", + "selectForCompare": "比較対象として選択", + "selectAnImageToCompare": "比較する画像を選択", + "slider": "スライダー", + "sideBySide": "横並び", + "hover": "ホバー", + "swapImages": "画像を入れ替える", + "stretchToFit": "画面に合わせる", + "exitCompare": "比較を終了する", + "compareHelp1": "Alt キーを押しながらギャラリー画像をクリックするか、矢印キーを使用して比較画像を変更します。", + "compareHelp3": "Cを押して、比較した画像を入れ替えます。", + "compareHelp4": "[Z]または[Esc]を押して終了します。", + "compareHelp2": "M キーを押して比較モードを切り替えます。", + "move": "移動", + "openViewer": "ビューアを開く", + "closeViewer": "ビューアを閉じる", + "exitSearch": "画像検索を終了", + "oldestFirst": "最古から", + "showStarredImagesFirst": "スター付き画像を最初に", + "exitBoardSearch": "ボード検索を終了", + "showArchivedBoards": "アーカイブされたボードを表示", + "searchImages": "メタデータで検索", + "gallery": "ギャラリー", + "newestFirst": "最新から", + "jump": "ジャンプ", + "go": "進む", + "sortDirection": "並び替え順", + "displayBoardSearch": "ボード検索", + "displaySearch": "画像を検索", + "boardsSettings": "ボード設定", + "imagesSettings": "ギャラリー画像設定" + }, + "hotkeys": { + "searchHotkeys": "ホットキーを検索", + "clearSearch": "検索をクリア", + "noHotkeysFound": "ホットキーが見つかりません", + "viewer": { + "runPostprocessing": { + "title": "ポストプロセスを実行" + }, + "useSize": { + "title": "サイズを使用" + }, + "recallPrompts": { + "title": "プロンプトを再使用" + }, + "recallAll": { + "title": "全てのメタデータを再使用" + }, + "recallSeed": { + "title": "シード値を再使用" + } + }, + "canvas": { + "redo": { + "title": "やり直し" + }, + "transformSelected": { + "title": "変形" + }, + "undo": { + "title": "取り消し" + }, + "selectEraserTool": { + "title": "消しゴムツール" + }, + "cancelTransform": { + "title": "変形をキャンセル" + }, + "resetSelected": { + "title": "レイヤーをリセット" + }, + "applyTransform": { + "title": "変形を適用" + }, + "selectColorPickerTool": { + "title": "スポイトツール" + }, + "fitBboxToCanvas": { + "title": "バウンディングボックスをキャンバスにフィット" + }, + "selectBrushTool": { + "title": "ブラシツール" + }, + "selectMoveTool": { + "title": "移動ツール" + }, + "selectBboxTool": { + "title": "バウンディングボックスツール" + }, + "title": "キャンバス", + "fitLayersToCanvas": { + "title": "レイヤーをキャンバスにフィット" + } + }, + "workflows": { + "undo": { + "title": "取り消し" + }, + "redo": { + "title": "やり直し" + } + }, + "app": { + "toggleLeftPanel": { + "title": "左パネルをトグル", + "desc": "左パネルを表示または非表示。" + }, + "title": "アプリケーション", + "invoke": { + "title": "Invoke" + }, + "cancelQueueItem": { + "title": "キャンセル" + }, + "clearQueue": { + "title": "キューをクリア" + } + }, + "hotkeys": "ホットキー" + }, + "modelManager": { + "modelManager": "モデルマネージャ", + "model": "モデル", + "allModels": "すべてのモデル", + "modelUpdated": "モデルをアップデート", + "manual": "手動", + "name": "名前", + "description": "概要", + "config": "コンフィグ", + "repo_id": "Repo ID", + "width": "幅", + "height": "高さ", + "addModel": "モデルを追加", + "availableModels": "モデルを有効化", + "search": "検索", + "load": "Load", + "active": "active", + "selected": "選択済", + "delete": "削除", + "deleteModel": "モデルを削除", + "deleteConfig": "設定を削除", + "deleteMsg1": "InvokeAIからこのモデルを削除してよろしいですか?", + "deleteMsg2": "これは、モデルがInvokeAIルートフォルダ内にある場合、ディスクからモデルを削除します。カスタム保存場所を使用している場合、モデルはディスクから削除されません。", + "none": "なし", + "convert": "変換", + "convertToDiffusersHelpText6": "このモデルを変換しますか?", + "settings": "設定", + "convertingModelBegin": "モデルを変換しています...", + "baseModel": "ベースモデル", + "modelDeleteFailed": "モデルの削除ができませんでした", + "convertToDiffusers": "ディフューザーに変換", + "alpha": "アルファ", + "modelConverted": "モデル変換が完了しました", + "predictionType": "予測タイプ(SD 2.x モデルおよび一部のSD 1.x モデル用)", + "selectModel": "モデルを選択", + "advanced": "高度な設定", + "modelDeleted": "モデルが削除されました", + "convertToDiffusersHelpText2": "このプロセスでは、モデルマネージャーのエントリーを同じモデルのディフューザーバージョンに置き換えます。", + "modelUpdateFailed": "モデル更新が失敗しました", + "convertToDiffusersHelpText5": "十分なディスク空き容量があることを確認してください。モデルは一般的に2GBから7GBのサイズがあります。", + "modelConversionFailed": "モデル変換が失敗しました", + "syncModels": "モデルを同期", + "modelType": "モデルタイプ", + "convertToDiffusersHelpText1": "このモデルは 🧨 Diffusers フォーマットに変換されます。", + "convertToDiffusersHelpText3": "チェックポイントファイルは、InvokeAIルートフォルダ内にある場合、ディスクから削除されます。カスタムロケーションにある場合は、削除されません。", + "convertToDiffusersHelpText4": "これは一回限りのプロセスです。コンピュータの仕様によっては、約30秒から60秒かかる可能性があります。", + "cancel": "キャンセル", + "uploadImage": "画像をアップロード", + "addModels": "モデルを追加" + }, + "parameters": { + "images": "画像", + "steps": "ステップ数", + "width": "幅", + "height": "高さ", + "seed": "シード値", + "shuffle": "シャッフル", + "strength": "強度", + "upscaling": "アップスケーリング", + "scale": "Scale", + "scaleBeforeProcessing": "処理前のスケール", + "scaledWidth": "幅のスケール", + "scaledHeight": "高さのスケール", + "downloadImage": "画像をダウンロード", + "usePrompt": "プロンプトを使用", + "useSeed": "シード値を使用", + "useAll": "すべてを使用", + "info": "情報", + "showOptionsPanel": "オプションパネルを表示", + "iterations": "生成回数", + "general": "基本設定", + "setToOptimalSize": "サイズをモデルに最適化", + "invoke": { + "addingImagesTo": "画像の追加先" + }, + "aspect": "縦横比", + "lockAspectRatio": "縦横比を固定", + "scheduler": "スケジューラー", + "sendToUpscale": "アップスケーラーに転送", + "useSize": "サイズを使用", + "postProcessing": "ポストプロセス (Shift + U)", + "denoisingStrength": "ノイズ除去強度", + "recallMetadata": "メタデータを再使用" + }, + "settings": { + "models": "モデル", + "displayInProgress": "生成中の画像を表示する", + "confirmOnDelete": "削除時に確認", + "resetWebUI": "WebUIをリセット", + "resetWebUIDesc1": "WebUIのリセットは、画像と保存された設定のキャッシュをリセットするだけです。画像を削除するわけではありません。", + "resetWebUIDesc2": "もしギャラリーに画像が表示されないなど、何か問題が発生した場合はGitHubにissueを提出する前にリセットを試してください。", + "resetComplete": "WebUIはリセットされました。F5を押して再読み込みしてください。" + }, + "toast": { + "uploadFailed": "アップロード失敗", + "imageCopied": "画像をコピー", + "imageUploadFailed": "画像のアップロードに失敗しました", + "uploadFailedInvalidUploadDesc": "画像はPNGかJPGである必要があります。", + "sentToUpscale": "アップスケーラーに転送しました", + "imageUploaded": "画像をアップロードしました" + }, + "accessibility": { + "invokeProgressBar": "進捗バー", + "reset": "リセット", + "uploadImage": "画像をアップロード", + "previousImage": "前の画像", + "nextImage": "次の画像", + "menu": "メニュー", + "createIssue": "問題を報告", + "resetUI": "$t(accessibility.reset) UI", + "mode": "モード:", + "about": "Invoke について", + "submitSupportTicket": "サポート依頼を送信する", + "uploadImages": "画像をアップロード", + "toggleLeftPanel": "左パネルをトグル (T)", + "toggleRightPanel": "右パネルをトグル (G)" + }, + "metadata": { + "Threshold": "ノイズ閾値", + "seed": "シード", + "width": "幅", + "workflow": "ワークフロー", + "steps": "ステップ", + "scheduler": "スケジューラー", + "positivePrompt": "ポジティブプロンプト", + "strength": "Image to Image 強度", + "recallParameters": "パラメータを再使用", + "recallParameter": "{{label}} を再使用" + }, + "queue": { + "queueEmpty": "キューが空です", + "pauseSucceeded": "処理が一時停止されました", + "queueFront": "キューの先頭へ追加", + "queueBack": "キューに追加", + "pause": "一時停止", + "queue": "キュー", + "pauseTooltip": "処理を一時停止", + "cancel": "キャンセル", + "resumeSucceeded": "処理が再開されました", + "resumeTooltip": "処理を再開", + "resume": "再開", + "status": "ステータス", + "pruneSucceeded": "キューから完了アイテム{{item_count}}件を削除しました", + "cancelTooltip": "現在のアイテムをキャンセル", + "in_progress": "進行中", + "notReady": "キューに追加できません", + "batchFailedToQueue": "バッチをキューに追加できませんでした", + "completed": "完了", + "cancelFailed": "アイテムのキャンセルに問題があります", + "batchQueued": "バッチをキューに追加しました", + "pauseFailed": "処理の一時停止に問題があります", + "clearFailed": "キューのクリアに問題があります", + "front": "先頭", + "clearSucceeded": "キューがクリアされました", + "pruneTooltip": "{{item_count}} の完了アイテムを削除", + "cancelSucceeded": "アイテムがキャンセルされました", + "batchQueuedDesc_other": "{{count}} セッションをキューの{{direction}}に追加しました", + "graphQueued": "グラフをキューに追加しました", + "batch": "バッチ", + "clearQueueAlertDialog": "キューをクリアすると、処理中のアイテムは直ちにキャンセルされ、キューは完全にクリアされます。", + "pending": "保留中", + "resumeFailed": "処理の再開に問題があります", + "clear": "クリア", + "total": "合計", + "canceled": "キャンセル", + "pruneFailed": "キューの削除に問題があります", + "cancelBatchSucceeded": "バッチがキャンセルされました", + "clearTooltip": "全てのアイテムをキャンセルしてクリア", + "current": "現在", + "failed": "失敗", + "cancelItem": "項目をキャンセル", + "next": "次", + "cancelBatch": "バッチをキャンセル", + "session": "セッション", + "enqueueing": "バッチをキューに追加", + "cancelBatchFailed": "バッチのキャンセルに問題があります", + "clearQueueAlertDialog2": "キューをクリアしてもよろしいですか?", + "item": "アイテム", + "graphFailedToQueue": "グラフをキューに追加できませんでした", + "batchFieldValues": "バッチの詳細", + "openQueue": "キューを開く", + "time": "時間", + "completedIn": "完了まで", + "back": "戻る", + "prune": "刈り込み", + "prompts_other": "プロンプト", + "iterations_other": "繰り返し", + "generations_other": "生成", + "canvas": "キャンバス", + "workflows": "ワークフロー", + "upscaling": "アップスケール", + "generation": "生成", + "other": "その他", + "gallery": "ギャラリー" + }, + "models": { + "noMatchingModels": "一致するモデルがありません", + "loading": "読み込み中", + "noMatchingLoRAs": "一致するLoRAがありません", + "noModelsAvailable": "使用可能なモデルがありません", + "selectModel": "モデルを選択してください", + "concepts": "コンセプト", + "addLora": "LoRAを追加" + }, + "nodes": { + "addNode": "ノードを追加", + "boolean": "ブーリアン", + "addNodeToolTip": "ノードを追加 (Shift+A, Space)", + "missingTemplate": "テンプレートが見つかりません", + "loadWorkflow": "ワークフローを読み込み", + "hideLegendNodes": "フィールドタイプの凡例を非表示", + "float": "浮動小数点", + "integer": "整数", + "nodeTemplate": "ノードテンプレート", + "inputMayOnlyHaveOneConnection": "入力は1つの接続しか持つことができません", + "nodeOutputs": "ノード出力", + "currentImageDescription": "ノードエディタ内の現在の画像を表示", + "downloadWorkflow": "ワークフローのJSONをダウンロード", + "fieldTypesMustMatch": "フィールドタイプが一致している必要があります", + "edge": "輪郭", + "animatedEdgesHelp": "選択したエッジおよび選択したノードに接続されたエッジをアニメーション化します", + "cannotDuplicateConnection": "重複した接続は作れません", + "noWorkflow": "ワークフローがありません", + "fullyContainNodesHelp": "ノードは選択ボックス内に完全に存在する必要があります", + "nodeType": "ノードタイプ", + "executionStateInProgress": "処理中", + "executionStateError": "エラー", + "hideMinimapnodes": "ミニマップを非表示", + "fitViewportNodes": "全体を表示", + "executionStateCompleted": "完了", + "node": "ノード", + "currentImage": "現在の画像", + "collection": "コレクション", + "cannotConnectInputToInput": "入力から入力には接続できません", + "cannotConnectOutputToOutput": "出力から出力には接続できません", + "cannotConnectToSelf": "自身のノードには接続できません", + "colorCodeEdges": "カラー-Code Edges", + "loadingNodes": "ノードを読み込み中...", + "scheduler": "スケジューラー" + }, + "boards": { + "autoAddBoard": "自動追加するボード", + "move": "移動", + "menuItemAutoAdd": "このボードに自動追加", + "myBoard": "マイボード", + "searchBoard": "ボードを検索...", + "noMatching": "一致するボードがありません", + "selectBoard": "ボードを選択", + "cancel": "キャンセル", + "addBoard": "ボードを追加", + "uncategorized": "未分類", + "downloadBoard": "ボードをダウンロード", + "changeBoard": "ボードを変更", + "loading": "ロード中...", + "topMessage": "このボードには、以下の機能で使用されている画像が含まれています:", + "bottomMessage": "このボードおよび画像を削除すると、現在これらを利用している機能はリセットされます。", + "clearSearch": "検索をクリア", + "deleteBoard": "ボードの削除", + "deleteBoardAndImages": "ボードと画像の削除", + "deleteBoardOnly": "ボードのみ削除", + "deletedBoardsCannotbeRestored": "削除されたボードは復元できません", + "movingImagesToBoard_other": "{{count}} の画像をボードに移動:", + "hideBoards": "ボードを隠す", + "assetsWithCount_other": "{{count}} のアセット", + "addPrivateBoard": "プライベートボードを追加", + "addSharedBoard": "共有ボードを追加", + "boards": "ボード", + "private": "プライベートボード", + "shared": "共有ボード", + "archiveBoard": "ボードをアーカイブ", + "archived": "アーカイブ完了", + "unarchiveBoard": "アーカイブされていないボード", + "imagesWithCount_other": "{{count}} の画像" + }, + "invocationCache": { + "invocationCache": "呼び出しキャッシュ", + "clearSucceeded": "呼び出しキャッシュをクリアしました", + "clearFailed": "呼び出しキャッシュのクリアに問題があります", + "enable": "有効", + "clear": "クリア", + "maxCacheSize": "最大キャッシュサイズ", + "cacheSize": "キャッシュサイズ", + "useCache": "キャッシュを使用", + "misses": "見つからないキャッシュ", + "hits": "見つかったキャッシュ", + "disableSucceeded": "呼び出しキャッシュが無効", + "disableFailed": "呼び出しキャッシュの無効化中に問題が発生", + "enableSucceeded": "呼び出しキャッシュが有効", + "disable": "無効", + "enableFailed": "呼び出しキャッシュの有効化中に問題が発生" + }, + "popovers": { + "paramRatio": { + "heading": "縦横比", + "paragraphs": [ + "生成された画像の縦横比。" + ] + }, + "regionalGuidanceAndReferenceImage": { + "heading": "領域ガイダンスと領域参照画像" + }, + "regionalReferenceImage": { + "heading": "領域参照画像" + }, + "paramScheduler": { + "heading": "スケジューラー" + }, + "regionalGuidance": { + "heading": "領域ガイダンス" + }, + "rasterLayer": { + "heading": "ラスターレイヤー" + }, + "globalReferenceImage": { + "heading": "全域参照画像" + }, + "paramUpscaleMethod": { + "heading": "アップスケール手法" + }, + "upscaleModel": { + "heading": "アップスケールモデル" + }, + "paramAspect": { + "heading": "縦横比" + } + }, + "accordions": { + "compositing": { + "infillTab": "インフィル", + "title": "コンポジション", + "coherenceTab": "コヒーレンスパス" + }, + "advanced": { + "title": "高度な設定" + }, + "control": { + "title": "コントロール" + }, + "generation": { + "title": "生成" + }, + "image": { + "title": "画像" + } + }, + "hrf": { + "metadata": { + "method": "高解像修復の手法", + "strength": "高解像修復の強度", + "enabled": "高解像修復が有効" + }, + "enableHrf": "高解像修復を有効", + "hrf": "高解像修復", + "upscaleMethod": "アップスケール手法" + }, + "prompt": { + "addPromptTrigger": "プロンプトトリガーを追加", + "compatibleEmbeddings": "互換性のある埋め込み", + "noMatchingTriggers": "一致するトリガーがありません" + }, + "ui": { + "tabs": { + "queue": "キュー" + } + }, + "controlLayers": { + "globalReferenceImage_withCount_other": "全域参照画像", + "regionalReferenceImage": "領域参照画像", + "saveLayerToAssets": "レイヤーをアセットに保存", + "global": "全域", + "inpaintMasks_withCount_hidden": "インペイントマスク ({{count}} hidden)", + "opacity": "透明度", + "canvasContextMenu": { + "newRegionalGuidance": "新規領域ガイダンス", + "bboxGroup": "バウンディングボックスから作成", + "cropCanvasToBbox": "キャンバスをバウンディングボックスでクロップ", + "newGlobalReferenceImage": "新規全域参照画像", + "newRegionalReferenceImage": "新規領域参照画像" + }, + "regionalGuidance": "領域ガイダンス", + "globalReferenceImage": "全域参照画像", + "moveForward": "前面へ移動", + "copyInpaintMaskTo": "$t(controlLayers.inpaintMask) をコピー", + "transform": { + "fitToBbox": "バウンディングボックスにフィット", + "transform": "変形", + "apply": "適用", + "cancel": "キャンセル", + "reset": "リセット" + }, + "resetCanvas": "キャンバスをリセット", + "cropLayerToBbox": "レイヤーをバウンディングボックスでクロップ", + "convertInpaintMaskTo": "$t(controlLayers.inpaintMask)を変換", + "regionalGuidance_withCount_other": "領域ガイダンス", + "tool": { + "colorPicker": "スポイト", + "brush": "ブラシ", + "rectangle": "矩形", + "move": "移動", + "eraser": "消しゴム" + }, + "saveCanvasToGallery": "キャンバスをギャラリーに保存", + "saveBboxToGallery": "バウンディングボックスをギャラリーへ保存", + "moveToBack": "最背面へ移動", + "duplicate": "複製", + "addLayer": "レイヤーを追加", + "rasterLayer": "ラスターレイヤー", + "inpaintMasks_withCount_visible": "({{count}}) インペイントマスク", + "regional": "領域", + "rectangle": "矩形", + "moveBackward": "背面へ移動", + "moveToFront": "最前面へ移動", + "mergeDown": "レイヤーを統合", + "inpaintMask_withCount_other": "インペイントマスク", + "canvas": "キャンバス", + "fitBboxToLayers": "バウンディングボックスをレイヤーにフィット", + "removeBookmark": "ブックマークを外す", + "savedToGalleryOk": "ギャラリーに保存しました" + }, + "stylePresets": { + "clearTemplateSelection": "選択したテンプレートをクリア", + "choosePromptTemplate": "プロンプトテンプレートを選択", + "myTemplates": "自分のテンプレート", + "flatten": "選択中のテンプレートをプロンプトに展開", + "uploadImage": "画像をアップロード", + "defaultTemplates": "デフォルトテンプレート", + "createPromptTemplate": "プロンプトテンプレートを作成", + "promptTemplateCleared": "プロンプトテンプレートをクリアしました", + "searchByName": "名前で検索", + "toggleViewMode": "表示モードを切り替え" + }, + "upscaling": { + "upscaleModel": "アップスケールモデル", + "postProcessingModel": "ポストプロセスモデル", + "upscale": "アップスケール" + }, + "sdxl": { + "denoisingStrength": "ノイズ除去強度", + "scheduler": "スケジューラー" + } +} diff --git a/invokeai/frontend/web/public/locales/ko.json b/invokeai/frontend/web/public/locales/ko.json new file mode 100644 index 0000000000000000000000000000000000000000..a79e7286dfe0c7e13ed13144c65195d595b005cc --- /dev/null +++ b/invokeai/frontend/web/public/locales/ko.json @@ -0,0 +1,342 @@ +{ + "common": { + "languagePickerLabel": "언어 설정", + "reportBugLabel": "버그 리포트", + "githubLabel": "Github", + "settingsLabel": "설정", + "nodes": "Workflow Editor", + "upload": "업로드", + "load": "불러오기", + "back": "뒤로 가기", + "statusDisconnected": "연결 끊김", + "hotkeysLabel": "단축키 설정", + "img2img": "이미지->이미지", + "discordLabel": "Discord", + "t2iAdapter": "T2I 어댑터", + "communityLabel": "커뮤니티", + "txt2img": "텍스트->이미지", + "dontAskMeAgain": "다시 묻지 마세요", + "checkpoint": "체크포인트", + "format": "형식", + "unknown": "알려지지 않음", + "areYouSure": "확실하나요?", + "folder": "폴더", + "inpaint": "inpaint", + "updated": "업데이트 됨", + "on": "켜기", + "save": "저장", + "created": "생성됨", + "error": "에러", + "prevPage": "이전 페이지", + "ipAdapter": "IP 어댑터", + "installed": "설치됨", + "accept": "수락", + "ai": "인공지능", + "auto": "자동", + "file": "파일", + "openInNewTab": "새 탭에서 열기", + "delete": "삭제", + "template": "템플릿", + "cancel": "취소", + "controlNet": "컨트롤넷", + "outputs": "결과물", + "unknownError": "알려지지 않은 에러", + "linear": "선형", + "imageFailedToLoad": "이미지를 로드할 수 없음", + "direction": "방향", + "data": "데이터", + "somethingWentWrong": "뭔가 잘못됐어요", + "modelManager": "Model Manager", + "safetensors": "Safetensors", + "outpaint": "outpaint", + "orderBy": "정렬 기준", + "copyError": "$t(gallery.copy) 에러", + "learnMore": "더 알아보기", + "nextPage": "다음 페이지", + "saveAs": "다른 이름으로 저장", + "loading": "불러오는 중", + "random": "랜덤", + "batch": "Batch 매니저", + "postprocessing": "후처리", + "advanced": "고급", + "input": "입력", + "details": "세부사항", + "notInstalled": "설치되지 않음" + }, + "gallery": { + "galleryImageSize": "이미지 크기", + "gallerySettings": "갤러리 설정", + "deleteSelection": "선택 항목 삭제", + "featuresWillReset": "이 이미지를 삭제하면 해당 기능이 즉시 재설정됩니다.", + "assets": "자산", + "noImagesInGallery": "보여줄 이미지가 없음", + "autoSwitchNewImages": "새로운 이미지로 자동 전환", + "loading": "불러오는 중", + "unableToLoad": "갤러리를 로드할 수 없음", + "image": "이미지", + "drop": "드랍", + "downloadSelection": "선택 항목 다운로드", + "deleteImage_other": "이미지 삭제", + "currentlyInUse": "이 이미지는 현재 다음 기능에서 사용되고 있습니다:", + "dropOrUpload": "$t(gallery.drop) 또는 업로드", + "copy": "복사", + "download": "다운로드", + "deleteImagePermanent": "삭제된 이미지는 복원할 수 없습니다.", + "noImageSelected": "선택된 이미지 없음", + "autoAssignBoardOnClick": "클릭 시 Board로 자동 할당", + "dropToUpload": "업로드를 위해 $t(gallery.drop)" + }, + "accessibility": { + "previousImage": "이전 이미지", + "nextImage": "다음 이미지", + "mode": "모드", + "menu": "메뉴", + "uploadImage": "이미지 업로드", + "reset": "리셋" + }, + "modelManager": { + "availableModels": "사용 가능한 모델", + "addModel": "모델 추가", + "none": "없음", + "modelConverted": "변환된 모델", + "width": "너비", + "convert": "변환", + "vae": "VAE", + "deleteModel": "모델 삭제", + "description": "Description", + "search": "검색", + "predictionType": "예측 유형(안정 확산 2.x 모델 및 간혹 안정 확산 1.x 모델의 경우)", + "selectModel": "모델 선택", + "repo_id": "Repo ID", + "convertToDiffusersHelpText6": "이 모델을 변환하시겠습니까?", + "config": "구성", + "selected": "선택된", + "advanced": "고급", + "load": "불러오기", + "height": "높이", + "modelDeleted": "삭제된 모델", + "convertToDiffusersHelpText2": "이 프로세스는 모델 관리자 항목을 동일한 모델의 Diffusers 버전으로 대체합니다.", + "modelUpdateFailed": "모델 업데이트 실패", + "modelUpdated": "업데이트된 모델", + "settings": "설정", + "convertToDiffusersHelpText5": "디스크 공간이 충분한지 확인해 주세요. 모델은 일반적으로 2GB에서 7GB 사이로 다양합니다.", + "deleteConfig": "구성 삭제", + "modelConversionFailed": "모델 변환 실패", + "deleteMsg1": "InvokeAI에서 이 모델을 삭제하시겠습니까?", + "syncModels": "동기화 모델", + "modelType": "모델 유형", + "convertingModelBegin": "모델 변환 중입니다. 잠시만 기다려 주십시오.", + "name": "이름", + "convertToDiffusersHelpText1": "이 모델은 🧨 Diffusers 형식으로 변환됩니다.", + "vaePrecision": "VAE 정밀도", + "deleteMsg2": "모델이 InvokeAI root 폴더에 있으면 디스크에서 모델이 삭제됩니다. 사용자 지정 위치를 사용하는 경우 모델이 디스크에서 삭제되지 않습니다.", + "baseModel": "기본 모델", + "manual": "매뉴얼", + "convertToDiffusersHelpText3": "디스크의 체크포인트 파일이 InvokeAI root 폴더에 있으면 삭제됩니다. 사용자 지정 위치에 있으면 삭제되지 않습니다.", + "modelManager": "모델 매니저", + "variant": "Variant", + "modelDeleteFailed": "모델을 삭제하지 못했습니다", + "convertToDiffusers": "Diffusers로 변환", + "allModels": "모든 모델", + "alpha": "Alpha", + "noModelSelected": "선택한 모델 없음", + "convertToDiffusersHelpText4": "이것은 한 번의 과정일 뿐입니다. 컴퓨터 사양에 따라 30-60초 정도 소요될 수 있습니다.", + "model": "모델", + "delete": "삭제" + }, + "nodes": { + "missingTemplate": "잘못된 노드: {{type}} 유형의 {{node}} 템플릿 누락(설치되지 않으셨나요?)", + "noNodeSelected": "선택한 노드 없음", + "addNode": "노드 추가", + "enum": "Enum", + "loadWorkflow": "Workflow 불러오기", + "noOutputRecorded": "기록된 출력 없음", + "colorCodeEdgesHelp": "연결된 필드에 따른 색상 코드 선", + "hideLegendNodes": "필드 유형 범례 숨기기", + "addLinearView": "Linear View에 추가", + "float": "실수", + "targetNodeFieldDoesNotExist": "잘못된 모서리: 대상/입력 필드 {{node}}. {{field}}이(가) 없습니다", + "animatedEdges": "애니메이션 모서리", + "integer": "정수", + "nodeTemplate": "노드 템플릿", + "nodeOpacity": "노드 불투명도", + "sourceNodeDoesNotExist": "잘못된 모서리: 소스/출력 노드 {{node}}이(가) 없습니다", + "noFieldsLinearview": "Linear View에 추가된 필드 없음", + "nodeSearch": "노드 검색", + "inputMayOnlyHaveOneConnection": "입력에 하나의 연결만 있을 수 있습니다", + "notes": "메모", + "nodeOutputs": "노드 결과물", + "currentImageDescription": "Node Editor에 현재 이미지를 표시합니다", + "downloadWorkflow": "Workflow JSON 다운로드", + "ipAdapter": "IP-Adapter", + "noConnectionInProgress": "진행중인 연결이 없습니다", + "fieldTypesMustMatch": "필드 유형은 일치해야 합니다", + "edge": "Edge", + "sourceNodeFieldDoesNotExist": "잘못된 모서리: 소스/출력 필드 {{node}}. {{field}}이(가) 없습니다", + "animatedEdgesHelp": "선택한 노드에 연결된 선택한 가장자리 및 가장자리를 애니메이션화합니다", + "cannotDuplicateConnection": "중복 연결을 만들 수 없습니다", + "noWorkflow": "Workflow 없음", + "fullyContainNodesHelp": "선택하려면 노드가 선택 상자 안에 완전히 있어야 합니다", + "nodePack": "Node pack", + "nodeType": "노드 유형", + "fullyContainNodes": "선택할 노드 전체 포함", + "executionStateInProgress": "진행중", + "executionStateError": "에러", + "boolean": "Booleans", + "hideMinimapnodes": "미니맵 숨기기", + "executionStateCompleted": "완료된", + "node": "노드", + "currentImage": "현재 이미지", + "collection": "컬렉션", + "cannotConnectInputToInput": "입력을 입력에 연결할 수 없습니다", + "collectionFieldType": "{{name}} 컬렉션", + "cannotConnectOutputToOutput": "출력을 출력에 연결할 수 없습니다", + "connectionWouldCreateCycle": "연결하면 주기가 생성됩니다", + "cannotConnectToSelf": "자체에 연결할 수 없습니다", + "notesDescription": "Workflow에 대한 메모 추가", + "colorCodeEdges": "색상-코드 선", + "targetNodeDoesNotExist": "잘못된 모서리: 대상/입력 노드 {{node}}이(가) 없습니다", + "mismatchedVersion": "잘못된 노드: {{type}} 유형의 {{node}} 노드에 일치하지 않는 버전이 있습니다(업데이트 해보시겠습니까?)", + "addNodeToolTip": "노드 추가(Shift+A, Space)", + "collectionOrScalarFieldType": "{{name}} 컬렉션|Scalar", + "nodeVersion": "노드 버전", + "loadingNodes": "노드 로딩중...", + "deletedInvalidEdge": "잘못된 모서리 {{source}} -> {{target}} 삭제" + }, + "queue": { + "status": "상태", + "pruneSucceeded": "Queue로부터 {{item_count}} 완성된 항목 잘라내기", + "cancelTooltip": "현재 항목 취소", + "queueEmpty": "비어있는 Queue", + "pauseSucceeded": "중지된 프로세서", + "in_progress": "진행 중", + "queueFront": "Front of Queue에 추가", + "notReady": "Queue를 생성할 수 없음", + "batchFailedToQueue": "Queue Batch에 실패", + "completed": "완성된", + "queueBack": "Queue에 추가", + "cancelFailed": "항목 취소 중 발생한 문제", + "batchQueued": "Batch Queued", + "pauseFailed": "프로세서 중지 중 발생한 문제", + "clearFailed": "Queue 제거 중 발생한 문제", + "front": "front", + "clearSucceeded": "제거된 Queue", + "pause": "중지", + "pruneTooltip": "{{item_count}} 완성된 항목 잘라내기", + "cancelSucceeded": "취소된 항목", + "batchQueuedDesc_other": "queue의 {{direction}}에 추가된 {{count}}세션", + "queue": "Queue", + "batch": "Batch", + "clearQueueAlertDialog": "Queue를 지우면 처리 항목이 즉시 취소되고 Queue가 완전히 지워집니다.", + "resumeFailed": "프로세서 재개 중 발생한 문제", + "clear": "제거하다", + "prune": "잘라내다", + "total": "총 개수", + "canceled": "취소된", + "pruneFailed": "Queue 잘라내는 중 발생한 문제", + "cancelBatchSucceeded": "취소된 Batch", + "clearTooltip": "모든 항목을 취소하고 제거", + "current": "최근", + "pauseTooltip": "프로세서 중지", + "failed": "실패한", + "cancelItem": "항목 취소", + "next": "다음", + "cancelBatch": "Batch 취소", + "back": "back", + "batchFieldValues": "Batch 필드 값들", + "cancel": "취소", + "session": "세션", + "time": "시간", + "resumeSucceeded": "재개된 프로세서", + "enqueueing": "Queueing Batch", + "resumeTooltip": "프로세서 재개", + "resume": "재개", + "cancelBatchFailed": "Batch 취소 중 발생한 문제", + "clearQueueAlertDialog2": "Queue를 지우시겠습니까?", + "item": "항목", + "graphFailedToQueue": "queue graph에 실패" + }, + "metadata": { + "positivePrompt": "긍정적 프롬프트", + "negativePrompt": "부정적인 프롬프트", + "generationMode": "Generation Mode", + "Threshold": "Noise Threshold", + "metadata": "Metadata", + "seed": "시드", + "imageDetails": "이미지 세부 정보", + "model": "모델", + "noImageDetails": "이미지 세부 정보를 찾을 수 없습니다", + "cfgScale": "CFG scale", + "recallParameters": "매개변수 호출", + "height": "Height", + "noMetaData": "metadata를 찾을 수 없습니다", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "width": "너비", + "vae": "VAE", + "createdBy": "~에 의해 생성된", + "workflow": "작업의 흐름", + "steps": "단계", + "scheduler": "스케줄러", + "noRecallParameters": "호출할 매개 변수가 없습니다" + }, + "invocationCache": { + "useCache": "캐시 사용", + "disable": "이용 불가능한", + "misses": "캐시 미스", + "enableFailed": "Invocation 캐시를 사용하도록 설정하는 중 발생한 문제", + "invocationCache": "Invocation 캐시", + "clearSucceeded": "제거된 Invocation 캐시", + "enableSucceeded": "이용 가능한 Invocation 캐시", + "clearFailed": "Invocation 캐시 제거 중 발생한 문제", + "hits": "캐시 적중", + "disableSucceeded": "이용 불가능한 Invocation 캐시", + "disableFailed": "Invocation 캐시를 이용하지 못하게 설정 중 발생한 문제", + "enable": "이용 가능한", + "clear": "제거", + "maxCacheSize": "최대 캐시 크기", + "cacheSize": "캐시 크기" + }, + "hrf": { + "enableHrf": "이용 가능한 고해상도 고정", + "upscaleMethod": "업스케일 방법", + "metadata": { + "strength": "고해상도 고정 강도", + "enabled": "고해상도 고정 사용", + "method": "고해상도 고정 방법" + }, + "hrf": "고해상도 고정" + }, + "models": { + "noMatchingModels": "일치하는 모델 없음", + "loading": "로딩중", + "noMatchingLoRAs": "일치하는 LoRA 없음", + "noModelsAvailable": "사용 가능한 모델이 없음", + "addLora": "LoRA 추가", + "selectModel": "모델 선택", + "noRefinerModelsInstalled": "SDXL Refiner 모델이 설치되지 않음", + "noLoRAsInstalled": "설치된 LoRA 없음" + }, + "boards": { + "autoAddBoard": "자동 추가 Board", + "topMessage": "이 보드에는 다음 기능에 사용되는 이미지가 포함되어 있습니다:", + "move": "이동", + "menuItemAutoAdd": "해당 Board에 자동 추가", + "myBoard": "나의 Board", + "searchBoard": "Board 찾는 중...", + "deleteBoardOnly": "Board만 삭제", + "noMatching": "일치하는 Board들이 없음", + "movingImagesToBoard_other": "{{count}}이미지를 Board로 이동시키기", + "selectBoard": "Board 선택", + "cancel": "취소", + "addBoard": "Board 추가", + "bottomMessage": "이 보드와 이미지를 삭제하면 현재 사용 중인 모든 기능이 재설정됩니다.", + "uncategorized": "미분류", + "downloadBoard": "Board 다운로드", + "changeBoard": "Board 바꾸기", + "loading": "불러오는 중...", + "clearSearch": "검색 지우기", + "deleteBoard": "Board 삭제", + "deleteBoardAndImages": "Board와 이미지 삭제", + "deletedBoardsCannotbeRestored": "삭제된 Board는 복원할 수 없습니다" + } +} diff --git a/invokeai/frontend/web/public/locales/mn.json b/invokeai/frontend/web/public/locales/mn.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/invokeai/frontend/web/public/locales/mn.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/web/public/locales/nl.json b/invokeai/frontend/web/public/locales/nl.json new file mode 100644 index 0000000000000000000000000000000000000000..151f20ac64a687a55c5b7a1132f11e22401568c2 --- /dev/null +++ b/invokeai/frontend/web/public/locales/nl.json @@ -0,0 +1,849 @@ +{ + "common": { + "hotkeysLabel": "Sneltoetsen", + "languagePickerLabel": "Taal", + "reportBugLabel": "Meld bug", + "settingsLabel": "Instellingen", + "img2img": "Afbeelding naar afbeelding", + "nodes": "Werkstromen", + "upload": "Upload", + "load": "Laad", + "statusDisconnected": "Niet verbonden", + "githubLabel": "Github", + "discordLabel": "Discord", + "back": "Terug", + "cancel": "Annuleer", + "accept": "Akkoord", + "loading": "Bezig met laden", + "txt2img": "Tekst naar afbeelding", + "postprocessing": "Naverwerking", + "dontAskMeAgain": "Vraag niet opnieuw", + "random": "Willekeurig", + "openInNewTab": "Open in nieuw tabblad", + "areYouSure": "Weet je het zeker?", + "linear": "Lineair", + "batch": "Seriebeheer", + "modelManager": "Modelbeheer", + "communityLabel": "Gemeenschap", + "t2iAdapter": "T2I-adapter", + "on": "Aan", + "ipAdapter": "IP-adapter", + "auto": "Autom.", + "controlNet": "ControlNet", + "imageFailedToLoad": "Kan afbeelding niet laden", + "learnMore": "Meer informatie", + "advanced": "Uitgebreid", + "file": "Bestand", + "installed": "Geïnstalleerd", + "notInstalled": "Niet $t(common.installed)", + "simple": "Eenvoudig", + "somethingWentWrong": "Er ging iets mis", + "add": "Voeg toe", + "checkpoint": "Checkpoint", + "details": "Details", + "outputs": "Uitvoeren", + "save": "Bewaar", + "nextPage": "Volgende pagina", + "blue": "Blauw", + "alpha": "Alfa", + "red": "Rood", + "editor": "Editor", + "folder": "Map", + "format": "structuur", + "goTo": "Ga naar", + "template": "Sjabloon", + "input": "Invoer", + "safetensors": "Safetensors", + "saveAs": "Bewaar als", + "created": "Gemaakt", + "green": "Groen", + "tab": "Tab", + "positivePrompt": "Positieve prompt", + "negativePrompt": "Negatieve prompt", + "selected": "Geselecteerd", + "orderBy": "Sorteer op", + "prevPage": "Vorige pagina", + "beta": "Bèta", + "copyError": "$t(gallery.copy) Fout", + "toResolve": "Op te lossen", + "aboutDesc": "Gebruik je Invoke voor het werk? Kijk dan naar:", + "aboutHeading": "Creatieve macht voor jou", + "copy": "Kopieer", + "data": "Gegevens", + "or": "of", + "updated": "Bijgewerkt", + "outpaint": "outpainten", + "ai": "ai", + "inpaint": "inpainten", + "unknown": "Onbekend", + "delete": "Verwijder", + "direction": "Richting", + "error": "Fout", + "localSystem": "Lokaal systeem", + "unknownError": "Onbekende fout" + }, + "gallery": { + "galleryImageSize": "Afbeeldingsgrootte", + "gallerySettings": "Instellingen galerij", + "autoSwitchNewImages": "Wissel autom. naar nieuwe afbeeldingen", + "noImagesInGallery": "Geen afbeeldingen om te tonen", + "deleteImage_one": "Verwijder afbeelding", + "deleteImage_other": "", + "deleteImagePermanent": "Verwijderde afbeeldingen kunnen niet worden hersteld.", + "assets": "Eigen onderdelen", + "autoAssignBoardOnClick": "Ken automatisch bord toe bij klikken", + "featuresWillReset": "Als je deze afbeelding verwijdert, dan worden deze functies onmiddellijk teruggezet.", + "loading": "Bezig met laden", + "unableToLoad": "Kan galerij niet laden", + "downloadSelection": "Download selectie", + "currentlyInUse": "Deze afbeelding is momenteel in gebruik door de volgende functies:", + "copy": "Kopieer", + "download": "Download" + }, + "modelManager": { + "modelManager": "Modelonderhoud", + "model": "Model", + "modelUpdated": "Model bijgewerkt", + "manual": "Handmatig", + "name": "Naam", + "description": "Beschrijving", + "config": "Configuratie", + "width": "Breedte", + "height": "Hoogte", + "addModel": "Voeg model toe", + "availableModels": "Beschikbare modellen", + "search": "Zoek", + "load": "Laad", + "active": "actief", + "selected": "Gekozen", + "delete": "Verwijder", + "deleteModel": "Verwijder model", + "deleteConfig": "Verwijder configuratie", + "deleteMsg1": "Weet je zeker dat je dit model wilt verwijderen uit InvokeAI?", + "deleteMsg2": "Hiermee ZAL het model van schijf worden verwijderd als het zich bevindt in de beginmap van InvokeAI. Als je het model vanaf een eigen locatie gebruikt, dan ZAL het model NIET van schijf worden verwijderd.", + "convertToDiffusersHelpText3": "Je checkpoint-bestand op de schijf ZAL worden verwijderd als het zich in de beginmap van InvokeAI bevindt. Het ZAL NIET worden verwijderd als het zich in een andere locatie bevindt.", + "convertToDiffusersHelpText6": "Wil je dit model omzetten?", + "allModels": "Alle modellen", + "repo_id": "Repo-id", + "convert": "Omzetten", + "convertToDiffusers": "Omzetten naar Diffusers", + "convertToDiffusersHelpText1": "Dit model wordt omgezet naar de🧨 Diffusers-indeling.", + "convertToDiffusersHelpText2": "Dit proces vervangt het onderdeel in Modelonderhoud met de Diffusers-versie van hetzelfde model.", + "convertToDiffusersHelpText4": "Dit is een eenmalig proces. Dit neemt ongeveer 30 tot 60 sec. in beslag, afhankelijk van de specificaties van je computer.", + "convertToDiffusersHelpText5": "Zorg ervoor dat je genoeg schijfruimte hebt. Modellen nemen gewoonlijk ongeveer 2 tot 7 GB ruimte in beslag.", + "modelConverted": "Model omgezet", + "alpha": "Alfa", + "none": "geen", + "baseModel": "Basismodel", + "vae": "VAE", + "variant": "Variant", + "modelConversionFailed": "Omzetten model mislukt", + "modelUpdateFailed": "Bijwerken model mislukt", + "selectModel": "Kies model", + "settings": "Instellingen", + "modelDeleted": "Model verwijderd", + "syncModels": "Synchroniseer Modellen", + "modelDeleteFailed": "Model kon niet verwijderd worden", + "convertingModelBegin": "Model aan het converteren. Even geduld.", + "predictionType": "Soort voorspelling", + "advanced": "Uitgebreid", + "modelType": "Soort model", + "vaePrecision": "Nauwkeurigheid VAE", + "loraTriggerPhrases": "LoRA-triggerzinnen", + "urlOrLocalPathHelper": "URL's zouden moeten wijzen naar een los bestand. Lokale paden kunnen wijzen naar een los bestand of map voor een individueel Diffusers-model.", + "modelName": "Modelnaam", + "path": "Pad", + "triggerPhrases": "Triggerzinnen", + "typePhraseHere": "Typ zin hier in", + "modelImageDeleteFailed": "Fout bij verwijderen modelafbeelding", + "modelImageUpdated": "Modelafbeelding bijgewerkt", + "modelImageUpdateFailed": "Fout bij bijwerken modelafbeelding", + "noMatchingModels": "Geen overeenkomende modellen", + "scanPlaceholder": "Pad naar een lokale map", + "noModelsInstalled": "Geen modellen geïnstalleerd", + "noModelsInstalledDesc1": "Installeer modellen met de", + "noModelSelected": "Geen model geselecteerd", + "starterModels": "Beginnermodellen", + "textualInversions": "Tekstuele omkeringen", + "upcastAttention": "Upcast-aandacht", + "uploadImage": "Upload afbeelding", + "mainModelTriggerPhrases": "Triggerzinnen hoofdmodel", + "urlOrLocalPath": "URL of lokaal pad", + "scanFolderHelper": "De map zal recursief worden ingelezen voor modellen. Dit kan enige tijd in beslag nemen voor erg grote mappen.", + "simpleModelPlaceholder": "URL of pad naar een lokaal pad of Diffusers-map", + "modelSettings": "Modelinstellingen", + "pathToConfig": "Pad naar configuratie", + "prune": "Snoei", + "pruneTooltip": "Snoei voltooide importeringen uit wachtrij", + "repoVariant": "Repovariant", + "scanFolder": "Lees map in", + "scanResults": "Resultaten inlezen", + "source": "Bron" + }, + "parameters": { + "images": "Afbeeldingen", + "steps": "Stappen", + "cfgScale": "CFG-schaal", + "width": "Breedte", + "height": "Hoogte", + "seed": "Seed", + "shuffle": "Mengseed", + "noiseThreshold": "Drempelwaarde ruis", + "perlinNoise": "Perlinruis", + "type": "Soort", + "strength": "Sterkte", + "upscaling": "Opschalen", + "scale": "Schaal", + "imageFit": "Pas initiële afbeelding in uitvoergrootte", + "scaleBeforeProcessing": "Schalen voor verwerking", + "scaledWidth": "Geschaalde B", + "scaledHeight": "Geschaalde H", + "infillMethod": "Infill-methode", + "tileSize": "Grootte tegel", + "downloadImage": "Download afbeelding", + "usePrompt": "Hergebruik invoertekst", + "useSeed": "Hergebruik seed", + "useAll": "Hergebruik alles", + "info": "Info", + "showOptionsPanel": "Toon deelscherm Opties (O of T)", + "symmetry": "Symmetrie", + "cancel": { + "cancel": "Annuleer" + }, + "general": "Algemeen", + "copyImage": "Kopieer afbeelding", + "denoisingStrength": "Sterkte ontruisen", + "scheduler": "Planner", + "seamlessXAxis": "Naadloze tegels in x-as", + "seamlessYAxis": "Naadloze tegels in y-as", + "clipSkip": "Overslaan CLIP", + "negativePromptPlaceholder": "Negatieve prompt", + "controlNetControlMode": "Aansturingsmodus", + "positivePromptPlaceholder": "Positieve prompt", + "maskBlur": "Vervaging van masker", + "invoke": { + "noNodesInGraph": "Geen knooppunten in graaf", + "noModelSelected": "Geen model ingesteld", + "invoke": "Start", + "noPrompts": "Geen prompts gegenereerd", + "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} invoer ontbreekt", + "systemDisconnected": "Systeem is niet verbonden", + "missingNodeTemplate": "Knooppuntsjabloon ontbreekt", + "missingFieldTemplate": "Veldsjabloon ontbreekt", + "addingImagesTo": "Bezig met toevoegen van afbeeldingen aan", + "layer": { + "controlAdapterNoModelSelected": "geen controle-adaptermodel geselecteerd", + "controlAdapterIncompatibleBaseModel": "niet-compatibele basismodel voor controle-adapter", + "ipAdapterIncompatibleBaseModel": "niet-compatibele basismodel voor IP-adapter", + "ipAdapterNoImageSelected": "geen afbeelding voor IP-adapter geselecteerd", + "rgNoRegion": "geen gebied geselecteerd", + "rgNoPromptsOrIPAdapters": "geen tekstprompts of IP-adapters", + "ipAdapterNoModelSelected": "geen IP-adapter geselecteerd" + } + }, + "patchmatchDownScaleSize": "Verklein", + "useCpuNoise": "Gebruik CPU-ruis", + "imageActions": "Afbeeldingshandeling", + "iterations": "Iteraties", + "coherenceMode": "Modus", + "infillColorValue": "Vulkleur", + "remixImage": "Meng afbeelding opnieuw", + "setToOptimalSize": "Optimaliseer grootte voor het model", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (is mogelijk te klein)", + "aspect": "Beeldverhouding", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (is mogelijk te groot)", + "lockAspectRatio": "Zet beeldverhouding vast", + "useSize": "Gebruik grootte", + "swapDimensions": "Wissel afmetingen om", + "coherenceEdgeSize": "Randgrootte", + "coherenceMinDenoise": "Min. ontruising", + "cfgRescaleMultiplier": "Vermenigvuldiger voor CFG-herschaling" + }, + "settings": { + "models": "Modellen", + "displayInProgress": "Toon voortgangsafbeeldingen", + "confirmOnDelete": "Bevestig bij verwijderen", + "resetWebUI": "Herstel web-UI", + "resetWebUIDesc1": "Herstel web-UI herstelt alleen de lokale afbeeldingscache en de onthouden instellingen van je browser. Het verwijdert geen afbeeldingen van schijf.", + "resetWebUIDesc2": "Als afbeeldingen niet getoond worden in de galerij of iets anders werkt niet, probeer dan eerst deze herstelfunctie voordat je een fout aanmeldt op GitHub.", + "resetComplete": "Webinterface is hersteld.", + "developer": "Ontwikkelaar", + "general": "Algemeen", + "showProgressInViewer": "Toon voortgangsafbeeldingen in viewer", + "generation": "Genereren", + "ui": "Gebruikersinterface", + "antialiasProgressImages": "Voer anti-aliasing uit op voortgangsafbeeldingen", + "beta": "Bèta", + "clearIntermediates": "Wis tussentijdse afbeeldingen", + "clearIntermediatesDesc3": "Je galerijafbeeldingen zullen niet worden verwijderd.", + "clearIntermediatesWithCount_one": "Wis {{count}} tussentijdse afbeelding", + "clearIntermediatesWithCount_other": "Wis {{count}} tussentijdse afbeeldingen", + "clearIntermediatesDesc2": "Tussentijdse afbeeldingen zijn nevenproducten bij het genereren. Deze wijken af van de uitvoerafbeeldingen in de galerij. Als je tussentijdse afbeeldingen wist, wordt schijfruimte vrijgemaakt.", + "intermediatesCleared_one": "{{count}} tussentijdse afbeelding gewist", + "intermediatesCleared_other": "{{count}} tussentijdse afbeeldingen gewist", + "clearIntermediatesDesc1": "Als je tussentijdse afbeeldingen wist, dan wordt de staat hersteld van je canvas en van ControlNet.", + "intermediatesClearedFailed": "Fout bij wissen van tussentijdse afbeeldingen", + "clearIntermediatesDisabled": "Wachtrij moet leeg zijn om tussentijdse afbeeldingen te kunnen leegmaken", + "enableInformationalPopovers": "Schakel informatieve hulpballonnen in", + "enableInvisibleWatermark": "Schakel onzichtbaar watermerk in", + "enableNSFWChecker": "Schakel NSFW-controle in", + "reloadingIn": "Opnieuw laden na" + }, + "toast": { + "uploadFailed": "Upload mislukt", + "imageCopied": "Afbeelding gekopieerd", + "parametersNotSet": "Parameters niet ingesteld", + "serverError": "Serverfout", + "connected": "Verbonden met server", + "canceled": "Verwerking geannuleerd", + "uploadFailedInvalidUploadDesc": "Moet een enkele PNG- of JPEG-afbeelding zijn", + "parameterNotSet": "{{parameter}} niet ingesteld", + "parameterSet": "{{parameter}} ingesteld", + "problemCopyingImage": "Kan Afbeelding Niet Kopiëren", + "baseModelChangedCleared_one": "Basismodel is gewijzigd: {{count}} niet-compatibel submodel weggehaald of uitgeschakeld", + "baseModelChangedCleared_other": "Basismodel is gewijzigd: {{count}} niet-compatibele submodellen weggehaald of uitgeschakeld", + "loadedWithWarnings": "Werkstroom geladen met waarschuwingen", + "setControlImage": "Ingesteld als controle-afbeelding", + "setNodeField": "Ingesteld als knooppuntveld", + "imageUploaded": "Afbeelding geüpload", + "addedToBoard": "Toegevoegd aan bord", + "workflowLoaded": "Werkstroom geladen", + "modelAddedSimple": "Model toegevoegd aan wachtrij", + "imageUploadFailed": "Fout bij uploaden afbeelding", + "workflowDeleted": "Werkstroom verwijderd", + "invalidUpload": "Ongeldige upload", + "problemRetrievingWorkflow": "Fout bij ophalen van werkstroom", + "parameters": "Parameters", + "modelImportCanceled": "Importeren model geannuleerd", + "problemDeletingWorkflow": "Fout bij verwijderen van werkstroom", + "prunedQueue": "Wachtrij gesnoeid", + "problemDownloadingImage": "Fout bij downloaden afbeelding" + }, + "accessibility": { + "invokeProgressBar": "Voortgangsbalk Invoke", + "reset": "Herstel", + "uploadImage": "Upload afbeelding", + "previousImage": "Vorige afbeelding", + "nextImage": "Volgende afbeelding", + "menu": "Menu", + "about": "Over", + "mode": "Modus", + "resetUI": "$t(accessibility.reset) UI", + "createIssue": "Maak probleem aan" + }, + "nodes": { + "zoomOutNodes": "Uitzoomen", + "fitViewportNodes": "Aanpassen aan beeld", + "hideMinimapnodes": "Minimap verbergen", + "showLegendNodes": "Typelegende veld tonen", + "zoomInNodes": "Inzoomen", + "showMinimapnodes": "Minimap tonen", + "hideLegendNodes": "Typelegende veld verbergen", + "reloadNodeTemplates": "Herlaad knooppuntsjablonen", + "loadWorkflow": "Laad werkstroom", + "downloadWorkflow": "Download JSON van werkstroom", + "scheduler": "Planner", + "missingTemplate": "Ongeldig knooppunt: knooppunt {{node}} van het soort {{type}} heeft een ontbrekend sjabloon (niet geïnstalleerd?)", + "workflowDescription": "Korte beschrijving", + "versionUnknown": " Versie onbekend", + "noNodeSelected": "Geen knooppunt gekozen", + "addNode": "Voeg knooppunt toe", + "unableToValidateWorkflow": "Kan werkstroom niet valideren", + "enum": "Enumeratie", + "noOutputRecorded": "Geen uitvoer opgenomen", + "updateApp": "Werk app bij", + "colorCodeEdgesHelp": "Kleurgecodeerde randen op basis van hun verbonden velden", + "float": "Zwevende-kommagetal", + "workflowContact": "Contactpersoon", + "animatedEdges": "Geanimeerde randen", + "integer": "Geheel getal", + "nodeTemplate": "Sjabloon knooppunt", + "nodeOpacity": "Dekking knooppunt", + "unableToLoadWorkflow": "Fout bij laden werkstroom", + "snapToGrid": "Lijn uit op raster", + "noFieldsLinearview": "Geen velden toegevoegd aan lineaire weergave", + "nodeSearch": "Zoek naar knooppunten", + "updateNode": "Werk knooppunt bij", + "version": "Versie", + "validateConnections": "Valideer verbindingen en graaf", + "inputMayOnlyHaveOneConnection": "Invoer mag slechts een enkele verbinding hebben", + "notes": "Opmerkingen", + "nodeOutputs": "Uitvoer knooppunt", + "currentImageDescription": "Toont de huidige afbeelding in de knooppunteditor", + "validateConnectionsHelp": "Voorkom dat er ongeldige verbindingen worden gelegd en dat er ongeldige grafen worden aangeroepen", + "problemSettingTitle": "Fout bij instellen titel", + "ipAdapter": "IP-adapter", + "noConnectionInProgress": "Geen verbinding bezig te maken", + "workflowVersion": "Versie", + "fieldTypesMustMatch": "Veldsoorten moeten overeenkomen", + "workflow": "Werkstroom", + "edge": "Rand", + "animatedEdgesHelp": "Animeer gekozen randen en randen verbonden met de gekozen knooppunten", + "cannotDuplicateConnection": "Kan geen dubbele verbindingen maken", + "unknownTemplate": "Onbekend sjabloon", + "noWorkflow": "Geen werkstroom", + "removeLinearView": "Verwijder uit lineaire weergave", + "workflowTags": "Labels", + "fullyContainNodesHelp": "Knooppunten moeten zich volledig binnen het keuzevak bevinden om te worden gekozen", + "workflowValidation": "Validatiefout werkstroom", + "nodeType": "Soort knooppunt", + "fullyContainNodes": "Omvat knooppunten volledig om ze te kiezen", + "executionStateInProgress": "Bezig", + "executionStateError": "Fout", + "boolean": "Booleaanse waarden", + "executionStateCompleted": "Voltooid", + "node": "Knooppunt", + "workflowAuthor": "Auteur", + "currentImage": "Huidige afbeelding", + "workflowName": "Naam", + "collection": "Verzameling", + "cannotConnectInputToInput": "Kan invoer niet aan invoer verbinden", + "workflowNotes": "Opmerkingen", + "string": "Tekenreeks", + "cannotConnectOutputToOutput": "Kan uitvoer niet aan uitvoer verbinden", + "connectionWouldCreateCycle": "Verbinding zou cyclisch worden", + "cannotConnectToSelf": "Kan niet aan zichzelf verbinden", + "notesDescription": "Voeg opmerkingen toe aan je werkstroom", + "unknownField": "Onbekend veld", + "colorCodeEdges": "Kleurgecodeerde randen", + "unknownNode": "Onbekend knooppunt", + "mismatchedVersion": "Ongeldig knooppunt: knooppunt {{node}} van het soort {{type}} heeft een niet-overeenkomende versie (probeer het bij te werken?)", + "addNodeToolTip": "Voeg knooppunt toe (Shift+A, spatie)", + "loadingNodes": "Bezig met laden van knooppunten...", + "snapToGridHelp": "Lijn knooppunten uit op raster bij verplaatsing", + "workflowSettings": "Instellingen werkstroomeditor", + "addLinearView": "Voeg toe aan lineaire weergave", + "nodePack": "Knooppuntpakket", + "unknownInput": "Onbekende invoer: {{name}}", + "sourceNodeFieldDoesNotExist": "Ongeldige rand: bron-/uitvoerveld {{node}}.{{field}} bestaat niet", + "collectionFieldType": "Verzameling {{name}}", + "deletedInvalidEdge": "Ongeldige hoek {{source}} -> {{target}} verwijderd", + "graph": "Grafiek", + "targetNodeDoesNotExist": "Ongeldige rand: doel-/invoerknooppunt {{node}} bestaat niet", + "resetToDefaultValue": "Herstel naar standaardwaarden", + "editMode": "Bewerk in Werkstroom-editor", + "showEdgeLabels": "Toon randlabels", + "showEdgeLabelsHelp": "Toon labels aan randen, waarmee de verbonden knooppunten mee worden aangegeven", + "clearWorkflowDesc2": "Je huidige werkstroom heeft niet-bewaarde wijzigingen.", + "unableToParseFieldType": "fout bij bepalen soort veld", + "sourceNodeDoesNotExist": "Ongeldige rand: bron-/uitvoerknooppunt {{node}} bestaat niet", + "unsupportedArrayItemType": "niet-ondersteunde soort van het array-onderdeel \"{{type}}\"", + "targetNodeFieldDoesNotExist": "Ongeldige rand: doel-/invoerveld {{node}}.{{field}} bestaat niet", + "reorderLinearView": "Herorden lineaire weergave", + "newWorkflowDesc": "Een nieuwe werkstroom aanmaken?", + "collectionOrScalarFieldType": "Verzameling|scalair {{name}}", + "newWorkflow": "Nieuwe werkstroom", + "unknownErrorValidatingWorkflow": "Onbekende fout bij valideren werkstroom", + "unsupportedAnyOfLength": "te veel union-leden ({{count}})", + "unknownOutput": "Onbekende uitvoer: {{name}}", + "viewMode": "Gebruik in lineaire weergave", + "unableToExtractSchemaNameFromRef": "fout bij het extraheren van de schemanaam via de ref", + "unsupportedMismatchedUnion": "niet-overeenkomende soort CollectionOrScalar met basissoorten {{firstType}} en {{secondType}}", + "unknownNodeType": "Onbekend soort knooppunt", + "edit": "Bewerk", + "updateAllNodes": "Werk knooppunten bij", + "allNodesUpdated": "Alle knooppunten bijgewerkt", + "nodeVersion": "Knooppuntversie", + "newWorkflowDesc2": "Je huidige werkstroom heeft niet-bewaarde wijzigingen.", + "clearWorkflow": "Maak werkstroom leeg", + "clearWorkflowDesc": "Deze werkstroom leegmaken en met een nieuwe beginnen?", + "inputFieldTypeParseError": "Fout bij bepalen van het soort invoerveld {{node}}.{{field}} ({{message}})", + "outputFieldTypeParseError": "Fout bij het bepalen van het soort uitvoerveld {{node}}.{{field}} ({{message}})", + "unableToExtractEnumOptions": "fout bij extraheren enumeratie-opties", + "unknownFieldType": "Soort $t(nodes.unknownField): {{type}}", + "unableToGetWorkflowVersion": "Fout bij ophalen schemaversie van werkstroom", + "betaDesc": "Deze uitvoering is in bèta. Totdat deze stabiel is kunnen er wijzigingen voorkomen gedurende app-updates die zaken kapotmaken. We zijn van plan om deze uitvoering op lange termijn te gaan ondersteunen.", + "prototypeDesc": "Deze uitvoering is een prototype. Er kunnen wijzigingen voorkomen gedurende app-updates die zaken kapotmaken. Deze kunnen op een willekeurig moment verwijderd worden.", + "noFieldsViewMode": "Deze werkstroom heeft geen geselecteerde velden om te tonen. Bekijk de volledige werkstroom om de waarden te configureren.", + "unableToUpdateNodes_one": "Fout bij bijwerken van {{count}} knooppunt", + "unableToUpdateNodes_other": "Fout bij bijwerken van {{count}} knooppunten" + }, + "dynamicPrompts": { + "seedBehaviour": { + "perPromptDesc": "Gebruik een verschillende seedwaarde per afbeelding", + "perIterationLabel": "Seedwaarde per iteratie", + "perIterationDesc": "Gebruik een verschillende seedwaarde per iteratie", + "perPromptLabel": "Seedwaarde per afbeelding", + "label": "Gedrag seedwaarde" + }, + "maxPrompts": "Max. prompts", + "dynamicPrompts": "Dynamische prompts", + "showDynamicPrompts": "Toon dynamische prompts", + "loading": "Genereren van dynamische prompts...", + "promptsPreview": "Voorvertoning prompts" + }, + "popovers": { + "noiseUseCPU": { + "paragraphs": [ + "Bepaalt of ruis wordt gegenereerd op de CPU of de GPU.", + "Met CPU-ruis ingeschakeld zal een bepaalde seedwaarde dezelfde afbeelding opleveren op welke machine dan ook.", + "Er is geen prestatieverschil bij het inschakelen van CPU-ruis." + ], + "heading": "Gebruik CPU-ruis" + }, + "paramScheduler": { + "paragraphs": [ + "De planner gebruikt gedurende het genereringsproces." + ], + "heading": "Planner" + }, + "scaleBeforeProcessing": { + "paragraphs": [ + "Schaalt het gekozen gebied naar de grootte die het meest geschikt is voor het model, vooraf aan het proces van het afbeeldingen genereren." + ], + "heading": "Schaal vooraf aan verwerking" + }, + "compositingMaskAdjustments": { + "heading": "Aanpassingen masker", + "paragraphs": [ + "Pas het masker aan." + ] + }, + "paramRatio": { + "heading": "Beeldverhouding", + "paragraphs": [ + "De beeldverhouding van de afmetingen van de afbeelding die wordt gegenereerd.", + "Een afbeeldingsgrootte (in aantal pixels) equivalent aan 512x512 wordt aanbevolen voor SD1.5-modellen. Een grootte-equivalent van 1024x1024 wordt aanbevolen voor SDXL-modellen." + ] + }, + "dynamicPrompts": { + "paragraphs": [ + "Dynamische prompts vormt een enkele prompt om in vele.", + "De basissyntax is \"a {red|green|blue} ball\". Dit zal de volgende drie prompts geven: \"a red ball\", \"a green ball\" en \"a blue ball\".", + "Gebruik de syntax zo vaak als je wilt in een enkele prompt, maar zorg ervoor dat het aantal gegenereerde prompts in lijn ligt met de instelling Max. prompts." + ], + "heading": "Dynamische prompts" + }, + "paramVAE": { + "paragraphs": [ + "Het model gebruikt voor het vertalen van AI-uitvoer naar de uiteindelijke afbeelding." + ], + "heading": "VAE" + }, + "paramIterations": { + "paragraphs": [ + "Het aantal te genereren afbeeldingen.", + "Als dynamische prompts is ingeschakeld, dan zal elke prompt dit aantal keer gegenereerd worden." + ], + "heading": "Iteraties" + }, + "paramVAEPrecision": { + "heading": "Nauwkeurigheid VAE", + "paragraphs": [ + "De nauwkeurigheid gebruikt tijdens de VAE-codering en -decodering. FP16/halve nauwkeurig is efficiënter, ten koste van kleine afbeeldingsvariaties." + ] + }, + "compositingCoherenceMode": { + "heading": "Modus", + "paragraphs": [ + "De modus van de coherentiefase." + ] + }, + "paramSeed": { + "paragraphs": [ + "Bepaalt de startruis die gebruikt wordt bij het genereren.", + "Schakel \"Willekeurige seedwaarde\" uit om identieke resultaten te krijgen met dezelfde genereer-instellingen." + ], + "heading": "Seedwaarde" + }, + "controlNetResizeMode": { + "heading": "Schaalmodus", + "paragraphs": [ + "Hoe de ControlNet-afbeelding zal worden geschaald aan de uitvoergrootte van de afbeelding." + ] + }, + "controlNetBeginEnd": { + "paragraphs": [ + "Op welke stappen van het ontruisingsproces ControlNet worden toegepast.", + "ControlNets die worden toegepast aan het begin begeleiden het compositieproces. ControlNets die worden toegepast aan het eind zorgen voor details." + ], + "heading": "Percentage begin- / eindstap" + }, + "dynamicPromptsSeedBehaviour": { + "paragraphs": [ + "Bepaalt hoe de seedwaarde wordt gebruikt bij het genereren van prompts.", + "Per iteratie zal een unieke seedwaarde worden gebruikt voor elke iteratie. Gebruik dit om de promptvariaties binnen een enkele seedwaarde te verkennen.", + "Bijvoorbeeld: als je vijf prompts heb, dan zal voor elke afbeelding dezelfde seedwaarde gebruikt worden.", + "De optie Per afbeelding zal een unieke seedwaarde voor elke afbeelding gebruiken. Dit biedt meer variatie." + ], + "heading": "Gedrag seedwaarde" + }, + "clipSkip": { + "paragraphs": [ + "Aantal over te slaan CLIP-modellagen.", + "Bepaalde modellen zijn beter geschikt met bepaalde Overslaan CLIP-instellingen." + ], + "heading": "Overslaan CLIP" + }, + "paramModel": { + "heading": "Model", + "paragraphs": [ + "Model gebruikt voor de ontruisingsstappen." + ] + }, + "compositingCoherencePass": { + "heading": "Coherentiefase", + "paragraphs": [ + "Een tweede ronde ontruising helpt bij het samenstellen van de erin- of eruitgetekende afbeelding." + ] + }, + "paramDenoisingStrength": { + "paragraphs": [ + "Hoeveel ruis wordt toegevoegd aan de invoerafbeelding.", + "0 levert een identieke afbeelding op, waarbij 1 een volledig nieuwe afbeelding oplevert." + ], + "heading": "Ontruisingssterkte" + }, + "paramNegativeConditioning": { + "paragraphs": [ + "Het genereerproces voorkomt de gegeven begrippen in de negatieve prompt. Gebruik dit om bepaalde zaken of voorwerpen uit te sluiten van de uitvoerafbeelding.", + "Ondersteunt Compel-syntax en -embeddingen." + ], + "heading": "Negatieve prompt" + }, + "compositingBlurMethod": { + "heading": "Vervagingsmethode", + "paragraphs": [ + "De methode van de vervaging die wordt toegepast op het gemaskeerd gebied." + ] + }, + "dynamicPromptsMaxPrompts": { + "heading": "Max. prompts", + "paragraphs": [ + "Beperkt het aantal prompts die kunnen worden gegenereerd door dynamische prompts." + ] + }, + "infillMethod": { + "paragraphs": [ + "Methode om een gekozen gebied in te vullen." + ], + "heading": "Invulmethode" + }, + "controlNetWeight": { + "heading": "Gewicht", + "paragraphs": [ + "Hoe sterk ControlNet effect heeft op de gegeneerde afbeelding." + ] + }, + "controlNet": { + "heading": "ControlNet", + "paragraphs": [ + "ControlNets begeleidt het genereerproces, waarbij geholpen wordt bij het maken van afbeeldingen met aangestuurde compositie, structuur of stijl, afhankelijk van het gekozen model." + ] + }, + "paramCFGScale": { + "heading": "CFG-schaal", + "paragraphs": [ + "Bepaalt hoeveel je prompt invloed heeft op het genereerproces." + ] + }, + "controlNetControlMode": { + "paragraphs": [ + "Geeft meer gewicht aan ofwel de prompt danwel ControlNet." + ], + "heading": "Controlemodus" + }, + "paramSteps": { + "heading": "Stappen", + "paragraphs": [ + "Het aantal uit te voeren stappen tijdens elke generatie.", + "Een hoger aantal stappen geven meestal betere afbeeldingen, ten koste van een hogere benodigde tijd om te genereren." + ] + }, + "paramPositiveConditioning": { + "heading": "Positieve prompt", + "paragraphs": [ + "Begeleidt het generartieproces. Gebruik een woord of frase naar keuze.", + "Syntaxes en embeddings voor Compel en dynamische prompts." + ] + }, + "lora": { + "heading": "Gewicht LoRA", + "paragraphs": [ + "Een hogere LoRA-gewicht zal leiden tot een groter effect op de uiteindelijke afbeelding." + ] + } + }, + "metadata": { + "positivePrompt": "Positieve prompt", + "negativePrompt": "Negatieve prompt", + "generationMode": "Genereermodus", + "Threshold": "Drempelwaarde ruis", + "metadata": "Metagegevens", + "strength": "Sterkte Afbeelding naar afbeelding", + "seed": "Seedwaarde", + "imageDetails": "Afbeeldingsdetails", + "model": "Model", + "noImageDetails": "Geen afbeeldingsdetails gevonden", + "cfgScale": "CFG-schaal", + "recallParameters": "Opnieuw aan te roepen parameters", + "height": "Hoogte", + "noMetaData": "Geen metagegevens gevonden", + "width": "Breedte", + "createdBy": "Gemaakt door", + "workflow": "Werkstroom", + "steps": "Stappen", + "scheduler": "Planner", + "noRecallParameters": "Geen opnieuw uit te voeren parameters gevonden" + }, + "queue": { + "status": "Status", + "pruneSucceeded": "{{item_count}} voltooide onderdelen uit wachtrij opgeruimd", + "cancelTooltip": "Annuleer huidig onderdeel", + "queueEmpty": "Wachtrij leeg", + "pauseSucceeded": "Verwerker onderbroken", + "in_progress": "Bezig", + "queueFront": "Voeg vooraan toe in wachtrij", + "notReady": "Fout bij plaatsen in wachtrij", + "batchFailedToQueue": "Fout bij reeks in wachtrij plaatsen", + "completed": "Voltooid", + "queueBack": "Voeg toe aan wachtrij", + "cancelFailed": "Fout bij annuleren onderdeel", + "batchQueued": "Reeks in wachtrij geplaatst", + "pauseFailed": "Fout bij onderbreken verwerker", + "clearFailed": "Fout bij wissen van wachtrij", + "front": "begin", + "clearSucceeded": "Wachtrij gewist", + "pause": "Onderbreek", + "pruneTooltip": "Ruim {{item_count}} voltooide onderdelen op", + "cancelSucceeded": "Onderdeel geannuleerd", + "batchQueuedDesc_one": "Voeg {{count}} sessie toe aan het {{direction}} van de wachtrij", + "batchQueuedDesc_other": "Voeg {{count}} sessies toe aan het {{direction}} van de wachtrij", + "graphQueued": "Graaf in wachtrij geplaatst", + "queue": "Wachtrij", + "batch": "Reeks", + "clearQueueAlertDialog": "Als je de wachtrij onmiddellijk wist, dan worden alle onderdelen die bezig zijn geannuleerd en wordt de wachtrij volledig gewist.", + "pending": "Wachtend", + "completedIn": "Voltooid na", + "resumeFailed": "Fout bij hervatten verwerker", + "clear": "Wis", + "prune": "Ruim op", + "total": "Totaal", + "canceled": "Geannuleerd", + "pruneFailed": "Fout bij opruimen van wachtrij", + "cancelBatchSucceeded": "Reeks geannuleerd", + "clearTooltip": "Annuleer en wis alle onderdelen", + "current": "Huidig", + "pauseTooltip": "Onderbreek verwerker", + "failed": "Mislukt", + "cancelItem": "Annuleer onderdeel", + "next": "Volgende", + "cancelBatch": "Annuleer reeks", + "back": "eind", + "cancel": "Annuleer", + "session": "Sessie", + "resumeSucceeded": "Verwerker hervat", + "enqueueing": "Bezig met toevoegen van reeks aan wachtrij", + "resumeTooltip": "Hervat verwerker", + "resume": "Hervat", + "cancelBatchFailed": "Fout bij annuleren van reeks", + "clearQueueAlertDialog2": "Weet je zeker dat je de wachtrij wilt wissen?", + "item": "Onderdeel", + "graphFailedToQueue": "Fout bij toevoegen graaf aan wachtrij" + }, + "sdxl": { + "refinerStart": "Startwaarde verfijning", + "scheduler": "Planner", + "cfgScale": "CFG-schaal", + "negStylePrompt": "Negatieve-stijlprompt", + "noModelsAvailable": "Geen modellen beschikbaar", + "refiner": "Verfijning", + "negAestheticScore": "Negatieve esthetische score", + "denoisingStrength": "Sterkte ontruising", + "refinermodel": "Verfijningsmodel", + "posAestheticScore": "Positieve esthetische score", + "concatPromptStyle": "Koppelen van prompt en stijl", + "loading": "Bezig met laden...", + "steps": "Stappen", + "posStylePrompt": "Positieve-stijlprompt", + "freePromptStyle": "Handmatige stijlprompt", + "refinerSteps": "Aantal stappen verfijner" + }, + "models": { + "noMatchingModels": "Geen overeenkomend modellen", + "loading": "bezig met laden", + "noMatchingLoRAs": "Geen overeenkomende LoRA's", + "noModelsAvailable": "Geen modellen beschikbaar", + "selectModel": "Kies een model", + "noLoRAsInstalled": "Geen LoRA's geïnstalleerd", + "noRefinerModelsInstalled": "Geen SDXL-verfijningsmodellen geïnstalleerd", + "defaultVAE": "Standaard-VAE", + "lora": "LoRA", + "addLora": "Voeg LoRA toe", + "concepts": "Concepten" + }, + "boards": { + "autoAddBoard": "Voeg automatisch bord toe", + "topMessage": "Dit bord bevat afbeeldingen die in gebruik zijn door de volgende functies:", + "move": "Verplaats", + "menuItemAutoAdd": "Voeg dit automatisch toe aan bord", + "myBoard": "Mijn bord", + "searchBoard": "Zoek borden...", + "noMatching": "Geen overeenkomende borden", + "selectBoard": "Kies een bord", + "cancel": "Annuleer", + "addBoard": "Voeg bord toe", + "bottomMessage": "Als je dit bord en alle afbeeldingen erop verwijdert, dan worden alle functies teruggezet die ervan gebruik maken.", + "uncategorized": "Zonder categorie", + "downloadBoard": "Download bord", + "changeBoard": "Wijzig bord", + "loading": "Bezig met laden...", + "clearSearch": "Maak zoekopdracht leeg", + "deleteBoard": "Verwijder bord", + "deleteBoardAndImages": "Verwijder bord en afbeeldingen", + "deleteBoardOnly": "Verwijder alleen bord", + "deletedBoardsCannotbeRestored": "Verwijderde borden kunnen niet worden hersteld", + "movingImagesToBoard_one": "Verplaatsen van {{count}} afbeelding naar bord:", + "movingImagesToBoard_other": "Verplaatsen van {{count}} afbeeldingen naar bord:" + }, + "invocationCache": { + "disable": "Schakel uit", + "misses": "Mislukt cacheverzoek", + "enableFailed": "Fout bij inschakelen aanroepcache", + "invocationCache": "Aanroepcache", + "clearSucceeded": "Aanroepcache gewist", + "enableSucceeded": "Aanroepcache ingeschakeld", + "clearFailed": "Fout bij wissen aanroepcache", + "hits": "Gelukt cacheverzoek", + "disableSucceeded": "Aanroepcache uitgeschakeld", + "disableFailed": "Fout bij uitschakelen aanroepcache", + "enable": "Schakel in", + "clear": "Wis", + "maxCacheSize": "Max. grootte cache", + "cacheSize": "Grootte cache" + }, + "accordions": { + "generation": { + "title": "Genereren" + }, + "image": { + "title": "Afbeelding" + }, + "advanced": { + "title": "Geavanceerd", + "options": "$t(accordions.advanced.title) Opties" + }, + "control": { + "title": "Besturing" + }, + "compositing": { + "title": "Samenstellen", + "coherenceTab": "Coherentiefase", + "infillTab": "Invullen" + } + }, + "hrf": { + "upscaleMethod": "Opschaalmethode", + "metadata": { + "strength": "Sterkte oplossing voor hoge resolutie", + "method": "Methode oplossing voor hoge resolutie", + "enabled": "Oplossing voor hoge resolutie ingeschakeld" + }, + "hrf": "Oplossing voor hoge resolutie", + "enableHrf": "Schakel oplossing in voor hoge resolutie" + }, + "prompt": { + "addPromptTrigger": "Voeg prompttrigger toe", + "compatibleEmbeddings": "Compatibele embeddings" + } +} diff --git a/invokeai/frontend/web/public/locales/pl.json b/invokeai/frontend/web/public/locales/pl.json new file mode 100644 index 0000000000000000000000000000000000000000..7c0f46896519282f8bd1fe0320a5ed48e4852b07 --- /dev/null +++ b/invokeai/frontend/web/public/locales/pl.json @@ -0,0 +1,70 @@ +{ + "common": { + "hotkeysLabel": "Skróty klawiszowe", + "languagePickerLabel": "Wybór języka", + "reportBugLabel": "Zgłoś błąd", + "settingsLabel": "Ustawienia", + "img2img": "Obraz na obraz", + "nodes": "Węzły", + "upload": "Prześlij", + "load": "Załaduj", + "statusDisconnected": "Odłączono od serwera", + "githubLabel": "GitHub", + "discordLabel": "Discord" + }, + "gallery": { + "galleryImageSize": "Rozmiar obrazów", + "gallerySettings": "Ustawienia galerii", + "autoSwitchNewImages": "Przełączaj na nowe obrazy", + "noImagesInGallery": "Brak obrazów w galerii" + }, + "parameters": { + "images": "L. obrazów", + "steps": "L. kroków", + "cfgScale": "Skala CFG", + "width": "Szerokość", + "height": "Wysokość", + "seed": "Inicjator", + "shuffle": "Losuj", + "noiseThreshold": "Poziom szumu", + "perlinNoise": "Szum Perlina", + "type": "Metoda", + "strength": "Siła", + "upscaling": "Powiększanie", + "scale": "Skala", + "imageFit": "Przeskaluj oryginalny obraz", + "scaleBeforeProcessing": "Tryb skalowania", + "scaledWidth": "Sk. do szer.", + "scaledHeight": "Sk. do wys.", + "infillMethod": "Metoda wypełniania", + "tileSize": "Rozmiar kafelka", + "downloadImage": "Pobierz obraz", + "usePrompt": "Skopiuj sugestie", + "useSeed": "Skopiuj inicjator", + "useAll": "Skopiuj wszystko", + "info": "Informacje", + "showOptionsPanel": "Pokaż panel ustawień" + }, + "settings": { + "models": "Modele", + "displayInProgress": "Podgląd generowanego obrazu", + "confirmOnDelete": "Potwierdzaj usuwanie", + "resetWebUI": "Zresetuj interfejs", + "resetWebUIDesc1": "Resetowanie interfejsu wyczyści jedynie dane i ustawienia zapisane w pamięci przeglądarki. Nie usunie żadnych obrazów z dysku.", + "resetWebUIDesc2": "Jeśli obrazy nie są poprawnie wyświetlane w galerii lub doświadczasz innych problemów, przed zgłoszeniem błędu spróbuj zresetować interfejs.", + "resetComplete": "Interfejs został zresetowany. Odśwież stronę, aby załadować ponownie." + }, + "toast": { + "uploadFailed": "Błąd przesyłania obrazu", + "imageCopied": "Skopiowano obraz", + "parametersNotSet": "Nie ustawiono parametrów" + }, + "accessibility": { + "invokeProgressBar": "Pasek postępu", + "reset": "Zerowanie", + "uploadImage": "Wgrywanie obrazu", + "previousImage": "Poprzedni obraz", + "nextImage": "Następny obraz", + "menu": "Menu" + } +} diff --git a/invokeai/frontend/web/public/locales/pt.json b/invokeai/frontend/web/public/locales/pt.json new file mode 100644 index 0000000000000000000000000000000000000000..114e2dd575c9751de54b5e8ea3d00d4e05b29af3 --- /dev/null +++ b/invokeai/frontend/web/public/locales/pt.json @@ -0,0 +1,126 @@ +{ + "common": { + "reportBugLabel": "Reportar Bug", + "settingsLabel": "Configurações", + "languagePickerLabel": "Seletor de Idioma", + "hotkeysLabel": "Hotkeys", + "img2img": "Imagem para Imagem", + "nodes": "Nós", + "upload": "Upload", + "load": "Abrir", + "back": "Voltar", + "statusDisconnected": "Desconectado", + "githubLabel": "Github", + "discordLabel": "Discord", + "loading": "A carregar" + }, + "gallery": { + "gallerySettings": "Configurações de Galeria", + "autoSwitchNewImages": "Trocar para Novas Imagens Automaticamente", + "noImagesInGallery": "Sem Imagens na Galeria", + "galleryImageSize": "Tamanho da Imagem" + }, + "modelManager": { + "modelUpdated": "Modelo Atualizado", + "description": "Descrição", + "repo_id": "Repo ID", + "width": "Largura", + "height": "Altura", + "deleteConfig": "Apagar Config", + "convertToDiffusersHelpText6": "Deseja converter este modelo?", + "alpha": "Alpha", + "config": "Configuração", + "modelConverted": "Modelo Convertido", + "manual": "Manual", + "name": "Nome", + "availableModels": "Modelos Disponíveis", + "load": "Carregar", + "active": "Ativado", + "deleteModel": "Apagar modelo", + "deleteMsg1": "Tem certeza de que deseja apagar esta entrada do modelo de InvokeAI?", + "deleteMsg2": "Isso não vai apagar o ficheiro de modelo checkpoint do seu disco. Pode lê-los, se desejar.", + "convertToDiffusers": "Converter para Diffusers", + "convertToDiffusersHelpText1": "Este modelo será convertido ao formato 🧨 Diffusers.", + "convertToDiffusersHelpText2": "Este processo irá substituir a sua entrada de Gestor de Modelos por uma versão Diffusers do mesmo modelo.", + "convertToDiffusersHelpText3": "O seu ficheiro de ponto de verificação no disco NÃO será excluído ou modificado de forma alguma. Pode adicionar o seu ponto de verificação ao Gestor de modelos novamente, se desejar.", + "none": "nenhum", + "modelManager": "Gerente de Modelo", + "model": "Modelo", + "allModels": "Todos os Modelos", + "addModel": "Adicionar Modelo", + "search": "Procurar", + "selected": "Selecionada", + "delete": "Apagar", + "convert": "Converter", + "convertToDiffusersHelpText4": "Este é um processo único. Pode levar cerca de 30 a 60s, a depender das especificações do seu computador.", + "convertToDiffusersHelpText5": "Por favor, certifique-se de que tenha espaço suficiente no disco. Os modelos geralmente variam entre 4GB e 7GB de tamanho." + }, + "parameters": { + "width": "Largura", + "seed": "Seed", + "general": "Geral", + "shuffle": "Embaralhar", + "noiseThreshold": "Limite de Ruído", + "perlinNoise": "Ruído de Perlin", + "type": "Tipo", + "denoisingStrength": "A força de remoção de ruído", + "scale": "Escala", + "imageFit": "Caber Imagem Inicial No Tamanho de Saída", + "tileSize": "Tamanho do Ladrilho", + "symmetry": "Simetria", + "usePrompt": "Usar Prompt", + "showOptionsPanel": "Mostrar Painel de Opções", + "strength": "Força", + "upscaling": "Redimensionando", + "scaleBeforeProcessing": "Escala Antes do Processamento", + "images": "Imagems", + "steps": "Passos", + "cfgScale": "Escala CFG", + "height": "Altura", + "scaledWidth": "L Escalada", + "scaledHeight": "A Escalada", + "infillMethod": "Método de Preenchimento", + "copyImage": "Copiar imagem", + "downloadImage": "Descarregar Imagem", + "useSeed": "Usar Seed", + "useAll": "Usar Todos", + "info": "Informações" + }, + "settings": { + "confirmOnDelete": "Confirmar Antes de Apagar", + "resetWebUIDesc1": "Reiniciar a interface apenas reinicia o cache local do broswer para imagens e configurações lembradas. Não apaga nenhuma imagem do disco.", + "models": "Modelos", + "displayInProgress": "Mostrar Progresso de Imagens Em Andamento", + "resetWebUI": "Reiniciar Interface", + "resetWebUIDesc2": "Se as imagens não estão a aparecer na galeria ou algo mais não está a funcionar, favor tentar reiniciar antes de postar um problema no GitHub.", + "resetComplete": "A interface foi reiniciada. Atualize a página para carregar." + }, + "toast": { + "uploadFailed": "Envio Falhou", + "imageCopied": "Imagem Copiada", + "parametersNotSet": "Parâmetros Não Definidos" + }, + "accessibility": { + "invokeProgressBar": "Invocar barra de progresso", + "reset": "Reiniciar", + "nextImage": "Próxima imagem", + "uploadImage": "Enviar imagem", + "previousImage": "Imagem Anterior", + "menu": "Menu", + "about": "Sobre", + "resetUI": "$t(accessibility.reset)UI", + "createIssue": "Reportar Problema", + "submitSupportTicket": "Submeter um ticket de Suporte", + "mode": "Modo" + }, + "boards": { + "selectedForAutoAdd": "Selecionado para Auto-Adicionar", + "addBoard": "Adicionar Quadro", + "addPrivateBoard": "Adicionar Quadro privado", + "addSharedBoard": "Adicionar quadro Compartilhado", + "boards": "Quadros", + "autoAddBoard": "Auto-adicao de Quadro", + "archiveBoard": "Arquivar Quadro", + "archived": "Arquivado" + } +} diff --git a/invokeai/frontend/web/public/locales/pt_BR.json b/invokeai/frontend/web/public/locales/pt_BR.json new file mode 100644 index 0000000000000000000000000000000000000000..d8b131c6cf86ffb510107802c6b42d2695f93b0c --- /dev/null +++ b/invokeai/frontend/web/public/locales/pt_BR.json @@ -0,0 +1,102 @@ +{ + "common": { + "hotkeysLabel": "Teclas de atalho", + "languagePickerLabel": "Seletor de Idioma", + "reportBugLabel": "Relatar Bug", + "settingsLabel": "Configurações", + "img2img": "Imagem Para Imagem", + "nodes": "Nódulos", + "upload": "Enviar", + "load": "Carregar", + "statusDisconnected": "Disconectado", + "githubLabel": "Github", + "discordLabel": "Discord", + "back": "Voltar", + "loading": "Carregando" + }, + "gallery": { + "galleryImageSize": "Tamanho da Imagem", + "gallerySettings": "Configurações de Galeria", + "autoSwitchNewImages": "Trocar para Novas Imagens Automaticamente", + "noImagesInGallery": "Sem Imagens na Galeria" + }, + "modelManager": { + "modelManager": "Gerente de Modelo", + "model": "Modelo", + "modelUpdated": "Modelo Atualizado", + "manual": "Manual", + "name": "Nome", + "description": "Descrição", + "config": "Configuração", + "width": "Largura", + "height": "Altura", + "addModel": "Adicionar Modelo", + "availableModels": "Modelos Disponíveis", + "search": "Procurar", + "load": "Carregar", + "active": "Ativado", + "selected": "Selecionada", + "delete": "Excluir", + "deleteModel": "Excluir modelo", + "deleteConfig": "Excluir Config", + "deleteMsg1": "Tem certeza de que deseja excluir esta entrada do modelo de InvokeAI?", + "deleteMsg2": "Isso não vai excluir o arquivo de modelo checkpoint do seu disco. Você pode lê-los, se desejar.", + "repo_id": "Repo ID", + "convertToDiffusers": "Converter para Diffusers", + "convertToDiffusersHelpText1": "Este modelo será convertido para o formato 🧨 Diffusers.", + "convertToDiffusersHelpText5": "Por favor, certifique-se de que você tenha espaço suficiente em disco. Os modelos geralmente variam entre 4GB e 7GB de tamanho.", + "convertToDiffusersHelpText6": "Você deseja converter este modelo?", + "convertToDiffusersHelpText3": "Seu arquivo de ponto de verificação no disco NÃO será excluído ou modificado de forma alguma. Você pode adicionar seu ponto de verificação ao Gerenciador de modelos novamente, se desejar.", + "convertToDiffusersHelpText4": "Este é um processo único. Pode levar cerca de 30 a 60s, dependendo das especificações do seu computador.", + "modelConverted": "Modelo Convertido", + "alpha": "Alpha", + "allModels": "Todos os Modelos", + "convert": "Converter", + "convertToDiffusersHelpText2": "Este processo irá substituir sua entrada de Gerenciador de Modelos por uma versão Diffusers do mesmo modelo." + }, + "parameters": { + "images": "Imagems", + "steps": "Passos", + "cfgScale": "Escala CFG", + "width": "Largura", + "height": "Altura", + "seed": "Seed", + "shuffle": "Embaralhar", + "noiseThreshold": "Limite de Ruído", + "perlinNoise": "Ruído de Perlin", + "type": "Tipo", + "strength": "Força", + "upscaling": "Redimensionando", + "scale": "Escala", + "imageFit": "Caber Imagem Inicial No Tamanho de Saída", + "scaleBeforeProcessing": "Escala Antes do Processamento", + "scaledWidth": "L Escalada", + "scaledHeight": "A Escalada", + "infillMethod": "Método de Preenchimento", + "tileSize": "Tamanho do Ladrilho", + "downloadImage": "Baixar Imagem", + "usePrompt": "Usar Prompt", + "useSeed": "Usar Seed", + "useAll": "Usar Todos", + "info": "Informações", + "showOptionsPanel": "Mostrar Painel de Opções", + "symmetry": "Simetria", + "copyImage": "Copiar imagem", + "denoisingStrength": "A força de remoção de ruído", + "general": "Geral" + }, + "settings": { + "models": "Modelos", + "displayInProgress": "Mostrar Progresso de Imagens Em Andamento", + "confirmOnDelete": "Confirmar Antes de Apagar", + "resetWebUI": "Reiniciar Interface", + "resetWebUIDesc1": "Reiniciar a interface apenas reinicia o cache local do broswer para imagens e configurações lembradas. Não apaga nenhuma imagem do disco.", + "resetWebUIDesc2": "Se as imagens não estão aparecendo na galeria ou algo mais não está funcionando, favor tentar reiniciar antes de postar um problema no GitHub.", + "resetComplete": "A interface foi reiniciada. Atualize a página para carregar." + }, + "toast": { + "uploadFailed": "Envio Falhou", + "imageCopied": "Imagem Copiada", + "parametersNotSet": "Parâmetros Não Definidos" + } +} diff --git a/invokeai/frontend/web/public/locales/ro.json b/invokeai/frontend/web/public/locales/ro.json new file mode 100644 index 0000000000000000000000000000000000000000..0967ef424bce6791893e9a57bb952f80fd536e93 --- /dev/null +++ b/invokeai/frontend/web/public/locales/ro.json @@ -0,0 +1 @@ +{} diff --git a/invokeai/frontend/web/public/locales/ru.json b/invokeai/frontend/web/public/locales/ru.json new file mode 100644 index 0000000000000000000000000000000000000000..6dc36b7cb111e019752504f7c7209f40d0c209bb --- /dev/null +++ b/invokeai/frontend/web/public/locales/ru.json @@ -0,0 +1,2065 @@ +{ + "common": { + "hotkeysLabel": "Горячие клавиши", + "languagePickerLabel": "Язык", + "reportBugLabel": "Сообщить об ошибке", + "settingsLabel": "Настройки", + "img2img": "Изображение в изображение (img2img)", + "nodes": "Рабочие процессы", + "upload": "Загрузить", + "load": "Загрузить", + "statusDisconnected": "Отключен", + "githubLabel": "Github", + "discordLabel": "Discord", + "loading": "Загрузка", + "back": "Назад", + "cancel": "Отменить", + "accept": "Принять", + "postprocessing": "Постобработка", + "txt2img": "Текст в изображение (txt2img)", + "linear": "Линейный вид", + "dontAskMeAgain": "Больше не спрашивать", + "areYouSure": "Вы уверены?", + "random": "Случайное", + "openInNewTab": "Открыть в новой вкладке", + "communityLabel": "Сообщество", + "batch": "Пакетный менеджер", + "modelManager": "Менеджер моделей", + "controlNet": "Controlnet", + "advanced": "Расширенные", + "t2iAdapter": "T2I Adapter", + "checkpoint": "Checkpoint", + "format": "Формат", + "unknown": "Неизвестно", + "folder": "Папка", + "inpaint": "Перерисовать", + "updated": "Обновлен", + "on": "На", + "save": "Сохранить", + "created": "Создано", + "error": "Ошибка", + "prevPage": "Предыдущая страница", + "simple": "Простой", + "ipAdapter": "IP Adapter", + "installed": "Установлено", + "ai": "ИИ", + "auto": "Авто", + "file": "Файл", + "delete": "Удалить", + "template": "Шаблон", + "outputs": "результаты", + "unknownError": "Неизвестная ошибка", + "imageFailedToLoad": "Невозможно загрузить изображение", + "direction": "Направление", + "data": "Данные", + "somethingWentWrong": "Что-то пошло не так", + "safetensors": "Safetensors", + "outpaint": "Расширить изображение", + "orderBy": "Сортировать по", + "copyError": "Ошибка $t(gallery.copy)", + "learnMore": "Узнать больше", + "nextPage": "Следущая страница", + "saveAs": "Сохранить как", + "input": "Вход", + "details": "Детали", + "notInstalled": "Нет $t(common.installed)", + "or": "или", + "aboutHeading": "Владей своей творческой силой", + "red": "Красный", + "green": "Зеленый", + "blue": "Синий", + "alpha": "Альфа", + "toResolve": "Чтоб решить", + "copy": "Копировать", + "localSystem": "Локальная система", + "aboutDesc": "Используя Invoke для работы? Проверьте это:", + "add": "Добавить", + "beta": "Бета", + "selected": "Выбрано", + "positivePrompt": "Позитивный запрос", + "negativePrompt": "Негативный запрос", + "editor": "Редактор", + "goTo": "Перейти к", + "tab": "Вкладка", + "enabled": "Включено", + "disabled": "Отключено", + "dontShowMeThese": "Не показывай мне это", + "apply": "Применить", + "loadingImage": "Загрузка изображения", + "off": "Выкл", + "openInViewer": "Открыть в просмотрщике", + "edit": "Редактировать", + "view": "Просмотреть", + "placeholderSelectAModel": "Выбрать модель", + "reset": "Сброс", + "none": "Ничего", + "new": "Новый", + "ok": "Ok", + "close": "Закрыть" + }, + "gallery": { + "galleryImageSize": "Размер изображений", + "gallerySettings": "Настройка галереи", + "autoSwitchNewImages": "Автоматически выбирать новые", + "noImagesInGallery": "Изображений нет", + "deleteImagePermanent": "Удаленные изображения невозможно восстановить.", + "deleteImage_one": "Удалить изображение", + "deleteImage_few": "Удалить {{count}} изображения", + "deleteImage_many": "Удалить {{count}} изображений", + "assets": "Ресурсы", + "autoAssignBoardOnClick": "Авто-назначение доски по клику", + "deleteSelection": "Удалить выделенное", + "featuresWillReset": "Если вы удалите это изображение, эти функции будут немедленно сброшены.", + "loading": "Загрузка", + "unableToLoad": "Невозможно загрузить галерею", + "image": "изображение", + "drop": "перебросить", + "downloadSelection": "Скачать выделенное", + "currentlyInUse": "В настоящее время это изображение используется в следующих функциях:", + "unstarImage": "Удалить из избранного", + "dropOrUpload": "$t(gallery.drop) или загрузить", + "copy": "Копировать", + "download": "Скачать", + "noImageSelected": "Изображение не выбрано", + "starImage": "Добавить в избранное", + "dropToUpload": "$t(gallery.drop) чтоб загрузить", + "bulkDownloadFailed": "Загрузка не удалась", + "bulkDownloadRequested": "Подготовка к скачиванию", + "bulkDownloadRequestedDesc": "Ваш запрос на скачивание готовится. Это может занять несколько минут.", + "bulkDownloadRequestFailed": "Возникла проблема при подготовке скачивания", + "alwaysShowImageSizeBadge": "Всегда показывать значок размера изображения", + "openInViewer": "Открыть в просмотрщике", + "selectForCompare": "Выбрать для сравнения", + "hover": "Наведение", + "swapImages": "Поменять местами", + "stretchToFit": "Растягивание до нужного размера", + "exitCompare": "Выйти из сравнения", + "compareHelp4": "Нажмите Z или Esc для выхода.", + "compareImage": "Сравнить изображение", + "viewerImage": "Изображение просмотрщика", + "selectAnImageToCompare": "Выберите изображение для сравнения", + "slider": "Слайдер", + "sideBySide": "Бок о бок", + "compareHelp1": "Удерживайте Alt при нажатии на изображение в галерее или при помощи клавиш со стрелками, чтобы изменить сравниваемое изображение.", + "compareHelp2": "Нажмите M, чтобы переключиться между режимами сравнения.", + "compareHelp3": "Нажмите C, чтобы поменять местами сравниваемые изображения.", + "newestFirst": "Сначала новые", + "sortDirection": "Направление сортировки", + "oldestFirst": "Сначала старые", + "showStarredImagesFirst": "Сначала избранные изображения", + "selectAllOnPage": "Выбрать все на странице", + "showArchivedBoards": "Показать архивированные доски", + "searchImages": "Поиск по метаданным", + "displayBoardSearch": "Поиск доски", + "displaySearch": "Поиск изображений", + "exitBoardSearch": "Выйти из поиска досок", + "go": "Перейти", + "exitSearch": "Выйти из поиска изображений", + "jump": "Пыгнуть", + "move": "Двигать", + "gallery": "Галерея", + "openViewer": "Открыть просмотрщик", + "closeViewer": "Закрыть просмотрщик", + "imagesTab": "Изображения, созданные и сохраненные в Invoke.", + "assetsTab": "Файлы, которые вы загрузили для использования в своих проектах.", + "boardsSettings": "Настройки доски", + "imagesSettings": "Настройки галереи изображений" + }, + "hotkeys": { + "searchHotkeys": "Поиск горячих клавиш", + "noHotkeysFound": "Горячие клавиши не найдены", + "clearSearch": "Очистить поиск", + "app": { + "title": "Приложение", + "invoke": { + "desc": "Добавить генерацию в конец очереди.", + "title": "Сгенерировать" + }, + "clearQueue": { + "title": "Очистить очередь", + "desc": "Отмена и очистка всех элементов очереди." + }, + "selectCanvasTab": { + "title": "Выбрать вкладку Холст", + "desc": "Выбирает вкладку Холст." + }, + "selectUpscalingTab": { + "title": "Выбрать вкладку Увеличение", + "desc": "Выбирает вкладку увеличения." + }, + "selectWorkflowsTab": { + "title": "Выбрать вкладку Рабочие Процессы", + "desc": "Выбирает вкладку рабочих процессов." + }, + "focusPrompt": { + "title": "Сфокусироваться на запросе", + "desc": "Перемещает фокус курсора на положительный запрос." + }, + "toggleLeftPanel": { + "title": "Переключить левую панель", + "desc": "Показывает или скрывает левую панель." + }, + "resetPanelLayout": { + "desc": "Верните левую и правую панели к размерам и расположению по умолчанию.", + "title": "Сброс расположения панелей" + }, + "invokeFront": { + "title": "Сгенерировать (вперед)", + "desc": "Добавьте генерацию вперед очереди." + }, + "cancelQueueItem": { + "title": "Отмена", + "desc": "Отмена текущего обрабатываемого элемента очереди." + }, + "selectModelsTab": { + "desc": "Выбирает вкладку моделей.", + "title": "Выбрать вкладку Модели" + }, + "selectQueueTab": { + "title": "Выбрать вкладку Очередь", + "desc": "Выбирает вкладку очереди." + }, + "togglePanels": { + "title": "Переключить панели", + "desc": "Показать или скрыть одновременно левую и правую панели." + }, + "toggleRightPanel": { + "title": "Переключить правую панель", + "desc": "Показывает или скрывает правую панель." + } + }, + "canvas": { + "title": "Холст", + "selectBrushTool": { + "title": "Инструмент кисть", + "desc": "Выбирает кисть." + }, + "selectBboxTool": { + "title": "Инструмент рамка", + "desc": "Выбрать инструмент «Ограничительная рамка»." + }, + "incrementToolWidth": { + "desc": "Increment the brush or eraser tool width, whichever is selected.", + "title": "Increment Tool Width" + }, + "selectColorPickerTool": { + "title": "Color Picker Tool", + "desc": "Select the color picker tool." + }, + "prevEntity": { + "title": "Prev Layer", + "desc": "Select the previous layer in the list." + }, + "filterSelected": { + "title": "Filter", + "desc": "Filter the selected layer. Only applies to Raster and Control layers." + }, + "undo": { + "desc": "Отменяет последнее действие на холсте.", + "title": "Отменить" + }, + "transformSelected": { + "title": "Transform", + "desc": "Transform the selected layer." + }, + "setZoomTo400Percent": { + "title": "Zoom to 400%", + "desc": "Set the canvas zoom to 400%." + }, + "setZoomTo200Percent": { + "title": "Zoom to 200%", + "desc": "Set the canvas zoom to 200%." + }, + "deleteSelected": { + "desc": "Delete the selected layer.", + "title": "Delete Layer" + }, + "resetSelected": { + "title": "Reset Layer", + "desc": "Reset the selected layer. Only applies to Inpaint Mask and Regional Guidance." + }, + "redo": { + "desc": "Возвращает последнее отмененное действие.", + "title": "Вернуть" + }, + "nextEntity": { + "title": "Next Layer", + "desc": "Select the next layer in the list." + }, + "setFillToWhite": { + "title": "Set Color to White", + "desc": "Set the current tool color to white." + }, + "applyFilter": { + "title": "Apply Filter", + "desc": "Apply the pending filter to the selected layer." + }, + "cancelFilter": { + "title": "Cancel Filter", + "desc": "Cancel the pending filter." + }, + "applyTransform": { + "desc": "Apply the pending transform to the selected layer.", + "title": "Apply Transform" + }, + "cancelTransform": { + "title": "Cancel Transform", + "desc": "Cancel the pending transform." + }, + "selectEraserTool": { + "title": "Eraser Tool", + "desc": "Select the eraser tool." + }, + "fitLayersToCanvas": { + "desc": "Scale and position the view to fit all visible layers.", + "title": "Fit Layers to Canvas" + }, + "decrementToolWidth": { + "title": "Decrement Tool Width", + "desc": "Decrement the brush or eraser tool width, whichever is selected." + }, + "setZoomTo800Percent": { + "title": "Zoom to 800%", + "desc": "Set the canvas zoom to 800%." + }, + "quickSwitch": { + "title": "Layer Quick Switch", + "desc": "Switch between the last two selected layers. If a layer is bookmarked, always switch between it and the last non-bookmarked layer." + }, + "fitBboxToCanvas": { + "title": "Fit Bbox to Canvas", + "desc": "Scale and position the view to fit the bbox." + }, + "setZoomTo100Percent": { + "title": "Zoom to 100%", + "desc": "Set the canvas zoom to 100%." + }, + "selectMoveTool": { + "desc": "Select the move tool.", + "title": "Move Tool" + }, + "selectRectTool": { + "title": "Rect Tool", + "desc": "Select the rect tool." + }, + "selectViewTool": { + "title": "View Tool", + "desc": "Select the view tool." + } + }, + "hotkeys": "Горячие клавиши", + "workflows": { + "undo": { + "title": "Отмена", + "desc": "Отменить последнее действие в рабочем процессе." + }, + "deleteSelection": { + "desc": "Удалить выделенные узлы и ребра.", + "title": "Delete" + }, + "redo": { + "title": "Вернуть", + "desc": "Вернуть последнее действие в рабочем процессе." + }, + "copySelection": { + "title": "Copy", + "desc": "Copy selected nodes and edges." + }, + "pasteSelection": { + "title": "Paste", + "desc": "Paste copied nodes and edges." + }, + "addNode": { + "desc": "Open the add node menu.", + "title": "Add Node" + }, + "title": "Workflows", + "pasteSelectionWithEdges": { + "title": "Paste with Edges", + "desc": "Paste copied nodes, edges, and all edges connected to copied nodes." + }, + "selectAll": { + "desc": "Select all nodes and edges.", + "title": "Select All" + } + }, + "viewer": { + "nextComparisonMode": { + "title": "Следующий режим сравнения", + "desc": "Циклическое переключение режимов сравнения." + }, + "loadWorkflow": { + "desc": "Загрузить сохраненный рабочий процесс текущего изображения (если он есть).", + "title": "Загрузить рабочий процесс" + }, + "recallAll": { + "desc": "Восстановить все метаданные текущего изображения.", + "title": "Восстановить все метаданные" + }, + "swapImages": { + "desc": "Поменять местами сравниваемые изображения.", + "title": "Swap Comparison Images" + }, + "title": "Просмотрщик изображений", + "toggleViewer": { + "title": "Открыть/закрыть просмотрщик", + "desc": "Показать или скрыть просмотрщик изображений. Доступно только на вкладке «Холст»." + }, + "recallSeed": { + "title": "Recall Seed", + "desc": "Recall the seed for the current image." + }, + "recallPrompts": { + "desc": "Recall the positive and negative prompts for the current image.", + "title": "Recall Prompts" + }, + "remix": { + "title": "Remix", + "desc": "Recall all metadata except for the seed for the current image." + }, + "useSize": { + "desc": "Use the current image's size as the bbox size.", + "title": "Use Size" + }, + "runPostprocessing": { + "title": "Run Postprocessing", + "desc": "Run the selected postprocessing on the current image." + }, + "toggleMetadata": { + "title": "Show/Hide Metadata", + "desc": "Show or hide the current image's metadata overlay." + } + }, + "gallery": { + "galleryNavRightAlt": { + "desc": "Same as Navigate Right, but selects the compare image, opening compare mode if it isn't already open.", + "title": "Navigate Right (Compare Image)" + }, + "galleryNavRight": { + "desc": "Navigate right in the gallery grid, selecting that image. If at the last image of the row, go to the next row. If at the last image of the page, go to the next page.", + "title": "Navigate Right" + }, + "galleryNavUp": { + "desc": "Navigate up in the gallery grid, selecting that image. If at the top of the page, go to the previous page.", + "title": "Navigate Up" + }, + "galleryNavDown": { + "title": "Navigate Down", + "desc": "Navigate down in the gallery grid, selecting that image. If at the bottom of the page, go to the next page." + }, + "galleryNavLeft": { + "title": "Navigate Left", + "desc": "Navigate left in the gallery grid, selecting that image. If at the first image of the row, go to the previous row. If at the first image of the page, go to the previous page." + }, + "galleryNavDownAlt": { + "title": "Navigate Down (Compare Image)", + "desc": "Same as Navigate Down, but selects the compare image, opening compare mode if it isn't already open." + }, + "galleryNavLeftAlt": { + "desc": "Same as Navigate Left, but selects the compare image, opening compare mode if it isn't already open.", + "title": "Navigate Left (Compare Image)" + }, + "clearSelection": { + "desc": "Clear the current selection, if any.", + "title": "Clear Selection" + }, + "deleteSelection": { + "title": "Delete", + "desc": "Delete all selected images. By default, you will be prompted to confirm deletion. If the images are currently in use in the app, you will be warned." + }, + "galleryNavUpAlt": { + "title": "Navigate Up (Compare Image)", + "desc": "Same as Navigate Up, but selects the compare image, opening compare mode if it isn't already open." + }, + "title": "Gallery", + "selectAllOnPage": { + "title": "Select All On Page", + "desc": "Select all images on the current page." + } + } + }, + "modelManager": { + "modelManager": "Менеджер моделей", + "model": "Модель", + "modelUpdated": "Модель обновлена", + "manual": "Ручное", + "name": "Название", + "description": "Описание", + "config": "Файл конфигурации", + "width": "Ширина", + "height": "Высота", + "addModel": "Добавить модель", + "availableModels": "Доступные модели", + "search": "Искать", + "load": "Загрузить", + "active": "активна", + "selected": "Выбраны", + "delete": "Удалить", + "deleteModel": "Удалить модель", + "deleteConfig": "Удалить конфигурацию", + "deleteMsg1": "Вы точно хотите удалить модель из InvokeAI?", + "deleteMsg2": "Это приведет К УДАЛЕНИЮ модели С ДИСКА, если она находится в корневой папке Invoke. Если вы используете пользовательское расположение, то модель НЕ будет удалена с диска.", + "convertToDiffusersHelpText5": "Пожалуйста, убедитесь, что у вас достаточно места на диске. Модели обычно занимают 2–7 Гб.", + "convertToDiffusersHelpText3": "Ваш файл контрольной точки НА ДИСКЕ будет УДАЛЕН, если он находится в корневой папке InvokeAI. Если он находится в пользовательском расположении, то он НЕ будет удален.", + "allModels": "Все модели", + "repo_id": "ID репозитория", + "convert": "Преобразовать", + "convertToDiffusers": "Преобразовать в Diffusers", + "convertToDiffusersHelpText1": "Модель будет преобразована в формат 🧨 Diffusers.", + "convertToDiffusersHelpText4": "Это единоразовое действие. Оно может занять 30—60 секунд в зависимости от характеристик вашего компьютера.", + "convertToDiffusersHelpText6": "Вы хотите преобразовать эту модель?", + "modelConverted": "Модель преобразована", + "alpha": "Альфа", + "none": "пусто", + "convertToDiffusersHelpText2": "Этот процесс заменит вашу запись в менеджере моделей на версию той же модели в Diffusers.", + "modelDeleted": "Модель удалена", + "variant": "Вариант", + "baseModel": "Базовая модель", + "vae": "VAE", + "modelDeleteFailed": "Не удалось удалить модель", + "convertingModelBegin": "Конвертация модели. Пожалуйста, подождите.", + "settings": "Настройки", + "selectModel": "Выберите модель", + "syncModels": "Синхронизация моделей", + "modelUpdateFailed": "Не удалось обновить модель", + "modelConversionFailed": "Не удалось сконвертировать модель", + "predictionType": "Тип прогноза", + "advanced": "Продвинутый", + "modelType": "Тип модели", + "vaePrecision": "Точность VAE", + "noModelSelected": "Модель не выбрана", + "addModels": "Добавить модели", + "cancel": "Отмена", + "defaultSettings": "Стандартные настройки", + "metadata": "Метаданные", + "imageEncoderModelId": "ID модели-энкодера изображений", + "typePhraseHere": "Введите фразы здесь", + "defaultSettingsSaved": "Стандартные настройки сохранены", + "edit": "Редактировать", + "path": "Путь", + "prune": "Удалить", + "pruneTooltip": "Удалить готовые импорты из очереди", + "repoVariant": "Вариант репозитория", + "scanFolder": "Сканировать папку", + "scanResults": "Результаты сканирования", + "source": "Источник", + "triggerPhrases": "Триггерные фразы", + "modelName": "Название модели", + "modelSettings": "Настройки модели", + "upcastAttention": "Внимание", + "deleteModelImage": "Удалить изображение модели", + "uploadImage": "Загрузить изображение", + "inplaceInstall": "Установка на месте", + "localOnly": "только локально", + "modelImageDeleted": "Изображение модели удалено", + "modelImageDeleteFailed": "Не получилось удалить изображение модели", + "modelImageUpdated": "Изображение модели обновлено", + "modelImageUpdateFailed": "Не удалось обновить изображение модели", + "pathToConfig": "Путь к конфигурации", + "loraTriggerPhrases": "Триггерные фразы LoRA", + "mainModelTriggerPhrases": "Триггерные фразы основной модели", + "inplaceInstallDesc": "Устанавливайте модели без копирования файлов. При использовании модели она будет загружаться из этого места. Если этот параметр отключен, файлы модели будут скопированы в каталог моделей, управляемых Invoke, во время установки.", + "huggingFaceRepoID": "ID репозитория HuggingFace", + "installQueue": "Очередь установки", + "installAll": "Установить все", + "install": "Установить", + "huggingFace": "HuggingFace", + "huggingFacePlaceholder": "пользователь/имя-модели", + "huggingFaceHelper": "Если в этом репозитории найдено несколько моделей, вам будет предложено выбрать одну из них для установки.", + "installRepo": "Установить репозиторий", + "scanFolderHelper": "Папка будет рекурсивно просканирована на наличие моделей. Для очень больших папок это может занять несколько минут.", + "scanPlaceholder": "Путь к локальной папке", + "simpleModelPlaceholder": "URL или путь к локальному файлу или папке diffusers", + "urlOrLocalPath": "URL или локальный путь", + "urlOrLocalPathHelper": "URL-адреса должны указывать на один файл. Локальные пути могут указывать на один файл или папку для одной модели диффузоров.", + "starterModels": "Стартовые модели", + "textualInversions": "Текстовые инверсии", + "loraModels": "LoRAs", + "main": "Основные", + "noModelsInstalled": "Нет установленных моделей", + "noModelsInstalledDesc1": "Установите модели с помощью", + "noMatchingModels": "Нет подходящих моделей", + "ipAdapters": "IP адаптеры", + "starterModelsInModelManager": "Стартовые модели можно найти в Менеджере моделей", + "learnMoreAboutSupportedModels": "Подробнее о поддерживаемых моделях", + "t5Encoder": "T5 энкодер", + "spandrelImageToImage": "Image to Image (Spandrel)", + "clipEmbed": "CLIP Embed", + "installingXModels_one": "Установка {{count}} модели", + "installingXModels_few": "Установка {{count}} моделей", + "installingXModels_many": "Установка {{count}} моделей", + "installingBundle": "Установка пакета", + "installingModel": "Установка модели", + "starterBundles": "Стартовые пакеты", + "skippingXDuplicates_one": ", пропуская {{count}} дубликат", + "skippingXDuplicates_few": ", пропуская {{count}} дубликата", + "skippingXDuplicates_many": ", пропуская {{count}} дубликатов", + "includesNModels": "Включает в себя {{n}} моделей и их зависимостей", + "starterBundleHelpText": "Легко установите все модели, необходимые для начала работы с базовой моделью, включая основную модель, сети управления, IP-адаптеры и многое другое. При выборе комплекта все уже установленные модели будут пропущены." + }, + "parameters": { + "images": "Изображения", + "steps": "Шаги", + "cfgScale": "Шкала точности (CFG)", + "width": "Ширина", + "height": "Высота", + "seed": "Сид", + "shuffle": "Обновить сид", + "noiseThreshold": "Порог шума", + "perlinNoise": "Шум Перлина", + "type": "Тип", + "strength": "Сила", + "upscaling": "Увеличение", + "scale": "Масштаб", + "imageFit": "Уместить изображение", + "scaleBeforeProcessing": "Масштабировать", + "scaledWidth": "Масштаб Ш", + "scaledHeight": "Масштаб В", + "infillMethod": "Способ заполнения", + "tileSize": "Размер области", + "downloadImage": "Скачать", + "usePrompt": "Использовать запрос", + "useSeed": "Использовать сид", + "useAll": "Использовать все", + "info": "Метаданные", + "showOptionsPanel": "Показать панель настроек", + "cancel": { + "cancel": "Отмена" + }, + "general": "Основное", + "symmetry": "Симметрия", + "denoisingStrength": "Сила зашумления", + "copyImage": "Скопировать изображение", + "seamlessXAxis": "Бесшовная ось X", + "seamlessYAxis": "Бесшовная ось Y", + "scheduler": "Планировщик", + "positivePromptPlaceholder": "Запрос", + "negativePromptPlaceholder": "Исключающий запрос", + "controlNetControlMode": "Режим управления", + "clipSkip": "CLIP Пропуск", + "maskBlur": "Размытие маски", + "invoke": { + "noNodesInGraph": "Нет узлов в графе", + "noModelSelected": "Модель не выбрана", + "noPrompts": "Подсказки не создаются", + "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} отсутствует ввод", + "systemDisconnected": "Система отключена", + "missingNodeTemplate": "Отсутствует шаблон узла", + "missingFieldTemplate": "Отсутствует шаблон поля", + "addingImagesTo": "Добавление изображений в", + "invoke": "Создать", + "layer": { + "ipAdapterNoModelSelected": "IP адаптер не выбран", + "controlAdapterNoModelSelected": "не выбрана модель адаптера контроля", + "controlAdapterIncompatibleBaseModel": "несовместимая базовая модель адаптера контроля", + "rgNoRegion": "регион не выбран", + "rgNoPromptsOrIPAdapters": "нет текстовых запросов или IP-адаптеров", + "ipAdapterIncompatibleBaseModel": "несовместимая базовая модель IP-адаптера", + "ipAdapterNoImageSelected": "изображение IP-адаптера не выбрано", + "t2iAdapterIncompatibleScaledBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, масштабированная ширина рамки {{width}}", + "t2iAdapterIncompatibleBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, высота рамки {{height}}", + "t2iAdapterIncompatibleBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, ширина рамки {{width}}", + "t2iAdapterIncompatibleScaledBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, масштабированная высота рамки {{height}}" + }, + "fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), ширина рамки {{width}}", + "fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), высота рамки {{height}}", + "fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), масштабированная высота рамки {{height}}", + "fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16) масштабированная ширина рамки {{width}}", + "noFLUXVAEModelSelected": "Для генерации FLUX не выбрана модель VAE", + "noT5EncoderModelSelected": "Для генерации FLUX не выбрана модель T5 энкодера", + "canvasIsFiltering": "Холст фильтруется", + "canvasIsTransforming": "Холст трансформируется", + "noCLIPEmbedModelSelected": "Для генерации FLUX не выбрана модель CLIP Embed", + "canvasIsRasterizing": "Холст растрируется", + "canvasIsCompositing": "Холст составляется" + }, + "cfgRescaleMultiplier": "Множитель масштабирования CFG", + "patchmatchDownScaleSize": "уменьшить", + "useCpuNoise": "Использовать шум CPU", + "imageActions": "Действия с изображениями", + "iterations": "Кол-во", + "useSize": "Использовать размер", + "coherenceMode": "Режим", + "aspect": "Соотношение", + "swapDimensions": "Поменять местами", + "setToOptimalSize": "Установить оптимальный для модели размер", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (может быть слишком маленьким)", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (может быть слишком большим)", + "lockAspectRatio": "Заблокировать соотношение", + "remixImage": "Ремикс изображения", + "coherenceMinDenoise": "Мин. шумоподавление", + "coherenceEdgeSize": "Размер края", + "infillColorValue": "Цвет заливки", + "postProcessing": "Постобработка (Shift + U)", + "processImage": "Обработка изображения", + "sendToUpscale": "Отправить на увеличение", + "gaussianBlur": "Размытие по Гауссу", + "staged": "Инсценировка", + "optimizedImageToImage": "Оптимизированное img2img", + "sendToCanvas": "Отправить на холст", + "guidance": "Точность", + "boxBlur": "Box Blur" + }, + "settings": { + "models": "Модели", + "displayInProgress": "Показывать процесс генерации", + "confirmOnDelete": "Подтверждать удаление", + "resetWebUI": "Сброс настроек веб-интерфейса", + "resetWebUIDesc1": "Сброс настроек веб-интерфейса удаляет только локальный кэш браузера с вашими изображениями и настройками. Он не удаляет изображения с диска.", + "resetWebUIDesc2": "Если изображения не отображаются в галерее или не работает что-то еще, пожалуйста, попробуйте сбросить настройки, прежде чем сообщать о проблеме на GitHub.", + "resetComplete": "Настройки веб-интерфейса были сброшены.", + "developer": "Разработчик", + "general": "Основное", + "showProgressInViewer": "Показывать процесс генерации в Просмотрщике", + "antialiasProgressImages": "Сглаживать предпоказ процесса генерации", + "generation": "Поколение", + "ui": "Пользовательский интерфейс", + "beta": "Бета", + "clearIntermediates": "Очистить промежуточные", + "clearIntermediatesDesc3": "Изображения вашей галереи не будут удалены.", + "clearIntermediatesWithCount_one": "Очистить {{count}} промежуточное", + "clearIntermediatesWithCount_few": "Очистить {{count}} промежуточных", + "clearIntermediatesWithCount_many": "Очистить {{count}} промежуточных", + "enableNSFWChecker": "Включить NSFW проверку", + "clearIntermediatesDisabled": "Очередь должна быть пуста, чтобы очистить промежуточные продукты", + "clearIntermediatesDesc2": "Промежуточные изображения — это побочные продукты генерации, отличные от результирующих изображений в галерее. Очистка промежуточных файлов освободит место на диске.", + "enableInvisibleWatermark": "Включить невидимый водяной знак", + "enableInformationalPopovers": "Включить информационные всплывающие окна", + "intermediatesCleared_one": "Очищено {{count}} промежуточное", + "intermediatesCleared_few": "Очищено {{count}} промежуточных", + "intermediatesCleared_many": "Очищено {{count}} промежуточных", + "clearIntermediatesDesc1": "Очистка промежуточных элементов приведет к сбросу состояния Canvas и ControlNet.", + "intermediatesClearedFailed": "Проблема очистки промежуточных", + "reloadingIn": "Перезагрузка через", + "informationalPopoversDisabled": "Информационные всплывающие окна отключены", + "informationalPopoversDisabledDesc": "Информационные всплывающие окна были отключены. Включите их в Настройках.", + "confirmOnNewSession": "Подтверждение нового сеанса" + }, + "toast": { + "uploadFailed": "Загрузка не удалась", + "imageCopied": "Изображение скопировано", + "parametersNotSet": "Параметры не заданы", + "serverError": "Ошибка сервера", + "connected": "Подключено к серверу", + "canceled": "Обработка отменена", + "uploadFailedInvalidUploadDesc": "Это должны быть изображения PNG или JPEG.", + "parameterNotSet": "Параметр не задан", + "parameterSet": "Параметр задан", + "problemCopyingImage": "Не удается скопировать изображение", + "baseModelChangedCleared_one": "Очищена или отключена {{count}} несовместимая подмодель", + "baseModelChangedCleared_few": "Очищено или отключено {{count}} несовместимых подмодели", + "baseModelChangedCleared_many": "Очищено или отключено {{count}} несовместимых подмоделей", + "loadedWithWarnings": "Рабочий процесс загружен с предупреждениями", + "setControlImage": "Установить как контрольное изображение", + "setNodeField": "Установить как поле узла", + "invalidUpload": "Неверная загрузка", + "imageUploaded": "Изображение загружено", + "addedToBoard": "Добавлено в активы доски {{name}}", + "workflowLoaded": "Рабочий процесс загружен", + "problemDeletingWorkflow": "Проблема с удалением рабочего процесса", + "modelAddedSimple": "Модель добавлена в очередь", + "workflowDeleted": "Рабочий процесс удален", + "problemRetrievingWorkflow": "Проблема с получением рабочего процесса", + "imageUploadFailed": "Не удалось загрузить изображение", + "problemDownloadingImage": "Не удается скачать изображение", + "prunedQueue": "Урезанная очередь", + "modelImportCanceled": "Импорт модели отменен", + "parameters": "Параметры", + "parameterSetDesc": "Задан {{parameter}}", + "parameterNotSetDesc": "Невозможно задать {{parameter}}", + "baseModelChanged": "Базовая модель сменена", + "parameterNotSetDescWithMessage": "Не удалось задать {{parameter}}: {{message}}", + "parametersSet": "Параметры заданы", + "errorCopied": "Ошибка скопирована", + "sessionRef": "Сессия: {{sessionId}}", + "outOfMemoryError": "Ошибка нехватки памяти", + "outOfMemoryErrorDesc": "Ваши текущие настройки генерации превышают возможности системы. Пожалуйста, измените настройки и повторите попытку.", + "somethingWentWrong": "Что-то пошло не так", + "importFailed": "Импорт неудачен", + "importSuccessful": "Импорт успешен", + "problemSavingLayer": "Не удалось сохранить слой", + "sentToCanvas": "Отправить на холст", + "unableToLoadImage": "Невозможно загрузить изображение", + "unableToLoadImageMetadata": "Невозможно загрузить метаданные изображения", + "imageSaved": "Изображение сохранено", + "stylePresetLoaded": "Предустановка стиля загружена", + "imageNotLoadedDesc": "Не удалось найти изображение", + "imageSavingFailed": "Не удалось сохранить изображение", + "problemCopyingLayer": "Не удалось скопировать слой", + "unableToLoadStylePreset": "Невозможно загрузить предустановку стиля", + "layerCopiedToClipboard": "Слой скопирован в буфер обмена", + "sentToUpscale": "Отправить на увеличение", + "layerSavedToAssets": "Слой сохранен в активах", + "linkCopied": "Ссылка скопирована", + "addedToUncategorized": "Добавлено в активы доски $t(boards.uncategorized)", + "imagesWillBeAddedTo": "Загруженные изображения будут добавлены в активы доски {{boardName}}.", + "uploadFailedInvalidUploadDesc_withCount_one": "Должно быть не более {{count}} изображения в формате PNG или JPEG.", + "uploadFailedInvalidUploadDesc_withCount_few": "Должно быть не более {{count}} изображений в формате PNG или JPEG.", + "uploadFailedInvalidUploadDesc_withCount_many": "Должно быть не более {{count}} изображений в формате PNG или JPEG." + }, + "accessibility": { + "uploadImage": "Загрузить изображение", + "nextImage": "Следующее изображение", + "previousImage": "Предыдущее изображение", + "invokeProgressBar": "Индикатор выполнения", + "reset": "Сброс", + "menu": "Меню", + "mode": "Режим", + "resetUI": "$t(accessibility.reset) интерфейс", + "createIssue": "Сообщить о проблеме", + "about": "Об этом", + "submitSupportTicket": "Отправить тикет в службу поддержки", + "toggleRightPanel": "Переключить правую панель (G)", + "toggleLeftPanel": "Переключить левую панель (T)", + "uploadImages": "Загрузить изображения" + }, + "nodes": { + "zoomInNodes": "Увеличьте масштаб", + "zoomOutNodes": "Уменьшите масштаб", + "fitViewportNodes": "Уместить вид", + "showLegendNodes": "Показать тип поля", + "hideMinimapnodes": "Скрыть миникарту", + "hideLegendNodes": "Скрыть тип поля", + "showMinimapnodes": "Показать миникарту", + "loadWorkflow": "Загрузить рабочий процесс", + "reloadNodeTemplates": "Перезагрузить шаблоны узлов", + "downloadWorkflow": "Скачать JSON рабочего процесса", + "addNode": "Добавить узел", + "addLinearView": "Добавить в линейный вид", + "animatedEdges": "Анимированные ребра", + "animatedEdgesHelp": "Анимация выбранных ребер и ребер, соединенных с выбранными узлами", + "boolean": "Логические значения", + "cannotConnectInputToInput": "Невозможно подключить вход к входу", + "cannotConnectOutputToOutput": "Невозможно подключить выход к выходу", + "addNodeToolTip": "Добавить узел (Shift+A, Пробел)", + "scheduler": "Планировщик", + "missingTemplate": "Недопустимый узел: узел {{node}} типа {{type}} не имеет шаблона (не установлен?)", + "workflowDescription": "Краткое описание", + "inputFieldTypeParseError": "Невозможно разобрать тип поля ввода {{node}}.{{field}} ({{message}})", + "unsupportedAnyOfLength": "слишком много элементов объединения ({{count}})", + "versionUnknown": " Версия неизвестна", + "unsupportedArrayItemType": "неподдерживаемый тип элемента массива \"{{type}}\"", + "noNodeSelected": "Узел не выбран", + "unableToValidateWorkflow": "Невозможно проверить рабочий процесс", + "enum": "Перечисления", + "updateAllNodes": "Обновить узлы", + "noOutputRecorded": "Выходы не зарегистрированы", + "updateApp": "Обновить приложение", + "colorCodeEdgesHelp": "Цветовая маркировка ребер в соответствии с их связанными полями", + "float": "Float", + "workflowContact": "Контакт", + "targetNodeFieldDoesNotExist": "Неверный край: целевое/вводное поле {{node}}.{{field}} не существует", + "unsupportedMismatchedUnion": "несовпадение типа CollectionOrScalar с базовыми типами {{firstType}} и {{secondType}}", + "allNodesUpdated": "Все узлы обновлены", + "integer": "Целое число", + "nodeTemplate": "Шаблон узла", + "nodeOpacity": "Непрозрачность узла", + "sourceNodeDoesNotExist": "Недопустимое ребро: исходный/выходной узел {{node}} не существует", + "unableToLoadWorkflow": "Невозможно загрузить рабочий процесс", + "unableToExtractEnumOptions": "невозможно извлечь параметры перечисления", + "snapToGrid": "Привязка к сетке", + "noFieldsLinearview": "Нет полей, добавленных в линейный вид", + "unableToParseFieldType": "невозможно проанализировать тип поля", + "nodeSearch": "Поиск узлов", + "updateNode": "Обновить узел", + "version": "Версия", + "validateConnections": "Проверка соединений и графика", + "inputMayOnlyHaveOneConnection": "Вход может иметь только одно соединение", + "notes": "Заметки", + "outputFieldTypeParseError": "Невозможно разобрать тип поля вывода {{node}}.{{field}} ({{message}})", + "nodeOutputs": "Выходы узла", + "currentImageDescription": "Отображает текущее изображение в редакторе узлов", + "validateConnectionsHelp": "Предотвратить создание недопустимых соединений и вызов недопустимых графиков", + "problemSettingTitle": "Проблема с настройкой названия", + "ipAdapter": "IP-адаптер", + "noConnectionInProgress": "Соединение не выполняется", + "workflowVersion": "Версия", + "fieldTypesMustMatch": "Типы полей должны совпадать", + "workflow": "Рабочий процесс", + "edge": "Край", + "sourceNodeFieldDoesNotExist": "Неверный край: поле источника/вывода {{node}}.{{field}} не существует", + "cannotDuplicateConnection": "Невозможно создать дубликаты соединений", + "unknownTemplate": "Неизвестный шаблон", + "noWorkflow": "Нет рабочего процесса", + "removeLinearView": "Удалить из линейного вида", + "workflowTags": "Теги", + "fullyContainNodesHelp": "Чтобы узлы были выбраны, они должны полностью находиться в поле выбора", + "unableToGetWorkflowVersion": "Не удалось получить версию схемы рабочего процесса", + "workflowValidation": "Ошибка проверки рабочего процесса", + "nodePack": "Пакет узлов", + "nodeType": "Тип узла", + "fullyContainNodes": "Выбор узлов с полным содержанием", + "executionStateInProgress": "В процессе", + "unableToExtractSchemaNameFromRef": "невозможно извлечь имя схемы из ссылки", + "executionStateError": "Ошибка", + "prototypeDesc": "Этот вызов является прототипом. Он может претерпевать изменения при обновлении приложения и может быть удален в любой момент.", + "unknownOutput": "Неизвестный вывод: {{name}}", + "executionStateCompleted": "Выполнено", + "node": "Узел", + "workflowAuthor": "Автор", + "currentImage": "Текущее изображение", + "workflowName": "Название", + "collection": "Коллекция", + "unknownErrorValidatingWorkflow": "Неизвестная ошибка при проверке рабочего процесса", + "collectionFieldType": "{{name}} (Коллекция)", + "workflowNotes": "Примечания", + "string": "Строка", + "unknownNodeType": "Неизвестный тип узла", + "unableToUpdateNodes_one": "Невозможно обновить {{count}} узел", + "unableToUpdateNodes_few": "Невозможно обновить {{count}} узла", + "unableToUpdateNodes_many": "Невозможно обновить {{count}} узлов", + "connectionWouldCreateCycle": "Соединение создаст цикл", + "cannotConnectToSelf": "Невозможно подключиться к самому себе", + "notesDescription": "Добавляйте заметки о своем рабочем процессе", + "unknownField": "Неизвестное поле", + "colorCodeEdges": "Ребра с цветовой кодировкой", + "unknownNode": "Неизвестный узел", + "targetNodeDoesNotExist": "Недопустимое ребро: целевой/входной узел {{node}} не существует", + "mismatchedVersion": "Недопустимый узел: узел {{node}} типа {{type}} имеет несоответствующую версию (попробовать обновить?)", + "unknownFieldType": "$t(nodes.unknownField) тип: {{type}}", + "collectionOrScalarFieldType": "{{name}} (Один или коллекция)", + "betaDesc": "Этот вызов находится в бета-версии. Пока он не станет стабильным, в нем могут происходить изменения при обновлении приложений. Мы планируем поддерживать этот вызов в течение длительного времени.", + "nodeVersion": "Версия узла", + "loadingNodes": "Загрузка узлов...", + "snapToGridHelp": "Привязка узлов к сетке при перемещении", + "workflowSettings": "Настройки редактора рабочих процессов", + "deletedInvalidEdge": "Удалено недопустимое ребро {{source}} -> {{target}}", + "unknownInput": "Неизвестный вход: {{name}}", + "newWorkflow": "Новый рабочий процесс", + "newWorkflowDesc": "Создать новый рабочий процесс?", + "clearWorkflow": "Очистить рабочий процесс", + "newWorkflowDesc2": "Текущий рабочий процесс имеет несохраненные изменения.", + "clearWorkflowDesc": "Очистить этот рабочий процесс и создать новый?", + "clearWorkflowDesc2": "Текущий рабочий процесс имеет несохраненные измерения.", + "reorderLinearView": "Изменить порядок линейного просмотра", + "viewMode": "Использовать в линейном представлении", + "editMode": "Открыть в редакторе узлов", + "resetToDefaultValue": "Сбросить к стандартному значкнию", + "edit": "Редактировать", + "noFieldsViewMode": "В этом рабочем процессе нет выбранных полей для отображения. Просмотрите полный рабочий процесс для настройки значений.", + "graph": "График", + "showEdgeLabels": "Показать метки на ребрах", + "showEdgeLabelsHelp": "Показать метки на ребрах, указывающие на соединенные узлы", + "cannotMixAndMatchCollectionItemTypes": "Невозможно смешивать и сопоставлять типы элементов коллекции", + "missingNode": "Отсутствует узел вызова", + "missingInvocationTemplate": "Отсутствует шаблон вызова", + "missingFieldTemplate": "Отсутствующий шаблон поля", + "singleFieldType": "{{name}} (Один)", + "noGraph": "Нет графика", + "imageAccessError": "Невозможно найти изображение {{image_name}}, сбрасываем на значение по умолчанию", + "boardAccessError": "Невозможно найти доску {{board_id}}, сбрасываем на значение по умолчанию", + "modelAccessError": "Невозможно найти модель {{key}}, сброс на модель по умолчанию", + "saveToGallery": "Сохранить в галерею", + "noWorkflows": "Нет рабочих процессов", + "noMatchingWorkflows": "Нет совпадающих рабочих процессов", + "workflowHelpText": "Нужна помощь? Ознакомьтесь с нашим руководством Getting Started with Workflows." + }, + "boards": { + "autoAddBoard": "Авто добавление Доски", + "topMessage": "Эта доска содержит изображения, используемые в следующих функциях:", + "move": "Перемещение", + "menuItemAutoAdd": "Авто добавление на эту доску", + "myBoard": "Моя Доска", + "searchBoard": "Поиск Доски...", + "noMatching": "Нет подходящих Досок", + "selectBoard": "Выбрать Доску", + "cancel": "Отменить", + "addBoard": "Добавить Доску", + "bottomMessage": "Удаление этой доски и ее изображений приведет к сбросу всех функций, использующихся их в данный момент.", + "uncategorized": "Без категории", + "changeBoard": "Изменить Доску", + "loading": "Загрузка...", + "clearSearch": "Очистить поиск", + "deleteBoardOnly": "Удалить только доску", + "movingImagesToBoard_one": "Перемещение {{count}} изображения на доску:", + "movingImagesToBoard_few": "Перемещение {{count}} изображений на доску:", + "movingImagesToBoard_many": "Перемещение {{count}} изображений на доску:", + "downloadBoard": "Скачать доску", + "deleteBoard": "Удалить доску", + "deleteBoardAndImages": "Удалить доску и изображения", + "deletedBoardsCannotbeRestored": "Удаленные доски не могут быть восстановлены. Выбор «Удалить только доску» переведет изображения в состояние без категории.", + "assetsWithCount_one": "{{count}} актив", + "assetsWithCount_few": "{{count}} актива", + "assetsWithCount_many": "{{count}} активов", + "imagesWithCount_one": "{{count}} изображение", + "imagesWithCount_few": "{{count}} изображения", + "imagesWithCount_many": "{{count}} изображений", + "archiveBoard": "Архивировать доску", + "archived": "Заархивировано", + "unarchiveBoard": "Разархивировать доску", + "selectedForAutoAdd": "Выбрано для автодобавления", + "addSharedBoard": "Добавить общую доску", + "boards": "Доски", + "addPrivateBoard": "Добавить личную доску", + "private": "Личные доски", + "shared": "Общие доски", + "hideBoards": "Скрыть доски", + "viewBoards": "Просмотреть доски", + "noBoards": "Нет досок {{boardType}}", + "deletedPrivateBoardsCannotbeRestored": "Удаленные доски не могут быть восстановлены. Выбор «Удалить только доску» переведет изображения в приватное состояние без категории для создателя изображения.", + "updateBoardError": "Ошибка обновления доски" + }, + "dynamicPrompts": { + "seedBehaviour": { + "perPromptDesc": "Используйте разные сиды для каждого изображения", + "perIterationLabel": "Сид на итерацию", + "perIterationDesc": "Используйте разные сиды для каждой итерации", + "perPromptLabel": "Сид для каждого изображения", + "label": "Поведение сида" + }, + "maxPrompts": "Максимум запросов", + "promptsPreview": "Предпросмотр запросов", + "dynamicPrompts": "Динамические запросы", + "loading": "Создание динамических запросов...", + "showDynamicPrompts": "Показать динамические запросы" + }, + "popovers": { + "noiseUseCPU": { + "paragraphs": [ + "Определяет, генерируется ли шум на CPU или на GPU.", + "Если включен шум CPU, определенное начальное число будет создавать одно и то же изображение на любом компьютере.", + "Включение шума CPU не влияет на производительность." + ], + "heading": "Использовать шум CPU" + }, + "paramScheduler": { + "paragraphs": [ + "Планировщик, используемый в процессе генерации.", + "Каждый планировщик определяет, как итеративно добавлять шум к изображению или как обновлять образец на основе выходных данных модели." + ], + "heading": "Планировщик" + }, + "scaleBeforeProcessing": { + "paragraphs": [ + "\"Авто\" масштабирует выбранную область до размера, наиболее подходящего для модели, до начала процесса создания изображения.", + "\"Вручную\" позволяет выбрать ширину и высоту, до которых будет масштабироваться выбранная область перед процессом создания изображения." + ], + "heading": "Масштабирование перед обработкой" + }, + "compositingMaskAdjustments": { + "heading": "Регулировка маски", + "paragraphs": [ + "Отрегулируйте маску." + ] + }, + "paramRatio": { + "heading": "Соотношение сторон", + "paragraphs": [ + "Соотношение сторон создаваемого изображения.", + "Размер изображения (в пикселях), эквивалентный 512x512, рекомендуется для моделей SD1.5, а размер, эквивалентный 1024x1024, рекомендуется для моделей SDXL." + ] + }, + "dynamicPrompts": { + "paragraphs": [ + "Динамические запросы превращают одно приглашение на множество.", + "Базовый синтакиси: \"a {red|green|blue} ball\". В итоге будет 3 запроса: \"a red ball\", \"a green ball\" и \"a blue ball\".", + "Вы можете использовать синтаксис столько раз, сколько захотите в одном запросе, но обязательно контролируйте количество генерируемых запросов с помощью параметра «Максимальное количество запросов»." + ], + "heading": "Динамические запросы" + }, + "paramVAE": { + "paragraphs": [ + "Модель, используемая для преобразования вывода AI в конечное изображение." + ], + "heading": "VAE" + }, + "paramIterations": { + "paragraphs": [ + "Количество изображений, которые нужно сгенерировать.", + "Если динамические подсказки включены, каждое из подсказок будет генерироваться столько раз." + ], + "heading": "Итерации" + }, + "paramVAEPrecision": { + "heading": "Точность VAE", + "paragraphs": [ + "Точность, используемая во время кодирования и декодирования VAE.", + "Точность Fp16/Half более эффективна за счет незначительных изменений изображения." + ] + }, + "compositingCoherenceMode": { + "heading": "Режим", + "paragraphs": [ + "Метод, используемый для создания связного изображения с вновь созданной замаскированной областью." + ] + }, + "paramSeed": { + "paragraphs": [ + "Управляет стартовым шумом, используемым для генерации.", + "Отключите опцию «Случайное», чтобы получить идентичные результаты с теми же настройками генерации." + ], + "heading": "Сид" + }, + "controlNetResizeMode": { + "heading": "Режим изменения размера", + "paragraphs": [ + "Метод подгонки размера входного изображения Control Adaptor к размеру выходного изображения." + ] + }, + "controlNetBeginEnd": { + "paragraphs": [ + "Часть процесса шумоподавления, к которой будет применен адаптер контроля.", + "ControlNet, применяемые в начале процесса, направляют композицию, а ControlNet, применяемые в конце, направляют детали." + ], + "heading": "Процент начала/конца шага" + }, + "dynamicPromptsSeedBehaviour": { + "paragraphs": [ + "Управляет использованием сида при создании запросов.", + "Для каждой итерации будет использоваться уникальный сид. Используйте это, чтобы изучить варианты запросов для одного сида.", + "Например, если у вас 5 запросов, каждое изображение будет использовать один и то же сид.", + "для каждого изображения будет использоваться уникальный сид. Это обеспечивает большую вариативность." + ], + "heading": "Поведение сида" + }, + "clipSkip": { + "paragraphs": [ + "Сколько слоев модели CLIP пропустить.", + "Некоторые модели лучше подходят для использования с CLIP Skip." + ], + "heading": "CLIP пропуск" + }, + "paramModel": { + "heading": "Модель", + "paragraphs": [ + "Модель, используемая для генерации. Разные модели обучены специализироваться на получении разных эстетических результатов и содержания." + ] + }, + "compositingCoherencePass": { + "heading": "Согласованность", + "paragraphs": [ + "Второй этап шумоподавления помогает исправить шов между изначальным изображением и перерисованной или расширенной частью." + ] + }, + "paramDenoisingStrength": { + "paragraphs": [ + "Количество шума, добавляемого к входному изображению.", + "0 приведет к идентичному изображению, а 1 - к совершенно новому." + ], + "heading": "Шумоподавление" + }, + "paramNegativeConditioning": { + "paragraphs": [ + "Stable Diffusion пытается избежать указанных в отрицательном запросе концепций. Используйте это, чтобы исключить качества или объекты из вывода.", + "Поддерживает синтаксис Compel и встраивания." + ], + "heading": "Негативный запрос" + }, + "compositingBlurMethod": { + "heading": "Метод размытия", + "paragraphs": [ + "Метод размытия, примененный к замаскированной области." + ] + }, + "dynamicPromptsMaxPrompts": { + "heading": "Макс. запросы", + "paragraphs": [ + "Ограничивает количество запросов, которые могут быть созданы с помощью динамических запросов." + ] + }, + "paramCFGRescaleMultiplier": { + "heading": "Множитель масштабирования CFG", + "paragraphs": [ + "Множитель масштабирования для шкалы CFG, используемый для моделей, обученных с использованием отношения сигнал/шум с нулевым терминалом (ztsnr).", + "Рекомендуемое значение 0,7 для этих моделей." + ] + }, + "infillMethod": { + "paragraphs": [ + "Метод заполнения в процессе зарисовки или перерисовки." + ], + "heading": "Метод заполнения" + }, + "controlNetWeight": { + "heading": "Вес", + "paragraphs": [ + "Вес адаптера управления. Более высокий вес приведет к большему воздействию на окончательное изображение." + ] + }, + "controlNet": { + "heading": "ControlNet", + "paragraphs": [ + "Сети ControlNets обеспечивают руководство процессом генерации, помогая создавать изображения с контролируемой композицией, структурой или стилем, в зависимости от выбранной модели." + ] + }, + "paramCFGScale": { + "heading": "Шкала точности (CFG)", + "paragraphs": [ + "Контролирует, насколько запрос влияет на процесс генерации.", + "Высокие значения шкалы CFG могут привести к перенасыщению и искажению результатов генерации. " + ] + }, + "controlNetControlMode": { + "paragraphs": [ + "Придает больший вес либо запросу, либо ControlNet." + ], + "heading": "Режим управления" + }, + "paramSteps": { + "heading": "Шаги", + "paragraphs": [ + "Количество шагов, которые будут выполнены в ходе генерации.", + "Большее количество шагов обычно приводит к созданию более качественных изображений, но требует больше времени на создание." + ] + }, + "paramPositiveConditioning": { + "heading": "Запрос", + "paragraphs": [ + "Направляет процесс генерации. Вы можете использовать любые слова и фразы.", + "Большинство моделей Stable Diffusion работают только с запросом на английском языке, но бывают исключения." + ] + }, + "lora": { + "heading": "LoRA", + "paragraphs": [ + "Облегченные модели, которые используются совместно с базовыми моделями." + ] + }, + "compositingMaskBlur": { + "heading": "Размытие маски", + "paragraphs": [ + "Радиус размытия маски." + ] + }, + "compositingCoherenceMinDenoise": { + "heading": "Минимальное шумоподавление", + "paragraphs": [ + "Минимальный уровень шумоподавления для режима Coherence", + "Минимальный уровень шумоподавления для области когерентности при перерисовывании или дорисовке" + ] + }, + "compositingCoherenceEdgeSize": { + "heading": "Размер края", + "paragraphs": [ + "Размер края прохода когерентности." + ] + }, + "paramUpscaleMethod": { + "heading": "Метод увеличения", + "paragraphs": [ + "Метод, используемый для масштабирования изображения для исправления высокого разрешения." + ] + }, + "refinerCfgScale": { + "heading": "Шкала CFG", + "paragraphs": [ + "Контролирует, насколько сильно запрос влияет на процесс генерации.", + "Аналогично CFG шкале генерации." + ] + }, + "controlNetProcessor": { + "heading": "Процессор", + "paragraphs": [ + "Метод обработки входного изображения для управления процессом генерации. Различные процессоры будут обеспечивать разные эффекты или стили для созданных изображений." + ] + }, + "paramHrf": { + "heading": "Включить исправление высокого разрешения", + "paragraphs": [ + "Создавайте изображения высокого качества с разрешением, превышающим оптимальное для модели. Обычно используется для предотвращения дублирования сгенерированного изображения." + ] + }, + "refinerModel": { + "paragraphs": [ + "Модель, используемая на этапе доработки в процессе генерации.", + "Аналогично модели генерации." + ], + "heading": "Модель доработчик" + }, + "refinerSteps": { + "paragraphs": [ + "Количество шагов, которые будут выполнены во время дорабатывающей части процесса генерации.", + "Похожие на шаги генерации." + ], + "heading": "Шаги" + }, + "imageFit": { + "heading": "Подогнать исходное изображение к выходному размеру", + "paragraphs": [ + "Изменяет размер исходного изображения до ширины и высоты выходного изображения. Рекомендуется включить." + ] + }, + "refinerNegativeAestheticScore": { + "heading": "Отрицательная эстетическая оценка", + "paragraphs": [ + "Поколение весов, чтобы быть более похожими на изображения с низкой эстетической оценкой, основанной на данных обучения." + ] + }, + "paramAspect": { + "heading": "Аспект", + "paragraphs": [ + "Соотношение сторон сгенерированного изображения. Изменение соотношения соответственно обновит ширину и высоту.", + "«Оптимизировать» установит оптимальные размеры ширины и высоты для выбранной модели." + ] + }, + "refinerStart": { + "heading": "Запуск доработки", + "paragraphs": [ + "Где в процессе генерации начнет использоваться доработчик.", + "0 означает, что доработчик будет использоваться на протяжении всего процесса генерации, 0,8 означает, что доработчик будет использоваться на последних 20% процесса генерации." + ] + }, + "paramWidth": { + "paragraphs": [ + "Ширина создаваемого изображения. Должно быть кратно 8." + ], + "heading": "Ширина" + }, + "patchmatchDownScaleSize": { + "heading": "Уменьшение масштаба", + "paragraphs": [ + "Насколько сильное масштабирование происходит перед заполнением.", + "Более высокое масштабирование улучшит производительность и ухудшит качество." + ] + }, + "refinerPositiveAestheticScore": { + "heading": "Положительная эстетическая оценка", + "paragraphs": [ + "Поколения веса должны быть больше похожи на изображения с высокой эстетической оценкой на основе данных обучения." + ] + }, + "refinerScheduler": { + "paragraphs": [ + "Планировщик, используемый на этапе доработки в процессе генерации.", + "Аналогично планировщику генерации." + ], + "heading": "Планировщик" + }, + "seamlessTilingXAxis": { + "heading": "Бесшовность по оси X", + "paragraphs": [ + "Плавно укладывайте изображение вдоль горизонтальной оси." + ] + }, + "loraWeight": { + "heading": "Вес", + "paragraphs": [ + "Вес LoRA. Более высокий вес приведет к большему воздействию на окончательное изображение." + ] + }, + "paramHeight": { + "paragraphs": [ + "Высота сгенерированного изображения. Должно быть кратно 8." + ], + "heading": "Высота" + }, + "seamlessTilingYAxis": { + "heading": "Бесшовность по оси Y", + "paragraphs": [ + "Плавно укладывайте изображение вдоль вертикальной оси." + ] + }, + "ipAdapterMethod": { + "heading": "Метод", + "paragraphs": [ + "Метод, с помощью которого применяется текущий IP-адаптер." + ] + }, + "structure": { + "paragraphs": [ + "Структура контролирует, насколько точно выходное изображение будет соответствовать макету оригинала. Низкая структура допускает значительные изменения, в то время как высокая структура строго сохраняет исходную композицию и макет." + ], + "heading": "Структура" + }, + "scale": { + "paragraphs": [ + "Масштаб управляет размером выходного изображения и основывается на кратном разрешении входного изображения. Например, при увеличении в 2 раза изображения 1024x1024 на выходе получится 2048 x 2048." + ], + "heading": "Масштаб" + }, + "creativity": { + "paragraphs": [ + "Креативность контролирует степень свободы, предоставляемой модели при добавлении деталей. При низкой креативности модель остается близкой к оригинальному изображению, в то время как высокая креативность позволяет вносить больше изменений. При использовании подсказки высокая креативность увеличивает влияние подсказки." + ], + "heading": "Креативность" + }, + "upscaleModel": { + "heading": "Модель увеличения", + "paragraphs": [ + "Модель увеличения масштаба масштабирует изображение до выходного размера перед добавлением деталей. Можно использовать любую поддерживаемую модель масштабирования, но некоторые из них специализированы для различных видов изображений, например фотографий или линейных рисунков." + ] + }, + "fluxDevLicense": { + "heading": "Некоммерческая лицензия", + "paragraphs": [ + "Модели FLUX.1 [dev] лицензируются по некоммерческой лицензии FLUX [dev]. Чтобы использовать этот тип модели в коммерческих целях в Invoke, посетите наш веб-сайт, чтобы узнать больше." + ] + }, + "optimizedDenoising": { + "heading": "Оптимизированный img2img", + "paragraphs": [ + "Включите опцию «Оптимизированный img2img», чтобы получить более плавную шкалу Denoise Strength для img2img и перерисовки с моделями Flux. Эта настройка улучшает возможность контролировать степень изменения изображения, но может быть отключена, если вы предпочитаете использовать стандартную шкалу Denoise Strength. Эта настройка все еще находится в стадии настройки и в настоящее время имеет статус бета-версии." + ] + }, + "paramGuidance": { + "paragraphs": [ + "Контролирует, насколько сильно запрос влияет на процесс генерации.", + "Высокие значения точности могут привести к перенасыщению, а высокие или низкие значения точности могут привести к искажению результатов генерации. Точность применима только к моделям FLUX DEV." + ], + "heading": "Точность" + } + }, + "metadata": { + "positivePrompt": "Запрос", + "negativePrompt": "Негативный запрос", + "generationMode": "Режим генерации", + "Threshold": "Шумовой порог", + "metadata": "Метаданные", + "strength": "Сила img2img", + "seed": "Сид", + "imageDetails": "Детали изображения", + "model": "Модель", + "noImageDetails": "Детали изображения не найдены", + "cfgScale": "Шкала точности", + "recallParameters": "Вызов параметров", + "height": "Высота", + "noMetaData": "Метаданные не найдены", + "width": "Ширина", + "vae": "VAE", + "createdBy": "Сделано", + "workflow": "Рабочий процесс", + "steps": "Шаги", + "scheduler": "Планировщик", + "noRecallParameters": "Параметры для вызова не найдены", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "parameterSet": "Параметр {{parameter}} установлен", + "parsingFailed": "Не удалось выполнить синтаксический анализ", + "recallParameter": "Отозвать {{label}}", + "allPrompts": "Все запросы", + "imageDimensions": "Размеры изображения", + "canvasV2Metadata": "Холст", + "guidance": "Точность" + }, + "queue": { + "status": "Статус", + "pruneSucceeded": "Из очереди удалено {{item_count}} выполненных элементов", + "cancelTooltip": "Отменить текущий элемент", + "queueEmpty": "Очередь пуста", + "pauseSucceeded": "Рендеринг приостановлен", + "in_progress": "В процессе", + "queueFront": "Добавить в начало очереди", + "notReady": "Невозможно поставить в очередь", + "batchFailedToQueue": "Не удалось поставить пакет в очередь", + "completed": "Выполнено", + "queueBack": "Добавить в очередь", + "cancelFailed": "Проблема с отменой элемента", + "batchQueued": "Пакетная очередь", + "pauseFailed": "Проблема с приостановкой рендеринга", + "clearFailed": "Проблема с очисткой очереди", + "front": "передний", + "clearSucceeded": "Очередь очищена", + "pause": "Пауза", + "pruneTooltip": "Удалить {{item_count}} выполненных задач", + "cancelSucceeded": "Элемент отменен", + "batchQueuedDesc_one": "Добавлен {{count}} сеанс в {{direction}} очереди", + "batchQueuedDesc_few": "Добавлено {{count}} сеанса в {{direction}} очереди", + "batchQueuedDesc_many": "Добавлено {{count}} сеансов в {{direction}} очереди", + "graphQueued": "График поставлен в очередь", + "queue": "Очередь", + "batch": "Пакет", + "clearQueueAlertDialog": "Очистка очереди немедленно отменяет все элементы обработки и полностью очищает очередь. Ожидающие фильтры будут отменены.", + "pending": "В ожидании", + "completedIn": "Завершено за", + "resumeFailed": "Проблема с возобновлением рендеринга", + "clear": "Очистить", + "prune": "Сократить", + "total": "Всего", + "canceled": "Отменено", + "pruneFailed": "Проблема с сокращением очереди", + "cancelBatchSucceeded": "Пакет отменен", + "clearTooltip": "Отменить все и очистить очередь", + "current": "Текущий", + "pauseTooltip": "Приостановить рендеринг", + "failed": "Неудачно", + "cancelItem": "Отменить элемент", + "next": "Следующий", + "cancelBatch": "Отменить пакет", + "back": "задний", + "batchFieldValues": "Пакетные значения полей", + "cancel": "Отмена", + "session": "Сессия", + "time": "Время", + "resumeSucceeded": "Рендеринг возобновлен", + "enqueueing": "Пакетная очередь", + "resumeTooltip": "Возобновить рендеринг", + "resume": "Продолжить", + "cancelBatchFailed": "Проблема с отменой пакета", + "clearQueueAlertDialog2": "Вы уверены, что хотите очистить очередь?", + "item": "Элемент", + "graphFailedToQueue": "Не удалось поставить график в очередь", + "openQueue": "Открыть очередь", + "prompts_one": "Запрос", + "prompts_few": "Запроса", + "prompts_many": "Запросов", + "iterations_one": "Итерация", + "iterations_few": "Итерации", + "iterations_many": "Итераций", + "generations_one": "Генерация", + "generations_few": "Генерации", + "generations_many": "Генераций", + "other": "Другое", + "gallery": "Галерея", + "upscaling": "Увеличение", + "canvas": "Холст", + "generation": "Генерация", + "workflows": "Рабочие процессы", + "origin": "Источник", + "destination": "Назначение" + }, + "sdxl": { + "refinerStart": "Запуск доработчика", + "scheduler": "Планировщик", + "cfgScale": "Шкала точности (CFG)", + "negStylePrompt": "Негативный запрос стиля", + "noModelsAvailable": "Нет доступных моделей", + "refiner": "Доработчик", + "negAestheticScore": "Отрицательная эстетическая оценка", + "denoisingStrength": "Шумоподавление", + "refinermodel": "Дорабатывающая модель", + "posAestheticScore": "Положительная эстетическая оценка", + "concatPromptStyle": "Связывание запроса и стиля", + "loading": "Загрузка...", + "steps": "Шаги", + "posStylePrompt": "Запрос стиля", + "freePromptStyle": "Ручной запрос стиля", + "refinerSteps": "Шаги доработчика" + }, + "invocationCache": { + "useCache": "Использовать кэш", + "disable": "Отключить", + "misses": "Промахи в кэше", + "enableFailed": "Проблема с включением кэша вызовов", + "invocationCache": "Кэш вызовов", + "clearSucceeded": "Кэш вызовов очищен", + "enableSucceeded": "Кэш вызовов включен", + "clearFailed": "Проблема с очисткой кэша вызовов", + "hits": "Попадания в кэш", + "disableSucceeded": "Кэш вызовов отключен", + "disableFailed": "Проблема с отключением кэша вызовов", + "enable": "Включить", + "clear": "Очистить", + "maxCacheSize": "Максимальный размер кэша", + "cacheSize": "Размер кэша" + }, + "workflows": { + "saveWorkflowAs": "Сохранить рабочий процесс как", + "workflowEditorMenu": "Меню редактора рабочего процесса", + "workflowName": "Имя рабочего процесса", + "saveWorkflow": "Сохранить рабочий процесс", + "openWorkflow": "Открытый рабочий процесс", + "clearWorkflowSearchFilter": "Очистить фильтр поиска рабочих процессов", + "workflowLibrary": "Библиотека", + "downloadWorkflow": "Сохранить в файл", + "workflowSaved": "Рабочий процесс сохранен", + "unnamedWorkflow": "Безымянный рабочий процесс", + "savingWorkflow": "Сохранение рабочего процесса...", + "problemLoading": "Проблема с загрузкой рабочих процессов", + "loading": "Загрузка рабочих процессов", + "searchWorkflows": "Поиск рабочих процессов", + "problemSavingWorkflow": "Проблема с сохранением рабочего процесса", + "deleteWorkflow": "Удалить рабочий процесс", + "workflows": "Рабочие процессы", + "noDescription": "Без описания", + "uploadWorkflow": "Загрузить из файла", + "newWorkflowCreated": "Создан новый рабочий процесс", + "saveWorkflowToProject": "Сохранить рабочий процесс в проект", + "workflowCleared": "Рабочий процесс очищен", + "noWorkflows": "Нет рабочих процессов", + "opened": "Открыто", + "updated": "Обновлено", + "ascending": "Восходящий", + "created": "Создано", + "descending": "Спуск", + "name": "Имя", + "loadWorkflow": "Рабочий процесс $t(common.load)", + "convertGraph": "Конвертировать график", + "loadFromGraph": "Загрузка рабочего процесса из графика", + "autoLayout": "Автоматическое расположение", + "userWorkflows": "Пользовательские рабочие процессы", + "projectWorkflows": "Рабочие процессы проекта", + "defaultWorkflows": "Стандартные рабочие процессы", + "deleteWorkflow2": "Вы уверены, что хотите удалить этот рабочий процесс? Это нельзя отменить.", + "chooseWorkflowFromLibrary": "Выбрать рабочий процесс из библиотеки", + "uploadAndSaveWorkflow": "Загрузить в библиотеку", + "edit": "Редактировать", + "download": "Скачать", + "copyShareLink": "Скопировать ссылку на общий доступ", + "copyShareLinkForWorkflow": "Скопировать ссылку на общий доступ для рабочего процесса", + "delete": "Удалить" + }, + "hrf": { + "enableHrf": "Включить исправление высокого разрешения", + "upscaleMethod": "Метод увеличения", + "metadata": { + "strength": "Сила исправления высокого разрешения", + "enabled": "Исправление высокого разрешения включено", + "method": "Метод исправления высокого разрешения" + }, + "hrf": "Исправление высокого разрешения" + }, + "models": { + "noMatchingModels": "Нет подходящих моделей", + "loading": "загрузка", + "noMatchingLoRAs": "Нет подходящих LoRA", + "noModelsAvailable": "Нет доступных моделей", + "addLora": "Добавить LoRA", + "selectModel": "Выберите модель", + "noRefinerModelsInstalled": "Дорабатывающие модели SDXL не установлены", + "noLoRAsInstalled": "Нет установленных LoRA", + "lora": "LoRA", + "defaultVAE": "Стандартное VAE", + "concepts": "LoRA" + }, + "accordions": { + "compositing": { + "infillTab": "Заполнение", + "coherenceTab": "Согласованность", + "title": "Композиция" + }, + "control": { + "title": "Контроль" + }, + "generation": { + "title": "Генерация" + }, + "advanced": { + "title": "Расширенные", + "options": "Опции $t(accordions.advanced.title)" + }, + "image": { + "title": "Изображение" + } + }, + "prompt": { + "addPromptTrigger": "Добавить триггер запроса", + "compatibleEmbeddings": "Совместимые встраивания", + "noMatchingTriggers": "Нет соответствующих триггеров" + }, + "controlLayers": { + "moveToBack": "На задний план", + "moveForward": "Переместить вперёд", + "moveBackward": "Переместить назад", + "autoNegative": "Авто негатив", + "deletePrompt": "Удалить запрос", + "rectangle": "Прямоугольник", + "addNegativePrompt": "Добавить $t(controlLayers.negativePrompt)", + "regionalGuidance": "Региональная точность", + "opacity": "Непрозрачность", + "addLayer": "Добавить слой", + "moveToFront": "На передний план", + "addPositivePrompt": "Добавить $t(controlLayers.prompt)", + "regional": "Региональный", + "bookmark": "Закладка для быстрого переключения", + "fitBboxToLayers": "Подогнать рамку к слоям", + "mergeVisibleOk": "Объединенные видимые слои", + "mergeVisibleError": "Ошибка объединения видимых слоев", + "clearHistory": "Очистить историю", + "mergeVisible": "Объединить видимые", + "removeBookmark": "Удалить закладку", + "saveLayerToAssets": "Сохранить слой в активы", + "clearCaches": "Очистить кэши", + "recalculateRects": "Пересчитать прямоугольники", + "saveBboxToGallery": "Сохранить рамку в галерею", + "resetCanvas": "Сбросить холст", + "canvas": "Холст", + "global": "Глобальный", + "newGlobalReferenceImageError": "Проблема с созданием глобального эталонного изображения", + "newRegionalReferenceImageOk": "Создано региональное эталонное изображение", + "newRegionalReferenceImageError": "Проблема создания регионального эталонного изображения", + "newControlLayerOk": "Создан слой управления", + "newControlLayerError": "Ошибка создания слоя управления", + "newRasterLayerOk": "Создан растровый слой", + "newRasterLayerError": "Ошибка создания растрового слоя", + "newGlobalReferenceImageOk": "Создано глобальное эталонное изображение", + "bboxOverlay": "Показать наложение ограничительной рамки", + "saveCanvasToGallery": "Сохранить холст в галерею", + "pullBboxIntoReferenceImageOk": "рамка перенесена в эталонное изображение", + "pullBboxIntoReferenceImageError": "Ошибка переноса рамки в эталонное изображение", + "regionIsEmpty": "Выбранный регион пуст", + "savedToGalleryOk": "Сохранено в галерею", + "savedToGalleryError": "Ошибка сохранения в галерею", + "pullBboxIntoLayerOk": "Рамка перенесена в слой", + "pullBboxIntoLayerError": "Проблема с переносом рамки в слой", + "newLayerFromImage": "Новый слой из изображения", + "filter": { + "lineart_anime_edge_detection": { + "label": "Обнаружение краев Lineart Anime", + "description": "Создает карту краев выбранного слоя с помощью модели обнаружения краев Lineart Anime." + }, + "hed_edge_detection": { + "scribble": "Штрих", + "label": "обнаружение границ HED", + "description": "Создает карту границ из выбранного слоя с использованием модели обнаружения границ HED." + }, + "mlsd_detection": { + "description": "Генерирует карту сегментов линий из выбранного слоя с помощью модели обнаружения сегментов линий MLSD.", + "score_threshold": "Пороговый балл", + "distance_threshold": "Порог расстояния", + "label": "Обнаружение сегментов линии" + }, + "canny_edge_detection": { + "low_threshold": "Низкий порог", + "high_threshold": "Высокий порог", + "label": "Обнаружение краев", + "description": "Создает карту краев выбранного слоя с помощью алгоритма обнаружения краев Canny." + }, + "color_map": { + "description": "Создайте цветовую карту из выбранного слоя.", + "label": "Цветная карта", + "tile_size": "Размер плитки" + }, + "depth_anything_depth_estimation": { + "model_size_base": "Базовая", + "model_size_large": "Большая", + "label": "Анализ глубины", + "model_size_small": "Маленькая", + "model_size_small_v2": "Маленькая v2", + "description": "Создает карту глубины из выбранного слоя с использованием модели Depth Anything.", + "model_size": "Размер модели" + }, + "mediapipe_face_detection": { + "min_confidence": "Минимальная уверенность", + "label": "Распознавание лиц MediaPipe", + "description": "Обнаруживает лица в выбранном слое с помощью модели обнаружения лиц MediaPipe.", + "max_faces": "Максимум лиц" + }, + "lineart_edge_detection": { + "label": "Обнаружение краев Lineart", + "description": "Создает карту краев выбранного слоя с помощью модели обнаружения краев Lineart.", + "coarse": "Грубый" + }, + "filterType": "Тип фильтра", + "autoProcess": "Автообработка", + "reset": "Сбросить", + "content_shuffle": { + "scale_factor": "Коэффициент", + "label": "Перетасовка контента", + "description": "Перемешивает содержимое выбранного слоя, аналогично эффекту «сжижения»." + }, + "dw_openpose_detection": { + "label": "Обнаружение DW Openpose", + "draw_hands": "Рисовать руки", + "description": "Обнаруживает позы человека в выбранном слое с помощью модели DW Openpose.", + "draw_face": "Рисовать лицо", + "draw_body": "Рисовать тело" + }, + "normal_map": { + "label": "Карта нормалей", + "description": "Создает карту нормалей для выбранного слоя." + }, + "spandrel_filter": { + "model": "Модель", + "label": "Модель img2img", + "autoScale": "Авто масштабирование", + "scale": "Целевой масштаб", + "description": "Запустить модель изображения к изображению на выбранном слое.", + "autoScaleDesc": "Выбранная модель будет работать до тех пор, пока не будет достигнут целевой масштаб." + }, + "pidi_edge_detection": { + "scribble": "Штрих", + "description": "Генерирует карту краев из выбранного слоя с помощью модели обнаружения краев PiDiNet.", + "label": "Обнаружение краев PiDiNet", + "quantize_edges": "Квантизация краев" + }, + "process": "Обработать", + "apply": "Применить", + "cancel": "Отменить", + "filter": "Фильтр", + "filters": "Фильтры" + }, + "HUD": { + "entityStatus": { + "isHidden": "{{title}} скрыт", + "isLocked": "{{title}} заблокирован", + "isDisabled": "{{title}} отключен", + "isEmpty": "{{title}} пуст", + "isFiltering": "{{title}} фильтруется", + "isTransforming": "{{title}} трансформируется" + }, + "scaledBbox": "Масштабированная рамка", + "bbox": "Ограничительная рамка" + }, + "canvasContextMenu": { + "saveBboxToGallery": "Сохранить рамку в галерею", + "newGlobalReferenceImage": "Новое глобальное эталонное изображение", + "bboxGroup": "Сохдать из рамки", + "canvasGroup": "Холст", + "newControlLayer": "Новый контрольный слой", + "newRasterLayer": "Новый растровый слой", + "saveToGalleryGroup": "Сохранить в галерею", + "saveCanvasToGallery": "Сохранить холст в галерею", + "cropCanvasToBbox": "Обрезать холст по рамке", + "newRegionalReferenceImage": "Новое региональное эталонное изображение" + }, + "fill": { + "solid": "Сплошной", + "fillStyle": "Стиль заполнения", + "fillColor": "Цвет заполнения", + "grid": "Сетка", + "horizontal": "Горизонтальная", + "diagonal": "Диагональная", + "crosshatch": "Штриховка", + "vertical": "Вертикальная" + }, + "showHUD": "Показать HUD", + "copyToClipboard": "Копировать в буфер обмена", + "ipAdapterMethod": { + "composition": "Только композиция", + "style": "Только стиль", + "ipAdapterMethod": "Метод IP адаптера", + "full": "Полный" + }, + "addReferenceImage": "Добавить $t(controlLayers.referenceImage)", + "inpaintMask": "Маска перерисовки", + "sendToGalleryDesc": "При нажатии кнопки Invoke создается изображение и сохраняется в вашей галерее.", + "sendToCanvas": "Отправить на холст", + "regionalGuidance_withCount_one": "$t(controlLayers.regionalGuidance)", + "regionalGuidance_withCount_few": "Региональных точности", + "regionalGuidance_withCount_many": "Региональных точностей", + "controlLayer_withCount_one": "$t(controlLayers.controlLayer)", + "controlLayer_withCount_few": "Контрольных слоя", + "controlLayer_withCount_many": "Контрольных слоев", + "newCanvasFromImage": "Новый холст из изображения", + "inpaintMask_withCount_one": "$t(controlLayers.inpaintMask)", + "inpaintMask_withCount_few": "Маски перерисовки", + "inpaintMask_withCount_many": "Масок перерисовки", + "globalReferenceImages_withCount_visible": "Глобальные эталонные изображения ({{count}})", + "controlMode": { + "prompt": "Запрос", + "controlMode": "Режим контроля", + "megaControl": "Мега контроль", + "balanced": "Сбалансированный", + "control": "Контроль" + }, + "settings": { + "isolatedPreview": "Изолированный предпросмотр", + "invertBrushSizeScrollDirection": "Инвертировать прокрутку для размера кисти", + "snapToGrid": { + "label": "Привязка к сетке", + "on": "Вкл", + "off": "Выкл" + }, + "pressureSensitivity": "Чувствительность к давлению", + "isolatedStagingPreview": "Изолированный предпросмотр на промежуточной стадии", + "preserveMask": { + "label": "Сохранить замаскированную область", + "alert": "Сохранение замаскированной области" + } + }, + "stagingArea": { + "discardAll": "Отбросить все", + "discard": "Отбросить", + "accept": "Принять", + "previous": "Предыдущий", + "next": "Следующий", + "saveToGallery": "Сохранить в галерею", + "showResultsOn": "Показать результаты", + "showResultsOff": "Скрыть результаты" + }, + "pullBboxIntoReferenceImage": "Поместить рамку в эталонное изображение", + "enableAutoNegative": "Включить авто негатив", + "maskFill": "Заполнение маски", + "viewProgressInViewer": "Просматривайте прогресс и результаты в Просмотрщике изображений.", + "tool": { + "move": "Двигать", + "bbox": "Ограничительная рамка", + "view": "Смотреть", + "brush": "Кисть", + "eraser": "Ластик", + "rectangle": "Прямоугольник", + "colorPicker": "Подборщик цветов" + }, + "rasterLayer": "Растровый слой", + "sendingToCanvas": "Постановка генераций на холст", + "rasterLayers_withCount_visible": "Растровые слои ({{count}})", + "regionalGuidance_withCount_hidden": "Региональная точность ({{count}} скрыто)", + "enableTransparencyEffect": "Включить эффект прозрачности", + "hidingType": "Скрыть {{type}}", + "addRegionalGuidance": "Добавить $t(controlLayers.regionalGuidance)", + "sendingToGallery": "Отправка генераций в галерею", + "viewProgressOnCanvas": "Просматривайте прогресс и результаты этапов на Холсте.", + "controlLayers_withCount_hidden": "Контрольные слои ({{count}} скрыто)", + "rasterLayers_withCount_hidden": "Растровые слои ({{count}} скрыто)", + "deleteSelected": "Удалить выбранное", + "stagingOnCanvas": "Постановка изображений на", + "pullBboxIntoLayer": "Поместить рамку в слой", + "locked": "Заблокировано", + "replaceLayer": "Заменить слой", + "width": "Ширина", + "controlLayer": "Слой управления", + "addRasterLayer": "Добавить $t(controlLayers.rasterLayer)", + "addControlLayer": "Добавить $t(controlLayers.controlLayer)", + "addInpaintMask": "Добавить $t(controlLayers.inpaintMask)", + "inpaintMasks_withCount_hidden": "Маски перерисовки ({{count}} скрыто)", + "regionalGuidance_withCount_visible": "Региональная точность ({{count}})", + "newGallerySessionDesc": "Это очистит холст и все настройки, кроме выбранной модели. Генерации будут отправлены в галерею.", + "newCanvasSession": "Новая сессия холста", + "newCanvasSessionDesc": "Это очистит холст и все настройки, кроме выбора модели. Генерации будут размещены на холсте.", + "cropLayerToBbox": "Обрезать слой по ограничительной рамке", + "clipToBbox": "Обрезка штрихов в рамке", + "outputOnlyMaskedRegions": "Вывод только маскированных областей", + "duplicate": "Дублировать", + "inpaintMasks_withCount_visible": "Маски перерисовки ({{count}})", + "layer_one": "Слой", + "layer_few": "Слоя", + "layer_many": "Слоев", + "prompt": "Запрос", + "negativePrompt": "Исключающий запрос", + "beginEndStepPercentShort": "Начало/конец %", + "transform": { + "transform": "Трансформировать", + "fitToBbox": "Вместить в рамку", + "reset": "Сбросить", + "apply": "Применить", + "cancel": "Отменить", + "fitModeContain": "Уместить", + "fitMode": "Режим подгонки", + "fitModeFill": "Заполнить" + }, + "disableAutoNegative": "Отключить авто негатив", + "deleteReferenceImage": "Удалить эталонное изображение", + "controlLayers_withCount_visible": "Контрольные слои ({{count}})", + "rasterLayer_withCount_one": "$t(controlLayers.rasterLayer)", + "rasterLayer_withCount_few": "Растровых слоя", + "rasterLayer_withCount_many": "Растровых слоев", + "transparency": "Прозрачность", + "weight": "Вес", + "newGallerySession": "Новая сессия галереи", + "sendToCanvasDesc": "Нажатие кнопки Invoke отображает вашу текущую работу на холсте.", + "globalReferenceImages_withCount_hidden": "Глобальные эталонные изображения ({{count}} скрыто)", + "layer_withCount_one": "Слой ({{count}})", + "layer_withCount_few": "Слои ({{count}})", + "layer_withCount_many": "Слои ({{count}})", + "disableTransparencyEffect": "Отключить эффект прозрачности", + "showingType": "Показать {{type}}", + "dynamicGrid": "Динамическая сетка", + "logDebugInfo": "Писать отладочную информацию", + "unlocked": "Разблокировано", + "showProgressOnCanvas": "Показать прогресс на холсте", + "globalReferenceImage_withCount_one": "$t(controlLayers.globalReferenceImage)", + "globalReferenceImage_withCount_few": "Глобальных эталонных изображения", + "globalReferenceImage_withCount_many": "Глобальных эталонных изображений", + "regionalReferenceImage": "Региональное эталонное изображение", + "globalReferenceImage": "Глобальное эталонное изображение", + "sendToGallery": "Отправить в галерею", + "referenceImage": "Эталонное изображение", + "addGlobalReferenceImage": "Добавить $t(controlLayers.globalReferenceImage)", + "newImg2ImgCanvasFromImage": "Новое img2img из изображения" + }, + "ui": { + "tabs": { + "generation": "Генерация", + "canvas": "Холст", + "workflowsTab": "$t(ui.tabs.workflows) $t(common.tab)", + "models": "Модели", + "workflows": "Рабочие процессы", + "modelsTab": "$t(ui.tabs.models) $t(common.tab)", + "queue": "Очередь", + "upscaling": "Увеличение", + "upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)", + "gallery": "Галерея" + } + }, + "upscaling": { + "exceedsMaxSize": "Параметры масштабирования превышают максимальный размер", + "exceedsMaxSizeDetails": "Максимальный предел масштабирования составляет {{maxUpscaleDimension}}x{{maxUpscaleDimension}} пикселей. Пожалуйста, попробуйте использовать меньшее изображение или уменьшите масштаб.", + "structure": "Структура", + "missingTileControlNetModel": "Не установлены подходящие модели ControlNet", + "missingUpscaleInitialImage": "Отсутствует увеличиваемое изображение", + "missingUpscaleModel": "Отсутствует увеличивающая модель", + "creativity": "Креативность", + "upscaleModel": "Модель увеличения", + "scale": "Масштаб", + "mainModelDesc": "Основная модель (архитектура SD1.5 или SDXL)", + "upscaleModelDesc": "Модель увеличения (img2img)", + "postProcessingModel": "Модель постобработки", + "tileControlNetModelDesc": "Модель ControlNet для выбранной архитектуры основной модели", + "missingModelsWarning": "Зайдите в Менеджер моделей чтоб установить необходимые модели:", + "postProcessingMissingModelWarning": "Посетите Менеджер моделей, чтобы установить модель постобработки (img2img).", + "upscale": "Увеличить" + }, + "stylePresets": { + "noMatchingTemplates": "Нет подходящих шаблонов", + "promptTemplatesDesc1": "Шаблоны подсказок добавляют текст к подсказкам, которые вы пишете в окне подсказок.", + "sharedTemplates": "Общие шаблоны", + "templateDeleted": "Шаблон запроса удален", + "toggleViewMode": "Переключить режим просмотра", + "type": "Тип", + "unableToDeleteTemplate": "Не получилось удалить шаблон запроса", + "viewModeTooltip": "Вот как будет выглядеть ваш запрос с выбранным шаблоном. Чтобы его отредактировать, щелкните в любом месте текстового поля.", + "viewList": "Просмотреть список шаблонов", + "active": "Активно", + "choosePromptTemplate": "Выберите шаблон запроса", + "defaultTemplates": "Стандартные шаблоны", + "deleteImage": "Удалить изображение", + "deleteTemplate": "Удалить шаблон", + "deleteTemplate2": "Вы уверены, что хотите удалить этот шаблон? Это нельзя отменить.", + "editTemplate": "Редактировать шаблон", + "exportPromptTemplates": "Экспорт моих шаблонов запроса (CSV)", + "exportDownloaded": "Экспорт скачан", + "exportFailed": "Невозможно сгенерировать и загрузить CSV", + "flatten": "Объединить выбранный шаблон с текущим запросом", + "acceptedColumnsKeys": "Принимаемые столбцы/ключи:", + "positivePromptColumn": "'prompt' или 'positive_prompt'", + "insertPlaceholder": "Вставить заполнитель", + "name": "Имя", + "negativePrompt": "Негативный запрос", + "promptTemplatesDesc3": "Если вы не используете заполнитель, шаблон будет добавлен в конец запроса.", + "positivePrompt": "Позитивный запрос", + "preview": "Предпросмотр", + "private": "Приватный", + "updatePromptTemplate": "Обновить шаблон запроса", + "uploadImage": "Загрузить изображение", + "useForTemplate": "Использовать для шаблона запроса", + "clearTemplateSelection": "Очистить выбор шаблона", + "copyTemplate": "Копировать шаблон", + "createPromptTemplate": "Создать шаблон запроса", + "importTemplates": "Импортировать шаблоны запроса (CSV/JSON)", + "nameColumn": "'name'", + "negativePromptColumn": "'negative_prompt'", + "myTemplates": "Мои шаблоны", + "noTemplates": "Нет шаблонов", + "promptTemplatesDesc2": "Используйте строку-заполнитель
{{placeholder}}
, чтобы указать место, куда должен быть включен ваш запрос в шаблоне.", + "searchByName": "Поиск по имени", + "shared": "Общий", + "promptTemplateCleared": "Шаблон запроса создан" + }, + "upsell": { + "inviteTeammates": "Пригласите членов команды", + "professional": "Профессионал", + "professionalUpsell": "Доступно в профессиональной версии Invoke. Нажмите здесь или посетите invoke.com/pricing для получения более подробной информации.", + "shareAccess": "Поделиться доступом" + }, + "system": { + "logNamespaces": { + "canvas": "Холст", + "config": "Конфигурация", + "generation": "Генерация", + "workflows": "Рабочие процессы", + "gallery": "Галерея", + "models": "Модели", + "logNamespaces": "Пространства имен логов", + "events": "События", + "system": "Система", + "queue": "Очередь", + "metadata": "Метаданные" + }, + "enableLogging": "Включить логи", + "logLevel": { + "logLevel": "Уровень логов", + "fatal": "Фатальное", + "debug": "Отладка", + "info": "Инфо", + "warn": "Предупреждение", + "error": "Ошибки", + "trace": "Трассировка" + } + }, + "whatsNew": { + "whatsNewInInvoke": "Что нового в Invoke" + }, + "newUserExperience": { + "toGetStarted": "Чтобы начать работу, введите в поле запрос и нажмите Invoke, чтобы сгенерировать первое изображение. Выберите шаблон запроса, чтобы улучшить результаты. Вы можете сохранить изображения непосредственно в Галерею или отредактировать их на Холсте.", + "gettingStartedSeries": "Хотите получить больше рекомендаций? Ознакомьтесь с нашей серией Getting Started Series для получения советов по раскрытию всего потенциала Invoke Studio." + } +} diff --git a/invokeai/frontend/web/public/locales/sv.json b/invokeai/frontend/web/public/locales/sv.json new file mode 100644 index 0000000000000000000000000000000000000000..b030adced941740cf629bc8f449526d23b7427e9 --- /dev/null +++ b/invokeai/frontend/web/public/locales/sv.json @@ -0,0 +1,34 @@ +{ + "accessibility": { + "uploadImage": "Ladda upp bild", + "invokeProgressBar": "Invoke förloppsmätare", + "nextImage": "Nästa bild", + "reset": "Starta om", + "previousImage": "Föregående bild" + }, + "common": { + "hotkeysLabel": "Snabbtangenter", + "reportBugLabel": "Rapportera bugg", + "githubLabel": "Github", + "discordLabel": "Discord", + "settingsLabel": "Inställningar", + "upload": "Ladda upp", + "cancel": "Avbryt", + "accept": "Acceptera", + "statusDisconnected": "Frånkopplad", + "loading": "Laddar", + "languagePickerLabel": "Språkväljare", + "txt2img": "Text till bild", + "nodes": "Noder", + "img2img": "Bild till bild", + "postprocessing": "Efterbehandling", + "load": "Ladda", + "back": "Bakåt" + }, + "gallery": { + "galleryImageSize": "Bildstorlek", + "gallerySettings": "Galleriinställningar", + "noImagesInGallery": "Inga bilder i galleriet", + "autoSwitchNewImages": "Ändra automatiskt till nya bilder" + } +} diff --git a/invokeai/frontend/web/public/locales/tr.json b/invokeai/frontend/web/public/locales/tr.json new file mode 100644 index 0000000000000000000000000000000000000000..22119932bf26e61aedd0424f8e50e6d19bd0b690 --- /dev/null +++ b/invokeai/frontend/web/public/locales/tr.json @@ -0,0 +1,418 @@ +{ + "accessibility": { + "invokeProgressBar": "Invoke durum çubuğu", + "nextImage": "Sonraki Görsel", + "reset": "Resetle", + "uploadImage": "Görsel Yükle", + "previousImage": "Önceki Görsel", + "menu": "Menü", + "about": "Hakkında", + "mode": "Kip", + "resetUI": "$t(accessibility.reset)Arayüz", + "createIssue": "Sorun Bildir" + }, + "common": { + "hotkeysLabel": "Kısayol Tuşları", + "languagePickerLabel": "Dil", + "reportBugLabel": "Sorun Bildir", + "githubLabel": "Github", + "discordLabel": "Discord", + "settingsLabel": "Seçenekler", + "txt2img": "Yazıdan Görsel", + "img2img": "Görselden Görsel", + "linear": "Doğrusal", + "nodes": "İş Akışı Düzenleyici", + "postprocessing": "Rötuş", + "batch": "Toplu İş Yöneticisi", + "accept": "Onayla", + "cancel": "Vazgeç", + "advanced": "Gelişmiş", + "copyError": "$t(gallery.copy) Hata", + "on": "Açık", + "or": "ya da", + "aboutDesc": "Invoke'u iş için mi kullanıyorsunuz? Şuna bir göz atın:", + "ai": "yapay zeka", + "auto": "Otomatik", + "communityLabel": "Topluluk", + "back": "Geri", + "areYouSure": "Emin misiniz?", + "notInstalled": "$t(common.installed) Değil", + "openInNewTab": "Yeni Sekmede Aç", + "aboutHeading": "Yaratıcı Gücünüzün Sahibi Olun", + "load": "Yükle", + "loading": "Yükleniyor", + "localSystem": "Yerel Sistem", + "inpaint": "içboyama", + "modelManager": "Model Yöneticisi", + "orderBy": "Sırala", + "outpaint": "dışboyama", + "outputs": "Çıktılar", + "learnMore": "Bilgi Edin", + "save": "Kaydet", + "random": "Rastgele", + "simple": "Basit", + "template": "Şablon", + "saveAs": "Farklı Kaydet", + "somethingWentWrong": "Bir sorun oluştu", + "statusDisconnected": "Bağlantı Kesildi", + "unknown": "Bilinmeyen", + "green": "Yeşil", + "red": "Kırmızı", + "blue": "Mavi", + "alpha": "Alfa", + "file": "Dosya", + "folder": "Klasör", + "format": "biçim", + "details": "Ayrıntılar", + "error": "Hata", + "imageFailedToLoad": "Görsel Yüklenemedi", + "safetensors": "Safetensors", + "upload": "Yükle", + "nextPage": "Sonraki Sayfa", + "prevPage": "Önceki Sayfa", + "dontAskMeAgain": "Bir daha sorma", + "delete": "Kaldır", + "direction": "Yön", + "unknownError": "Bilinmeyen Hata", + "installed": "Yüklü", + "data": "Veri", + "input": "Giriş", + "copy": "Kopyala", + "created": "Yaratma", + "updated": "Güncelleme", + "ipAdapter": "IP Aracı", + "t2iAdapter": "T2I Aracı", + "controlNet": "ControlNet" + }, + "accordions": { + "generation": { + "title": "Oluşturma" + }, + "image": { + "title": "Görsel" + }, + "advanced": { + "title": "Gelişmiş" + }, + "compositing": { + "title": "Birleştirme", + "coherenceTab": "Uyum Geçişi", + "infillTab": "Doldurma" + }, + "control": { + "title": "Yönetim" + } + }, + "boards": { + "autoAddBoard": "Panoya Otomatik Ekleme", + "cancel": "Vazgeç", + "clearSearch": "Aramayı Sil", + "deleteBoard": "Panoyu Sil", + "loading": "Yükleniyor...", + "myBoard": "Panom", + "selectBoard": "Bir Pano Seç", + "addBoard": "Pano Ekle", + "deleteBoardAndImages": "Panoyu ve Görselleri Sil", + "deleteBoardOnly": "Sadece Panoyu Sil", + "deletedBoardsCannotbeRestored": "Silinen panolar geri getirilemez", + "menuItemAutoAdd": "Bu panoya otomatik olarak ekle", + "move": "Taşı", + "movingImagesToBoard_one": "{{count}} görseli şu panoya taşı:", + "movingImagesToBoard_other": "{{count}} görseli şu panoya taşı:", + "noMatching": "Eşleşen pano yok", + "searchBoard": "Pano Ara...", + "topMessage": "Bu pano, şuralarda kullanılan görseller içeriyor:", + "downloadBoard": "Panoyu İndir", + "uncategorized": "Kategorisiz", + "changeBoard": "Panoyu Değiştir", + "bottomMessage": "Bu panoyu ve görselleri silmek, bunları kullanan özelliklerin resetlemesine neden olacaktır." + }, + "queue": { + "resumeSucceeded": "İşlem Sürdürüldü", + "openQueue": "Sırayı Göster", + "cancelSucceeded": "İş Geri Çekildi", + "cancelFailed": "İşi Geri Çekmede Sorun", + "prune": "Arındır", + "pruneTooltip": "{{item_count}} Bitmiş İşi Sil", + "resumeFailed": "İşlemi Sürdürmede Sorun", + "pauseFailed": "İşlemi Duraklatmada Sorun", + "cancelBatchSucceeded": "Toplu İşten Vazgeçildi", + "pruneSucceeded": "{{item_count}} Bitmiş İş Sıradan Silindi", + "in_progress": "İşleniyor", + "completed": "Bitti", + "canceled": "Vazgeçildi", + "back": "arka", + "queueFront": "Sıranın Başına Ekle", + "queueBack": "Sıraya Ekle", + "resumeTooltip": "İşlemi Sürdür", + "clearQueueAlertDialog2": "Sırayı boşaltmak istediğinizden emin misiniz?", + "batchQueuedDesc_one": "{{count}} iş sıranın {{direction}} eklendi", + "batchQueuedDesc_other": "{{count}} iş sıranın {{direction}} eklendi", + "batchFailedToQueue": "Toplu İş Sıraya Alınamadı", + "front": "ön", + "queue": "Sıra", + "resume": "Sürdür", + "queueEmpty": "Sıra Boş", + "clearQueueAlertDialog": "Sırayı boşaltma düğmesi geçerli işlemi durdurur ve sırayı boşaltır.", + "current": "Şimdiki", + "time": "Süre", + "pause": "Duraklat", + "pauseTooltip": "İşlemi Duraklat", + "pruneFailed": "Sırayı Arındırmada Sorun", + "clearTooltip": "Vazgeç ve Tüm İşleri Sil", + "clear": "Boşalt", + "cancelBatchFailed": "Toplu İşten Vazgeçmede Sorun", + "next": "Sonraki", + "status": "Durum", + "failed": "Başarısız", + "item": "İş", + "enqueueing": "Toplu İş Sıraya Alınıyor", + "pauseSucceeded": "İşlem Duraklatıldı", + "cancel": "Vazgeç", + "cancelTooltip": "Bu İşi Geri Çek", + "clearSucceeded": "Sıra Boşaltıldı", + "clearFailed": "Sırayı Boşaltmada Sorun", + "cancelBatch": "Toplu İşten Vazgeç", + "cancelItem": "İşi Geri Çek", + "total": "Toplam", + "pending": "Sırada", + "completedIn": "'de bitirildi", + "batch": "Toplu İş", + "session": "Oturum", + "batchQueued": "Toplu İş Sıraya Alındı", + "notReady": "Sıraya Alınamadı", + "batchFieldValues": "Toplu İş Değişkenleri", + "graphFailedToQueue": "Çizge sıraya alınamadı", + "graphQueued": "Çizge sıraya alındı" + }, + "invocationCache": { + "cacheSize": "Önbellek Boyutu", + "disable": "Kapat", + "clear": "Boşalt", + "maxCacheSize": "Maksimum Önbellek Boyutu", + "useCache": "Önbellek Kullan", + "enable": "Aç" + }, + "gallery": { + "deleteImagePermanent": "Silinen görseller geri getirilemez.", + "assets": "Özkaynaklar", + "autoAssignBoardOnClick": "Tıklanan Panoya Otomatik Atama", + "loading": "Yükleniyor", + "starImage": "Yıldız Koy", + "download": "İndir", + "deleteSelection": "Seçileni Sil", + "featuresWillReset": "Bu görseli silerseniz, o özellikler resetlenecektir.", + "noImageSelected": "Görsel Seçilmedi", + "unstarImage": "Yıldızı Kaldır", + "gallerySettings": "Galeri Düzeni", + "image": "görsel", + "galleryImageSize": "Görsel Boyutu", + "copy": "Kopyala", + "noImagesInGallery": "Gösterilecek Görsel Yok", + "autoSwitchNewImages": "Yeni Görseli Biter Bitmez Gör", + "currentlyInUse": "Bu görsel şurada kullanımda:", + "deleteImage_one": "Görseli Sil", + "deleteImage_other": "", + "unableToLoad": "Galeri Yüklenemedi", + "downloadSelection": "Seçileni İndir", + "dropOrUpload": "$t(gallery.drop) ya da Yükle", + "dropToUpload": "Yüklemek için $t(gallery.drop)", + "drop": "Bırak" + }, + "hrf": { + "hrf": "Yüksek Çözünürlük Kürü", + "enableHrf": "Yüksek Çözünürlük Kürünü Aç", + "metadata": { + "enabled": "Yüksek Çözünürlük Kürü Açık", + "strength": "Yüksek Çözünürlük Kürü Etkisi", + "method": "Yüksek Çözünürlük Kürü Yöntemi" + }, + "upscaleMethod": "Büyütme Yöntemi" + }, + "hotkeys": { + "noHotkeysFound": "Kısayol Tuşu Bulanamadı", + "searchHotkeys": "Kısayol Tuşlarında Ara", + "clearSearch": "Aramayı Sil" + }, + "nodes": { + "unableToValidateWorkflow": "İş Akışı Doğrulanamadı", + "workflowContact": "İletişim", + "loadWorkflow": "İş Akışı Yükle", + "workflowNotes": "Notlar", + "workflow": "İş Akışı", + "notesDescription": "İş akışınız hakkında not düşün", + "workflowTags": "Etiketler", + "workflowDescription": "Kısa Tanım", + "workflowValidation": "İş Akışı Doğrulama Sorunu", + "workflowVersion": "Sürüm", + "newWorkflow": "Yeni İş Akışı", + "currentImageDescription": "İşlemdeki görseli Çizge Düzenleyicide gösterir", + "workflowAuthor": "Yaratıcı", + "workflowName": "Ad", + "workflowSettings": "İş Akışı Düzenleyici Seçenekleri", + "currentImage": "İşlemdeki Görsel", + "noWorkflow": "İş Akışı Yok", + "newWorkflowDesc": "Yeni iş akışı?", + "downloadWorkflow": "İş Akışını İndir (JSON)", + "unknownErrorValidatingWorkflow": "İş akışını doğrulamada bilinmeyen bir sorun", + "unableToGetWorkflowVersion": "İş akışı sürümüne ulaşılamadı", + "newWorkflowDesc2": "Geçerli iş akışında kaydedilmemiş değişiklikler var.", + "unableToLoadWorkflow": "İş Akışı Yüklenemedi", + "cannotConnectInputToInput": "Giriş girişe bağlanamaz", + "zoomInNodes": "Yakınlaştır", + "boolean": "Boole Değeri", + "edge": "Uç", + "zoomOutNodes": "Uzaklaştır", + "cannotConnectOutputToOutput": "Çıkış çıkışa bağlanamaz", + "cannotConnectToSelf": "Kendisine bağlanamaz", + "cannotDuplicateConnection": "Kopya bağlantılar yaratılamaz" + }, + "workflows": { + "searchWorkflows": "İş Akışlarında Ara", + "workflowName": "İş Akışı Adı", + "problemSavingWorkflow": "İş Akışını Kaydetmede Sorun", + "saveWorkflow": "İş Akışını Kaydet", + "uploadWorkflow": "Dosyadan Yükle", + "newWorkflowCreated": "Yeni İş Akışı Yaratıldı", + "problemLoading": "İş Akışlarını Yüklemede Sorun", + "loading": "İş Akışları Yükleniyor", + "noDescription": "Tanımsız", + "clearWorkflowSearchFilter": "İş Akışı Aramasını Resetle", + "workflowEditorMenu": "İş Akışı Düzenleyici Menüsü", + "downloadWorkflow": "İndir", + "saveWorkflowAs": "İş Akışını Farklı Kaydet", + "savingWorkflow": "İş Akışı Kaydediliyor...", + "workflows": "İş Akışları", + "workflowLibrary": "Depo", + "deleteWorkflow": "İş Akışını Sil", + "unnamedWorkflow": "Adsız İş Akışı", + "noWorkflows": "İş Akışı Yok", + "workflowSaved": "İş Akışı Kaydedildi" + }, + "toast": { + "problemRetrievingWorkflow": "İş Akışını Getirmede Sorun", + "workflowDeleted": "İş Akışı Silindi", + "loadedWithWarnings": "İş Akışı Yüklendi Ancak Uyarılar Var", + "workflowLoaded": "İş Akışı Yüklendi", + "problemDeletingWorkflow": "İş Akışını Silmede Sorun" + }, + "parameters": { + "invoke": { + "noPrompts": "İstem oluşturulmadı", + "noModelSelected": "Model seçilmedi", + "systemDisconnected": "Sistem bağlantısı kesildi", + "invoke": "Invoke" + }, + "clipSkip": "CLIP Atlama", + "cfgScale": "CFG Ölçeği", + "controlNetControlMode": "Yönetim Kipi", + "general": "Genel", + "seamlessYAxis": "Dikişsiz Döşeme Y Ekseni", + "maskBlur": "Bulandırma", + "images": "Görseller", + "info": "Bilgi", + "positivePromptPlaceholder": "Olumlu İstem", + "scaledHeight": "Ölçekli Boy", + "lockAspectRatio": "En-Boy Oranını Koru", + "swapDimensions": "Çevir", + "setToOptimalSize": "Modele göre en uygun boyut", + "copyImage": "Görseli Kopyala", + "height": "Boy", + "width": "En", + "useSize": "Boyutu Kullan", + "symmetry": "Bakışım", + "tileSize": "Döşeme Boyutu", + "strength": "Güç", + "useAll": "Hepsini Kullan", + "denoisingStrength": "Arındırma Ölçüsü", + "imageFit": "Öngörseli Çıktı Boyutuna Sığdır", + "noiseThreshold": "Gürültü Eşiği", + "seed": "Tohum", + "imageActions": "Görsel İşlemleri", + "showOptionsPanel": "Yan Paneli Göster (O ya da T)", + "shuffle": "Kar", + "usePrompt": "İstemi Kullan", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (çok küçük olabilir)", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (çok büyük olabilir)", + "cfgRescaleMultiplier": "CFG Rescale Çarpanı", + "infillMethod": "Doldurma Yöntemi", + "steps": "Adım", + "upscaling": "Büyütülüyor", + "useSeed": "Tohumu Kullan", + "scheduler": "Planlayıcı", + "coherenceMode": "Kip", + "useCpuNoise": "CPU Gürültüsü Kullan", + "negativePromptPlaceholder": "Olumsuz İstem", + "patchmatchDownScaleSize": "Küçült", + "perlinNoise": "Perlin Gürültüsü", + "scaledWidth": "Ölçekli En", + "seamlessXAxis": "Dikişsiz Döşeme X Ekseni", + "downloadImage": "Görseli İndir", + "type": "Tür" + }, + "modelManager": { + "baseModel": "Ana Model", + "active": "etkin", + "deleteConfig": "Yapılandırmayı Sil", + "availableModels": "Kullanılabilir Modeller", + "advanced": "Gelişmiş", + "allModels": "Tüm Modeller", + "alpha": "Alfa", + "config": "Yapılandırma", + "addModel": "Model Ekle", + "height": "Boy", + "modelDeleted": "Model Kaldırıldı", + "vaePrecision": "VAE Kesinliği", + "convertToDiffusersHelpText6": "Bu modeli dönüştürmek istiyor musunuz?", + "deleteMsg1": "Bu modeli InvokeAI'dan silmek istediğinize emin misiniz?", + "settings": "Seçenekler", + "vae": "VAE", + "width": "En", + "delete": "Sil", + "convert": "Dönüştür", + "syncModels": "Modelleri Senkronize Et", + "variant": "Tür", + "convertingModelBegin": "Model Dönüştürülüyor. Lütfen bekleyiniz.", + "none": "hiçbiri", + "search": "Ara", + "model": "Model", + "modelType": "Model Türü", + "modelUpdated": "Model Güncellendi", + "modelUpdateFailed": "Model Güncellenemedi", + "name": "Ad", + "selected": "Seçili", + "convertToDiffusersHelpText5": "Lütfen yeterli depolama alanınız olduğundan emin olun. Modeller çoğunlukla 2-7 GB boyutundadır.", + "modelManager": "Model Yöneticisi", + "convertToDiffusersHelpText4": "Bu işlem yalnızca bir kez yapılır, bilgisayarınızın özelliklerine bağlı olarak yaklaşık 30-60 saniye sürebilir.", + "deleteModel": "Modeli Sil", + "deleteMsg2": "Model InvokeAI ana klasöründeyse bilgisayarınızdan silinir, bu klasör dışındaysa bilgisayarınızdan silinmeyecektir.", + "load": "Yükle", + "modelDeleteFailed": "Model kaldırılamadı", + "noModelSelected": "Model Seçilmedi", + "predictionType": "Saptama Türü", + "selectModel": "Model Seç", + "modelConversionFailed": "Model Dönüşümü Başarısız", + "modelConverted": "Model Dönüştürüldü", + "description": "Tanım" + }, + "models": { + "addLora": "LoRA Ekle", + "defaultVAE": "Varsayılan VAE", + "lora": "LoRA", + "noModelsAvailable": "Model yok", + "noMatchingLoRAs": "Uygun LoRA Yok", + "noMatchingModels": "Uygun Model Yok", + "loading": "yükleniyor", + "selectModel": "Model Seçin", + "noLoRAsInstalled": "LoRA Yok" + }, + "settings": { + "generation": "Oluşturma" + }, + "sdxl": { + "cfgScale": "CFG Ölçeği", + "loading": "Yükleniyor...", + "denoisingStrength": "Arındırma Ölçüsü", + "concatPromptStyle": "İstem ve Stili Bitiştir" + } +} diff --git a/invokeai/frontend/web/public/locales/uk.json b/invokeai/frontend/web/public/locales/uk.json new file mode 100644 index 0000000000000000000000000000000000000000..7a3b9ff8aa66efa5f7646eb22a0a2a0603fb3559 --- /dev/null +++ b/invokeai/frontend/web/public/locales/uk.json @@ -0,0 +1,119 @@ +{ + "common": { + "hotkeysLabel": "Гарячi клавіші", + "languagePickerLabel": "Мова", + "reportBugLabel": "Повідомити про помилку", + "settingsLabel": "Налаштування", + "img2img": "Зображення із зображення (img2img)", + "nodes": "Вузли", + "upload": "Завантажити", + "load": "Завантажити", + "statusDisconnected": "Відключено", + "cancel": "Скасувати", + "accept": "Підтвердити", + "back": "Назад", + "postprocessing": "Постобробка", + "loading": "Завантаження", + "githubLabel": "Github", + "txt2img": "Текст в зображення (txt2img)", + "discordLabel": "Discord", + "linear": "Лінійна обробка" + }, + "gallery": { + "galleryImageSize": "Розмір зображень", + "gallerySettings": "Налаштування галереї", + "autoSwitchNewImages": "Автоматично вибирати нові", + "noImagesInGallery": "Зображень немає" + }, + "modelManager": { + "modelManager": "Менеджер моделей", + "model": "Модель", + "modelUpdated": "Модель оновлена", + "manual": "Ручне", + "name": "Назва", + "description": "Опис", + "config": "Файл конфігурації", + "width": "Ширина", + "height": "Висота", + "addModel": "Додати модель", + "availableModels": "Доступні моделі", + "search": "Шукати", + "load": "Завантажити", + "active": "активна", + "selected": "Обрані", + "delete": "Видалити", + "deleteModel": "Видалити модель", + "deleteConfig": "Видалити конфігурацію", + "deleteMsg1": "Ви точно хочете видалити модель із InvokeAI?", + "deleteMsg2": "Це не призведе до видалення файлу моделі з диску. Позніше ви можете додати його знову.", + "allModels": "Усі моделі", + "convert": "Конвертувати", + "convertToDiffusers": "Конвертувати в Diffusers", + "convertToDiffusersHelpText3": "Файл моделі на диску НЕ буде видалено або змінено. Ви можете знову додати його в Model Manager, якщо потрібно.", + "alpha": "Альфа", + "repo_id": "ID репозиторію", + "convertToDiffusersHelpText5": "Переконайтеся, що у вас достатньо місця на диску. Моделі зазвичай займають від 4 до 7 Гб.", + "convertToDiffusersHelpText6": "Ви хочете перетворити цю модель?", + "modelConverted": "Модель перетворено", + "none": "пусто", + "convertToDiffusersHelpText4": "Це одноразова дія. Вона може зайняти від 30 до 60 секунд в залежності від характеристик вашого комп'ютера.", + "convertToDiffusersHelpText1": "Ця модель буде конвертована в формат 🧨 Diffusers.", + "convertToDiffusersHelpText2": "Цей процес замінить ваш запис в Model Manager на версію тієї ж моделі в Diffusers." + }, + "parameters": { + "images": "Зображення", + "steps": "Кроки", + "cfgScale": "Рівень CFG", + "width": "Ширина", + "height": "Висота", + "seed": "Сід", + "shuffle": "Оновити", + "noiseThreshold": "Поріг шуму", + "perlinNoise": "Шум Перліна", + "type": "Тип", + "strength": "Сила", + "upscaling": "Збільшення", + "scale": "Масштаб", + "imageFit": "Вмістити зображення", + "scaleBeforeProcessing": "Масштабувати", + "scaledWidth": "Масштаб Ш", + "scaledHeight": "Масштаб В", + "infillMethod": "Засіб заповнення", + "tileSize": "Розмір області", + "downloadImage": "Завантажити", + "usePrompt": "Використати запит", + "useSeed": "Використати сід", + "useAll": "Використати все", + "info": "Метадані", + "showOptionsPanel": "Показати панель налаштувань", + "general": "Основне", + "denoisingStrength": "Сила шумоподавлення", + "copyImage": "Копіювати зображення", + "symmetry": "Симетрія" + }, + "settings": { + "models": "Моделі", + "displayInProgress": "Показувати процес генерації", + "confirmOnDelete": "Підтверджувати видалення", + "resetWebUI": "Повернути початкові", + "resetWebUIDesc1": "Скидання настройок веб-інтерфейсу видаляє лише локальний кеш браузера з вашими зображеннями та налаштуваннями. Це не призводить до видалення зображень з диску.", + "resetWebUIDesc2": "Якщо зображення не відображаються в галереї або не працює ще щось, спробуйте скинути налаштування, перш ніж повідомляти про проблему на GitHub.", + "resetComplete": "Інтерфейс скинуто. Оновіть цю сторінку." + }, + "toast": { + "uploadFailed": "Не вдалося завантажити", + "imageCopied": "Зображення скопійоване", + "parametersNotSet": "Параметри не задані", + "serverError": "Помилка сервера", + "connected": "Підключено до сервера", + "canceled": "Обробку скасовано" + }, + "accessibility": { + "nextImage": "Наступне зображення", + "invokeProgressBar": "Індикатор виконання", + "reset": "Скинути", + "uploadImage": "Завантажити зображення", + "previousImage": "Попереднє зображення", + "menu": "Меню" + } +} diff --git a/invokeai/frontend/web/public/locales/vi.json b/invokeai/frontend/web/public/locales/vi.json new file mode 100644 index 0000000000000000000000000000000000000000..c4720a516e7d5418494b2add4090cd59bea4a2db --- /dev/null +++ b/invokeai/frontend/web/public/locales/vi.json @@ -0,0 +1,2141 @@ +{ + "accessibility": { + "uploadImages": "Tải Lên Hình Ảnh", + "previousImage": "Ảnh trước đó", + "about": "Giới Thiệu", + "nextImage": "Ảnh tiếp theo", + "reset": "Khởi Động Lại", + "toggleRightPanel": "Bật/Tắt Bảng Bên Phải (G)", + "toggleLeftPanel": "Bật/Tắt Bảng Bên Trái (T)", + "menu": "Menu", + "createIssue": "Mở Vấn Đề", + "resetUI": "$t(accessibility.reset) Giao Diện Người Dùng", + "mode": "Chế Độ", + "invokeProgressBar": "Thanh Tiến Trình", + "submitSupportTicket": "Gửi Phiếu Hỗ Trợ", + "uploadImage": "Tải Lên Hình Ảnh" + }, + "boards": { + "autoAddBoard": "Tự Động Thêm Bảng", + "addBoard": "Thêm Bảng", + "downloadBoard": "Tải Xuống Bảng", + "movingImagesToBoard_other": "Di chuyển {{count}} ảnh vào Bảng:", + "viewBoards": "Xem Bảng", + "hideBoards": "Ẩn Bảng", + "noBoards": "Không Có Bảng Thuộc Loại {{boardType}}", + "noMatching": "Không Có Bảng Tương Ứng", + "searchBoard": "Tìm Bảng...", + "addPrivateBoard": "Thêm Bảng Cá Nhân", + "addSharedBoard": "Thêm Bảng Nhóm", + "boards": "Bảng", + "selectedForAutoAdd": "Đã Chọn Để Tự động thêm", + "myBoard": "Bảng Của Tôi", + "deletedPrivateBoardsCannotbeRestored": "Bảng đã xoá sẽ không thể khôi phục lại. Chọn 'Chỉ Xoá Bảng' sẽ dời ảnh vào trạng thái chưa phân loại riêng cho chủ ảnh.", + "changeBoard": "Thay Đổi Bảng", + "clearSearch": "Làm Sạch Thanh Tìm Kiếm", + "updateBoardError": "Lỗi khi cập nhật Bảng", + "private": "Bảng Cá Nhân", + "shared": "Bảng Nhóm", + "imagesWithCount_other": "{{count}} hình ảnh", + "cancel": "Huỷ", + "deleteBoard": "Xoá Bảng", + "deleteBoardAndImages": "Xoá Bảng Lẫn Hình ảnh", + "deleteBoardOnly": "Chỉ Xoá Bảng", + "deletedBoardsCannotbeRestored": "Bảng đã xoá sẽ không thể khôi phục lại. Chọn 'Chỉ Xoá Bảng' sẽ dời ảnh vào trạng thái chưa phân loại.", + "bottomMessage": "Xoá bảng này lẫn ảnh của nó sẽ khởi động lại mọi tính năng đang sử dụng chúng.", + "menuItemAutoAdd": "Tự động thêm cho Bảng này", + "move": "Di Chuyển", + "topMessage": "Bảng này chứa ảnh được dùng với những tính năng sau:", + "uncategorized": "Chưa Phân Loại", + "archived": "Được Lưu Trữ", + "loading": "Đang Tải...", + "selectBoard": "Chọn Bảng", + "archiveBoard": "Lưu trữ Bảng", + "unarchiveBoard": "Ngừng Lưu Trữ Bảng", + "assetsWithCount_other": "{{count}} tài nguyên" + }, + "gallery": { + "swapImages": "Đổi Hình Ảnh", + "dropToUpload": "$t(gallery.drop) Để Tải Lên", + "deleteSelection": "Xoá Phần Được Lựa Chọn", + "hover": "Di Chuột", + "deleteImage_other": "Xoá {{count}} Hình Ảnh", + "compareImage": "So Sánh Ảnh", + "compareHelp4": "Nhấn Z hoặc Esc để thoát.", + "compareHelp3": "Nhấn C để đổi ảnh được so sánh.", + "compareHelp1": "Giữ Alt khi bấm vào ảnh trong thư viện hoặc dùng phím mũi tên để đổi ảnh dùng cho so sánh.", + "showArchivedBoards": "Hiển Thị Bảng Được Lưu Trữ", + "drop": "Thả", + "copy": "Sao Chép", + "selectAllOnPage": "Chọn Tất Cả Trên Trang", + "bulkDownloadFailed": "Tải Xuống Thất Bại", + "bulkDownloadRequestFailed": "Có Vấn Đề Khi Đang Chuẩn Bị Tải Xuống", + "download": "Tải Xuống", + "dropOrUpload": "$t(gallery.drop) Hoặc Tải Lên", + "currentlyInUse": "Hình ảnh này hiện đang sử dụng các tính năng sau:", + "deleteImagePermanent": "Ảnh đã xoá không thể phục hồi.", + "exitSearch": "Thoát Tìm Kiếm Hình Ảnh", + "exitBoardSearch": "Thoát Tìm Kiểm Bảng", + "gallery": "Thư Viện", + "galleryImageSize": "Kích Thước Ảnh", + "downloadSelection": "Tải xuống Phần Được Lựa Chọn", + "bulkDownloadRequested": "Chuẩn Bị Tải Xuống", + "unableToLoad": "Không Thể Tải Thư viện", + "newestFirst": "Mới Nhất Trước", + "showStarredImagesFirst": "Hiển Thị Ảnh Gắn Sao Trước", + "bulkDownloadRequestedDesc": "Yêu cầu tải xuống đang được chuẩn bị. Vui lòng chờ trong giây lát.", + "starImage": "Gắn Sao Cho Ảnh", + "openViewer": "Mở Trình Xem", + "assets": "Tài Nguyên", + "viewerImage": "Trình Xem Ảnh", + "sideBySide": "Cạnh Nhau", + "alwaysShowImageSizeBadge": "Luôn Hiển Thị Kích Thước Ảnh", + "autoAssignBoardOnClick": "Tự Động Gán Vào Bảng Khi Nhấp Chuột", + "jump": "Nhảy Vào", + "go": "Đi", + "autoSwitchNewImages": "Tự Động Đổi Sang Hình Ảnh Mới", + "featuresWillReset": "Nếu bạn xoá hình ảnh này, những tính năng đó sẽ lập tức được khởi động lại.", + "openInViewer": "Mở Trong Trình Xem", + "searchImages": "Tìm Theo Metadata", + "selectForCompare": "Chọn Để So Sánh", + "closeViewer": "Đóng Trình Xem", + "move": "Di Chuyển", + "displayBoardSearch": "Tìm Kiếm Bảng", + "displaySearch": "Tìm Kiếm Hình Ảnh", + "selectAnImageToCompare": "Chọn Ảnh Để So Sánh", + "slider": "Thanh Trượt", + "gallerySettings": "Cài Đặt Thư Viện", + "image": "hình ảnh", + "noImageSelected": "Không Có Ảnh Được Chọn", + "noImagesInGallery": "Không Có Ảnh Để Hiển Thị", + "assetsTab": "Tài liệu bạn đã tải lên để dùng cho dự án của mình.", + "imagesTab": "hình bạn vừa được tạo và lưu trong Invoke.", + "loading": "Đang Tải", + "oldestFirst": "Cũ Nhất Trước", + "exitCompare": "Ngừng So Sánh", + "stretchToFit": "Kéo Dài Cho Vừa Vặn", + "sortDirection": "Cách Sắp Xếp", + "unstarImage": "Ngừng Gắn Sao Cho Ảnh", + "compareHelp2": "Nhấn M để tuần hoàn trong chế độ so sánh.", + "boardsSettings": "Thiết Lập Bảng", + "imagesSettings": "Cài Đặt Thư Viện Ảnh" + }, + "common": { + "ipAdapter": "IP Adapter", + "positivePrompt": "Lệnh Tích Cực", + "negativePrompt": "Lệnh Tiêu Cực", + "editor": "Biên Tập Viên", + "loading": "Đang Tải", + "clipboard": "Clipboard", + "learnMore": "Tìm Hiểu Thêm", + "openInViewer": "Mở Trong Trình Xem", + "nextPage": "Trang Sau", + "alpha": "Alpha", + "edit": "Sửa", + "nodes": "Workflow", + "format": "định dạng", + "delete": "Xoá", + "details": "Chi Tiết", + "imageFailedToLoad": "Không Thể Tải Hình Ảnh", + "img2img": "Hình ảnh sang Hình ảnh", + "upload": "Tải Lên", + "somethingWentWrong": "Có vấn đề phát sinh", + "statusDisconnected": "Mất Kết Nối", + "t2iAdapter": "T2I Adapter", + "orderBy": "Sắp Xếp Theo", + "random": "Ngẫu Nhiên", + "settingsLabel": "Cài Đặt", + "reportBugLabel": "Báo Lỗi", + "controlNet": "ControlNet", + "apply": "Áp Dụng", + "view": "Xem", + "dontAskMeAgain": "Không hỏi lại", + "error": "Lỗi", + "or": "hoặc", + "installed": "Đã Tải Xuống", + "simple": "Cơ Bản", + "linear": "Tuyến Tính", + "safetensors": "Safetensors", + "off": "Tắt", + "add": "Thêm", + "load": "Tải", + "accept": "Đồng Ý", + "communityLabel": "Cộng Đồng", + "discordLabel": "Discord", + "back": "Trở Về", + "advanced": "Nâng Cao", + "batch": "Quản Lý Hàng Loạt", + "modelManager": "Quản Lý Model", + "dontShowMeThese": "Không hiển thị thứ này", + "ok": "OK", + "placeholderSelectAModel": "Chọn một model", + "reset": "Khởi Động Lại", + "none": "Không Có", + "on": "Bật", + "checkpoint": "Checkpoint", + "txt2img": "Từ Ngữ Sang Hình Ảnh", + "prevPage": "Trang Trước", + "unknown": "Không Rõ", + "githubLabel": "Github", + "folder": "Thư mục", + "goTo": "Đến", + "hotkeysLabel": "Phím Tắt", + "loadingImage": "Đang Tải Hình ảnh", + "localSystem": "Hệ Thống Máy Chủ", + "input": "Đầu Vào", + "languagePickerLabel": "Ngôn Ngữ", + "openInNewTab": "Mở Trong Tab Mới", + "outpaint": "outpaint", + "notInstalled": "Chưa $t(common.installed)", + "save": "Lưu", + "saveAs": "Lưu Như", + "auto": "Tự Động", + "inpaint": "inpaint", + "beta": "Beta", + "toResolve": "Để khắc phục", + "areYouSure": "Bạn chắc chứ?", + "ai": "ai", + "aboutDesc": "Sử dụng Invoke cho công việc? Xem thử:", + "aboutHeading": "Sở Hữu Khả Năng Sáng Tạo Cho Riêng Mình", + "enabled": "Đã Bật", + "close": "Đóng", + "data": "Dữ Liệu", + "file": "Tài liệu", + "outputs": "Đầu Ra", + "postprocessing": "Xử Lý Hậu Kỳ", + "template": "Mẫu Trình Bày", + "copy": "Sao Chép", + "copyError": "Lỗi Khi $t(gallery.copy)", + "updated": "Đã Cập Nhật", + "created": "Đã Tạo", + "red": "Đỏ", + "disabled": "Đã Tắt", + "new": "Mới", + "blue": "Lam", + "green": "Lục", + "cancel": "Huỷ", + "direction": "Phương Hướng", + "unknownError": "Lỗi Không Rõ", + "selected": "Đã chọn", + "tab": "Tab" + }, + "prompt": { + "addPromptTrigger": "Thêm Prompt Trigger", + "compatibleEmbeddings": "Embedding Tương Thích", + "noMatchingTriggers": "Không có trigger phù hợp" + }, + "queue": { + "resume": "Tiếp Tục", + "enqueueing": "Xếp Vào Hàng Hàng Loạt", + "prompts_other": "Lệnh", + "iterations_other": "Lặp Lại", + "total": "Tổng Cộng", + "pruneFailed": "Có Vấn Đề Khi Cắt Bớt Mục Khỏi Hàng", + "clearSucceeded": "Hàng Đã Được Dọn Sạch", + "cancel": "Huỷ Bỏ", + "clearQueueAlertDialog2": "Bạn chắc chắn muốn dọn sạch hàng không?", + "queueEmpty": "Hàng Trống", + "queueBack": "Thêm Vào Hàng", + "batchFieldValues": "Giá Trị Vùng Hàng Loạt", + "openQueue": "Mở Queue", + "pause": "Dừng Lại", + "pauseFailed": "Có Vấn Đề Khi Dừng Lại Bộ Xử Lý", + "batchQueued": "Hàng Loạt Đã Vào hàng", + "batchFailedToQueue": "Lỗi Khi Xếp Hàng Loạt Vào Hàng", + "next": "Tiếp Theo", + "in_progress": "Đang Tiến Hành", + "failed": "Thất Bại", + "canceled": "Bị Huỷ", + "cancelBatchFailed": "Có Vấn Đề Khi Huỷ Bỏ Hàng Loạt", + "workflows": "Workflow (Luồng làm việc)", + "canvas": "Canvas (Vùng ảnh)", + "upscaling": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)", + "generation": "Generation (Máy Tạo sinh)", + "back": "sau", + "pruneTooltip": "Cắt bớt {{item_count}} mục đã hoàn tất", + "pruneSucceeded": "Đã cắt bớt {{item_count}} mục đã hoàn tất khỏi hàng", + "clearTooltip": "Huỷ Và Dọn Dẹp Tất Cả Mục", + "clearQueueAlertDialog": "Dọn dẹp hàng đợi sẽ ngay lập tức huỷ tất cả mục đang xử lý và làm sạch hàng hoàn toàn. Bộ lọc đang chờ xử lý sẽ bị huỷ bỏ.", + "session": "Phiên", + "item": "Mục", + "resumeFailed": "Có Vấn Đề Khi Tiếp Tục Bộ Xử Lý", + "resumeSucceeded": "Bộ Xử Lý Đã Tiếp Tục", + "cancelTooltip": "Huỷ Bỏ Mục Hiện Tại", + "cancelFailed": "Có Vấn Đề Khi Huỷ Bỏ Mục Hiện Tại", + "prune": "Cắt Bớt", + "clear": "Dọn Dẹp", + "queue": "Queue (Hàng Đợi)", + "queueFront": "Thêm Vào Đầu Hàng", + "resumeTooltip": "Tiếp Tục Bộ Xử Lý", + "clearFailed": "Có Vấn Đề Khi Dọn Dẹp Hàng", + "generations_other": "Máy Tạo Sinh", + "cancelBatch": "Huỷ Bỏ Hàng Loạt", + "status": "Trạng Thái", + "pending": "Đang Chờ", + "gallery": "Thư Viện", + "front": "trước", + "batch": "Hàng Loạt", + "origin": "Nguồn Gốc", + "destination": "Điểm Đến", + "other": "Khác", + "graphFailedToQueue": "Lỗi Khi Xếp Đồ Thị Vào Hàng", + "notReady": "Không Thể Xếp Hàng", + "cancelItem": "Huỷ Bỏ Mục", + "cancelBatchSucceeded": "Mục Hàng Loạt Đã Huỷ Bỏ", + "current": "Hiện Tại", + "time": "Thời Gian", + "completed": "Hoàn Tất", + "pauseTooltip": "Dừng Lại Bộ Xử Lý", + "pauseSucceeded": "Bộ Xử Lý Đã Dừng Lại", + "cancelSucceeded": "Mục Đã Huỷ Bỏ", + "completedIn": "Hoàn tất trong", + "graphQueued": "Đồ Thị Đã Vào Hàng", + "batchQueuedDesc_other": "Thêm {{count}} phiên vào {{direction}} của hàng" + }, + "hotkeys": { + "canvas": { + "fitLayersToCanvas": { + "title": "Xếp Vừa Layers Vào Canvas", + "desc": "Căn chỉnh để góc nhìn vừa vặn với tất cả layer." + }, + "setZoomTo800Percent": { + "desc": "Phóng to canvas lên 800%.", + "title": "Phóng To Vào 800%" + }, + "setFillToWhite": { + "title": "Chỉnh Màu Sang Trắng", + "desc": "Chỉnh màu hiện tại sang màu trắng." + }, + "transformSelected": { + "title": "Biến Đổi", + "desc": "Biến đổi layer được chọn." + }, + "fitBboxToCanvas": { + "title": "Xếp Vừa Hộp Giới Hạn Vào Canvas", + "desc": "Căn chỉnh để góc nhìn vừa vặn với hộp giới hạn." + }, + "setZoomTo400Percent": { + "desc": "Phóng to canvas lên 400%.", + "title": "Phóng To Vào 400%" + }, + "decrementToolWidth": { + "desc": "Giảm độ rộng của cọ hoặc tẩy, tuỳ theo cái được chọn.", + "title": "Giảm Độ Rộng" + }, + "setZoomTo100Percent": { + "desc": "Phóng to canvas lên 100%.", + "title": "Phóng To Vào 100%" + }, + "setZoomTo200Percent": { + "title": "Phóng To Vào 200%", + "desc": "Phóng to canvas lên 200%." + }, + "prevEntity": { + "desc": "Chọn layer trước đó trong danh sách.", + "title": "Layer Trước Đó" + }, + "redo": { + "title": "Làm Lại", + "desc": "Khôi phục hành động cuối cùng lên canvas sau khi bị hoàn tác." + }, + "nextEntity": { + "title": "Layer Tiếp Theo", + "desc": "Chọn layer tiếp theo trong danh sách." + }, + "selectBrushTool": { + "title": "Cọ", + "desc": "Dùng cọ." + }, + "selectBboxTool": { + "desc": "Dùng hộp giới hạn.", + "title": "Hộp Giới Hạn" + }, + "incrementToolWidth": { + "title": "Tăng Độ Rộng", + "desc": "Tăng độ rộng của cọ hoặc tẩy, tuỳ theo cái được chọn." + }, + "selectEraserTool": { + "title": "Tẩy", + "desc": "Dùng tẩy." + }, + "title": "Canvas (Vùng Ảnh)", + "selectColorPickerTool": { + "title": "Chọn Màu", + "desc": "Dùng công cụ chọn màu." + }, + "selectViewTool": { + "title": "Xem", + "desc": "Dùng công cụ xem." + }, + "selectRectTool": { + "desc": "Dùng công cụ vẽ hình chữ nhật.", + "title": "Hình Chữ Nhật" + }, + "selectMoveTool": { + "title": "Di Chuyển", + "desc": "Dùng công cụ di chuyển." + }, + "deleteSelected": { + "desc": "Xoá layer được chọn.", + "title": "Xoá Layer" + }, + "quickSwitch": { + "title": "Đổi Layer Nhanh", + "desc": "Đổi giữa hai layer cuối cùng được chọn. Nếu một layer bị đánh dấu, luôn luôn đổi giữa nó với layer bị đánh dấu cuối cùng." + }, + "undo": { + "title": "Hoàn Tác", + "desc": "Hoàn tác hành động cuối cùng lên canvas." + }, + "applyTransform": { + "desc": "Áp dụng lệnh biến đổi đang chờ sẵn cho layer được chọn.", + "title": "Áp Dụng Lệnh Biến Đổi" + }, + "cancelFilter": { + "title": "Huỷ Bộ Lọc", + "desc": "Huỷ bộ lọc đang chờ sẵn." + }, + "cancelTransform": { + "title": "Huỷ Lệnh Biến Đổi", + "desc": "Huỷ lệnh biến đổi đang chờ sẵn cho layer được chọn." + }, + "resetSelected": { + "title": "Làm Mới Layer", + "desc": "Làm mới lại layer được chọn. Chỉ áp dụng cho Lớp Phủ Inpaint và Chỉ Dẫn Khu Vực." + }, + "filterSelected": { + "title": "Bộ Lọc", + "desc": "Lọc layer được lựa chọn. Chỉ áp dụng cho layer dạng Raster và layer điều khiển được." + }, + "applyFilter": { + "title": "Áp Dụng Bộ Lộc", + "desc": "Áp dụng bộ lọc đang chờ sẵn cho layer được chọn." + } + }, + "workflows": { + "title": "Workflow (Luồng Làm Việc)", + "pasteSelection": { + "desc": "Dán node và kết nối đã chọn.", + "title": "Dán" + }, + "pasteSelectionWithEdges": { + "title": "Dán Với Các Kết Nối", + "desc": "Dán tất cả node, kết nối và toàn bộ kết nối liên kết với node được sao chép." + }, + "copySelection": { + "title": "Sao Chép", + "desc": "Sao chép node và kết nối đã chọn." + }, + "deleteSelection": { + "title": "Xoá", + "desc": "Xoá node và kết nối." + }, + "redo": { + "title": "Làm Lại", + "desc": "Khôi phục hành động cuối cùng lên workflow được hoàn tác." + }, + "addNode": { + "desc": "Mở menu thêm node.", + "title": "Thêm Node" + }, + "selectAll": { + "title": "Chọn Tất Cả", + "desc": "Chọn tất cả node và kết nối." + }, + "undo": { + "desc": "Hoàn tác hành động cuối cùng lên workflow.", + "title": "Hoàn Tác" + } + }, + "viewer": { + "recallAll": { + "desc": "Gợi lại tất cả metadata của ảnh hiện tại.", + "title": "Gợi Lại Tất Cả Metadata" + }, + "recallSeed": { + "title": "Gợi Lại Tham Số Hạt Giống", + "desc": "Gợi lại tham số hạt giống của ảnh hiện tại." + }, + "useSize": { + "title": "Dùng Kích Thước", + "desc": "Dùng kích thước của ảnh hiện tại cho kích thước của hộp giới hạn." + }, + "toggleMetadata": { + "desc": "Hiển thị hoặc ẩn lớp phủ từ metadata của ảnh hiện tại.", + "title": "Hiển Thị/Ẩn Metadata" + }, + "title": "Trình Xem Ảnh", + "toggleViewer": { + "title": "Hiển Thị/Ẩn Trình Xem Ảnh", + "desc": "Hiển thị hoặc ẩn trình xem ảnh. Chỉ có trên tab Canvas." + }, + "recallPrompts": { + "title": "Gợi Lại Lệnh", + "desc": "Gợi lại lệnh tích cực lẫn tiêu cực của ảnh hiện tại." + }, + "loadWorkflow": { + "title": "Tải Từ Workflow", + "desc": "Tải hình ảnh hiện tại được lưu trong workflow (nếu có)." + }, + "nextComparisonMode": { + "title": "Chế Độ So Sánh Kế Tiếp", + "desc": "Tuần hoàn trong chế độ so sánh." + }, + "swapImages": { + "desc": "Đổi ảnh được so sánh.", + "title": "Đổi Ảnh So Sánh" + }, + "remix": { + "desc": "Gợi lại tất cả metadata cho tham số hạt giống của ảnh hiện tại.", + "title": "Phối Lại" + }, + "runPostprocessing": { + "title": "Chạy Trình Xử Lý Hậu Kỳ", + "desc": "Chạy trình xử lý hậu kỳ được chọn cho anh hiện tại." + } + }, + "gallery": { + "galleryNavRight": { + "desc": "Sang phải theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến cuối hàng, qua hàng tiếp theo. Nếu đến hình ảnh cuối cùng, qua trang tiếp theo.", + "title": "Sang Phải" + }, + "galleryNavDown": { + "title": "Đi Xuống", + "desc": "Đi xuống theo mạng lưới thư viện, chọn hình ảnh đó. Nếu xuống cuối cùng trang, sang trang tiếp theo." + }, + "galleryNavLeft": { + "title": "Sang Trái", + "desc": "Sang trái theo mạng lưới thư viện, chọn hình ảnh đó. Nếu đến đầu hàng, về lại hàng trước đó. Nếu đến hình ảnh đầu tiên, về lại trang trước đó." + }, + "galleryNavUpAlt": { + "title": "Đi Lên (So Sánh Ảnh)", + "desc": "Giống với \"Đi Lên\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở." + }, + "deleteSelection": { + "desc": "Xoá ảnh được chọn. Theo mặc định, bạn sẽ được nhắc để chấp nhận thực hiện xoá. Nếu ảnh đang được dùng trong ứng dụng, bạn sẽ được cảnh báo.", + "title": "Xoá" + }, + "galleryNavUp": { + "title": "Đi Lên", + "desc": "Đi lên theo mạng lưới thư viện, chọn hình ảnh đó. Nếu lên trên cùng trang, về lại trang trước đó." + }, + "galleryNavRightAlt": { + "title": "Sang Phải (So Sánh Ảnh)", + "desc": "Giống với \"Sang Phải\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở." + }, + "selectAllOnPage": { + "title": "Chọn Tất Cả Trên Trang", + "desc": "Chọn tất cả ảnh trên trang hiện tại." + }, + "title": "Thư Viện", + "galleryNavDownAlt": { + "title": "Đi Xuống (So Sánh Ảnh)", + "desc": "Giống với \"Đi Xuống\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở." + }, + "galleryNavLeftAlt": { + "desc": "Giống với \"Sang Trái\", nhưng là chọn ảnh được so sánh, mở chế độ so sánh nếu chưa được mở.", + "title": "Sang Trái (So Sánh Ảnh)" + }, + "clearSelection": { + "desc": "Xoá phần lựa chọn hiện tại nếu có.", + "title": "Xoá Phần Lựa Chọn" + } + }, + "app": { + "togglePanels": { + "title": "Bật/Tắt Bảng", + "desc": "Hiển thị hoặc ẩn phần bảng bên trái và phải cùng lúc." + }, + "focusPrompt": { + "desc": "Đưa con trỏ chuột vào vùng lệnh tích cực.", + "title": "Chuyển Tập Trung Vào Lệnh" + }, + "toggleLeftPanel": { + "desc": "Hiển thị hoặc ẩn phần bảng bên trái.", + "title": "Bật/Tắt Bảng Bên Trái" + }, + "toggleRightPanel": { + "desc": "Hiển thị hoặc ẩn phần bảng bên phải.", + "title": "Bật/Tắt Bảng Bên Phải" + }, + "resetPanelLayout": { + "title": "Khởi Động Lại Cách Trình Bày Của Bảng", + "desc": "Khởi động lại phần bảng bên trái và phải vào kích thước và cách trình bày ban đầu." + }, + "selectQueueTab": { + "desc": "Chọn tab Queue (Hàng Đợi).", + "title": "Chọn Tab Queue" + }, + "invoke": { + "desc": "Xếp một đợt tạo sinh vào cuối hàng.", + "title": "Kích Hoạt" + }, + "invokeFront": { + "desc": "Xếp một đợt tạo sinh vào đầu hàng.", + "title": "Kích Hoạt (Đằng Trước)" + }, + "cancelQueueItem": { + "desc": "Huỷ bỏ mục hiện đang xếp hàng xử lý.", + "title": "Huỷ Bỏ" + }, + "clearQueue": { + "desc": "Huỷ và dọn sạch các mục đang xếp hàng.", + "title": "Dọn Sạch Hàng Đợi" + }, + "selectCanvasTab": { + "desc": "Chọn tab Canvas (Vùng ảnh).", + "title": "Chọn Tab Canvas" + }, + "title": "Ứng Dụng", + "selectUpscalingTab": { + "title": "Chọn Tab Upscale", + "desc": "Chọn tab Upscale (Nâng cấp chất lượng Hình ảnh)." + }, + "selectWorkflowsTab": { + "title": "Chọn Tab Workflow", + "desc": "Chọn tab Workflow (Luồng làm việc)." + }, + "selectModelsTab": { + "desc": "Chọn tab Model (Mô Hình).", + "title": "Chọn Tab Model" + } + }, + "searchHotkeys": "Tìm Phím tắt", + "noHotkeysFound": "Không Tìm Thấy Phím Tắt", + "clearSearch": "Làm Sạch Thanh Tìm Kiếm", + "hotkeys": "Phím Tắt" + }, + "modelManager": { + "modelConverted": "Model Đã Được Chuyển Đổi", + "model": "Model", + "convertingModelBegin": "Đang chuyển đổi Model. Chờ chút.", + "hfForbidden": "Bạn không có quyền truy cập vào model HF này", + "convertToDiffusersHelpText3": "Checkpoint của bạn trên ổ đĩa SẼ bị xoá nên nó nằm trong thư mục gốc của InvokeAI. Nếu nó ở vị trí tuỳ chỉnh thì SẼ KHÔNG bị xoá.", + "modelDeleted": "Model Đã Được Xoá", + "alpha": "Alpha", + "convertToDiffusersHelpText5": "Hãy chắc chắn bạn có đủ chỗ trống trong ổ đĩa. Model thường ngốn khoảng 2-7GB.", + "convertToDiffusersHelpText6": "Bạc có chắc muốn chuyển đổi model này?", + "installAll": "Tải Xuống Toàn Bộ", + "advanced": "Nâng Cao", + "convertToDiffusers": "Đổi Sang Diffusers", + "convertToDiffusersHelpText1": "Model này sẽ được đổi sang định dạng 🧨 Diffusers.", + "modelSettings": "Thiết Lập Model", + "metadata": "Metadata", + "noDefaultSettings": "Không có thiết lập cấu hình mặc định cho model này. Hãy vào Trình Quản Lý Model để thêm thiết lập mặc định.", + "restoreDefaultSettings": "Nhấp vào để xem thiết lập mặc định của model.", + "defaultSettingsOutOfSync": "Một vài thiết lập không khớp với mặc định của model:", + "usingDefaultSettings": "Dùng thiết lập mặc định của model", + "deleteMsg1": "Bạn có chắc muốn xoá model này khỏi InvokeAI?", + "modelManager": "Quản Lý Model", + "name": "Tên", + "noModelSelected": "Không Có Model Được Chọn", + "installQueue": "Tải Xuống Danh Sách Đợi", + "modelDeleteFailed": "Xoá model thất bại", + "inplaceInstallDesc": "Tải xuống model mà không sao chép toàn bộ tài nguyên. Khi sử dụng model, nó được sẽ tải từ vị trí được đặt. Nếu bị tắt, toàn bộ tài nguyên của model sẽ được sao chép vào thư mục quản lý model của Invoke trong quá trình tải xuống.", + "modelType": "Loại Model", + "install": "Tải Xuống", + "active": "khởi động", + "addModel": "Thêm Model", + "addModels": "Thêm Model", + "allModels": "Tất Cả Model", + "clipEmbed": "CLIP Embed", + "defaultSettings": "Thiết Lập Mặc Định", + "convertToDiffusersHelpText2": "Quá trình này sẽ thay thế đầu vào của Trình Quản Lý Model bằng phiên bản Diffusers của model đó.", + "defaultSettingsSaved": "Đã Lưu Thiết Lập Mặc Định", + "description": "Dòng Mô Tả", + "imageEncoderModelId": "ID Model Image Encoder", + "hfForbiddenErrorMessage": "Chúng tôi gợi ý vào trang repository trên HuggingFace.com. Chủ sở hữu có thể yêu cầu chấp nhận điều khoản để tải xuống.", + "hfTokenSaved": "Đã Lưu HF Token", + "learnMoreAboutSupportedModels": "Tìm hiểu thêm về những model được hỗ trợ", + "availableModels": "Model Có Sẵn", + "load": "Tải", + "cancel": "Huỷ", + "huggingFace": "HuggingFace (HF)", + "huggingFacePlaceholder": "chủ-sỡ-hữu/tên-model", + "includesNModels": "Thêm vào {{n}} model và dependency của nó", + "localOnly": "chỉ ở trên máy chủ", + "manual": "Thủ Công", + "convertToDiffusersHelpText4": "Đây là quá trình diễn ra chỉ một lần. Nó có thể tốn tầm 30-60 giây tuỳ theo thông số kỹ thuật của máy tính.", + "edit": "Sửa", + "huggingFaceRepoID": "ID HuggingFace Repository", + "huggingFaceHelper": "Nếu nhiều model được tìm thấy trong repository này, bạn sẽ được nhắc để chọn một trong số chúng để tải.", + "modelImageDeleted": "Model Ảnh Đã Xoá", + "delete": "Xoá", + "deleteConfig": "Xoá Cấu Hình", + "modelUpdateFailed": "Cập Nhật Model Thất Bại", + "deleteMsg2": "Model trên ổ đĩa SẼ bị xoá nên nó nằm trong thư mục gốc của InvokeAI. Nếu bạn dùng ở vị trí tuỳ chỉnh thì SẼ KHÔNG bị xoá.", + "deleteModel": "Xoá Model", + "modelImageDeleteFailed": "Xoá Model Ảnh Thất Bại", + "height": "Chiều Dài", + "deleteModelImage": "Xoá Model Ảnh", + "none": "trống", + "modelImageUpdated": "Model Ảnh Đã Được Cập Nhật", + "modelImageUpdateFailed": "Cập Nhật Model Ảnh Thất Bại", + "path": "Đường Dẫn", + "noModelsInstalledDesc1": "Tải xuống model với", + "noModelsInstalled": "Chưa Tải Model", + "config": "Cấu Hình", + "convert": "Chuyển Đổi", + "baseModel": "Model Cơ Sở", + "hfTokenLabel": "HuggingFace Token (Bắt buộc cho một vài model)", + "hfTokenHelperText": "HF Token là cần thiết để sử dụng một số model. Nhấp vào đây để tạo hoặc lấy token của bạn.", + "hfTokenInvalid": "HF Token Không Hợp Lệ Hoặc Bị Thiếu", + "hfTokenInvalidErrorMessage": "HuggingFace token không hợp lệ hoặc bị thiếu.", + "hfTokenRequired": "Bạn đang tải xuống model yêu cầu HuggingFace Token hợp lệ.", + "hfTokenInvalidErrorMessage2": "Cập nhật vào ", + "hfTokenUnableToVerify": "Không Thể Xác Minh HF Token", + "hfTokenUnableToVerifyErrorMessage": "Không thể xác minh HuggingFace token. Khả năng cao lỗi mạng. Vui lòng thử lại sau.", + "inplaceInstall": "Tải Xuống Tại Chỗ", + "installRepo": "Tải Xuống Kho Lưu Trữ (Repository)", + "ipAdapters": "IP Adapters", + "loraModels": "LoRA", + "main": "Chính", + "modelConversionFailed": "Chuyển Đổi Model Thất Bại", + "modelName": "Tên Model", + "modelUpdated": "Model Đã Được Cập Nhật", + "noMatchingModels": "Không Có Model Phù Hợp", + "predictionType": "Loại Prediction", + "repoVariant": "Phiên Bản Repository", + "simpleModelPlaceholder": "Url hoặc đường đẫn đến tệp hoặc thư mục chứa diffusers trong máy chủ", + "selectModel": "Chọn Model", + "spandrelImageToImage": "Hình Ảnh Sang Hình Ảnh (Spandrel)", + "starterBundles": "Quà Tân Thủ", + "vae": "VAE", + "urlOrLocalPath": "URL Hoặc Đường Dẫn Trên Máy Chủ", + "triggerPhrases": "Từ Ngữ Kích Hoạt", + "variant": "Biến Thể", + "urlOrLocalPathHelper": "Url cần chỉ vào một tệp duy nhất. Còn đường dẫn trên máy chủ có thể chỉ vào một tệp hoặc một thư mục cho chỉ một model diffusers.", + "prune": "Cắt Bớt", + "uploadImage": "Tải Lên Hình Ảnh", + "syncModels": "Liên Kết Model", + "pruneTooltip": "Cắt bớt những thành phần đã hoàn tất trong hàng", + "scanPlaceholder": "Dường đẫn đến thư mục trong máy chủ", + "pathToConfig": "Đường Dẫn Đến Tệp Cấu Hình", + "search": "Tìm Kiếm", + "selected": "Đã Chọn", + "settings": "Cài Đặt", + "source": "Nguồn", + "starterBundleHelpText": "Tải toàn bộ những model cần thiết để bắt đầu với một model cơ sở, bao gồm model chính, controlnet, IP adapter, v.v... Chọn nguyên một bộ sẽ bỏ qua những model khác bạn đã tải.", + "starterModels": "Model Khởi Đầu", + "typePhraseHere": "Thêm từ ngữ ở đây", + "upcastAttention": "Upcast Attention", + "vaePrecision": "VAE Precision", + "installingBundle": "Đang Tải Nguyên Bộ", + "installingModel": "Đang Tải Model", + "installingXModels_other": "Đang tải {{count}} model", + "skippingXDuplicates_other": ", bỏ qua {{count}} thành phần bị lặp lại", + "repo_id": "ID Repository", + "scanFolder": "Quét Thư Mục", + "scanFolderHelper": "Thư mục sẽ được quét để tìm model. Có thể sẽ mất nhiều thời gian với những thư mục lớn.", + "scanResults": "Kết Quả Quét", + "t5Encoder": "T5 Encoder", + "mainModelTriggerPhrases": "Từ Ngữ Kích Hoạt Cho Model Chính", + "textualInversions": "Bộ Đảo Ngược Văn Bản", + "loraTriggerPhrases": "Từ Ngữ Kích Hoạt Cho LoRA", + "width": "Chiều Rộng", + "starterModelsInModelManager": "Model khởi đầu có thể tìm thấy ở Trình Quản Lý Model" + }, + "metadata": { + "guidance": "Hướng Dẫn", + "noRecallParameters": "Không tìm thấy tham số", + "imageDetails": "Chi Tiết Ảnh", + "createdBy": "Được Tạo Bởi", + "parsingFailed": "Lỗi Cú Pháp", + "canvasV2Metadata": "Canvas", + "parameterSet": "Dữ liệu tham số {{parameter}}", + "positivePrompt": "Lệnh Tích Cực", + "recallParameter": "Gợi Nhớ {{label}}", + "seed": "Tham Số Hạt Giống", + "negativePrompt": "Lệnh Tiêu Cực", + "noImageDetails": "Không tìm thấy chí tiết ảnh", + "strength": "Mức độ mạnh từ ảnh sang ảnh", + "Threshold": "Ngưỡng Nhiễu", + "width": "Chiều Rộng", + "steps": "Tham Số Bước", + "vae": "VAE", + "workflow": "Workflow", + "seamlessXAxis": "Trục X Liền Mạch", + "seamlessYAxis": "Trục Y Liền Mạch", + "cfgScale": "Thước Đo CFG", + "allPrompts": "Tất Cả Lệnh", + "generationMode": "Chế Độ Tạo Sinh", + "height": "Chiều Dài", + "metadata": "Metadata", + "model": "Model", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "recallParameters": "Gợi Nhớ Tham Số", + "scheduler": "Scheduler", + "noMetaData": "Không tìm thấy metadata", + "imageDimensions": "Kích Thước Ảnh" + }, + "accordions": { + "generation": { + "title": "Generation (Máy Tạo Sinh)" + }, + "image": { + "title": "Hình Ảnh" + }, + "advanced": { + "title": "Nâng Cao", + "options": "Lựa Chọn $t(accordions.advanced.title)" + }, + "compositing": { + "coherenceTab": "Coherence Pass (Lớp Kết Hợp)", + "title": "Kết Hợp", + "infillTab": "Infill (Lấp Đầy)" + }, + "control": { + "title": "Điều Khiển" + } + }, + "invocationCache": { + "disableSucceeded": "Bộ Nhớ Đệm Kích Hoạt Đã Tắt", + "disableFailed": "Có Vấn Đề Khi Tắt Bộ Nhớ Đệm Kích Hoạt", + "hits": "Truy Cập Bộ Nhớ Đệm", + "maxCacheSize": "Kích Thước Tối Đa Bộ Nhớ Đệm", + "cacheSize": "Kích Thước Bộ Nhớ Đệm", + "enableFailed": "Có Vấn Đề Khi Bật Bộ Nhớ Đệm Kích Hoạt", + "disable": "Tắt", + "invocationCache": "Bộ Nhớ Đệm Kích Hoạt", + "clearSucceeded": "Bộ Nhớ Đệm Kích Hoạt Đã Được Dọn", + "enableSucceeded": "Bộ Nhớ Đệm Kích Hoạt Đã Bật", + "useCache": "Dùng Bộ Nhớ Đệm", + "enable": "Bật", + "misses": "Không Truy Cập Bộ Nhớ Đệm", + "clear": "Dọn Dẹp", + "clearFailed": "Có Vấn Đề Khi Dọn Dẹp Bộ Nhớ Đệm Kích Hoạt" + }, + "hrf": { + "metadata": { + "enabled": "Đã Bật Sửa Độ Phân Giải Cao", + "strength": "Mức Độ Mạnh Của Sửa Độ Phân Giải Cao", + "method": "Cách Thức Sửa Độ Phân Giải Cao" + }, + "hrf": "Sửa Độ Phân Giải Cao", + "enableHrf": "Cho Phép Sửa Độ Phân Giải Cao", + "upscaleMethod": "Cách Thức Upscale" + }, + "nodes": { + "validateConnectionsHelp": "Ngăn chặn những kết nối không hợp lý được tạo ra, và đồ thị không hợp lệ bị kích hoạt", + "nodeOpacity": "Độ Mờ Đục Của Node", + "nodeVersion": "Phiên Bản Của Node", + "clearWorkflowDesc": "Dọn workflow này và bắt đầu cái mới?", + "enum": "Dữ Liệu Cố Định", + "newWorkflow": "Workflow Mới", + "integer": "Số Nguyên", + "workflowHelpText": "Cần hỗ trợ? Xem hướng dẫn ở Làm Quen Với Workflow.", + "scheduler": "Scheduler", + "snapToGridHelp": "Gắn các node vào lưới khi di chuyển", + "showMinimapnodes": "HIển Thị Bản Đồ Thu Nhỏ", + "newWorkflowDesc2": "Workflow hiện tại của bạn vẫn chưa lưu các thay đổi.", + "unableToValidateWorkflow": "Không Thể Xác Thực Workflow", + "inputFieldTypeParseError": "Không thể phân tích loại dữ liệu đầu vào của {{node}}.{{field}} ({{message}})", + "boolean": "Đúng/Sai", + "missingInvocationTemplate": "Thiếu mẫu trình bày kích hoạt", + "nodeOutputs": "Đầu Ra Của Node", + "unableToUpdateNodes_other": "Không thể cập nhật {{count}} node", + "notesDescription": "Thêm ghi chú vào workflow", + "noConnectionInProgress": "Không có kết nối nào đang diễn ra", + "float": "Số Thực", + "missingNode": "Thiếu node kích hoạt", + "currentImage": "Hình Ảnh Hiện Tại", + "removeLinearView": "Xoá Khỏi Chế Độ Xem Tuyến Tính", + "unknownErrorValidatingWorkflow": "Lỗi không rõ khi xác thực workflow", + "unableToLoadWorkflow": "Không Thể Tải Workflow", + "workflowSettings": "Cài Đặt Trình Biên Tập Viên Workflow", + "workflowVersion": "Phiên Bản", + "unableToGetWorkflowVersion": "Không thể tìm phiên bản của lược đồ workflow", + "collection": "Đa tài nguyên", + "cannotMixAndMatchCollectionItemTypes": "Không thể trộn và kết nối với loại đa tài nguyên", + "colorCodeEdges": "Mã Màu Kết Nối", + "ipAdapter": "IP Adapter", + "cannotDuplicateConnection": "Không thể tạo hai kết nối trùng lặp", + "workflowValidation": "Lỗi Xác Thực Workflow", + "mismatchedVersion": "Node không hợp lệ: node {{node}} thuộc loại {{type}} có phiên bản không khớp (thử cập nhật?)", + "sourceNodeFieldDoesNotExist": "Kết nối không phù hợp: nguồn/đầu ra của vùng {{node}}.{{field}} không tồn tại", + "targetNodeFieldDoesNotExist": "Kết nối không phù hợp: đích đến/đầu vào của vùng {{node}}.{{field}} không tồn tại", + "missingTemplate": "Node không hợp lệ: node {{node}} thuộc loại {{type}} bị thiếu mẫu trình bày (chưa tải?)", + "unsupportedMismatchedUnion": "Dạng số lượng dữ liệu không khớp với {{firstType}} và {{secondType}}", + "unknownOutput": "Đầu Ra Không Rõ: {{name}}", + "betaDesc": "Trình kích hoạt này vẫn trong giai đoạn beta. Cho đến khi ổn định, nó có thể phá hỏng thay đổi trong khi cập nhật ứng dụng. Chúng tôi dự định hỗ trợ trình kích hoạt này về lâu dài.", + "cannotConnectInputToInput": "Không thế kết nối đầu vào với đầu vào", + "showEdgeLabelsHelp": "Hiển thị tên trên kết nối, chỉ ra những node được kết nối", + "unsupportedArrayItemType": "loại mảng không được hỗ trợ: \"{{type}}\"", + "boardAccessError": "Không thể tìm thấy bảng {{board_id}}, chuyển về mặc định", + "collectionOrScalarFieldType": "{{name}} (Đơn/Đa)", + "edge": "Kết Nối", + "graph": "Đồ Thị", + "workflowAuthor": "Tác Giả", + "addLinearView": "Thêm Vào Chế Độ Xem Tuyến Tính", + "showEdgeLabels": "Hiển Thị Tên Kết Nối", + "unknownField": "Vùng Dữ Liệu Không Rõ", + "executionStateCompleted": "Đã Hoàn Tất", + "loadingNodes": "Đang Tải Node...", + "singleFieldType": "{{name}} (Đơn)", + "clearWorkflowDesc2": "Workflow hiện tại của bạn vẫn chưa lưu các thay đổi.", + "clearWorkflow": "Dọn Dẹp Workflow", + "unableToParseFieldType": "không thể phân tích vùng dữ liệu", + "allNodesUpdated": "Cập Nhật Tất Cả Node", + "noGraph": "Không Có Đồ Thị", + "collectionFieldType": "{{name}} (Đa)", + "noOutputRecorded": "Chưa có đầu ra được ghi nhận", + "noNodeSelected": "Không có node được chọn", + "snapToGrid": "Gắn Vào Lưới", + "unknownFieldType": "Loại $t(nodes.unknownField): {{type}}", + "zoomOutNodes": "Phóng Nhỏ", + "deletedInvalidEdge": "Xoá kết nối không hợp lệ {{source}} -> {{target}}", + "unableToExtractSchemaNameFromRef": "không thể trích xuất tên lược đồ từ tham chiếu", + "nodePack": "Gói node", + "workflowDescription": "Mô Tả Ngắn", + "prototypeDesc": "Trình kích hoạt này chỉ mới là bản mẫu. Nó có thể phá hỏng thay đổi trong khi cập nhật ứng dụng và có thể bị xoá bất cứ lúc nào.", + "updateNode": "Cập Nhật Node", + "noWorkflow": "Không Có Workflow", + "loadWorkflow": "Tải Workflow", + "nodeSearch": "Tìm node", + "unableToExtractEnumOptions": "không thể trích xuất lựa chọn trong dữ liệu cố định", + "node": "Node", + "nodeTemplate": "Mẫu Trình Bày Của Node", + "nodeType": "Loại Node", + "noFieldsLinearview": "Không có vùng được thêm vào Chế Độ Xem Tuyến Tính", + "notes": "Ghi Chú", + "updateApp": "Cập Nhật Ứng Dụng", + "updateAllNodes": "Cập Nhật Các Node", + "zoomInNodes": "Phóng To", + "imageAccessError": "Không thể tìm thấy ảnh {{image_name}}, chuyển về mặc định", + "unknownNode": "Node Không Rõ", + "unknownNodeType": "Loại Node Không Rõ", + "unknownTemplate": "Mẫu Trình Bày Không Rõ", + "cannotConnectOutputToOutput": "Không thế kết nối đầu ra với đầu vào", + "cannotConnectToSelf": "Không thể kết nối với chính nó", + "workflow": "Workflow", + "addNodeToolTip": "Thêm Node (Shift+A, Space)", + "animatedEdges": "Hoạt Hoạ Các Kết Nối", + "animatedEdgesHelp": "Hoạt hoạ kết nối được chọn và các kết nối liên kết với node được chọn", + "colorCodeEdgesHelp": "Mã màu kết nối dựa theo vùng kết nối của nó", + "currentImageDescription": "Hiển thị hình ảnh hiện tại trong Trình Biên Tập Node", + "missingFieldTemplate": "Thiếu vùng mẫu trình bày", + "downloadWorkflow": "Tải Xuống Workflow Dưới Dạng JSON", + "executionStateError": "Lỗi", + "fieldTypesMustMatch": "Loại của vùng cần giống nhau", + "fitViewportNodes": "Chế Độ Xem Vừa Khớp", + "fullyContainNodes": "Bao Phủ Node Hoàn Toàn Để Chọn", + "fullyContainNodesHelp": "Node phải được phủ kín hoàn toàn trong hộp lựa chọn để được lựa chọn", + "hideLegendNodes": "Ẩn Vùng Nhập", + "hideMinimapnodes": "Ẩn Bản Đồ Thu Nhỏ", + "inputMayOnlyHaveOneConnection": "Đầu vào chỉ có thể có một kết nối", + "noWorkflows": "Không Có Workflow", + "noMatchingWorkflows": "Không Có Workflow Phù Hợp", + "sourceNodeDoesNotExist": "Kết nối không phù hợp: nguồn/đầu ra của node {{node}} không tồn tại", + "targetNodeDoesNotExist": "Kết nối không phù hợp: đích đến/đầu vào của node {{node}} không tồn tại", + "noFieldsViewMode": "Workflow này chưa có vùng được chọn để hiển thị. Xem workflow đầy đủ để tuỳ chỉnh dữ liệu.", + "problemSettingTitle": "Có Vấn Đề Khi Thiết Lập Tiêu Đề", + "resetToDefaultValue": "Đặt lại giá trị mặc định", + "reloadNodeTemplates": "Tải Lại Mẫu Trình Bày Node", + "reorderLinearView": "Sắp Xếp Lại Chế Độ Xem Tuyến Tính", + "viewMode": "Dùng Chế Độ Xem Tuyến Tính", + "newWorkflowDesc": "Tạo workflow mới?", + "string": "Chuỗi Ký Tự", + "version": "Phiên Bản", + "versionUnknown": " Phiên Bản Không Rõ", + "workflowContact": "Thông Tin Liên Lạc", + "workflowName": "Tên", + "saveToGallery": "Lưu Vào Thư Viện", + "connectionWouldCreateCycle": "Kết nối này sẽ tạo ra vòng lặp", + "addNode": "Thêm Node", + "unsupportedAnyOfLength": "quá nhiều dữ liệu hợp nhất: {{count}}", + "unknownInput": "Đầu Vào Không Rõ: {{name}}", + "validateConnections": "Xác Thực Kết Nối Và Đồ Thị", + "workflowNotes": "Ghi Chú", + "workflowTags": "Thẻ Tên", + "editMode": "Chỉnh sửa trong Trình Biên Tập Workflow", + "edit": "Chỉnh Sửa", + "executionStateInProgress": "Đang Xử Lý", + "showLegendNodes": "Hiển Thị Vùng Nhập", + "outputFieldTypeParseError": "Không thể phân tích loại dữ liệu đầu ra của {{node}}.{{field}} ({{message}})", + "modelAccessError": "Không thể tìm thấy model {{key}}, chuyển về mặc định" + }, + "popovers": { + "paramCFGRescaleMultiplier": { + "heading": "CFG Rescale Multiplier", + "paragraphs": [ + "Hệ số nhân điều chỉnh cho hướng dẫn CFG, dùng cho model được huấn luyện bằng zero-terminal SNR (ztsnr).", + "Giá trị khuyến cáo là 0.7 cho những model này." + ] + }, + "refinerScheduler": { + "heading": "Scheduler", + "paragraphs": [ + "Scheduler được dùng khi tinh chế các phần nhỏ của quá trình tạo sinh.", + "Giống với scheduler để tạo sinh." + ] + }, + "paramCFGScale": { + "heading": "Thước Đo CFG", + "paragraphs": [ + "Điều khiển mức độ lệnh tác động lên quá trình tạo sinh.", + "Giá trị của Thước đo CFG quá cao có thể tạo độ bão hoà quá mức và khiến ảnh tạo sinh bị méo mó. " + ] + }, + "paramScheduler": { + "heading": "Scheduler", + "paragraphs": [ + "Scheduler được dùng trong quá trình tạo sinh.", + "Mỗi scheduler định nghĩa cách thêm độ nhiễu vào hình ảnh hoặc cách cập nhật mẫu dữ liệu dự vào đầu ra của model." + ] + }, + "compositingCoherencePass": { + "heading": "Coherence Pass (Lớp Kết Hợp)", + "paragraphs": [ + "Bước thứ hai trong quá trình khử nhiễu để hợp nhất với ảnh inpaint/outpaint." + ] + }, + "refinerNegativeAestheticScore": { + "heading": "Điểm Tiêu Cực Cho Tiêu Chuẩn", + "paragraphs": [ + "Trọng lượng để tạo sinh ảnh giống với ảnh có điểm tiêu chuẩn thấp, dựa vào dữ liệu huấn luyện." + ] + }, + "refinerCfgScale": { + "paragraphs": [ + "Điều khiển mức độ lệnh tác động lên quá trình tạo sinh.", + "Giống với thước đo CFG để tạo sinh." + ], + "heading": "Thước Đo CFG" + }, + "refinerSteps": { + "heading": "Tham Số Bước", + "paragraphs": [ + "Số bước diễn ra trong khi tinh chế các phần nhỏ của quá trình tạo sinh.", + "Giống với tham số bước để tạo sinh." + ] + }, + "paramSteps": { + "heading": "Tham Số Bước", + "paragraphs": [ + "Số bước dùng để biểu diễn trong mỗi lần tạo sinh.", + "Số bước càng cao thường sẽ tạo ra ảnh tốt hơn nhưng ngốn nhiều thời gian hơn." + ] + }, + "paramWidth": { + "heading": "Chiều Rộng", + "paragraphs": [ + "Chiều rộng của ảnh tạo sinh. Phải là bội số của 8." + ] + }, + "inpainting": { + "heading": "Chế Độ Inpaint", + "paragraphs": [ + "Điều khiển vị trí cần sửa đổi, được chỉ dẫn theo Sức Mạnh Khử Nhiễu." + ] + }, + "rasterLayer": { + "paragraphs": [ + "Dữ liệu dựa vào pixel trên ảnh, dùng cho để tạo sinh ảnh." + ], + "heading": "Layer Raster" + }, + "creativity": { + "heading": "Độ Sáng Tạo", + "paragraphs": [ + "Độ sáng tạo điều khiển mức độ tự do được trao cho model khi thêm chi tiết. Độ sáng tạo thấp cho ra ảnh gần giống với ảnh ban đầu, trong khi độ sáng tạo cao cho phép nhiều thay đổi hơn. Khi dùng lệnh, độ phân giải cao tăng ảnh hưởng của lệnh lên đầu ra." + ] + }, + "refinerPositiveAestheticScore": { + "paragraphs": [ + "Trọng lượng để tạo sinh ảnh giống với ảnh có điểm tiêu chuẩn cao, dựa vào dữ liệu huấn luyện." + ], + "heading": "Điểm Tích Cực Cho Tiêu Chuẩn" + }, + "paramVAEPrecision": { + "paragraphs": [ + "Độ chính xác dùng trong khi mã hoá và giải mã VAE.", + "Chính xác một nửa/Fp16 sẽ hiệu quả hơn, đổi lại cho những thay đổi nhỏ với ảnh." + ], + "heading": "VAE Precision" + }, + "fluxDevLicense": { + "heading": "Giấy Phép Phi Thương Mại", + "paragraphs": [ + "Model FLUX.1 [dev] được cấp phép dưới giấy phép phi thương mại FLUX [dev]. Để dùng loại model này cho lý do thương mại trong Invoke, vào trang web chúng tôi để tìm hiểu thêm." + ] + }, + "scaleBeforeProcessing": { + "heading": "Chia Tỉ Lệ Trước Khi Xử Lý", + "paragraphs": [ + "\"Tự động\" chỉnh tỉ lệ cho vùng được chọn thành kích thước phù hợp nhất cho model trước khi tạo sinh.", + "\"Thủ công\" cho phép bạn chọn chiều rộng và chiều dài cho vùng được chọn sẽ được chia tỉ lệ trước khi tạo sinh." + ] + }, + "paramHeight": { + "paragraphs": [ + "Chiều dài của ảnh tạo sinh. Phải là bội số của 8." + ], + "heading": "Chiều Dài" + }, + "paramRatio": { + "paragraphs": [ + "Tỉ lệ khung hình của kích thước của ảnh được tạo ra.", + "Kích thước ảnh (theo số lượng pixel) tương đương với 512x512 được khuyến nghị cho model SD1.5 và kích thước tương đương với 1024x1024 được khuyến nghị cho model SDXL." + ], + "heading": "Tỉ Lệ Khung Hình" + }, + "seamlessTilingYAxis": { + "paragraphs": [ + "Lát khối liền mạch bức ảnh theo trục dọc." + ], + "heading": "Lát Khối Liền Mạch Trục Y" + }, + "controlNetControlMode": { + "paragraphs": [ + "Đưa thêm trọng lượng vào lệnh hoặc ControlNet." + ], + "heading": "Chế Độ Điều Khiển" + }, + "compositingMaskAdjustments": { + "paragraphs": [ + "Điều chỉnh cái lớp bao phủ." + ], + "heading": "Điều Chỉnh Lớp Phủ" + }, + "regionalGuidance": { + "paragraphs": [ + "Vẽ để chỉ dẫn nơi các yếu tố từ lệnh cần xuất hiện." + ], + "heading": "Chỉ Dẫn Khu Vực" + }, + "controlNetWeight": { + "paragraphs": [ + "Trọng lượng của Control Adapter. Trọng lượng càng cao sẽ dẫn đến tác động càng lớn lên ảnh cuối cùng." + ], + "heading": "Trọng Lượng" + }, + "regionalReferenceImage": { + "heading": "Ảnh Mẫu Khu Vực", + "paragraphs": [ + "Vẽ để áp dụng ảnh tham khảo vào nơi cụ thể." + ] + }, + "paramHrf": { + "paragraphs": [ + "Tạo ra ảnh chất lượng cao với độ phân giải lớn hơn giá trị tối ưu cho model. Thường được dùng để tránh trùng lập trong ảnh tạo sinh." + ], + "heading": "Cho Phép Sửa Độ Phân Giải Cao" + }, + "patchmatchDownScaleSize": { + "heading": "Downscale", + "paragraphs": [ + "Downscale xảy ra bao nhiêu lần trước khi bắt đầu infill.", + "Downscale nhiều sẽ cải thiện hiệu suất nhưng giảm chất lượng." + ] + }, + "compositingCoherenceMinDenoise": { + "paragraphs": [ + "Sức mạnh khử nhiễu nhỏ nhất cho chế độ kết hợp", + "Sức mạnh khử nhiễu nhỏ nhất cho vùng kết hợp khi inpaint/outpaint" + ], + "heading": "Độ Khử Nhiễu Tối Thiểu" + }, + "compositingCoherenceEdgeSize": { + "paragraphs": [ + "Kích thước cạnh cho lớp kết hợp." + ], + "heading": "Kích Thước Cạnh" + }, + "compositingMaskBlur": { + "heading": "Mask Blur", + "paragraphs": [ + "Độ mờ của phần được phủ." + ] + }, + "ipAdapterMethod": { + "paragraphs": [ + "Cách thức dùng để áp dụng IP Adapter hiện tại." + ], + "heading": "Cách Thức" + }, + "dynamicPrompts": { + "heading": "Dynamic Prompt", + "paragraphs": [ + "Dynamic Prompt phân tích một lệnh đơn thành nhiều lệnh.", + "Cú pháp cơ bản là \"a {red|green|blue} ball\". Nó sẽ cấu thành ba lệnh: \"a red ball\", \"a green ball\" và \"a blue ball\".", + "Bạn có thể dùng cú pháp bao nhiêu lần tuỳ thích trong một lệnh đơn, nhưng hãy chắc chắn số lệnh tạo sinh không vượt mức Số lệnh Tối đa trong cài đặt." + ] + }, + "imageFit": { + "heading": "Xếp Vừa Ảnh Ban Đầu Với Kích Thước Đầu Ra", + "paragraphs": [ + "Điều chỉnh tỉ lệ ảnh ban đầu thành chiều dài và chiều rộng của ảnh đầu ra. Khuyến cáo nên bật." + ] + }, + "noiseUseCPU": { + "paragraphs": [ + "Điều chỉnh độ nhiễu được tạo ra trên CPU hay GPU.", + "Với Độ nhiễu CPU được bật, một tham số hạt giống cụ thể sẽ tạo ra hình ảnh giống nhau trên mọi máy.", + "Không có tác động nào đến hiệu suất khi bật Độ nhiễu CPU." + ], + "heading": "Dùng Độ Nhiễu CPU" + }, + "lora": { + "heading": "LoRA", + "paragraphs": [ + "Model nhẹ dùng để kết hợp với model cơ sở." + ] + }, + "refinerModel": { + "paragraphs": [ + "Model được dùng khi tinh chế các phần nhỏ của quá trình tạo sinh.", + "Giống với model để tạo sinh." + ], + "heading": "Model Refiner" + }, + "compositingBlurMethod": { + "heading": "Phương Thức Làm Mờ", + "paragraphs": [ + "Cách làm mờ trên vùng được phủ." + ] + }, + "controlNetBeginEnd": { + "paragraphs": [ + "Một phần trong quá trình xử lý khử nhiễu mà sẽ được Control Adapter áp dụng.", + "Nói chung, Control Adapter áp dụng vào lúc bắt đầu của quá trình hướng dẫn thành phần, và cũng áp dụng vào lúc kết thúc hướng dẫn chi tiết." + ], + "heading": "Phần Trăm Tham Số Bước Khi Bắt Đầu/Kết Thúc" + }, + "scale": { + "heading": "Tỉ Lệ", + "paragraphs": [ + "Tỉ lệ điều khiển kích thước ảnh đầu ra, và dựa vào bội số độ phân giải ảnh đầu vào. Ví dụ upscale 2x lần lên ảnh 1024x1024 sẽ cho ra ảnh đầu ra 2048x2048." + ] + }, + "upscaleModel": { + "paragraphs": [ + "Model upscale đặt tỉ lệ hình ảnh vào kích thước đầu ra trước khi thêm vào các chi tiết. Bất kỳ model upscale được hỗ trợ đều có thể sử dụng, nhưng một số sẽ chuyên về một lĩnh vực, như là ảnh chụp hay ảnh vẽ phát thảo nét." + ], + "heading": "Model Upscale" + }, + "globalReferenceImage": { + "heading": "Ảnh Mẫu Toàn Vùng", + "paragraphs": [ + "Áp dụng ảnh tham khảo để ảnh hưởng lên toàn bộ quá trình tạo sinh." + ] + }, + "dynamicPromptsSeedBehaviour": { + "paragraphs": [ + "Điều khiển cách tham số hạt giống được dùng khi tạo sinh từ lệnh.", + "Cứ mỗi lần lặp, một tham số hạt giống mới sẽ được dùng. Dùng nó để khám phá những biến thể từ lệnh trên mỗi tham số hạt giống.", + "Ví dụ, nếu bạn có 5 lệnh, mỗi ảnh sẽ dùng cùng tham số hạt giống.", + "Một tham số hạt giống mới sẽ được dùng cho từng ảnh. Nó tạo ra nhiều biến thể." + ], + "heading": "Hành Động Cho Tham Số Hạt Giống" + }, + "paramGuidance": { + "heading": "Hướng Dẫn", + "paragraphs": [ + "Điều khiển mức độ lệnh tác động lên quá trình tạo sinh.", + "Giá trị hướng dẫn cao có thể gây bão hoà quá mức, giá trị hướng dẫn quá cao hoặc quá thấp còn có nguy cơ khiến ảnh tạo sinh bị méo mó. Hướng dẫn chỉ áp dụng cho model FLUX DEV." + ] + }, + "paramVAE": { + "paragraphs": [ + "Model được dùng để dịch đầu ra của AI thành ảnh cuối cùng." + ], + "heading": "VAE" + }, + "controlNet": { + "paragraphs": [ + "ControlNet cung cấp hướng dẫn cho quá trình tạo sinh, giúp tạo ảnh với thành phần, cấu trúc hoặc phong cách được kiểm soát, tuỳ vào model được chọn." + ], + "heading": "ControlNet" + }, + "controlNetProcessor": { + "heading": "Bộ Xử Lý", + "paragraphs": [ + "Cách thức xử lý ảnh đầu vào để hướng dẫn xử lý quá trình tạo sinh. Bộ xử lý khác như sẽ cung cấp hiệu ứng hoặc phong cách khác nhau cho ảnh được tạo sinh." + ] + }, + "paramAspect": { + "paragraphs": [ + "Tỉ lệ khung hành của ảnh tạo sinh. Điều chỉnh tỉ lệ se cập nhật Chiều Rộng và Chiều Dài tương ứng.", + "\"Tối ưu hoá\" sẽ đặt Chiều Rộng và Chiều Dài vào kích thước tối ưu cho model được chọn." + ], + "heading": "Khung Hình" + }, + "paramNegativeConditioning": { + "heading": "Lệnh Tiêu Cực", + "paragraphs": [ + "Quá trình tạo sinh sẽ tránh những nội dung trong lệnh tiêu cực. Dùng nó để loại bỏ nội dung khỏi đầu ra.", + "Hỗ trợ Compel Syntax và Embedding." + ] + }, + "optimizedDenoising": { + "paragraphs": [ + "Bật \"Tối Ưu Hoá Hình Ảnh Sang Hình Ảnh\" cho một thang đo Sức Mạnh Khử Nhiễu tiến dần dành cho các dạng biến đổi ảnh sang ảnh và inpaint với model Flux. Cài đặt này cải thiện khả năng điều khiển số lượng biến đổi được áp dụng lên hình ảnh, nhưng có thể được tắt nếu bạn muốn thang đo Sức Mạnh Khử Nhiễu tiêu chuẩn. Cài đặt này vẫn còn được chỉnh sửa và trong quá trình beta." + ], + "heading": "Tối Ưu Hoá Hình Ảnh Sang Hình Ảnh" + }, + "refinerStart": { + "paragraphs": [ + "Nơi trong quá trình xử lý tạo sinh mà refiner bắt đầu được dùng.", + "0 nghĩa là bộ refiner sẽ được dùng trong toàn bộ quá trình tạo sinh , 0.8 nghĩa là refiner sẽ được dùng trong 20% cuối cùng quá trình tạo sinh." + ], + "heading": "Nơi Bắt Đầu Refiner" + }, + "paramUpscaleMethod": { + "paragraphs": [ + "Cách thức dùng để upscale để Sửa Độ Phân Giải Cao." + ], + "heading": "Phương Thức Upscale" + }, + "dynamicPromptsMaxPrompts": { + "paragraphs": [ + "Giới hạn số lệnh được tạo sinh bởi Dynamic Prompt." + ], + "heading": "Số Lệnh Tối Đa" + }, + "structure": { + "paragraphs": [ + "Độ cấu trúc điều khiển mức độ của ảnh đầu ra sẽ giữ nguyên các trình bày của bản gốc. Độ cấu trúc thấp cho phép các thay đổi đáng kể, trong khi độ cấu trúc cao nghiêm khắc hơn về cách trình bày và thành phần của bản gốc." + ], + "heading": "Độ Cấu Trúc" + }, + "infillMethod": { + "heading": "Cách Thức Infill", + "paragraphs": [ + "Cách thức làm infill trong quá trình inpaint/outpaint." + ] + }, + "paramDenoisingStrength": { + "paragraphs": [ + "Kiểm soát độ khác nhau giữa các ảnh được tạo sinh và layer dạng raster.", + "Sức mạnh thấp cho ảnh giống với sự kết hợp của các layer dạng raster đang hiển thị. Sức mạnh cao lại cho ảnh phụ thuộc nhiều vào lệnh.", + "Khi không có gì được hiển thị bởi các layer dạng raster, điều chỉnh này sẽ được bỏ qua." + ], + "heading": "Sức Mạnh Khử Nhiễu" + }, + "paramPositiveConditioning": { + "paragraphs": [ + "Hướng dẫn cách máy tạo sinh xử lý. Bạn nên dùng từ hoặc cụm từ.", + "Hỗ trợ cú Compel Syntax, Dynamic Prompt và Embedding." + ], + "heading": "Lệnh Tích Cực" + }, + "controlNetResizeMode": { + "heading": "Chế Độ Điều Chỉnh Kích Thước", + "paragraphs": [ + "Phương thức để đặt kích thước ảnh đầu vào của Control Adapter lên kích thước đầu ra." + ] + }, + "paramSeed": { + "paragraphs": [ + "Điều khiển độ nhiễu ban đầu được dùng để tạo sinh.", + "Tắt lựa chọn \"Ngẫu Nhiên\" để tạo ra kết quá y hệt nhau với cùng một thiết lập tạo sinh." + ], + "heading": "Tham Số Hạt Giống" + }, + "clipSkip": { + "heading": "CLIP Skip", + "paragraphs": [ + "Bao nhiêu lớp model CLIP được bỏ qua.", + "Một số model nhất định sẽ phù hợp hơn khi đi cùng CLIP Skip." + ] + }, + "loraWeight": { + "heading": "Trọng Lượng", + "paragraphs": [ + "Trọng lượng của LoRA. Trọng lượng càng cao sẽ dẫn đến tác động càng lớn lên ảnh cuối cùng." + ] + }, + "paramIterations": { + "heading": "Vòng Lặp", + "paragraphs": [ + "Số ảnh được tạo ra.", + "Nếu Dynamic Prompt được bật, một lệnh sẽ tạo sinh ảnh bấy nhiêu lần." + ] + }, + "compositingCoherenceMode": { + "heading": "Chế Độ", + "paragraphs": [ + "Cách thức được dùng để kết hợp ảnh với vùng bao phủ vừa được tạo sinh." + ] + }, + "paramModel": { + "paragraphs": [ + "Model dùng để tạo sinh. Model khác nhau được huấn luyện để chuyên vào một kết quả và nội dung tiêu chuẩn." + ], + "heading": "Model" + }, + "regionalGuidanceAndReferenceImage": { + "heading": "Chỉ Dẫn Khu Vực Và Ảnh Mẫu Khu Vực", + "paragraphs": [ + "Dành cho Chỉ Dẫn Khu Vực, vẽ để chỉ dẫn nơi các yếu tố từ lệnh cần xuất hiện.", + "Dành cho Ảnh Mẫu Khu Vực, vẽ để áp dụng ảnh tham khảo vào nơi cụ thể." + ] + }, + "seamlessTilingXAxis": { + "paragraphs": [ + "Lát khối liền mạch bức ảnh theo trục ngang." + ], + "heading": "Lát Khối Liền Mạch Trục X" + } + }, + "models": { + "addLora": "Thêm LoRA", + "concepts": "Khái Niệm", + "loading": "đang tải", + "lora": "LoRA", + "noMatchingLoRAs": "Không có LoRA phù hợp", + "noRefinerModelsInstalled": "Chưa có model SDXL Refiner được tải xuống", + "noLoRAsInstalled": "Chưa có LoRA được tải xuống", + "defaultVAE": "VAE Mặc Định", + "noMatchingModels": "Không có Model phù hợp", + "noModelsAvailable": "Không có model", + "selectModel": "Chọn Model" + }, + "parameters": { + "postProcessing": "Xử Lý Hậu Kỳ (Shift + U)", + "symmetry": "Tính Đối Xứng", + "type": "Loại", + "seed": "Tham Số Hạt Giống", + "processImage": "Xử Lý Hình Ảnh", + "useSize": "Dùng Kích Thước", + "invoke": { + "layer": { + "t2iAdapterIncompatibleScaledBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, tỉ lệ chiều dài hộp giới hạn là {{height}}", + "rgNoRegion": "không có vùng được chọn", + "ipAdapterNoModelSelected": "không có IP Adapter được lựa chọn", + "ipAdapterNoImageSelected": "không có ảnh IP Adapter được lựa chọn", + "t2iAdapterIncompatibleBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, chiều dài hộp giới hạn là {{height}}", + "t2iAdapterIncompatibleScaledBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, tỉ lệ chiều rộng hộp giới hạn là {{width}}", + "t2iAdapterIncompatibleBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}}, chiều rộng hộp giới hạn là {{width}}", + "rgNoPromptsOrIPAdapters": "không có lệnh chữ hoặc IP Adapter", + "controlAdapterIncompatibleBaseModel": "model cơ sở của Control Adapter không tương thích", + "ipAdapterIncompatibleBaseModel": "dạng model cơ sở của IP Adapter không tương thích", + "controlAdapterNoModelSelected": "không có model Control Adapter được chọn" + }, + "fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), chiều rộng hộp giới hạn là {{width}}", + "noModelSelected": "Không có model được lựa chọn", + "fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), tỉ lệ chiều dài hộp giới hạn là {{height}}", + "canvasIsFiltering": "Canvas đang được lọc", + "canvasIsRasterizing": "Canvas đang được raster hoá", + "canvasIsTransforming": "Canvas đang được biến đổi", + "canvasIsCompositing": "Canvas đang được kết hợp", + "noPrompts": "Không có lệnh được tạo", + "noNodesInGraph": "Không có node trong đồ thị", + "addingImagesTo": "Thêm ảnh vào", + "noT5EncoderModelSelected": "Không có model T5 Encoder được lựa chọn cho máy tạo sinh FLUX", + "noFLUXVAEModelSelected": "Không có model VAE được lựa chọn cho máy tạo sinh FLUX", + "noCLIPEmbedModelSelected": "Không có model CLIP Embed được lựa chọn cho máy tạo sinh FLUX", + "systemDisconnected": "Hệ thống mất kết nối", + "invoke": "Kích Hoạt", + "missingNodeTemplate": "Thiếu mẫu trình bày node", + "fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), chiều dài hộp giới hạn là {{height}}", + "fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16), tỉ lệ chiều rộng hộp giới hạn là {{width}}", + "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} thiếu đầu ra", + "missingFieldTemplate": "Thiếu vùng mẫu trình bày" + }, + "cfgScale": "Thước Đo CFG", + "useSeed": "Dùng Tham Số Hạt Giống", + "imageActions": "Hành Động Với Hình Ảnh", + "steps": "Tham Số Bước", + "aspect": "Khung Hình", + "coherenceMode": "Chế Độ", + "coherenceEdgeSize": "Kích Thước Cạnh", + "coherenceMinDenoise": "Tham Số Khử Nhiễu Nhỏ Nhất", + "denoisingStrength": "Sức Mạnh Khử Nhiễu", + "infillMethod": "Cách Thức Infill", + "setToOptimalSize": "Tối ưu hoá kích cỡ cho model", + "maskBlur": "Mask Blur", + "width": "Chiều Rộng", + "scale": "Tỉ Lệ", + "recallMetadata": "Gợi Lại Metadata", + "clipSkip": "CLIP Skip", + "general": "Cài Đặt Chung", + "boxBlur": "Box Blur", + "gaussianBlur": "Gaussian Blur", + "staged": "Staged (Tăng khử nhiễu có hệ thống)", + "scaledHeight": "Tỉ Lệ Chiều Dài", + "cancel": { + "cancel": "Huỷ" + }, + "infillColorValue": "Màu Lấp Đầy", + "optimizedImageToImage": "Tối Ưu Hoá Hình Ảnh Sang Hình Ảnh", + "sendToCanvas": "Gửi Vào Canvas", + "sendToUpscale": "Gửi Vào Upscale", + "scaledWidth": "Tỉ Lệ Chiều Rộng", + "scheduler": "Scheduler", + "seamlessXAxis": "Trục X Liền Mạch", + "seamlessYAxis": "Trục Y Liền Mạch", + "guidance": "Hướng Dẫn", + "height": "Chiều Cao", + "noiseThreshold": "Ngưỡng Nhiễu", + "negativePromptPlaceholder": "Lệnh Tiêu Cực", + "iterations": "Lặp Lại", + "strength": "Sức Mạnh", + "perlinNoise": "Nhiễu Loại Perlin", + "positivePromptPlaceholder": "Lệnh Tích Cực", + "scaleBeforeProcessing": "Tỉ Lệ Trước Khi Xử Lý", + "patchmatchDownScaleSize": "Downscale", + "useAll": "Dùng Tất Cả", + "useCpuNoise": "Dùng Độ Nhiễu CPU", + "remixImage": "Phối Lại Hình Ảnh", + "showOptionsPanel": "Hiển Thị Bảng Bên Cạnh (O hoặc T)", + "shuffle": "Xáo Trộn Tham Số Hạt Giống", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (lớn quá)", + "cfgRescaleMultiplier": "CFG Rescale Multiplier", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (nhỏ quá)", + "images": "Ảnh Ban Đầu", + "controlNetControlMode": "Chế Độ Điều Khiển", + "lockAspectRatio": "Khoá Tỉ Lệ Khung Hình", + "swapDimensions": "Hoán Đổi Kích Thước", + "copyImage": "Sao Chép Hình Ảnh", + "downloadImage": "Tải Xuống Hình Ảnh", + "imageFit": "Căn Chỉnh Ảnh Ban Đầu Thành Kích Thước Đầu Ra", + "info": "Thông Tin", + "usePrompt": "Dùng Lệnh", + "upscaling": "Upscale", + "tileSize": "Kích Thước Khối", + "disabledNoRasterContent": "Đã Tắt (Không Có Nội Dung Dạng Raster)" + }, + "dynamicPrompts": { + "seedBehaviour": { + "perIterationDesc": "Sử dụng tham số hạt giống khác nhau cho mỗi lần lặp lại", + "perPromptDesc": "Sử dụng tham số hạt giống khác nhau cho mỗi hình ảnh", + "label": "Hành Động Cho Tham Số Hạt Giống", + "perPromptLabel": "Tham Số Hạt Giống Mỗi Hình Ảnh", + "perIterationLabel": "Tham Số Hạt Giống Mỗi Lần Lặp Lại" + }, + "loading": "Tạo Sinh Dùng Dynamic Prompt...", + "showDynamicPrompts": "HIện Dynamic Prompt", + "maxPrompts": "Số Lệnh Tối Đa", + "promptsPreview": "Xem Trước Lệnh", + "dynamicPrompts": "Dynamic Prompt" + }, + "settings": { + "beta": "Beta", + "general": "Cài Đặt Chung", + "confirmOnDelete": "Chắp Nhận Xoá", + "developer": "Nhà Phát Triển", + "confirmOnNewSession": "Chắp Nhận Mở Phiên Mới", + "antialiasProgressImages": "Xử Lý Khử Răng Cưa Hình Ảnh", + "models": "Models", + "informationalPopoversDisabledDesc": "Hộp thoại hỗ trợ thông tin đã tắt. Bật lại trong Cài đặt.", + "modelDescriptionsDisabled": "Trình Mô Tả Model Bằng Hộp Thả Đã Tắt", + "enableModelDescriptions": "Bật Trình Mô Tả Model Bằng Hộp Thả", + "modelDescriptionsDisabledDesc": "Trình mô tả model bằng hộp thả đã tắt. Bật lại trong Cài đặt.", + "enableNSFWChecker": "Bật Trình Kiểm Tra NSFW", + "clearIntermediatesWithCount_other": "Dọn sạch {{count}} sản phẩm trung gian", + "reloadingIn": "Tải lại trong", + "resetWebUIDesc1": "Khởi động lại giao diện web chỉ làm mới bộ nhớ đệm của trình duyệt về ảnh và các thiết lập. Nó không hề xoá bất kỳ ảnh nào trong ổ đĩa.", + "intermediatesCleared_other": "Đã dọn {{count}} sản phẩm trung gian", + "generation": "Máy Tạo Sinh", + "enableInformationalPopovers": "Bật Hộp Thoại Hỗ Trợ Thông Tin", + "clearIntermediates": "Dọn Sạch Sản Phẩm Trung Gian", + "clearIntermediatesDisabled": "Hàng đợi phải trống để dọn dẹp các sản phẩm trung gian", + "clearIntermediatesDesc1": "Dọn dẹp các sản phẩm trung gian sẽ làm mới trạng thái của Canvas và ControlNet.", + "clearIntermediatesDesc2": "Các sản phẩm ảnh trung gian là sản phẩm phụ trong quá trình tạo sinh, khác với ảnh trong thư viện. Xoá sản phẩm trung gian sẽ giúp làm trống ổ đĩa.", + "resetWebUI": "Khởi Động Lại Giao Diện Web", + "showProgressInViewer": "Hiển Thị Hình Ảnh Đang Xử Lý Trong Trình Xem", + "ui": "Giao Diện Người Dùng", + "clearIntermediatesDesc3": "Ảnh trong thư viện sẽ không bị xoá.", + "informationalPopoversDisabled": "Hộp Thoại Hỗ Trợ Thông Tin Đã Tắt", + "resetComplete": "Giao diện web đã được khởi động lại.", + "resetWebUIDesc2": "Nếu ảnh không được xuất hiện trong thư viện hoặc điều gì đó không ổn đang diễn ra, hãy thử khởi động lại trước khi báo lỗi trên Github.", + "displayInProgress": "Hiển Thị Hình Ảnh Đang Xử Lý", + "intermediatesClearedFailed": "Có Vấn Đề Khi Dọn Sạch Sản Phẩm Trung Gian", + "enableInvisibleWatermark": "Bật Chế Độ Ẩn Watermark" + }, + "sdxl": { + "loading": "Đang Tải...", + "posAestheticScore": "Điểm Tích Cực Cho Tiêu Chuẩn", + "steps": "Tham Số Bước", + "refinerSteps": "Tham Số Bước Refiner", + "refinermodel": "Model Refiner", + "refinerStart": "Nơi Bắt Đầu Refiner", + "denoisingStrength": "Sức Mạnh Khử Nhiễu", + "posStylePrompt": "Điểm Tích Cực Cho Lệnh Phong Cách", + "scheduler": "Scheduler", + "refiner": "Refiner", + "cfgScale": "Thước Đo CFG", + "concatPromptStyle": "Liên Kết Lệnh & Phong Cách", + "freePromptStyle": "Viết Lệnh Thủ Công Cho Phong Cách", + "negStylePrompt": "Điểm Tiêu Cực Cho Lệnh Phong Cách", + "negAestheticScore": "Điểm Tiêu Cực Cho Tiêu Chuẩn", + "noModelsAvailable": "Không có sẵn model" + }, + "controlLayers": { + "width": "Chiều Rộng", + "negativePrompt": "Lệnh Tiêu Cực", + "removeBookmark": "Bỏ Đánh Dấu", + "saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện", + "global": "Toàn Vùng", + "pullBboxIntoReferenceImageError": "Có Vấn Đề Khi Chuyển Hộp Giới Hạn Thành Ảnh Mẫu", + "clearHistory": "Xoá Lịch Sử", + "recalculateRects": "Tính Toán Lại Hình Chữ Nhật", + "mergeVisibleOk": "Đã gộp layer", + "saveLayerToAssets": "Lưu Layer Vào Khu Tài Nguyên", + "canvas": "Canvas", + "savedToGalleryOk": "Đã Lưu Vào Thư Viện", + "addGlobalReferenceImage": "Thêm $t(controlLayers.globalReferenceImage)", + "clipToBbox": "Chuyển Nét Thành Hộp Giới Hạn", + "moveToFront": "Chuyển Lên Trước", + "mergeVisible": "Gộp Layer Đang Hiển Thị", + "savedToGalleryError": "Lỗi khi lưu vào thư viện", + "moveToBack": "Chuyển Về Sau", + "moveBackward": "Chuyển Xuống Cuối", + "newGlobalReferenceImageError": "Có Vấn Đề Khi Tạo Ảnh Mẫu Toàn Vùng", + "newRegionalReferenceImageOk": "Đã Tạo Ảnh Mẫu Khu Vực", + "newControlLayerOk": "Đã Tạo Layer Điều Khiển Được", + "newControlLayerError": "Có Vấn Đề Khi Tạo Layer Điều Khiển Được", + "newRasterLayerOk": "Đã Tạo Layer Dạng Raster", + "pullBboxIntoLayerOk": "Chuyển Hợp Giới Hạn Thành Layer", + "newGlobalReferenceImageOk": "Đã Tạo Ảnh Mẫu Toàn Vùng", + "newRegionalReferenceImageError": "Có Vấn Đề Khi Tạo Ảnh Mẫu Khu Vực", + "newRasterLayerError": "Có Vấn Đề Khi Tạo Layer Dạng Raster", + "pullBboxIntoLayerError": "Có Vấn Đề Khi Chuyển Hộp Giới Hạn Thành Layer", + "pullBboxIntoReferenceImageOk": "Chuyển Hộp Giới Hạn Thành Ảnh Mẫu", + "clearCaches": "Xoá Bộ Nhớ Đệm", + "outputOnlyMaskedRegions": "Chỉ Xuất Đầu Ra Ở Vùng Phủ", + "addLayer": "Thêm Layer", + "regional": "Khu Vực", + "regionIsEmpty": "Vùng được chọn trống", + "bookmark": "Đánh Dấu Để Đổi Nhanh", + "saveCanvasToGallery": "Lưu Canvas Vào Thư Viện", + "cropLayerToBbox": "Xén Layer Vào Hộp Giới Hạn", + "newFromImage": "Mới Từ Ảnh", + "mergeDown": "Gộp Xuống", + "mergeVisibleError": "Lỗi khi gộp layer", + "bboxOverlay": "Hiển Thị Lớp Phủ Trên Hộp Giới Hạn", + "resetCanvas": "Khởi Động Lại Canvas", + "duplicate": "Nhân Bản", + "moveForward": "Chuyển Lên Đầu", + "fitBboxToLayers": "Xếp Vừa Hộp Giới Hạn Vào Layer", + "ipAdapterMethod": { + "full": "Đầy Đủ", + "style": "Chỉ Lấy Phong Cách", + "composition": "Chỉ Lấy Thành Phần", + "ipAdapterMethod": "Cách Thức IP Adapter" + }, + "deletePrompt": "Xoá Lệnh", + "rasterLayer": "Layer Dạng Raster", + "disableAutoNegative": "Tắt Tự Động Đảo Chiều", + "controlLayer": "Layer Điều Khiển Được", + "enableTransparencyEffect": "Bật Hiệu Ứng Trong Suốt", + "deleteSelected": "Xoá Phần Được Chọn", + "showHUD": "Hiển Thị HUD", + "autoNegative": "Tự Động Đảo Chiều", + "replaceLayer": "Thay Đổi Layer", + "regionalGuidance": "Chỉ Dẫn Khu Vực", + "newCanvasFromImage": "Canvas Mới Từ Ảnh", + "rasterLayers_withCount_visible": "Layer Dạng Raster ({{count}})", + "regionalGuidance_withCount_visible": "Chỉ Dẫn Khu Vực ({{count}})", + "convertRasterLayerTo": "Chuyển Đổi $t(controlLayers.rasterLayer) Thành", + "convertControlLayerTo": "Chuyển Đổi $t(controlLayers.controlLayer) Thành", + "convertInpaintMaskTo": "Chuyển Đổi $t(controlLayers.inpaintMask) Thành", + "convertRegionalGuidanceTo": "Chuyển Đổi $t(controlLayers.regionalGuidance) Thành", + "copyInpaintMaskTo": "Sao Chép $t(controlLayers.inpaintMask) Tới", + "copyRegionalGuidanceTo": "Sao Chép $t(controlLayers.regionalGuidance) Tới", + "newControlLayer": "$t(controlLayers.controlLayer) Mới", + "newRasterLayer": "$t(controlLayers.rasterLayer) Mới", + "enableAutoNegative": "Bật Tự Động Đảo Chiều", + "sendToCanvas": "Chuyển Tới Canvas", + "inpaintMasks_withCount_hidden": "Lớp Phủ Inpaint ({{count}} đang ẩn)", + "globalReferenceImages_withCount_visible": "Ảnh Mẫu Toàn Vùng ({{count}})", + "replaceCurrent": "Thay Đổi Cái Hiện Tại", + "controlLayers_withCount_visible": "Layer Điều Khiển Được ({{count}})", + "hidingType": "Ẩn {{type}}", + "canvasAsRasterLayer": "Biến $t(controlLayers.canvas) Thành $t(controlLayers.rasterLayer)", + "newImg2ImgCanvasFromImage": "Chuyển Đổi Ảnh Sang Ảnh Mới Từ Ảnh", + "copyToClipboard": "Sao Chép Vào Clipboard", + "logDebugInfo": "Thông Tin Log Gỡ Lỗi", + "regionalReferenceImage": "Ảnh Mẫu Khu Vực", + "newLayerFromImage": "Layer Mới Từ Ảnh", + "fill": { + "fillStyle": "Kiểu Lấp Đầy", + "fillColor": "Màu Lấp Đầy", + "grid": "Theo Lưới", + "diagonal": "Đường Chéo", + "horizontal": "Đường Ngang", + "crosshatch": "Đường Chéo Song Song (Crosshatch)", + "vertical": "Đường Dọc", + "solid": "Chắc Chắn" + }, + "addControlLayer": "Thêm $t(controlLayers.controlLayer)", + "inpaintMask": "Lớp Phủ Inpaint", + "dynamicGrid": "Lưới Dynamic", + "layer_other": "Layer", + "layer_withCount_other": "Layer ({{count}})", + "pullBboxIntoLayer": "Chuyển Hộp Giới Hạn Vào Layer", + "addInpaintMask": "Thêm $t(controlLayers.inpaintMask)", + "addRegionalGuidance": "Thêm $t(controlLayers.regionalGuidance)", + "sendToGallery": "Chuyển Tới Thư Viện", + "unlocked": "Mở Khoá", + "addReferenceImage": "Thêm $t(controlLayers.referenceImage)", + "canvasAsControlLayer": "Biến $t(controlLayers.canvas) Thành $t(controlLayers.controlLayer)", + "sendingToCanvas": "Chuyển Ảnh Tạo Sinh Vào Canvas", + "sendingToGallery": "Chuyển Ảnh Tạo Sinh Vào Thư Viện", + "viewProgressOnCanvas": "Xem quá trình xử lý và ảnh đầu ra trong Canvas.", + "inpaintMask_withCount_other": "Lớp Phủ Inpaint", + "regionalGuidance_withCount_other": "Chỉ Dẫn Khu Vực", + "controlLayers_withCount_hidden": "Layer Điều Khiển Được ({{count}} đang ẩn)", + "globalReferenceImages_withCount_hidden": "Ảnh Mẫu Toàn Vùng ({{count}} đang ẩn)", + "rasterLayer_withCount_other": "Layer Dạng Raster", + "globalReferenceImage_withCount_other": "Ảnh Mẫu Toàn Vùng", + "copyRasterLayerTo": "Sao Chép $t(controlLayers.rasterLayer) Tới", + "copyControlLayerTo": "Sao Chép $t(controlLayers.controlLayer) Tới", + "newRegionalGuidance": "$t(controlLayers.regionalGuidance) Mới", + "newGallerySessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến thư viện.", + "stagingOnCanvas": "Hiển thị hình ảnh lên", + "pullBboxIntoReferenceImage": "Chuyển Hộp Giới Hạn Vào Ảnh Mẫu", + "maskFill": "Lấp Đầy Lớp Phủ", + "addRasterLayer": "Thêm $t(controlLayers.rasterLayer)", + "rasterLayers_withCount_hidden": "Layer Dạng Raster ({{count}} đang ẩn)", + "referenceImage": "Ảnh Mẫu", + "showProgressOnCanvas": "Hiện Quá Trình Xử Lý Lên Canvas", + "prompt": "Lệnh", + "beginEndStepPercentShort": "Phần Trăm Bắt Đầu/Kết Thúc", + "weight": "Trọng Lượng", + "controlMode": { + "controlMode": "Chế Độ Điều Khiển", + "balanced": "Cân Bằng (khuyến khích)", + "prompt": "Lệnh", + "control": "Điều Khiển", + "megaControl": "Siêu Điều Khiển" + }, + "addPositivePrompt": "Thêm $t(controlLayers.prompt)", + "deleteReferenceImage": "Xoá Ảnh Mẫu", + "inpaintMasks_withCount_visible": "Lớp Phủ Inpaint ({{count}})", + "disableTransparencyEffect": "Tắt Hiệu Ứng Trong Suốt", + "newGallerySession": "Phiên Thư Viện Mới", + "sendToGalleryDesc": "Bấm 'Kích Hoạt' sẽ tiến hành tạo sinh và lưu ảnh vào thư viện.", + "opacity": "Độ Mờ Đục", + "rectangle": "Hình Chữ Nhật", + "addNegativePrompt": "Thêm $t(controlLayers.negativePrompt)", + "globalReferenceImage": "Ảnh Mẫu Toàn Vùng", + "sendToCanvasDesc": "Bấm 'Kích Hoạt' sẽ hiển thị công việc đang xử lý của bạn lên canvas.", + "viewProgressInViewer": "Xem quá trình xử lý và ảnh đầu ra trong Trình Xem Ảnh.", + "regionalGuidance_withCount_hidden": "Chỉ Dẫn Khu Vực ({{count}} đang ẩn)", + "controlLayer_withCount_other": "Layer Điều Khiển Được", + "newInpaintMask": "$t(controlLayers.inpaintMask) Mới", + "locked": "Khoá", + "newCanvasSession": "Phiên Canvas Mới", + "transparency": "Độ Trong Suốt", + "showingType": "Hiển Thị {{type}}", + "newCanvasSessionDesc": "Nó sẽ dọn sạch canvas và các thiết lập trừ model được chọn. Các ảnh được tạo sinh sẽ được chuyển đến canvas.", + "selectObject": { + "help2": "Bắt đầu mới một điểm Bao Gồm trong đối tượng được chọn. Cho thêm điểm để tinh chế phần chọn. Ít điểm hơn thường mang lại kết quả tốt hơn.", + "invertSelection": "Đảo Ngược Phần Chọn", + "include": "Bao Gồm", + "exclude": "Loại Trừ", + "reset": "Làm Mới", + "saveAs": "Lưu Như", + "help1": "Chọn một đối tượng. Thêm điểm Bao GồmLoại Trừ để chỉ ra phần nào trong layer là đối tượng mong muốn.", + "dragToMove": "Kéo kiểm để di chuyển nó", + "help3": "Đảo ngược phần chọn để chọn mọi thứ trừ đối tượng được chọn.", + "clickToAdd": "Nhấp chuột vào layer để thêm điểm", + "clickToRemove": "Nhấp chuột vào một điểm để xoá", + "selectObject": "Chọn Đối Tượng", + "pointType": "Loại Điểm", + "neutral": "Trung Hoà", + "apply": "Áp Dụng", + "cancel": "Huỷ Bỏ", + "process": "Xử Lý" + }, + "canvasContextMenu": { + "saveBboxToGallery": "Lưu Hộp Giới Hạn Vào Thư Viện", + "newGlobalReferenceImage": "Ảnh Mẫu Toàn Vùng Mới", + "cropCanvasToBbox": "Xén Canvas Vào Hộp Giới Hạn", + "newRegionalGuidance": "Chỉ Dẫn Khu Vực Mới", + "saveToGalleryGroup": "Lưu Vào Thư Viện", + "newInpaintMask": "Lớp Phủ Inpaint Mới", + "saveCanvasToGallery": "Lưu Canvas Vào Thư Viện", + "newRegionalReferenceImage": "Ảnh Mẫu Khu Vực Mới", + "newControlLayer": "Layer Điều Khiển Được Mới", + "newRasterLayer": "Layer Dạng Raster Mới", + "bboxGroup": "Được Tạo Từ Hộp Giới Hạn", + "canvasGroup": "Canvas" + }, + "stagingArea": { + "saveToGallery": "Lưu Vào Thư Viện", + "accept": "Chấp Nhận", + "discard": "Bỏ Đi", + "previous": "Trước", + "next": "Sau", + "showResultsOn": "Hiển Thị Kết Quả", + "discardAll": "Bỏ Đi Tất Cả", + "showResultsOff": "Ẩn Đi Kết Quả" + }, + "filter": { + "dw_openpose_detection": { + "draw_face": "Vẽ Mặt", + "description": "Phát hiện tư thế người trong layer được chọn bằng model DW Openpose.", + "draw_hands": "Vẽ Tay", + "label": "Trình Phát Hiện DW Openpose", + "draw_body": "Vẽ Cơ Thể" + }, + "hed_edge_detection": { + "label": "Trình Phát Hiện HED Edge", + "description": "Tạo ra dữ liệu cạnh từ layer được chọn bằng model phát hiện HED Edge.", + "scribble": "Vẽ Nguệch Ngoạc" + }, + "canny_edge_detection": { + "low_threshold": "Ngưỡng Thấp", + "high_threshold": "Ngưỡng Cao", + "label": "Trình Phát Hiện Cạnh Canny", + "description": "Tạo sinh một dữ liệu cạnh từ layer được chọn bằng thuật toán phát hiện cạnh Canny." + }, + "depth_anything_depth_estimation": { + "label": "Depth Anything", + "model_size_small_v2": "Small v2", + "model_size": "Kích Thước Model", + "description": "Tạo dữ liệu chiều sâu từ layer được chọn bằng model Depth Anything.", + "model_size_base": "Base", + "model_size_small": "Small", + "model_size_large": "Large" + }, + "mediapipe_face_detection": { + "min_confidence": "Độ Tư Tin Tối Thiểu", + "label": "Trình Phát Hiện Mặt MediaPipe", + "description": "Phát hiện mặt trong layer được chọn bằng model phát hiện mặt MediaPipe.", + "max_faces": "Số Lượng Mặt Tối Đa" + }, + "lineart_edge_detection": { + "description": "Tạo ra dữ liệu cạnh từ layer được chọn bằng model phát hiện cạnh Lineart.", + "coarse": "Thô", + "label": "Trình Phát Hiện Cạnh Lineart" + }, + "process": "Xử Lý", + "reset": "Làm Mới", + "cancel": "Huỷ Bỏ", + "pidi_edge_detection": { + "label": "Trình Phát Hiện Cạnh PiDiNet", + "scribble": "Vẽ Nguệch Ngoạc", + "quantize_edges": "Lượng Tử Hoá Cạnh", + "description": "Tạo ra dữ liệu cạnh từ layer được chọn bằng model phát hiện cạnh PiDiNet." + }, + "spandrel_filter": { + "model": "Model", + "scale": "Tỉ Lệ Mong Muốn", + "label": "Model Hình Ảnh Sang Hình Ảnh", + "description": "Chạy model ảnh sang ảnh trên layer được chọn.", + "autoScale": "Tự Động Chỉnh Tỉ Lệ", + "autoScaleDesc": "Model được chọn sẽ chạy cho đến khi chạm đến tỉ lệ mong muốn." + }, + "filterType": "Kiểu Lọc", + "apply": "Áp Dụng", + "mlsd_detection": { + "score_threshold": "Ngưỡng Điểm", + "distance_threshold": "Ngưỡng Xa", + "label": "Trình Phát Hiện Đoạn Thẳng", + "description": "Tạo ra dữ liệu đoạn thẳng từ layer được chọn bằng model phát hiện đoạn thẳng MLSD." + }, + "content_shuffle": { + "description": "Xáo trộn nội dung của layer được chọn, giống với hiệu ứng kéo (liquify).", + "label": "Xáo Trộn Nội Dung", + "scale_factor": "Hệ Số Tỉ Lệ" + }, + "normal_map": { + "label": "Dữ Liệu Bình Thường", + "description": "Tạo một dữ liệu bình thường từ layer được chọn." + }, + "filters": "Bộ Lọc", + "autoProcess": "Tự Động Xử Lý", + "lineart_anime_edge_detection": { + "label": "Trình Phát Hiện Cạnh Lineart Anime", + "description": "Tạo ra dữ liệu cạnh từ layer được chọn bằng model phát hiện cạnh Lineart Anime." + }, + "filter": "Bộ Lọc", + "color_map": { + "description": "Tạo một dữ liệu màu từ layer được chọn.", + "tile_size": "Kích Thước Khối", + "label": "Dữ Liệu Màu" + }, + "advanced": "Nâng Cao", + "processingLayerWith": "Đang xử lý layer với bộ lọc {{type}}.", + "forMoreControl": "Để kiểm soát tốt hơn, bấm vào mục Nâng Cao bên dưới." + }, + "transform": { + "fitModeCover": "Che Phủ", + "fitModeFill": "Lấp Đầy", + "transform": "Biến Hình", + "fitToBbox": "Xếp Vừa Vào Hộp Giới Hạn", + "fitMode": "Chế Độ Xếp Vừa", + "apply": "Áp Dụng", + "cancel": "Huỷ Bỏ", + "fitModeContain": "Bao Gồm", + "reset": "Làm Mới" + }, + "HUD": { + "entityStatus": { + "isHidden": "{{title}} đang được ẩn", + "isTransforming": "{{title}} đang được biến đổi", + "isEmpty": "{{title}} đang trống", + "isLocked": "{{title}} đang bị khoá", + "isFiltering": "{{title}} đang được lọc", + "isDisabled": "{{title}} đang bị tắt" + }, + "bbox": "Hộp Giới Hạn", + "scaledBbox": "Hộp Giới Hạn Được Chia Tỉ Lệ" + }, + "settings": { + "isolatedLayerPreview": "Xem Trước Layer Bị Cô Lập", + "invertBrushSizeScrollDirection": "Cuộn Ngược Lại Cho Cỡ Cọ", + "snapToGrid": { + "on": "Bật", + "label": "Gắn Vào Lưới", + "off": "Tắt" + }, + "pressureSensitivity": "Độ Nhạy Áp Lực", + "preserveMask": { + "label": "Bảo Vệ Vùng Bao Phủ", + "alert": "Đang Bảo Vệ Vùng Bao Phủ" + }, + "isolatedLayerPreviewDesc": "Có hay không hiển thị riêng layer này khi thực hiện các thao tác như lọc hay biến đổi.", + "isolatedStagingPreview": "Xem Trước Tổng Quan Phần Cô Lập", + "isolatedPreview": "Xem Trước Phần Cô Lập" + }, + "tool": { + "eraser": "Tẩy", + "brush": "Cọ", + "rectangle": "Hình Chữ Nhật", + "bbox": "Hộp Giới Hạn", + "move": "Di Chuyển", + "view": "Công Cụ Xem", + "colorPicker": "Chọn Màu" + }, + "mergingLayers": "Đang gộp layer", + "controlLayerEmptyState": "Tải lên ảnh, kéo thả ảnh từ thư viện vào layer này, hoặc vẽ trên canvas để bắt đầu." + }, + "stylePresets": { + "negativePrompt": "Lệnh Tiêu Cực", + "viewModeTooltip": "Đây là cách lệnh của bạn sẽ trông giống khi dùng với mẫu trình bày được chọn hiện tại. Để chỉnh sửa lệnh, nhấp chuột vào bất kỳ nơi nào trên hộp văn bản.", + "flatten": "Chuyển mẫu trình bày đang chọn thành lệnh hiện tại", + "promptTemplatesDesc3": "Nếu bạn bỏ quá ký tự tạm thời, mẫu trình bày sẽ được thêm vào ở cuối lệnh.", + "positivePrompt": "Lệnh Tích Cực", + "private": "Cá Nhân", + "toggleViewMode": "Tắt Chế Độ Xem", + "acceptedColumnsKeys": "Các cột/từ khoá được chấp nhận:", + "positivePromptColumn": "'prompt' hoặc 'positive_prompt'", + "noMatchingTemplates": "Không có mẫu trình bày phù hợp", + "myTemplates": "Mẫu Trình Bày Của Tôi", + "type": "Loại", + "copyTemplate": "Sao Chép Mẫu Trình Bày", + "exportFailed": "Không thể tạo ra và tải xuống CSV", + "searchByName": "Tìm theo tên", + "sharedTemplates": "Mẫu Trình Bày Nhóm", + "shared": "Nhóm", + "uploadImage": "Tải Lên Ảnh", + "deleteTemplate": "Xoá Mẫu Trình Bày", + "editTemplate": "Chỉnh Sửa Mẫu Trình Bày", + "insertPlaceholder": "Thêm ký hiệu tạm thời", + "promptTemplatesDesc1": "Mẫu trình bày cho lệnh thêm từ ngữ cho lệnh bạn viết trong hộp lệnh.", + "preview": "Xem Trước", + "updatePromptTemplate": "Cập Nhật Mẫu Trình Bày Cho Lệnh", + "negativePromptColumn": "'negative_prompt'", + "useForTemplate": "Dùng Cho Mẫu Trình Bày Cho Lệnh", + "choosePromptTemplate": "Chọn Mẫu Trình Bày Cho Lệnh", + "defaultTemplates": "Mẫu Trình Bày Mặc Định", + "deleteTemplate2": "Bạn có chắc muốn xoá mẫu trình bày này? Không đi lại được đâu.", + "active": "Hiệu Lực", + "promptTemplatesDesc2": "Dùng ký tự tạm thời
{{placeholder}}
để cụ thể hoá nơi lệnh nên được bao gồm trong mẫu trình bày.", + "viewList": "Xem Danh Sách Mẫu Trình Bày", + "createPromptTemplate": "Tạo Mẫu Trình Bày Cho Lệnh", + "nameColumn": "'name'", + "name": "Tên", + "importTemplates": "Nhập Vào Mẫu Trình Bày Cho Lệnh (CSV/JSON)", + "clearTemplateSelection": "Dọn Sạch Mẫu Trình Bày Đã Chọn", + "exportDownloaded": "Xuất Mẫu Đã Tải Xuống", + "noTemplates": "Không có mẫu trình bày", + "promptTemplateCleared": "Mẫu Trình Bày Cho Lệnh Đã Được Dọn", + "deleteImage": "Xoá Hình Ảnh", + "exportPromptTemplates": "Xuất Mẫu Trình Bày Cho Lệnh Ra (CSV)", + "templateDeleted": "Mẫu trình bày cho lệnh đã được xoá", + "unableToDeleteTemplate": "Không thể xoá mẫu trình bày cho lệnh" + }, + "system": { + "enableLogging": "Bật Chế Độ Ghi Log", + "logNamespaces": { + "models": "Models", + "gallery": "Thư Viện", + "config": "Cấu Hình", + "queue": "Queue", + "workflows": "Workflow", + "events": "Sự Kiện", + "metadata": "Metadata", + "generation": "Generation", + "system": "Hệ Thống", + "canvas": "Canvas", + "logNamespaces": "Nơi Được Log" + }, + "logLevel": { + "logLevel": "Cấp Độ Log", + "error": "Error", + "fatal": "Fatal", + "trace": "Trace", + "warn": "Warn", + "debug": "Debug", + "info": "Info" + } + }, + "toast": { + "imageUploadFailed": "Tải Lên Ảnh Thất Bại", + "layerCopiedToClipboard": "Sao Chép Layer Vào Clipboard", + "uploadFailedInvalidUploadDesc_withCount_other": "Tối đa là {{count}} ảnh PNG hoặc JPEG.", + "imageCopied": "Ảnh Đã Được Sao Chép", + "sentToUpscale": "Chuyển Vào Upscale", + "unableToLoadImage": "Không Thể Tải Hình Ảnh", + "unableToLoadStylePreset": "Không Thể Tải Phong Cách Được Cài Đặt Trước", + "stylePresetLoaded": "Phong Cách Được Cài Đặt Trước Đã Tải", + "imageNotLoadedDesc": "Không thể tìm thấy ảnh", + "imageSaved": "Ảnh Đã Lưu", + "imageSavingFailed": "Lưu Ảnh Thất Bại", + "unableToLoadImageMetadata": "Không Thể Tải Metadata Của Ảnh", + "workflowLoaded": "Workflow Đã Tải", + "uploadFailed": "Tải Lên Thất Bại", + "uploadFailedInvalidUploadDesc": "Phải là ảnh PNG hoặc JPEG.", + "serverError": "Lỗi Server", + "addedToBoard": "Thêm vào tài nguyên của bảng {{name}}", + "sessionRef": "Phiên: {{sessionId}}", + "sentToCanvas": "Chuyển Vào Canvas", + "importFailed": "Nhập Vào Thất Bại", + "importSuccessful": "Nhập Vào Thành Công", + "workflowDeleted": "Workflow Đã Xoá", + "setControlImage": "Đặt làm ảnh điều khiển được", + "connected": "Kết Nối Đến Server", + "imageUploaded": "Ảnh Đã Được Tải Lên", + "invalidUpload": "Dữ Liệu Tải Lên Không Hợp Lệ", + "modelImportCanceled": "Nhập Vào Model Thất Bại", + "parameters": "Tham Số", + "parameterSet": "Gợi Lại Tham Số", + "parameterSetDesc": "Gợi lại {{parameter}}", + "loadedWithWarnings": "Đã Tải Workflow Với Cảnh Báo", + "outOfMemoryErrorDesc": "Thiết lập tạo sinh hiện tại đã vượt mức cho phép của thiết bị. Hãy điều chỉnh thiết lập và thử lại.", + "setNodeField": "Đặt làm vùng cho node", + "problemRetrievingWorkflow": "Có Vấn Đề Khi Lấy Lại Workflow", + "somethingWentWrong": "Có Vấn Đề Phát Sinh", + "problemDeletingWorkflow": "Có Vấn Đề Khi Xoá Workflow", + "parameterNotSet": "Tham Số Không Được Gợi Lại", + "parameterNotSetDescWithMessage": "Không thể gợi lại {{parameter}}: {{message}}", + "parametersNotSet": "Tham Số Không Được Gợi Lại", + "errorCopied": "Lỗi Khi Sao Chép", + "prunedQueue": "Cắt Bớt Hàng Đợi", + "imagesWillBeAddedTo": "Ảnh đã tải lên sẽ được thêm vào tài nguyên của bảng {{boardName}}.", + "baseModelChangedCleared_other": "Dọn sạch hoặc tắt {{count}} model phụ không tương thích", + "canceled": "Quá Trình Xử Lý Đã Huỷ", + "baseModelChanged": "Model Cơ Sở Đã Đổi", + "addedToUncategorized": "Thêm vào tài nguyên của bảng $t(boards.uncategorized)", + "linkCopied": "Đường Liên Kết Đã Được Sao Chép", + "outOfMemoryError": "Lỗi Vượt Quá Bộ Nhớ", + "layerSavedToAssets": "Lưu Layer Vào Khu Tài Nguyên", + "modelAddedSimple": "Đã Thêm Model Vào Hàng Đợi", + "parametersSet": "Tham Số Đã Được Gợi Lại", + "parameterNotSetDesc": "Không thể gợi lại {{parameter}}", + "problemCopyingImage": "Không Thể Sao Chép Ảnh", + "problemDownloadingImage": "Không Thể Tải Xuống Ảnh", + "problemCopyingLayer": "Không Thể Sao Chép Layer", + "problemSavingLayer": "Không Thể Lưu Layer" + }, + "ui": { + "tabs": { + "gallery": "Thư Viện", + "models": "Models", + "generation": "Generation (Máy Tạo Sinh)", + "upscaling": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)", + "canvas": "Canvas (Vùng Ảnh)", + "upscalingTab": "$t(common.tab) $t(ui.tabs.upscaling)", + "modelsTab": "$t(common.tab) $t(ui.tabs.models)", + "queue": "Queue (Hàng Đợi)", + "workflows": "Workflow (Luồng Làm Việc)", + "workflowsTab": "$t(common.tab) $t(ui.tabs.workflows)" + } + }, + "workflows": { + "delete": "Xoá", + "descending": "Giảm Dần", + "created": "Ngày Tạo", + "edit": "Chỉnh Sửa", + "download": "Tải Xuống", + "copyShareLink": "Sao Chép Liên Kết Chia Sẻ", + "deleteWorkflow2": "Bạn có chắc muốn xoá workflow này không? Không có đi lại được đâu.", + "workflowSaved": "Workflow Đã Được Lưu", + "saveWorkflowAs": "Lưu Workflow Như", + "downloadWorkflow": "Lưu Vào Tệp", + "noWorkflows": "Không Có Workflow", + "problemLoading": "Có Vấn Đề Khi Tải Workflow", + "clearWorkflowSearchFilter": "Xoá Workflow Khỏi Bộ Lọc Tìm Kiếm", + "defaultWorkflows": "Workflow Mặc Định", + "userWorkflows": "Workflow Của Người Dùng", + "projectWorkflows": "Dự Án Workflow", + "savingWorkflow": "Đang Lưu Workflow...", + "ascending": "Tăng Dần", + "loading": "Đang Tải Workflow", + "chooseWorkflowFromLibrary": "Chọn Workflow Từ Túi Đồ", + "workflows": "Workflow", + "copyShareLinkForWorkflow": "Sao Chép Liên Kết Chia Sẻ Cho Workflow", + "openWorkflow": "Mở Workflow", + "name": "Tên", + "unnamedWorkflow": "Workflow Vô Danh", + "saveWorkflow": "Lưu Workflow", + "problemSavingWorkflow": "Có Vấn Đề Khi Lưu Workflow", + "noDescription": "Không có mô tả", + "updated": "Ngày Cập Nhật", + "uploadWorkflow": "Tải Từ Tệp", + "autoLayout": "Bố Trí Tự Động", + "loadWorkflow": "$t(common.load) Workflow", + "searchWorkflows": "Tìm Workflow", + "newWorkflowCreated": "Workflow Mới Được Tạo", + "workflowCleared": "Đã Dọn Dẹp Workflow", + "loadFromGraph": "Tải Workflow Từ Đồ Thị", + "convertGraph": "Chuyển Đổi Đồ Thị", + "saveWorkflowToProject": "Lưu Workflow Vào Dự Án", + "workflowName": "Tên Workflow", + "workflowLibrary": "Túi Đồ", + "opened": "Ngày Mở", + "deleteWorkflow": "Xoá Workflow", + "workflowEditorMenu": "Menu Biên Tập Viên Workflow", + "uploadAndSaveWorkflow": "Tải Lên Túi Đồ" + }, + "upscaling": { + "missingUpscaleInitialImage": "Thiếu ảnh dùng để upscale", + "scale": "Tỉ Lệ", + "upscale": "Upscale (Nâng Cấp Chất Lượng Hình Ảnh)", + "upscaleModel": "Model Upscale", + "upscaleModelDesc": "Model upscale (ảnh sang ảnh)", + "missingUpscaleModel": "Thiếu model upscale", + "missingTileControlNetModel": "Không có model ControlNet Tile phù hợp đã cài đặt", + "creativity": "Độ Sáng Tạo", + "structure": "Độ Cấu Trúc", + "exceedsMaxSize": "Thiết lập upscale vượt quá giới hạn kích thước tối đa", + "tileControlNetModelDesc": "Model ControlNet Tile dành cho phiên bản model chính đã chọn", + "exceedsMaxSizeDetails": "Giới hạn upscale tối đa là {{maxUpscaleDimension}}x{{maxUpscaleDimension}} pixel. Hãy thử lại ảnh nhỏ hơn hoặc giảm thang đo upscale xuống.", + "postProcessingModel": "Model Xử Lý Hậu Kỳ", + "mainModelDesc": "Model chính (SD1.5 hoặc SDXL)", + "postProcessingMissingModelWarning": "Đến Trình Quản Lý Model để tải model xử lý hậu kỳ (ảnh sang ảnh).", + "missingModelsWarning": "Đến Trình Quản Lý Model để tải model cần thiết:", + "incompatibleBaseModel": "Phiên bản model chính không được hỗ trợ để upscale", + "incompatibleBaseModelDesc": "Upscale chỉ hỗ trợ cho model phiên bản SD1.5 và SDXL. Đổi model chính để bật lại tính năng upscale." + }, + "newUserExperience": { + "toGetStartedLocal": "Để bắt đầu, hãy chắc chắn đã tải xuống hoặc thêm vào model cần để chạy Invoke. Sau đó, nhập lệnh vào hộp và nhấp chuột vào Kích Hoạt để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào Thư Viện hoặc chỉnh sửa chúng ở Canvas.", + "gettingStartedSeries": "Cần thêm hướng dẫn? Xem thử Bắt Đầu Làm Quen để biết thêm mẹo khai thác toàn bộ tiềm năng của Invoke Studio.", + "toGetStarted": "Để bắt đầu, hãy nhập lệnh vào hộp và nhấp chuột vào Kích Hoạt để tạo ra bức ảnh đầu tiên. Chọn một mẫu trình bày cho lệnh để cải thiện kết quả. Bạn có thể chọn để lưu ảnh trực tiếp vào Thư Viện hoặc chỉnh sửa chúng ở Canvas.", + "downloadStarterModels": "Tải Xuống Model Khởi Đầu", + "importModels": "Nhập Vào Model", + "noModelsInstalled": "Hình như bạn không có model nào được tải cả" + }, + "whatsNew": { + "whatsNewInInvoke": "Có Gì Mới Ở Invoke", + "readReleaseNotes": "Đọc Ghi Chú Phát Hành", + "watchRecentReleaseVideos": "Xem Video Phát Hành Mới Nhất", + "watchUiUpdatesOverview": "Xem Tổng Quan Về Những Cập Nhật Cho Giao Diện Người Dùng", + "items": [ + "SD 3.5: Hỗ trợ cho Từ ngữ Sang Hình Ảnh trong Workflow với phiên bản SD 3.5 Medium hoặc Large.", + "Canvas: Hợp lý hoá cách xử lý Layer Điều Khiển Được và cải thiện thiết lập điều khiển mặc định." + ] + }, + "upsell": { + "professional": "Chuyên Nghiệp", + "inviteTeammates": "Thêm Đồng Đội", + "shareAccess": "Chia Sẻ Quyền Truy Cập", + "professionalUpsell": "Không có sẵn Phiên Bản Chuyên Nghiệp cho Invoke. Bấm vào đây hoặc đến invoke.com/pricing để thêm chi tiết." + } +} diff --git a/invokeai/frontend/web/public/locales/zh_CN.json b/invokeai/frontend/web/public/locales/zh_CN.json new file mode 100644 index 0000000000000000000000000000000000000000..1d1802d4d78df71801e7518b8b194148dec1777d --- /dev/null +++ b/invokeai/frontend/web/public/locales/zh_CN.json @@ -0,0 +1,1767 @@ +{ + "common": { + "hotkeysLabel": "快捷键", + "languagePickerLabel": "语言", + "reportBugLabel": "反馈错误", + "settingsLabel": "设置", + "img2img": "图生图", + "nodes": "工作流", + "upload": "上传", + "load": "加载", + "statusDisconnected": "未连接", + "accept": "同意", + "cancel": "取消", + "dontAskMeAgain": "不要再次询问", + "areYouSure": "你确认吗?", + "random": "随机", + "openInNewTab": "在新的标签页打开", + "back": "返回", + "githubLabel": "GitHub", + "discordLabel": "Discord", + "txt2img": "文生图", + "postprocessing": "后期处理", + "loading": "加载中", + "linear": "线性的", + "batch": "批次管理器", + "communityLabel": "社区", + "modelManager": "模型管理器", + "imageFailedToLoad": "无法加载图像", + "learnMore": "了解更多", + "advanced": "高级", + "t2iAdapter": "T2I Adapter", + "ipAdapter": "IP Adapter", + "controlNet": "ControlNet", + "on": "开", + "auto": "自动", + "checkpoint": "Checkpoint", + "inpaint": "内补重绘", + "simple": "简单", + "template": "模板", + "outputs": "输出", + "data": "数据", + "safetensors": "Safetensors", + "outpaint": "外扩绘制", + "details": "详情", + "format": "格式", + "unknown": "未知", + "folder": "文件夹", + "error": "错误", + "installed": "已安装", + "file": "文件", + "somethingWentWrong": "出了点问题", + "copyError": "$t(gallery.copy) 错误", + "input": "输入", + "notInstalled": "非 $t(common.installed)", + "delete": "删除", + "updated": "已上传", + "save": "保存", + "created": "已创建", + "prevPage": "上一页", + "unknownError": "未知错误", + "direction": "指向", + "orderBy": "排序方式:", + "nextPage": "下一页", + "saveAs": "保存为", + "ai": "ai", + "or": "或", + "aboutDesc": "使用 Invoke 工作?来看看:", + "add": "添加", + "copy": "复制", + "localSystem": "本地系统", + "aboutHeading": "掌握你的创造力", + "enabled": "已启用", + "disabled": "已禁用", + "red": "红", + "editor": "编辑器", + "positivePrompt": "正向提示词", + "negativePrompt": "反向提示词", + "selected": "选中的", + "green": "绿", + "blue": "蓝", + "goTo": "前往", + "dontShowMeThese": "请勿显示这些内容", + "beta": "测试版", + "toResolve": "解决", + "tab": "标签页", + "apply": "应用", + "edit": "编辑", + "off": "关", + "loadingImage": "正在加载图片", + "ok": "确定", + "placeholderSelectAModel": "选择一个模型", + "close": "关闭", + "reset": "重设", + "none": "无", + "new": "新建", + "view": "视图", + "alpha": "透明度通道", + "openInViewer": "在查看器中打开", + "clipboard": "剪贴板" + }, + "gallery": { + "galleryImageSize": "预览大小", + "gallerySettings": "预览设置", + "autoSwitchNewImages": "自动切换到新图像", + "noImagesInGallery": "无图像可用于显示", + "deleteImage_other": "删除{{count}}张图片", + "deleteImagePermanent": "删除的图片无法被恢复。", + "assets": "素材", + "autoAssignBoardOnClick": "点击后自动分配面板", + "featuresWillReset": "如果您删除该图像,这些功能会立即被重置。", + "loading": "加载中", + "unableToLoad": "无法加载图库", + "currentlyInUse": "该图像目前在以下功能中使用:", + "copy": "复制", + "download": "下载", + "downloadSelection": "下载所选内容", + "noImageSelected": "无选中的图像", + "deleteSelection": "删除所选内容", + "image": "图像", + "drop": "弃用", + "dropOrUpload": "$t(gallery.drop) 或上传", + "dropToUpload": "$t(gallery.drop) 以上传", + "unstarImage": "取消收藏图像", + "starImage": "收藏图像", + "alwaysShowImageSizeBadge": "始终显示图像尺寸", + "selectForCompare": "选择以比较", + "selectAnImageToCompare": "选择一个图像进行比较", + "slider": "滑块", + "sideBySide": "并排", + "bulkDownloadFailed": "下载失败", + "bulkDownloadRequested": "准备下载", + "bulkDownloadRequestedDesc": "您的下载请求正在准备中,这可能需要一些时间。", + "bulkDownloadRequestFailed": "下载准备过程中出现问题", + "viewerImage": "查看器图像", + "compareImage": "对比图像", + "openInViewer": "在查看器中打开", + "hover": "悬停", + "selectAllOnPage": "选择本页全部", + "swapImages": "交换图像", + "exitBoardSearch": "退出面板搜索", + "exitSearch": "退出图像搜索", + "oldestFirst": "最旧在前", + "sortDirection": "排序方向", + "showStarredImagesFirst": "优先显示收藏的图片", + "compareHelp3": "按 C 键对调正在比较的图片。", + "showArchivedBoards": "显示已归档的面板", + "newestFirst": "最新在前", + "compareHelp4": "按 ZEsc 键退出。", + "searchImages": "按元数据搜索", + "jump": "跳过", + "compareHelp2": "按 M 键切换不同的比较模式。", + "displayBoardSearch": "板块搜索", + "displaySearch": "图像搜索", + "stretchToFit": "拉伸以适应", + "exitCompare": "退出对比", + "compareHelp1": "在点击图库中的图片或使用箭头键切换比较图片时,请按住Alt 键。", + "go": "运行", + "boardsSettings": "画板设置", + "imagesSettings": "画廊图片设置", + "gallery": "画廊", + "move": "移动", + "imagesTab": "您在Invoke中创建和保存的图片。", + "openViewer": "打开查看器", + "closeViewer": "关闭查看器", + "assetsTab": "您已上传用于项目的文件。" + }, + "hotkeys": { + "searchHotkeys": "检索快捷键", + "noHotkeysFound": "未找到快捷键", + "clearSearch": "清除检索项", + "app": { + "cancelQueueItem": { + "title": "取消", + "desc": "取消当前正在处理的队列项目。" + }, + "selectQueueTab": { + "title": "选择队列标签", + "desc": "选择队列标签。" + }, + "toggleLeftPanel": { + "desc": "显示或隐藏左侧面板。", + "title": "开关左侧面板" + }, + "resetPanelLayout": { + "title": "重设面板布局", + "desc": "将左侧和右侧面板重置为默认大小和布局。" + }, + "togglePanels": { + "title": "开关面板", + "desc": "同时显示或隐藏左右两侧的面板。" + }, + "selectWorkflowsTab": { + "title": "选择工作流标签", + "desc": "选择工作流标签。" + }, + "selectModelsTab": { + "title": "选择模型标签", + "desc": "选择模型标签。" + }, + "toggleRightPanel": { + "title": "开关右侧面板", + "desc": "显示或隐藏右侧面板。" + }, + "clearQueue": { + "title": "清除队列", + "desc": "取消并清除所有队列条目。" + }, + "selectCanvasTab": { + "title": "选择画布标签", + "desc": "选择画布标签。" + }, + "invokeFront": { + "desc": "将生成请求排队,添加到队列的前面。", + "title": "调用(前台)" + }, + "selectUpscalingTab": { + "title": "选择放大选项卡", + "desc": "选择高清放大选项卡。" + }, + "focusPrompt": { + "title": "聚焦提示", + "desc": "将光标焦点移动到正向提示。" + }, + "title": "应用程序", + "invoke": { + "title": "调用", + "desc": "将生成请求排队,添加到队列的末尾。" + } + }, + "canvas": { + "selectBrushTool": { + "title": "画笔工具", + "desc": "选择画笔工具。" + }, + "selectEraserTool": { + "title": "橡皮擦工具", + "desc": "选择橡皮擦工具。" + }, + "title": "画布", + "selectColorPickerTool": { + "title": "拾色器工具", + "desc": "选择拾色器工具。" + }, + "fitBboxToCanvas": { + "title": "使边界框适应画布", + "desc": "缩放并调整视图以适应边界框。" + }, + "setZoomTo400Percent": { + "title": "缩放到400%", + "desc": "将画布的缩放设置为400%。" + }, + "setZoomTo800Percent": { + "desc": "将画布的缩放设置为800%。", + "title": "缩放到800%" + }, + "redo": { + "desc": "重做上一次画布操作。", + "title": "重做" + }, + "nextEntity": { + "title": "下一层", + "desc": "在列表中选择下一层。" + }, + "selectRectTool": { + "title": "矩形工具", + "desc": "选择矩形工具。" + }, + "selectViewTool": { + "title": "视图工具", + "desc": "选择视图工具。" + }, + "prevEntity": { + "desc": "在列表中选择上一层。", + "title": "上一层" + }, + "transformSelected": { + "desc": "变换所选图层。", + "title": "变换" + }, + "selectBboxTool": { + "title": "边界框工具", + "desc": "选择边界框工具。" + }, + "setZoomTo200Percent": { + "title": "缩放到200%", + "desc": "将画布的缩放设置为200%。" + }, + "applyFilter": { + "title": "应用过滤器", + "desc": "将待处理的过滤器应用于所选图层。" + }, + "filterSelected": { + "title": "过滤器", + "desc": "对所选图层进行过滤。仅适用于栅格层和控制层。" + }, + "cancelFilter": { + "title": "取消过滤器", + "desc": "取消待处理的过滤器。" + }, + "incrementToolWidth": { + "title": "增加工具宽度", + "desc": "增加所选的画笔或橡皮擦工具的宽度。" + }, + "decrementToolWidth": { + "desc": "减少所选的画笔或橡皮擦工具的宽度。", + "title": "减少工具宽度" + }, + "selectMoveTool": { + "title": "移动工具", + "desc": "选择移动工具。" + }, + "setFillToWhite": { + "title": "将颜色设置为白色", + "desc": "将当前工具的颜色设置为白色。" + }, + "cancelTransform": { + "desc": "取消待处理的变换。", + "title": "取消变换" + }, + "applyTransform": { + "title": "应用变换", + "desc": "将待处理的变换应用于所选图层。" + }, + "setZoomTo100Percent": { + "title": "缩放到100%", + "desc": "将画布的缩放设置为100%。" + }, + "resetSelected": { + "title": "重置图层", + "desc": "重置选定的图层。仅适用于修复蒙版和区域指导。" + }, + "undo": { + "title": "撤消", + "desc": "撤消上一次画布操作。" + }, + "quickSwitch": { + "title": "图层快速切换", + "desc": "在最后两个选定的图层之间切换。如果某个图层被书签标记,则始终在该图层和最后一个未标记的图层之间切换。" + }, + "fitLayersToCanvas": { + "title": "使图层适应画布", + "desc": "缩放并调整视图以适应所有可见图层。" + }, + "deleteSelected": { + "title": "删除图层", + "desc": "删除选定的图层。" + } + }, + "hotkeys": "快捷键", + "workflows": { + "pasteSelection": { + "title": "粘贴", + "desc": "粘贴复制的节点和边。" + }, + "title": "工作流", + "addNode": { + "title": "添加节点", + "desc": "打开添加节点菜单。" + }, + "copySelection": { + "desc": "复制选定的节点和边。", + "title": "复制" + }, + "pasteSelectionWithEdges": { + "title": "带边缘的粘贴", + "desc": "粘贴复制的节点、边,以及与复制的节点连接的所有边。" + }, + "selectAll": { + "title": "全选", + "desc": "选择所有节点和边。" + }, + "deleteSelection": { + "title": "删除", + "desc": "删除选定的节点和边。" + }, + "undo": { + "title": "撤销", + "desc": "撤销上一个工作流操作。" + }, + "redo": { + "desc": "重做上一个工作流操作。", + "title": "重做" + } + }, + "gallery": { + "title": "画廊", + "galleryNavUp": { + "title": "向上导航", + "desc": "在图库网格中向上导航,选择该图像。如果在页面顶部,则转到上一页。" + }, + "galleryNavUpAlt": { + "title": "向上导航(比较图像)", + "desc": "与向上导航相同,但选择比较图像,如果比较模式尚未打开,则将其打开。" + }, + "selectAllOnPage": { + "desc": "选择当前页面上的所有图像。", + "title": "选页面上的所有内容" + }, + "galleryNavDownAlt": { + "title": "向下导航(比较图像)", + "desc": "与向下导航相同,但选择比较图像,如果比较模式尚未打开,则将其打开。" + }, + "galleryNavLeftAlt": { + "title": "向左导航(比较图像)", + "desc": "与向左导航相同,但选择比较图像,如果比较模式尚未打开,则将其打开。" + }, + "clearSelection": { + "title": "清除选择", + "desc": "清除当前的选择(如果有的话)。" + }, + "deleteSelection": { + "title": "删除", + "desc": "删除所有选定的图像。默认情况下,系统会提示您确认删除。如果这些图像当前在应用中使用,系统将发出警告。" + }, + "galleryNavLeft": { + "title": "向左导航", + "desc": "在图库网格中向左导航,选择该图像。如果处于行的第一张图像,转到上一行。如果处于页面的第一张图像,转到上一页。" + }, + "galleryNavRight": { + "title": "向右导航", + "desc": "在图库网格中向右导航,选择该图像。如果在行的最后一张图像,转到下一行。如果在页面的最后一张图像,转到下一页。" + }, + "galleryNavDown": { + "desc": "在图库网格中向下导航,选择该图像。如果在页面底部,则转到下一页。", + "title": "向下导航" + }, + "galleryNavRightAlt": { + "title": "向右导航(比较图像)", + "desc": "与向右导航相同,但选择比较图像,如果比较模式尚未打开,则将其打开。" + } + }, + "viewer": { + "toggleMetadata": { + "desc": "显示或隐藏当前图像的元数据覆盖。", + "title": "显示/隐藏元数据" + }, + "recallPrompts": { + "desc": "召回当前图像的正面和负面提示。", + "title": "召回提示" + }, + "toggleViewer": { + "title": "显示/隐藏图像查看器", + "desc": "显示或隐藏图像查看器。仅在画布选项卡上可用。" + }, + "recallAll": { + "desc": "召回当前图像的所有元数据。", + "title": "召回所有元数据" + }, + "recallSeed": { + "title": "召回种子", + "desc": "召回当前图像的种子。" + }, + "swapImages": { + "title": "交换比较图像", + "desc": "交换正在比较的图像。" + }, + "nextComparisonMode": { + "title": "下一个比较模式", + "desc": "环浏览比较模式。" + }, + "loadWorkflow": { + "title": "加载工作流", + "desc": "加载当前图像的保存工作流程(如果有的话)。" + }, + "title": "图像查看器", + "remix": { + "title": "混合", + "desc": "召回当前图像的所有元数据,除了种子。" + }, + "useSize": { + "title": "使用尺寸", + "desc": "使用当前图像的尺寸作为边界框尺寸。" + }, + "runPostprocessing": { + "title": "行后处理", + "desc": "对当前图像运行所选的后处理。" + } + } + }, + "modelManager": { + "modelManager": "模型管理器", + "model": "模型", + "modelUpdated": "模型已更新", + "manual": "手动", + "name": "名称", + "description": "描述", + "config": "配置", + "width": "宽度", + "height": "高度", + "addModel": "添加模型", + "availableModels": "可用模型", + "search": "检索", + "load": "加载", + "active": "活跃", + "selected": "已选择", + "delete": "删除", + "deleteModel": "删除模型", + "deleteConfig": "删除配置", + "deleteMsg1": "您确定要将该模型从 InvokeAI 删除吗?", + "deleteMsg2": "磁盘中放置在 InvokeAI 根文件夹的 checkpoint 文件会被删除。若你正在使用自定义目录,则不会从磁盘中删除他们。", + "convertToDiffusersHelpText1": "模型会被转换成 🧨 Diffusers 格式。", + "convertToDiffusersHelpText2": "这个过程会替换你的模型管理器的入口中相同 Diffusers 版本的模型。", + "convertToDiffusersHelpText4": "这是一次性的处理过程。根据你电脑的配置不同耗时 30 - 60 秒。", + "convertToDiffusersHelpText6": "你希望转换这个模型吗?", + "allModels": "全部模型", + "convertToDiffusers": "转换为 Diffusers", + "repo_id": "项目 ID", + "modelConverted": "模型已转换", + "convertToDiffusersHelpText3": "磁盘中放置在 InvokeAI 根文件夹的 checkpoint 文件会被删除. 若位于自定义目录, 则不会受影响.", + "convertToDiffusersHelpText5": "请确认你有足够的磁盘空间,模型大小通常在 2 GB - 7 GB 之间。", + "convert": "转换", + "none": "无", + "modelDeleteFailed": "模型删除失败", + "selectModel": "选择模型", + "settings": "设置", + "syncModels": "同步模型", + "modelDeleted": "模型已删除", + "modelUpdateFailed": "模型更新失败", + "modelConversionFailed": "模型转换失败", + "baseModel": "基底模型", + "convertingModelBegin": "模型转换中. 请稍候.", + "predictionType": "预测类型", + "advanced": "高级", + "modelType": "模型类别", + "variant": "变体", + "vae": "VAE", + "alpha": "Alpha", + "vaePrecision": "VAE 精度", + "noModelSelected": "无选中的模型", + "modelImageUpdateFailed": "模型图像更新失败", + "scanFolder": "扫描文件夹", + "path": "路径", + "pathToConfig": "配置路径", + "cancel": "取消", + "install": "安装", + "simpleModelPlaceholder": "本地文件或diffusers文件夹的URL或路径", + "noModelsInstalledDesc1": "安装模型时使用", + "inplaceInstallDesc": "安装模型时,不复制文件,直接从原位置加载。如果关闭此选项,模型文件将在安装过程中被复制到Invoke管理的模型文件夹中.", + "installAll": "安装全部", + "noModelsInstalled": "无已安装的模型", + "urlOrLocalPathHelper": "链接应该指向单个文件.本地路径可以指向单个文件,或者对于单个扩散模型(diffusers model),可以指向一个文件夹.", + "modelSettings": "模型设置", + "scanPlaceholder": "本地文件夹路径", + "installRepo": "安装仓库", + "modelImageDeleted": "模型图像已删除", + "modelImageDeleteFailed": "模型图像删除失败", + "scanFolderHelper": "此文件夹将进行递归扫描以寻找模型.对于大型文件夹,这可能需要一些时间.", + "scanResults": "扫描结果", + "noMatchingModels": "无匹配的模型", + "pruneTooltip": "清理队列中已完成的导入任务", + "urlOrLocalPath": "链接或本地路径", + "localOnly": "仅本地", + "huggingFaceHelper": "如果在此代码库中检测到多个模型,系统将提示您选择其中一个进行安装.", + "imageEncoderModelId": "图像编码器模型ID", + "modelImageUpdated": "模型图像已更新", + "modelName": "模型名称", + "prune": "清理", + "repoVariant": "代码库版本", + "defaultSettings": "默认设置", + "inplaceInstall": "就地安装", + "main": "主界面", + "starterModels": "初始模型", + "installQueue": "安装队列", + "mainModelTriggerPhrases": "主模型触发词", + "typePhraseHere": "在此输入触发词", + "triggerPhrases": "触发词", + "metadata": "元数据", + "deleteModelImage": "删除模型图片", + "edit": "编辑", + "source": "来源", + "uploadImage": "上传图像", + "addModels": "添加模型", + "textualInversions": "文本逆向生成", + "upcastAttention": "是否为高精度权重", + "defaultSettingsSaved": "默认设置已保存", + "huggingFacePlaceholder": "所有者或模型名称", + "huggingFaceRepoID": "HuggingFace仓库ID", + "loraTriggerPhrases": "LoRA 触发词", + "ipAdapters": "IP适配器", + "spandrelImageToImage": "图生图(Spandrel)", + "starterModelsInModelManager": "您可以在模型管理器中找到初始模型", + "noDefaultSettings": "此模型没有配置默认设置。请访问模型管理器添加默认设置。", + "clipEmbed": "CLIP 嵌入", + "defaultSettingsOutOfSync": "某些设置与模型的默认值不匹配:", + "restoreDefaultSettings": "点击以使用模型的默认设置。", + "usingDefaultSettings": "使用模型的默认设置", + "huggingFace": "HuggingFace", + "hfTokenInvalid": "HF 令牌无效或缺失", + "hfTokenLabel": "HuggingFace 令牌(某些模型所需)", + "hfTokenHelperText": "使用某些模型需要 HF 令牌。点击这里创建或获取你的令牌。", + "includesNModels": "包括 {{n}} 个模型及其依赖项", + "starterBundles": "启动器包", + "learnMoreAboutSupportedModels": "了解更多关于我们支持的模型的信息", + "hfForbidden": "您没有权限访问这个 HF 模型", + "hfTokenInvalidErrorMessage": "无效或缺失 HuggingFace 令牌。", + "hfTokenRequired": "您正在尝试下载一个需要有效 HuggingFace 令牌的模型。", + "hfTokenSaved": "HF 令牌已保存", + "hfForbiddenErrorMessage": "我们建议访问 HuggingFace.com 上的仓库页面。所有者可能要求您接受条款才能下载。", + "hfTokenUnableToVerifyErrorMessage": "无法验证 HuggingFace 令牌。这可能是由于网络错误导致的。请稍后再试。", + "hfTokenInvalidErrorMessage2": "在这里更新它。 ", + "hfTokenUnableToVerify": "无法验证 HF 令牌", + "skippingXDuplicates_other": "跳过 {{count}} 个重复项", + "starterBundleHelpText": "轻松安装所有用于启动基础模型所需的模型,包括主模型、ControlNets、IP适配器等。选择一个安装包时,会跳过已安装的模型。", + "installingBundle": "正在安装模型包", + "installingModel": "正在安装模型", + "installingXModels_other": "正在安装 {{count}} 个模型" + }, + "parameters": { + "images": "图像", + "steps": "步数", + "cfgScale": "CFG 等级", + "width": "宽度", + "height": "高度", + "seed": "种子", + "shuffle": "随机生成种子", + "noiseThreshold": "噪声阈值", + "perlinNoise": "Perlin 噪声", + "type": "种类", + "strength": "强度", + "upscaling": "放大", + "scale": "等级", + "imageFit": "使生成图像长宽适配初始图像", + "scaleBeforeProcessing": "处理前缩放", + "scaledWidth": "缩放宽度", + "scaledHeight": "缩放长度", + "infillMethod": "填充方法", + "tileSize": "方格尺寸", + "downloadImage": "下载图像", + "usePrompt": "使用提示", + "useSeed": "使用种子", + "useAll": "使用所有参数", + "info": "信息", + "showOptionsPanel": "显示侧栏浮窗 (O 或 T)", + "seamlessYAxis": "无缝平铺 Y 轴", + "seamlessXAxis": "无缝平铺 X 轴", + "denoisingStrength": "去噪强度", + "cancel": { + "cancel": "取消" + }, + "copyImage": "复制图片", + "symmetry": "对称性", + "positivePromptPlaceholder": "正向提示词", + "negativePromptPlaceholder": "负向提示词", + "scheduler": "调度器", + "general": "通用", + "controlNetControlMode": "控制模式", + "maskBlur": "遮罩模糊", + "invoke": { + "noNodesInGraph": "节点图中无节点", + "noModelSelected": "无已选中的模型", + "invoke": "调用", + "missingInputForField": "{{nodeLabel}} -> {{fieldLabel}} 缺失输入", + "systemDisconnected": "系统已断开连接", + "missingNodeTemplate": "缺失节点模板", + "missingFieldTemplate": "缺失模板", + "addingImagesTo": "添加图像到", + "noPrompts": "没有已生成的提示词", + "layer": { + "ipAdapterNoModelSelected": "未选择IP adapter", + "controlAdapterNoModelSelected": "未选择Control Adapter模型", + "rgNoPromptsOrIPAdapters": "无文本提示或IP Adapters", + "controlAdapterIncompatibleBaseModel": "Control Adapter的基础模型不兼容", + "ipAdapterIncompatibleBaseModel": "IP Adapter的基础模型不兼容", + "ipAdapterNoImageSelected": "未选择IP Adapter图像", + "rgNoRegion": "未选择区域", + "t2iAdapterIncompatibleBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}},边界框宽度为 {{width}}", + "t2iAdapterIncompatibleScaledBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}},缩放后的边界框高度为 {{height}}", + "t2iAdapterIncompatibleBboxHeight": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}},边界框高度为 {{height}}", + "t2iAdapterIncompatibleScaledBboxWidth": "$t(parameters.invoke.layer.t2iAdapterRequiresDimensionsToBeMultipleOf) {{multiple}},缩放后的边界框宽度为 {{width}}" + }, + "canvasIsFiltering": "画布正在过滤", + "fluxModelIncompatibleScaledBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16),缩放后的边界框高度为 {{height}}", + "noCLIPEmbedModelSelected": "未为FLUX生成选择CLIP嵌入模型", + "noFLUXVAEModelSelected": "未为FLUX生成选择VAE模型", + "canvasIsRasterizing": "画布正在栅格化", + "canvasIsCompositing": "画布正在合成", + "fluxModelIncompatibleBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16),边界框宽度为 {{width}}", + "fluxModelIncompatibleScaledBboxWidth": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16),缩放后的边界框宽度为 {{width}}", + "noT5EncoderModelSelected": "未为FLUX生成选择T5编码器模型", + "fluxModelIncompatibleBboxHeight": "$t(parameters.invoke.fluxRequiresDimensionsToBeMultipleOf16),边界框高度为 {{height}}", + "canvasIsTransforming": "画布正在变换" + }, + "patchmatchDownScaleSize": "缩小", + "clipSkip": "CLIP 跳过层", + "useCpuNoise": "使用 CPU 噪声", + "coherenceMode": "模式", + "imageActions": "图像操作", + "iterations": "迭代数", + "cfgRescaleMultiplier": "CFG 重缩放倍数", + "useSize": "使用尺寸", + "setToOptimalSize": "优化模型大小", + "setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (可能过小)", + "lockAspectRatio": "锁定纵横比", + "swapDimensions": "交换尺寸", + "aspect": "纵横", + "setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (可能过大)", + "remixImage": "重新混合图像", + "coherenceEdgeSize": "边缘尺寸", + "postProcessing": "后处理(Shift + U)", + "sendToUpscale": "发送到放大", + "processImage": "处理图像", + "infillColorValue": "填充颜色", + "coherenceMinDenoise": "最小去噪", + "sendToCanvas": "发送到画布", + "disabledNoRasterContent": "已禁用(无栅格内容)", + "optimizedImageToImage": "优化的图生图", + "guidance": "引导", + "gaussianBlur": "高斯模糊", + "recallMetadata": "调用元数据", + "boxBlur": "方框模糊", + "staged": "已分阶段处理" + }, + "settings": { + "models": "模型", + "displayInProgress": "显示处理中的图像", + "confirmOnDelete": "删除时确认", + "resetWebUI": "重置网页界面", + "resetWebUIDesc1": "重置网页只会重置浏览器中缓存的图像和设置,不会删除任何图像。", + "resetWebUIDesc2": "如果图像没有显示在图库中,或者其他东西不工作,请在GitHub上提交问题之前尝试重置。", + "resetComplete": "网页界面已重置。", + "showProgressInViewer": "在查看器中展示过程图片", + "antialiasProgressImages": "对过程图像应用抗锯齿", + "generation": "生成", + "ui": "用户界面", + "general": "通用", + "developer": "开发者", + "beta": "Beta", + "clearIntermediates": "清除中间产物", + "clearIntermediatesDesc3": "您图库中的图像不会被删除。", + "clearIntermediatesDesc2": "中间产物图像是生成过程中产生的副产品,与图库中的结果图像不同。清除中间产物可释放磁盘空间。", + "intermediatesCleared_other": "已清除 {{count}} 个中间产物", + "clearIntermediatesDesc1": "清除中间产物会重置您的画布和 ControlNet 状态。", + "intermediatesClearedFailed": "清除中间产物时出现问题", + "clearIntermediatesWithCount_other": "清除 {{count}} 个中间产物", + "clearIntermediatesDisabled": "队列为空才能清理中间产物", + "enableNSFWChecker": "启用成人内容检测器", + "enableInvisibleWatermark": "启用不可见水印", + "enableInformationalPopovers": "启用信息弹窗", + "reloadingIn": "重新加载中", + "informationalPopoversDisabled": "信息提示框已禁用", + "informationalPopoversDisabledDesc": "信息提示框已被禁用.请在设置中重新启用.", + "enableModelDescriptions": "在下拉菜单中启用模型描述", + "confirmOnNewSession": "新会话时确认", + "modelDescriptionsDisabledDesc": "下拉菜单中的模型描述已被禁用。可在设置中启用。", + "modelDescriptionsDisabled": "下拉菜单中的模型描述已禁用" + }, + "toast": { + "uploadFailed": "上传失败", + "imageCopied": "图像已复制", + "parametersNotSet": "参数未恢复", + "uploadFailedInvalidUploadDesc": "必须是单个 PNG 或 JPEG 图像。", + "connected": "服务器连接", + "parameterSet": "参数已恢复", + "parameterNotSet": "参数未恢复", + "serverError": "服务器错误", + "canceled": "处理取消", + "problemCopyingImage": "无法复制图像", + "modelAddedSimple": "模型已加入队列", + "loadedWithWarnings": "已加载带有警告的工作流", + "setControlImage": "设为控制图像", + "setNodeField": "设为节点字段", + "imageUploaded": "图像已上传", + "addedToBoard": "添加到{{name}}的资产中", + "workflowLoaded": "工作流已加载", + "imageUploadFailed": "图像上传失败", + "baseModelChangedCleared_other": "已清除或禁用{{count}}个不兼容的子模型", + "invalidUpload": "无效的上传", + "problemDeletingWorkflow": "删除工作流时出现问题", + "workflowDeleted": "已删除工作流", + "problemRetrievingWorkflow": "检索工作流时发生问题", + "baseModelChanged": "基础模型已更改", + "problemDownloadingImage": "无法下载图像", + "outOfMemoryError": "内存不足错误", + "parameters": "参数", + "parameterNotSetDescWithMessage": "无法恢复 {{parameter}}: {{message}}", + "parameterSetDesc": "已恢复 {{parameter}}", + "parameterNotSetDesc": "无法恢复{{parameter}}", + "sessionRef": "会话: {{sessionId}}", + "somethingWentWrong": "出现错误", + "prunedQueue": "已清理队列", + "outOfMemoryErrorDesc": "您当前的生成设置已超出系统处理能力.请调整设置后再次尝试.", + "parametersSet": "参数已恢复", + "errorCopied": "错误信息已复制", + "modelImportCanceled": "模型导入已取消", + "importFailed": "导入失败", + "importSuccessful": "导入成功", + "layerSavedToAssets": "图层已保存到资产", + "sentToUpscale": "已发送到放大处理", + "addedToUncategorized": "已添加到看板 $t(boards.uncategorized) 的资产中", + "linkCopied": "链接已复制", + "uploadFailedInvalidUploadDesc_withCount_other": "最多只能上传 {{count}} 张 PNG 或 JPEG 图像。", + "problemSavingLayer": "无法保存图层", + "unableToLoadImage": "无法加载图像", + "imageNotLoadedDesc": "无法找到图像", + "unableToLoadStylePreset": "无法加载样式预设", + "stylePresetLoaded": "样式预设已加载", + "problemCopyingLayer": "无法复制图层", + "sentToCanvas": "已发送到画布", + "unableToLoadImageMetadata": "无法加载图像元数据", + "imageSaved": "图像已保存", + "imageSavingFailed": "图像保存失败", + "layerCopiedToClipboard": "图层已复制到剪贴板", + "imagesWillBeAddedTo": "上传的图像将添加到看板 {{boardName}} 的资产中。" + }, + "accessibility": { + "invokeProgressBar": "Invoke 进度条", + "reset": "重置", + "nextImage": "下一张图片", + "uploadImage": "上传图片", + "previousImage": "上一张图片", + "menu": "菜单", + "mode": "模式", + "resetUI": "$t(accessibility.reset) UI", + "createIssue": "创建问题", + "about": "关于", + "submitSupportTicket": "提交支持工单", + "toggleRightPanel": "切换右侧面板(G)", + "uploadImages": "上传图片", + "toggleLeftPanel": "开关左侧面板(T)" + }, + "nodes": { + "zoomInNodes": "放大", + "loadWorkflow": "加载工作流", + "zoomOutNodes": "缩小", + "reloadNodeTemplates": "重载节点模板", + "fitViewportNodes": "自适应视图", + "showMinimapnodes": "显示缩略图", + "hideMinimapnodes": "隐藏缩略图", + "showLegendNodes": "显示字段类型图例", + "hideLegendNodes": "隐藏字段类型图例", + "downloadWorkflow": "下载工作流 JSON", + "workflowDescription": "简述", + "versionUnknown": " 未知版本", + "noNodeSelected": "无选中的节点", + "addNode": "添加节点", + "unableToValidateWorkflow": "无法验证工作流", + "noOutputRecorded": "无已记录输出", + "updateApp": "升级 App", + "colorCodeEdgesHelp": "根据连接区域对边缘编码颜色", + "workflowContact": "联系", + "animatedEdges": "边缘动效", + "nodeTemplate": "节点模板", + "unableToLoadWorkflow": "无法加载工作流", + "snapToGrid": "对齐网格", + "noFieldsLinearview": "线性视图中未添加任何字段", + "nodeSearch": "检索节点", + "version": "版本", + "validateConnections": "验证连接和节点图", + "inputMayOnlyHaveOneConnection": "输入仅能有一个连接", + "notes": "注释", + "nodeOutputs": "节点输出", + "currentImageDescription": "在节点编辑器中显示当前图像", + "validateConnectionsHelp": "防止建立无效连接和调用无效节点图", + "problemSettingTitle": "设定标题时出现问题", + "noConnectionInProgress": "没有正在进行的连接", + "workflowVersion": "版本", + "fieldTypesMustMatch": "类型必须匹配", + "workflow": "工作流", + "animatedEdgesHelp": "为选中边缘和其连接的选中节点的边缘添加动画", + "unknownTemplate": "未知模板", + "removeLinearView": "从线性视图中移除", + "workflowTags": "标签", + "fullyContainNodesHelp": "节点必须完全位于选择框中才能被选中", + "workflowValidation": "工作流验证错误", + "executionStateInProgress": "处理中", + "executionStateError": "错误", + "executionStateCompleted": "已完成", + "workflowAuthor": "作者", + "currentImage": "当前图像", + "workflowName": "名称", + "cannotConnectInputToInput": "无法将输入连接到输入", + "workflowNotes": "注释", + "cannotConnectOutputToOutput": "无法将输出连接到输出", + "connectionWouldCreateCycle": "连接将创建一个循环", + "cannotConnectToSelf": "无法连接自己", + "notesDescription": "添加有关您的工作流的注释", + "unknownField": "未知", + "colorCodeEdges": "边缘颜色编码", + "unknownNode": "未知节点", + "addNodeToolTip": "添加节点 (Shift+A, Space)", + "loadingNodes": "加载节点中...", + "snapToGridHelp": "移动时将节点与网格对齐", + "workflowSettings": "工作流编辑器设置", + "scheduler": "调度器", + "missingTemplate": "无效的节点:类型为 {{type}} 的节点 {{node}} 缺失模板(无已安装模板?)", + "nodeOpacity": "节点不透明度", + "updateNode": "更新节点", + "edge": "边缘", + "noWorkflow": "无工作流", + "nodeType": "节点类型", + "fullyContainNodes": "完全包含节点来进行选择", + "node": "节点", + "collection": "合集", + "string": "字符串", + "mismatchedVersion": "无效的节点:类型为 {{type}} 的节点 {{node}} 版本不匹配(是否尝试更新?)", + "cannotDuplicateConnection": "无法创建重复的连接", + "enum": "Enum (枚举)", + "float": "浮点", + "integer": "整数", + "boolean": "布尔值", + "ipAdapter": "IP-Adapter", + "updateAllNodes": "更新节点", + "unableToUpdateNodes_other": "{{count}} 个节点无法完成更新", + "inputFieldTypeParseError": "无法解析 {{node}} 的输入类型 {{field}}。({{message}})", + "unsupportedArrayItemType": "不支持的数组类型 \"{{type}}\"", + "addLinearView": "添加到线性视图", + "targetNodeFieldDoesNotExist": "无效的边缘:{{node}} 的目标/输入区域 {{field}} 不存在", + "unsupportedMismatchedUnion": "合集或标量类型与基类 {{firstType}} 和 {{secondType}} 不匹配", + "allNodesUpdated": "已更新所有节点", + "sourceNodeDoesNotExist": "无效的边缘:{{node}} 的源/输出节点不存在", + "unableToExtractEnumOptions": "无法提取枚举选项", + "unableToParseFieldType": "无法解析类型", + "outputFieldTypeParseError": "无法解析 {{node}} 的输出类型 {{field}}。({{message}})", + "sourceNodeFieldDoesNotExist": "无效的边缘:{{node}} 的源/输出区域 {{field}} 不存在", + "unableToGetWorkflowVersion": "无法获取工作流架构版本", + "nodePack": "节点包", + "unableToExtractSchemaNameFromRef": "无法从参考中提取架构名", + "unknownOutput": "未知输出:{{name}}", + "unknownErrorValidatingWorkflow": "验证工作流时出现未知错误", + "collectionFieldType": "{{name}}(合集)", + "unknownNodeType": "未知节点类型", + "targetNodeDoesNotExist": "无效的边缘:{{node}} 的目标/输入节点不存在", + "unknownFieldType": "$t(nodes.unknownField) 类型:{{type}}", + "collectionOrScalarFieldType": "{{name}} (单一项目或项目集合)", + "nodeVersion": "节点版本", + "deletedInvalidEdge": "已删除无效的边缘 {{source}} -> {{target}}", + "unknownInput": "未知输入:{{name}}", + "prototypeDesc": "此调用是一个原型 (prototype)。它可能会在本项目更新期间发生破坏性更改,并且随时可能被删除。", + "betaDesc": "此调用尚处于测试阶段。在稳定之前,它可能会在项目更新期间发生破坏性更改。本项目计划长期支持这种调用。", + "newWorkflow": "新建工作流", + "newWorkflowDesc": "是否创建一个新的工作流?", + "newWorkflowDesc2": "当前工作流有未保存的更改。", + "unsupportedAnyOfLength": "联合(union)数据类型数目过多 ({{count}})", + "resetToDefaultValue": "重置为默认值", + "clearWorkflowDesc2": "您当前的工作流有未保存的更改.", + "missingNode": "缺少调用节点", + "missingInvocationTemplate": "缺少调用模版", + "noFieldsViewMode": "此工作流程未选择任何要显示的字段.请查看完整工作流程以进行配置.", + "reorderLinearView": "调整线性视图顺序", + "viewMode": "在线性视图中使用", + "showEdgeLabelsHelp": "在边缘上显示标签,指示连接的节点", + "cannotMixAndMatchCollectionItemTypes": "集合项目类型不能混用", + "missingFieldTemplate": "缺少字段模板", + "editMode": "在工作流编辑器中编辑", + "showEdgeLabels": "显示边缘标签", + "clearWorkflowDesc": "是否清除当前工作流并创建新的?", + "graph": "图表", + "noGraph": "无图表", + "edit": "编辑", + "clearWorkflow": "清除工作流", + "imageAccessError": "无法找到图像 {{image_name}},正在恢复默认设置", + "boardAccessError": "无法找到面板 {{board_id}},正在恢复默认设置", + "modelAccessError": "无法找到模型 {{key}},正在恢复默认设置", + "noWorkflows": "无工作流程", + "workflowHelpText": "需要帮助?请查看我们的《工作流程入门指南》。", + "noMatchingWorkflows": "无匹配的工作流程", + "saveToGallery": "保存到图库", + "singleFieldType": "{{name}}(单一模型)" + }, + "queue": { + "status": "状态", + "cancelTooltip": "取消当前项目", + "queueEmpty": "队列为空", + "pauseSucceeded": "处理器已暂停", + "in_progress": "处理中", + "queueFront": "添加到队列前", + "completed": "已完成", + "queueBack": "添加到队列", + "cancelFailed": "取消项目时出现问题", + "pauseFailed": "暂停处理器时出现问题", + "clearFailed": "清除队列时出现问题", + "clearSucceeded": "队列已清除", + "pause": "暂停", + "cancelSucceeded": "项目已取消", + "queue": "队列", + "batch": "批处理", + "clearQueueAlertDialog": "清空队列将立即取消所有正在处理的项目,并完全清空队列。待处理的过滤器将被取消。", + "pending": "待定", + "completedIn": "完成于", + "resumeFailed": "恢复处理器时出现问题", + "clear": "清除", + "prune": "修剪", + "total": "总计", + "canceled": "已取消", + "pruneFailed": "修剪队列时出现问题", + "cancelBatchSucceeded": "批处理已取消", + "clearTooltip": "取消并清除所有项目", + "current": "当前", + "pauseTooltip": "暂停处理器", + "failed": "已失败", + "cancelItem": "取消项目", + "next": "下一个", + "cancelBatch": "取消批处理", + "cancel": "取消", + "resumeSucceeded": "处理器已恢复", + "resumeTooltip": "恢复处理器", + "resume": "恢复", + "cancelBatchFailed": "取消批处理时出现问题", + "clearQueueAlertDialog2": "您确定要清除队列吗?", + "item": "项目", + "pruneSucceeded": "从队列修剪 {{item_count}} 个已完成的项目", + "notReady": "无法排队", + "batchFailedToQueue": "批次加入队列失败", + "batchQueued": "加入队列的批次", + "front": "前", + "pruneTooltip": "修剪 {{item_count}} 个已完成的项目", + "batchQueuedDesc_other": "在队列的 {{direction}} 中添加了 {{count}} 个会话", + "graphQueued": "节点图已加入队列", + "back": "后", + "session": "会话", + "enqueueing": "队列中的批次", + "graphFailedToQueue": "节点图加入队列失败", + "batchFieldValues": "批处理值", + "time": "时间", + "openQueue": "打开队列", + "prompts_other": "提示词", + "iterations_other": "迭代", + "generations_other": "生成", + "canvas": "画布", + "workflows": "工作流", + "generation": "生成", + "other": "其他", + "gallery": "画廊", + "destination": "目标存储", + "upscaling": "高清放大", + "origin": "来源" + }, + "sdxl": { + "refinerStart": "Refiner 开始作用时机", + "scheduler": "调度器", + "cfgScale": "CFG 等级", + "negStylePrompt": "负向样式提示词", + "noModelsAvailable": "无可用模型", + "negAestheticScore": "负向美学评分", + "denoisingStrength": "去噪强度", + "refinermodel": "Refiner 模型", + "posAestheticScore": "正向美学评分", + "concatPromptStyle": "链接提示词 & 样式", + "loading": "加载中...", + "steps": "步数", + "posStylePrompt": "正向样式提示词", + "refiner": "Refiner", + "freePromptStyle": "手动输入样式提示词", + "refinerSteps": "精炼步数" + }, + "metadata": { + "positivePrompt": "正向提示词", + "negativePrompt": "负向提示词", + "generationMode": "生成模式", + "Threshold": "噪声阈值", + "metadata": "元数据", + "strength": "图生图强度", + "seed": "种子", + "imageDetails": "图像详细信息", + "model": "模型", + "noImageDetails": "未找到图像详细信息", + "cfgScale": "CFG 等级", + "height": "高度", + "noMetaData": "未找到元数据", + "width": "宽度", + "createdBy": "创建者是", + "workflow": "工作流", + "steps": "步数", + "scheduler": "调度器", + "recallParameters": "召回参数", + "noRecallParameters": "未找到要召回的参数", + "vae": "VAE", + "cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)", + "allPrompts": "所有提示", + "parsingFailed": "解析失败", + "recallParameter": "调用{{label}}", + "imageDimensions": "图像尺寸", + "parameterSet": "已设置参数{{parameter}}", + "guidance": "指导", + "seamlessXAxis": "无缝 X 轴", + "seamlessYAxis": "无缝 Y 轴", + "canvasV2Metadata": "画布" + }, + "models": { + "noMatchingModels": "无相匹配的模型", + "loading": "加载中", + "noMatchingLoRAs": "无相匹配的 LoRA", + "noModelsAvailable": "无可用模型", + "selectModel": "选择一个模型", + "noRefinerModelsInstalled": "无已安装的 SDXL Refiner 模型", + "noLoRAsInstalled": "无已安装的 LoRA", + "addLora": "添加 LoRA", + "lora": "LoRA", + "defaultVAE": "默认 VAE", + "concepts": "概念" + }, + "boards": { + "autoAddBoard": "自动添加面板", + "topMessage": "该面板包含的图像正使用以下功能:", + "move": "移动", + "menuItemAutoAdd": "自动添加到该面板", + "myBoard": "我的面板", + "searchBoard": "检索面板...", + "noMatching": "没有相匹配的面板", + "selectBoard": "选择一个面板", + "cancel": "取消", + "addBoard": "添加面板", + "bottomMessage": "删除该面板并且将其对应的图像将重置当前使用该面板的所有功能。", + "uncategorized": "未分类", + "changeBoard": "更改面板", + "loading": "加载中...", + "clearSearch": "清除检索", + "downloadBoard": "下载面板", + "deleteBoardOnly": "仅删除面板", + "deleteBoard": "删除面板", + "deleteBoardAndImages": "删除面板和图像", + "deletedBoardsCannotbeRestored": "删除的面板无法恢复。选择“仅删除面板”选项后,相关图片将会被移至未分类区域。", + "movingImagesToBoard_other": "移动 {{count}} 张图像到面板:", + "selectedForAutoAdd": "已选中自动添加", + "hideBoards": "隐藏面板", + "noBoards": "没有{{boardType}}类型的面板", + "unarchiveBoard": "恢复面板", + "viewBoards": "查看面板", + "addPrivateBoard": "创建私密面板", + "addSharedBoard": "创建共享面板", + "boards": "面板", + "imagesWithCount_other": "{{count}}张图片", + "deletedPrivateBoardsCannotbeRestored": "删除的面板无法恢复。选择“仅删除面板”后,相关图片将会被移至图片创建者的私密未分类区域。", + "private": "私密面板", + "shared": "共享面板", + "archiveBoard": "归档面板", + "archived": "已归档", + "assetsWithCount_other": "{{count}}项资源", + "updateBoardError": "更新画板出错" + }, + "dynamicPrompts": { + "seedBehaviour": { + "perPromptDesc": "每次生成图像使用不同的种子", + "perIterationLabel": "每次迭代的种子", + "perIterationDesc": "每次迭代使用不同的种子", + "perPromptLabel": "每张图像的种子", + "label": "种子行为" + }, + "maxPrompts": "最大提示词数", + "dynamicPrompts": "动态提示词", + "promptsPreview": "提示词预览", + "showDynamicPrompts": "显示动态提示词", + "loading": "生成动态提示词中..." + }, + "popovers": { + "compositingMaskAdjustments": { + "heading": "遮罩调整", + "paragraphs": [ + "调整遮罩。" + ] + }, + "paramRatio": { + "heading": "纵横比", + "paragraphs": [ + "生成图像的尺寸纵横比。", + "图像尺寸(单位:像素)建议 SD 1.5 模型使用等效 512x512 的尺寸,SDXL 模型使用等效 1024x1024 的尺寸。" + ] + }, + "noiseUseCPU": { + "heading": "使用 CPU 噪声", + "paragraphs": [ + "选择由 CPU 或 GPU 生成噪声。", + "启用 CPU 噪声后,特定的种子将会在不同的设备上产生下相同的图像。", + "启用 CPU 噪声不会对性能造成影响。" + ] + }, + "paramVAEPrecision": { + "heading": "VAE 精度", + "paragraphs": [ + "在VAE编码和解码过程中使用的精度.", + "Fp16/半精度更高效,但可能会造成图像的一些微小差异." + ] + }, + "compositingCoherenceMode": { + "heading": "模式", + "paragraphs": [ + "用于将新生成的遮罩区域与原图像融合的方法." + ] + }, + "controlNetResizeMode": { + "heading": "缩放模式", + "paragraphs": [ + "调整Control Adapter输入图像大小以适应输出图像尺寸的方法." + ] + }, + "clipSkip": { + "paragraphs": [ + "跳过CLIP模型的层数.", + "某些模型更适合结合CLIP Skip功能使用." + ], + "heading": "CLIP 跳过层" + }, + "paramModel": { + "heading": "模型", + "paragraphs": [ + "用于图像生成的模型.不同的模型经过训练,专门用于产生不同的美学效果和内容." + ] + }, + "paramIterations": { + "heading": "迭代数", + "paragraphs": [ + "生成图像的数量。", + "若启用动态提示词,每种提示词都会生成这么多次。" + ] + }, + "compositingCoherencePass": { + "heading": "一致性层", + "paragraphs": [ + "第二轮去噪有助于合成内补/外扩图像。" + ] + }, + "paramNegativeConditioning": { + "paragraphs": [ + "生成过程会避免生成负向提示词中的概念。使用此选项来使输出排除部分质量或对象。", + "支持 Compel 语法 和 embeddings。" + ], + "heading": "负向提示词" + }, + "compositingBlurMethod": { + "heading": "模糊方式", + "paragraphs": [ + "应用于遮罩区域的模糊方法。" + ] + }, + "paramScheduler": { + "heading": "调度器", + "paragraphs": [ + "生成过程中所使用的调度器.", + "每个调度器决定了在生成过程中如何逐步向图像添加噪声,或者如何根据模型的输出更新样本." + ] + }, + "controlNetWeight": { + "heading": "权重", + "paragraphs": [ + "Control Adapter的权重.权重越高,对最终图像的影响越大." + ] + }, + "paramCFGScale": { + "heading": "CFG 等级", + "paragraphs": [ + "控制提示对生成过程的影响程度.", + "较高的CFG比例值可能会导致生成结果过度饱和和扭曲. " + ] + }, + "paramSteps": { + "heading": "步数", + "paragraphs": [ + "每次生成迭代执行的步数。", + "通常情况下步数越多结果越好,但需要更多生成时间。" + ] + }, + "paramPositiveConditioning": { + "heading": "正向提示词", + "paragraphs": [ + "引导生成过程。您可以使用任何单词或短语。", + "Compel 语法、动态提示词语法和 embeddings。" + ] + }, + "lora": { + "heading": "LoRA", + "paragraphs": [ + "与基础模型结合使用的轻量级模型." + ] + }, + "infillMethod": { + "heading": "填充方法", + "paragraphs": [ + "在重绘过程中使用的填充方法." + ] + }, + "controlNetBeginEnd": { + "heading": "开始 / 结束步数百分比", + "paragraphs": [ + "去噪过程中将应用Control Adapter 的部分.", + "通常,在去噪过程初期应用的Control Adapters用于指导整体构图,而在后期应用的Control Adapters则用于调整细节。" + ] + }, + "scaleBeforeProcessing": { + "heading": "处理前缩放", + "paragraphs": [ + "\"自动\"选项会在图像生成之前将所选区域调整到最适合模型的大小.", + "\"手动\"选项允许您在图像生成之前自行选择所选区域的宽度和高度." + ] + }, + "paramDenoisingStrength": { + "heading": "去噪强度", + "paragraphs": [ + "为输入图像添加的噪声量。", + "输入 0 会导致结果图像和输入完全相同,输入 1 则会生成全新的图像。", + "当没有具有可见内容的栅格图层时,此设置将被忽略。" + ] + }, + "paramSeed": { + "heading": "种子", + "paragraphs": [ + "控制用于生成的起始噪声。", + "禁用\"随机\"选项,以使用相同的生成设置产生一致的结果." + ] + }, + "controlNetControlMode": { + "heading": "控制模式", + "paragraphs": [ + "在提示词和ControlNet之间分配更多的权重." + ] + }, + "dynamicPrompts": { + "paragraphs": [ + "动态提示词可将单个提示词解析为多个。", + "基本语法示例:\"a {red|green|blue} ball\"。这会产生三种提示词:\"a red ball\", \"a green ball\" 和 \"a blue ball\"。", + "可以在单个提示词中多次使用该语法,但务必请使用最大提示词设置来控制生成的提示词数量。" + ], + "heading": "动态提示词" + }, + "paramVAE": { + "paragraphs": [ + "用于将 AI 输出转换成最终图像的模型。" + ], + "heading": "VAE" + }, + "dynamicPromptsSeedBehaviour": { + "paragraphs": [ + "控制生成提示词时种子的使用方式。", + "每次迭代过程都会使用一个唯一的种子。使用本选项来探索单个种子的提示词变化。", + "例如,如果你有 5 种提示词,则生成的每个图像都会使用相同种子。", + "为每张图像使用独立的唯一种子。这可以提供更多变化。" + ], + "heading": "种子行为" + }, + "dynamicPromptsMaxPrompts": { + "heading": "最大提示词数量", + "paragraphs": [ + "限制动态提示词可生成的提示词数量。" + ] + }, + "controlNet": { + "paragraphs": [ + "ControlNet 为生成过程提供引导,为生成具有受控构图、结构、样式的图像提供帮助,具体的功能由所选的模型决定。" + ], + "heading": "ControlNet" + }, + "paramCFGRescaleMultiplier": { + "heading": "CFG 重缩放倍数", + "paragraphs": [ + "CFG指导的重缩放乘数,适用于使用零终端信噪比(ztsnr)训练的模型.", + "对于这些模型,建议的数值为0.7." + ] + }, + "imageFit": { + "paragraphs": [ + "将初始图像调整到与输出图像相同的宽度和高度.建议启用此功能." + ], + "heading": "将初始图像适配到输出大小" + }, + "paramAspect": { + "paragraphs": [ + "生成图像的宽高比.调整宽高比会相应地更新图像的宽度和高度.", + "选择\"优化\"将把图像的宽度和高度设置为所选模型的最优尺寸." + ], + "heading": "宽高比" + }, + "refinerSteps": { + "paragraphs": [ + "在图像生成过程中的细化阶段将执行的步骤数.", + "与生成步骤相似." + ], + "heading": "步数" + }, + "compositingMaskBlur": { + "heading": "遮罩模糊", + "paragraphs": [ + "遮罩的模糊范围." + ] + }, + "compositingCoherenceMinDenoise": { + "paragraphs": [ + "连贯模式下的最小去噪力度", + "在图像修复或重绘过程中,连贯区域的最小去噪力度" + ], + "heading": "最小去噪" + }, + "loraWeight": { + "paragraphs": [ + "LoRA的权重,权重越高对最终图像的影响越大." + ], + "heading": "权重" + }, + "paramHrf": { + "heading": "启用高分辨率修复", + "paragraphs": [ + "以高于模型最优分辨率的大分辨率生成高质量图像.这通常用于防止生成图像中出现重复内容." + ] + }, + "compositingCoherenceEdgeSize": { + "paragraphs": [ + "连贯处理的边缘尺寸." + ], + "heading": "边缘尺寸" + }, + "paramWidth": { + "paragraphs": [ + "生成图像的宽度.必须是8的倍数." + ], + "heading": "宽度" + }, + "refinerScheduler": { + "paragraphs": [ + "在图像生成过程中的细化阶段所使用的调度程序.", + "与生成调度程序相似." + ], + "heading": "调度器" + }, + "seamlessTilingXAxis": { + "paragraphs": [ + "沿水平轴将图像进行无缝平铺." + ], + "heading": "无缝平铺X轴" + }, + "paramUpscaleMethod": { + "heading": "放大方法", + "paragraphs": [ + "用于高分辨率修复的图像放大方法." + ] + }, + "refinerModel": { + "paragraphs": [ + "在图像生成过程中的细化阶段所使用的模型.", + "与生成模型相似." + ], + "heading": "精炼模型" + }, + "paramHeight": { + "paragraphs": [ + "生成图像的高度.必须是8的倍数." + ], + "heading": "高" + }, + "patchmatchDownScaleSize": { + "heading": "缩小", + "paragraphs": [ + "在填充之前图像缩小的程度.", + "较高的缩小比例会提升处理速度,但可能会降低图像质量." + ] + }, + "seamlessTilingYAxis": { + "heading": "Y轴上的无缝平铺", + "paragraphs": [ + "沿垂直轴将图像进行无缝平铺." + ] + }, + "ipAdapterMethod": { + "paragraphs": [ + "当前IP Adapter的应用方法." + ], + "heading": "方法" + }, + "controlNetProcessor": { + "paragraphs": [ + "处理输入图像以引导生成过程的方法.不同的处理器会在生成图像中产生不同的效果或风格." + ], + "heading": "处理器" + }, + "refinerPositiveAestheticScore": { + "paragraphs": [ + "根据训练数据,对生成结果进行加权,使其更接近于具有高美学评分的图像." + ], + "heading": "正面美学评分" + }, + "refinerStart": { + "paragraphs": [ + "在图像生成过程中精炼阶段开始被使用的时刻.", + "0表示精炼器将全程参与图像生成,0.8表示细化器仅在生成过程的最后20%阶段被使用." + ], + "heading": "精炼开始" + }, + "refinerCfgScale": { + "paragraphs": [ + "控制提示对生成过程的影响程度.", + "与生成CFG Scale相似." + ], + "heading": "CFG比例" + }, + "structure": { + "heading": "结构", + "paragraphs": [ + "结构决定了输出图像在多大程度上保持原始图像的布局.较低的结构设置允许进行较大的变化,而较高的结构设置则会严格保持原始图像的构图和布局." + ] + }, + "creativity": { + "paragraphs": [ + "创造力决定了模型在添加细节时的自由度.较低的创造力会使生成结果更接近原始图像,而较高的创造力则允许更多的变化.在使用提示时,较高的创造力会增加提示对生成结果的影响." + ], + "heading": "创造力" + }, + "refinerNegativeAestheticScore": { + "paragraphs": [ + "根据训练数据,对生成结果进行加权,使其更接近于具有低美学评分的图像." + ], + "heading": "负面美学评分" + }, + "upscaleModel": { + "heading": "放大模型", + "paragraphs": [ + "上采样模型在添加细节之前将图像放大到输出尺寸.虽然可以使用任何支持的上采样模型,但有些模型更适合处理特定类型的图像,例如照片或线条画." + ] + }, + "scale": { + "heading": "缩放", + "paragraphs": [ + "比例控制决定了输出图像的大小,它是基于输入图像分辨率的倍数来计算的.例如对一张1024x1024的图像进行2倍上采样,将会得到一张2048x2048的输出图像." + ] + }, + "globalReferenceImage": { + "heading": "全局参考图像", + "paragraphs": [ + "应用参考图像以影响整个生成过程。" + ] + }, + "rasterLayer": { + "paragraphs": [ + "画布的基于像素的内容,用于图像生成过程。" + ], + "heading": "栅格图层" + }, + "regionalGuidanceAndReferenceImage": { + "paragraphs": [ + "对于区域引导,使用画笔引导全局提示中的元素应出现的位置。", + "对于区域参考图像,使用画笔将参考图像应用到特定区域。" + ], + "heading": "区域引导与区域参考图像" + }, + "regionalReferenceImage": { + "heading": "区域参考图像", + "paragraphs": [ + "使用画笔将参考图像应用到特定区域。" + ] + }, + "optimizedDenoising": { + "heading": "优化的图生图", + "paragraphs": [ + "启用‘优化的图生图’功能,可在使用 Flux 模型进行图生图和图像修复转换时提供更平滑的降噪强度调节。此设置可以提高对图像变化程度的控制能力,但如果您更倾向于使用标准的降噪强度调节方式,也可以关闭此功能。该设置仍在优化中,目前处于测试阶段。" + ] + }, + "inpainting": { + "paragraphs": [ + "控制由降噪强度引导的修改区域。" + ], + "heading": "图像重绘" + }, + "regionalGuidance": { + "heading": "区域引导", + "paragraphs": [ + "使用画笔引导全局提示中的元素应出现的位置。" + ] + }, + "fluxDevLicense": { + "heading": "非商业许可", + "paragraphs": [ + "FLUX.1 [dev] 模型受 FLUX [dev] 非商业许可协议的约束。如需在 Invoke 中将此模型类型用于商业目的,请访问我们的网站了解更多信息。" + ] + }, + "paramGuidance": { + "paragraphs": [ + "控制提示对生成过程的影响程度。", + "较高的引导值可能导致过度饱和,而过高或过低的引导值可能导致生成结果失真。引导仅适用于FLUX DEV模型。" + ], + "heading": "引导" + } + }, + "invocationCache": { + "disable": "禁用", + "misses": "缓存未中", + "enableFailed": "启用调用缓存时出现问题", + "invocationCache": "调用缓存", + "clearSucceeded": "调用缓存已清除", + "enableSucceeded": "调用缓存已启用", + "clearFailed": "清除调用缓存时出现问题", + "hits": "缓存命中", + "disableSucceeded": "调用缓存已禁用", + "disableFailed": "禁用调用缓存时出现问题", + "enable": "启用", + "clear": "清除", + "maxCacheSize": "最大缓存大小", + "cacheSize": "缓存大小", + "useCache": "使用缓存" + }, + "hrf": { + "enableHrf": "启用高分辨率修复", + "upscaleMethod": "放大方法", + "metadata": { + "strength": "高分辨率修复强度", + "enabled": "高分辨率修复已启用", + "method": "高分辨率修复方法" + }, + "hrf": "高分辨率修复" + }, + "workflows": { + "saveWorkflowAs": "保存工作流为", + "workflowEditorMenu": "工作流编辑器菜单", + "workflowName": "工作流名称", + "saveWorkflow": "保存工作流", + "openWorkflow": "打开工作流", + "clearWorkflowSearchFilter": "清除工作流检索过滤器", + "workflowLibrary": "工作流库", + "downloadWorkflow": "保存到文件", + "workflowSaved": "已保存工作流", + "unnamedWorkflow": "未命名的工作流", + "savingWorkflow": "保存工作流中...", + "problemLoading": "加载工作流时出现问题", + "loading": "加载工作流中", + "searchWorkflows": "检索工作流", + "problemSavingWorkflow": "保存工作流时出现问题", + "deleteWorkflow": "删除工作流", + "workflows": "工作流", + "noDescription": "无描述", + "uploadWorkflow": "从文件中加载", + "newWorkflowCreated": "已创建新的工作流", + "name": "名称", + "created": "已创建", + "ascending": "升序", + "descending": "降序", + "updated": "已更新", + "opened": "已打开", + "workflowCleared": "工作流已清除", + "saveWorkflowToProject": "保存工作流到项目", + "noWorkflows": "无工作流", + "convertGraph": "转换图表", + "loadWorkflow": "$t(common.load) 工作流", + "loadFromGraph": "从图表加载工作流", + "autoLayout": "自动布局", + "edit": "编辑", + "copyShareLinkForWorkflow": "复制工作流程的分享链接", + "delete": "删除", + "download": "下载", + "defaultWorkflows": "默认工作流程", + "userWorkflows": "用户工作流程", + "projectWorkflows": "项目工作流程", + "copyShareLink": "复制分享链接", + "chooseWorkflowFromLibrary": "从库中选择工作流程", + "uploadAndSaveWorkflow": "上传到库", + "deleteWorkflow2": "您确定要删除此工作流程吗?此操作无法撤销。" + }, + "accordions": { + "compositing": { + "infillTab": "内补", + "coherenceTab": "一致性层", + "title": "合成" + }, + "control": { + "title": "Control" + }, + "generation": { + "title": "生成" + }, + "advanced": { + "title": "高级", + "options": "$t(accordions.advanced.title) 选项" + }, + "image": { + "title": "图像" + } + }, + "prompt": { + "addPromptTrigger": "添加提示词触发器", + "noMatchingTriggers": "没有匹配的触发器", + "compatibleEmbeddings": "兼容的嵌入" + }, + "controlLayers": { + "autoNegative": "自动反向", + "moveForward": "向前移动", + "moveBackward": "向后移动", + "regionalGuidance": "区域导向", + "moveToBack": "移动到后面", + "moveToFront": "移动到前面", + "addLayer": "添加层", + "deletePrompt": "删除提示词", + "addPositivePrompt": "添加 $t(controlLayers.prompt)", + "addNegativePrompt": "添加 $t(controlLayers.negativePrompt)", + "rectangle": "矩形", + "opacity": "透明度", + "canvas": "画布", + "fitBboxToLayers": "将边界框适配到图层", + "cropLayerToBbox": "将图层裁剪到边界框", + "saveBboxToGallery": "将边界框保存到图库", + "savedToGalleryOk": "已保存到图库", + "saveLayerToAssets": "将图层保存到资产", + "removeBookmark": "移除书签", + "regional": "区域", + "saveCanvasToGallery": "将画布保存到图库", + "global": "全局", + "bookmark": "添加书签以快速切换" + }, + "ui": { + "tabs": { + "generation": "生成", + "queue": "队列", + "canvas": "画布", + "upscaling": "放大中", + "workflows": "工作流", + "models": "模型" + } + }, + "upscaling": { + "structure": "结构", + "upscaleModel": "放大模型", + "missingUpscaleModel": "缺少放大模型", + "missingTileControlNetModel": "没有安装有效的tile ControlNet 模型", + "missingUpscaleInitialImage": "缺少用于放大的原始图像", + "creativity": "创造力", + "postProcessingModel": "后处理模型", + "scale": "缩放", + "tileControlNetModelDesc": "根据所选的主模型架构,选择相应的Tile ControlNet模型", + "upscaleModelDesc": "图像放大(图像到图像转换)模型", + "postProcessingMissingModelWarning": "请访问 模型管理器来安装一个后处理(图像到图像转换)模型.", + "missingModelsWarning": "请访问模型管理器 安装所需的模型:", + "mainModelDesc": "主模型(SD1.5或SDXL架构)", + "exceedsMaxSize": "放大设置超出了最大尺寸限制", + "exceedsMaxSizeDetails": "最大放大限制是 {{maxUpscaleDimension}}x{{maxUpscaleDimension}} 像素.请尝试一个较小的图像或减少您的缩放选择.", + "upscale": "放大" + }, + "upsell": { + "inviteTeammates": "邀请团队成员", + "professional": "专业", + "professionalUpsell": "可在 Invoke 的专业版中使用.点击此处或访问 invoke.com/pricing 了解更多详情.", + "shareAccess": "共享访问权限" + }, + "stylePresets": { + "positivePrompt": "正向提示词", + "preview": "预览", + "deleteImage": "删除图像", + "deleteTemplate": "删除模版", + "deleteTemplate2": "您确定要删除这个模板吗?请注意,删除后无法恢复.", + "importTemplates": "导入提示模板,支持CSV或JSON格式", + "insertPlaceholder": "插入一个占位符", + "myTemplates": "我的模版", + "name": "名称", + "type": "类型", + "unableToDeleteTemplate": "无法删除提示模板", + "updatePromptTemplate": "更新提示词模版", + "exportPromptTemplates": "导出我的提示模板为CSV格式", + "exportDownloaded": "导出已下载", + "noMatchingTemplates": "无匹配的模版", + "promptTemplatesDesc1": "提示模板可以帮助您在编写提示时添加预设的文本内容.", + "promptTemplatesDesc3": "如果您没有使用占位符,那么模板的内容将会被添加到您提示的末尾.", + "searchByName": "按名称搜索", + "shared": "已分享", + "sharedTemplates": "已分享的模版", + "templateDeleted": "提示模版已删除", + "toggleViewMode": "切换显示模式", + "uploadImage": "上传图像", + "active": "激活", + "choosePromptTemplate": "选择提示词模板", + "clearTemplateSelection": "清除模版选择", + "copyTemplate": "拷贝模版", + "createPromptTemplate": "创建提示词模版", + "defaultTemplates": "默认模版", + "editTemplate": "编辑模版", + "exportFailed": "无法生成并下载CSV文件", + "flatten": "将选定的模板内容合并到当前提示中", + "negativePrompt": "反向提示词", + "promptTemplateCleared": "提示模板已清除", + "useForTemplate": "用于提示词模版", + "viewList": "预览模版列表", + "viewModeTooltip": "这是您的提示在当前选定的模板下的预览效果。如需编辑提示,请直接在文本框中点击进行修改.", + "noTemplates": "无模版", + "private": "私密" + } +} diff --git a/invokeai/frontend/web/public/locales/zh_Hant.json b/invokeai/frontend/web/public/locales/zh_Hant.json new file mode 100644 index 0000000000000000000000000000000000000000..582c0870e396b88ccf8518bdcb31f086537f6cd4 --- /dev/null +++ b/invokeai/frontend/web/public/locales/zh_Hant.json @@ -0,0 +1,207 @@ +{ + "common": { + "nodes": "工作流程", + "img2img": "圖片轉圖片", + "statusDisconnected": "已中斷連線", + "back": "返回", + "load": "載入", + "settingsLabel": "設定", + "upload": "上傳", + "discordLabel": "Discord", + "reportBugLabel": "回報錯誤", + "githubLabel": "GitHub", + "hotkeysLabel": "快捷鍵", + "languagePickerLabel": "語言", + "cancel": "取消", + "txt2img": "文字轉圖片", + "controlNet": "ControlNet", + "advanced": "進階", + "folder": "資料夾", + "installed": "已安裝", + "accept": "接受", + "goTo": "前往", + "input": "輸入", + "random": "隨機", + "selected": "已選擇", + "communityLabel": "社群", + "loading": "載入中", + "delete": "刪除", + "copy": "複製", + "error": "錯誤", + "file": "檔案", + "format": "格式", + "imageFailedToLoad": "無法載入圖片" + }, + "accessibility": { + "invokeProgressBar": "Invoke 進度條", + "uploadImage": "上傳圖片", + "reset": "重置", + "nextImage": "下一張圖片", + "previousImage": "上一張圖片", + "menu": "選單", + "about": "關於", + "createIssue": "建立問題", + "resetUI": "$t(accessibility.reset) 介面", + "submitSupportTicket": "提交支援工單", + "mode": "模式" + }, + "boards": { + "loading": "載入中…", + "movingImagesToBoard_other": "正在移動 {{count}} 張圖片至板上:", + "move": "移動", + "uncategorized": "未分類", + "cancel": "取消" + }, + "metadata": { + "workflow": "工作流程", + "steps": "步數", + "model": "模型", + "seed": "種子", + "vae": "VAE", + "metadata": "元數據", + "width": "寬度", + "height": "高度" + }, + "accordions": { + "control": { + "title": "控制" + }, + "compositing": { + "title": "合成" + }, + "advanced": { + "title": "進階", + "options": "$t(accordions.advanced.title) 選項" + } + }, + "modelManager": { + "advanced": "進階", + "allModels": "全部模型", + "variant": "變體", + "config": "配置", + "model": "模型", + "selected": "已選擇", + "huggingFace": "HuggingFace", + "install": "安裝", + "metadata": "元數據", + "delete": "刪除", + "description": "描述", + "cancel": "取消", + "convert": "轉換", + "manual": "手動", + "none": "無", + "name": "名稱", + "load": "載入", + "height": "高度", + "width": "寬度", + "search": "搜尋", + "vae": "VAE", + "settings": "設定" + }, + "queue": { + "queue": "佇列", + "canceled": "已取消", + "failed": "已失敗", + "completed": "已完成", + "cancel": "取消", + "session": "工作階段", + "batch": "批量", + "item": "項目", + "completedIn": "完成於", + "notReady": "無法排隊" + }, + "parameters": { + "cancel": { + "cancel": "取消" + }, + "height": "高度", + "type": "類型", + "symmetry": "對稱性", + "images": "圖片", + "width": "寬度", + "coherenceMode": "模式", + "seed": "種子", + "general": "一般", + "strength": "強度", + "steps": "步數", + "info": "資訊" + }, + "settings": { + "beta": "Beta", + "developer": "開發者", + "general": "一般", + "models": "模型" + }, + "popovers": { + "paramModel": { + "heading": "模型" + }, + "compositingCoherenceMode": { + "heading": "模式" + }, + "paramSteps": { + "heading": "步數" + }, + "controlNetProcessor": { + "heading": "處理器" + }, + "paramVAE": { + "heading": "VAE" + }, + "paramHeight": { + "heading": "高度" + }, + "paramSeed": { + "heading": "種子" + }, + "paramWidth": { + "heading": "寬度" + }, + "refinerSteps": { + "heading": "步數" + } + }, + "nodes": { + "workflowName": "名稱", + "notes": "註釋", + "workflowVersion": "版本", + "workflowNotes": "註釋", + "executionStateError": "錯誤", + "unableToUpdateNodes_other": "無法更新 {{count}} 個節點", + "integer": "整數", + "workflow": "工作流程", + "enum": "枚舉", + "edit": "編輯", + "string": "字串", + "workflowTags": "標籤", + "node": "節點", + "boolean": "布林值", + "workflowAuthor": "作者", + "version": "版本", + "executionStateCompleted": "已完成", + "edge": "邊緣", + "versionUnknown": " 版本未知" + }, + "sdxl": { + "steps": "步數", + "loading": "載入中…", + "refiner": "精煉器" + }, + "gallery": { + "copy": "複製", + "download": "下載", + "loading": "載入中" + }, + "ui": { + "tabs": { + "models": "模型", + "queue": "佇列" + } + }, + "models": { + "loading": "載入中" + }, + "workflows": { + "name": "名稱" + } +} diff --git a/invokeai/frontend/web/scripts/clean_translations.py b/invokeai/frontend/web/scripts/clean_translations.py new file mode 100644 index 0000000000000000000000000000000000000000..a422747ef5c4e95af66e5eef15c7f88c76682f9f --- /dev/null +++ b/invokeai/frontend/web/scripts/clean_translations.py @@ -0,0 +1,89 @@ +# Cleans translations by removing unused keys +# Usage: python clean_translations.py +# Note: Must be run from invokeai/frontend/web/scripts directory +# +# After running the script, open `en.json` and check for empty objects (`{}`) and remove them manually. +# Also, the script does not handle keys with underscores. They need to be checked manually. + +import json +import os +import re +from typing import TypeAlias, Union + +from tqdm import tqdm + +RecursiveDict: TypeAlias = dict[str, Union["RecursiveDict", str]] + + +class TranslationCleaner: + file_cache: dict[str, str] = {} + + def _get_keys(self, obj: RecursiveDict, current_path: str = "", keys: list[str] | None = None): + if keys is None: + keys = [] + for key in obj: + new_path = f"{current_path}.{key}" if current_path else key + next_ = obj[key] + if isinstance(next_, dict): + self._get_keys(next_, new_path, keys) + elif "_" in key: + # This typically means its a pluralized key + continue + else: + keys.append(new_path) + return keys + + def _search_codebase(self, key: str): + for root, _dirs, files in os.walk("../src"): + for file in files: + if file.endswith(".ts") or file.endswith(".tsx"): + full_path = os.path.join(root, file) + if full_path in self.file_cache: + content = self.file_cache[full_path] + else: + with open(full_path, "r") as f: + content = f.read() + self.file_cache[full_path] = content + + # match the whole key, surrounding by quotes + if re.search(r"['\"`]" + re.escape(key) + r"['\"`]", self.file_cache[full_path]): + return True + # math the stem of the key, with quotes at the end + if re.search(re.escape(key.split(".")[-1]) + r"['\"`]", self.file_cache[full_path]): + return True + return False + + def _remove_key(self, obj: RecursiveDict, key: str): + path = key.split(".") + last_key = path[-1] + for k in path[:-1]: + obj = obj[k] + del obj[last_key] + + def clean(self, obj: RecursiveDict) -> RecursiveDict: + keys = self._get_keys(obj) + pbar = tqdm(keys, desc="Checking keys") + for key in pbar: + if not self._search_codebase(key): + self._remove_key(obj, key) + return obj + + +def main(): + try: + with open("../public/locales/en.json", "r") as f: + data = json.load(f) + except FileNotFoundError as e: + raise FileNotFoundError( + "Unable to find en.json file - must be run from invokeai/frontend/web/scripts directory" + ) from e + + cleaner = TranslationCleaner() + cleaned_data = cleaner.clean(data) + + with open("../public/locales/en.json", "w") as f: + json.dump(cleaned_data, f, indent=4) + + +if __name__ == "__main__": + main() diff --git a/invokeai/frontend/web/scripts/package.json b/invokeai/frontend/web/scripts/package.json new file mode 100644 index 0000000000000000000000000000000000000000..3dbc1ca591c0557e35b6004aeba250e6a70b56e3 --- /dev/null +++ b/invokeai/frontend/web/scripts/package.json @@ -0,0 +1,3 @@ +{ + "type": "module" +} diff --git a/invokeai/frontend/web/scripts/typegen.js b/invokeai/frontend/web/scripts/typegen.js new file mode 100644 index 0000000000000000000000000000000000000000..7b3a7472857e3d15a4c61bbb30956f388f895642 --- /dev/null +++ b/invokeai/frontend/web/scripts/typegen.js @@ -0,0 +1,74 @@ +/* eslint-disable no-console */ +import fs from 'node:fs'; + +import openapiTS, { astToString } from 'openapi-typescript'; +import ts from 'typescript'; + +const OPENAPI_URL = 'http://127.0.0.1:9090/openapi.json'; +const OUTPUT_FILE = 'src/services/api/schema.ts'; + +async function generateTypes(schema) { + process.stdout.write(`Generating types ${OUTPUT_FILE}...`); + + // Use https://ts-ast-viewer.com to figure out how to create these AST nodes - define a type and use the bottom-left pane's output + // `Blob` type + const BLOB = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('Blob')); + // `null` type + const NULL = ts.factory.createLiteralTypeNode(ts.factory.createNull()); + // `Record` type + const RECORD_STRING_UNKNOWN = ts.factory.createTypeReferenceNode(ts.factory.createIdentifier('Record'), [ + ts.factory.createKeywordTypeNode(ts.SyntaxKind.StringKeyword), + ts.factory.createKeywordTypeNode(ts.SyntaxKind.UnknownKeyword), + ]); + + const types = await openapiTS(schema, { + exportType: true, + transform: (schemaObject) => { + if ('format' in schemaObject && schemaObject.format === 'binary') { + return schemaObject.nullable ? ts.factory.createUnionTypeNode([BLOB, NULL]) : BLOB; + } + if (schemaObject.title === 'MetadataField') { + // This is `Record` by default, but it actually accepts any a dict of any valid JSON value. + return RECORD_STRING_UNKNOWN; + } + }, + defaultNonNullable: false, + }); + fs.writeFileSync(OUTPUT_FILE, astToString(types)); + process.stdout.write(`\nOK!\r\n`); +} + +function main() { + const encoding = 'utf-8'; + + if (process.stdin.isTTY) { + // Handle generating types with an arg (e.g. URL or path to file) + if (process.argv.length > 3) { + console.error('Usage: typegen.js '); + process.exit(1); + } + if (process.argv[2]) { + const schema = new Buffer.from(process.argv[2], encoding); + generateTypes(schema); + } else { + generateTypes(OPENAPI_URL); + } + } else { + // Handle generating types from stdin + let schema = ''; + process.stdin.setEncoding(encoding); + + process.stdin.on('readable', function () { + const chunk = process.stdin.read(); + if (chunk !== null) { + schema += chunk; + } + }); + + process.stdin.on('end', function () { + generateTypes(JSON.parse(schema)); + }); + } +} + +main(); diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx new file mode 100644 index 0000000000000000000000000000000000000000..67003f5b62e313212076ceed7be941907a864ef4 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -0,0 +1,115 @@ +import { Box, useGlobalModifiersInit } from '@invoke-ai/ui-library'; +import { GlobalImageHotkeys } from 'app/components/GlobalImageHotkeys'; +import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; +import { useStudioInitAction } from 'app/hooks/useStudioInitAction'; +import { useSyncQueueStatus } from 'app/hooks/useSyncQueueStatus'; +import { useLogger } from 'app/logging/useLogger'; +import { useSyncLoggingConfig } from 'app/logging/useSyncLoggingConfig'; +import { appStarted } from 'app/store/middleware/listenerMiddleware/listeners/appStarted'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import type { PartialAppConfig } from 'app/types/invokeai'; +import { useFocusRegionWatcher } from 'common/hooks/focus'; +import { useClearStorage } from 'common/hooks/useClearStorage'; +import { useGlobalHotkeys } from 'common/hooks/useGlobalHotkeys'; +import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardModal'; +import { + NewCanvasSessionDialog, + NewGallerySessionDialog, +} from 'features/controlLayers/components/NewSessionConfirmationAlertDialog'; +import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal'; +import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone'; +import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; +import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; +import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; +import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/WorkflowListMenu/ShareWorkflowModal'; +import { ClearQueueConfirmationsAlertDialog } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; +import { DeleteStylePresetDialog } from 'features/stylePresets/components/DeleteStylePresetDialog'; +import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal'; +import RefreshAfterResetModal from 'features/system/components/SettingsModal/RefreshAfterResetModal'; +import { configChanged } from 'features/system/store/configSlice'; +import { selectLanguage } from 'features/system/store/systemSelectors'; +import { AppContent } from 'features/ui/components/AppContent'; +import { DeleteWorkflowDialog } from 'features/workflowLibrary/components/DeleteLibraryWorkflowConfirmationAlertDialog'; +import { NewWorkflowConfirmationAlertDialog } from 'features/workflowLibrary/components/NewWorkflowConfirmationAlertDialog'; +import i18n from 'i18n'; +import { size } from 'lodash-es'; +import { memo, useCallback, useEffect } from 'react'; +import { ErrorBoundary } from 'react-error-boundary'; +import { useGetOpenAPISchemaQuery } from 'services/api/endpoints/appInfo'; +import { useSocketIO } from 'services/events/useSocketIO'; + +import AppErrorBoundaryFallback from './AppErrorBoundaryFallback'; + +const DEFAULT_CONFIG = {}; + +interface Props { + config?: PartialAppConfig; + studioInitAction?: StudioInitAction; +} + +const App = ({ config = DEFAULT_CONFIG, studioInitAction }: Props) => { + const language = useAppSelector(selectLanguage); + const logger = useLogger('system'); + const dispatch = useAppDispatch(); + const clearStorage = useClearStorage(); + + // singleton! + useSocketIO(); + useGlobalModifiersInit(); + useGlobalHotkeys(); + useGetOpenAPISchemaQuery(); + useSyncLoggingConfig(); + + const handleReset = useCallback(() => { + clearStorage(); + location.reload(); + return false; + }, [clearStorage]); + + useEffect(() => { + i18n.changeLanguage(language); + }, [language]); + + useEffect(() => { + if (size(config)) { + logger.info({ config }, 'Received config'); + dispatch(configChanged(config)); + } + }, [dispatch, config, logger]); + + useEffect(() => { + dispatch(appStarted()); + }, [dispatch]); + + useStudioInitAction(studioInitAction); + useStarterModelsToast(); + useSyncQueueStatus(); + useFocusRegionWatcher(); + + return ( + + + + + + + + + + + + + + + + + + + + + + ); +}; + +export default memo(App); diff --git a/invokeai/frontend/web/src/app/components/AppErrorBoundaryFallback.tsx b/invokeai/frontend/web/src/app/components/AppErrorBoundaryFallback.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b20a8148e236238a169de8d9a2f14fcdec1f9326 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/AppErrorBoundaryFallback.tsx @@ -0,0 +1,85 @@ +import { Button, Flex, Heading, Image, Link, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectConfigSlice } from 'features/system/store/configSlice'; +import { toast } from 'features/toast/toast'; +import newGithubIssueUrl from 'new-github-issue-url'; +import InvokeLogoYellow from 'public/assets/images/invoke-symbol-ylw-lrg.svg'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowCounterClockwiseBold, PiArrowSquareOutBold, PiCopyBold } from 'react-icons/pi'; +import { serializeError } from 'serialize-error'; + +type Props = { + error: Error; + resetErrorBoundary: () => void; +}; + +const selectIsLocal = createSelector(selectConfigSlice, (config) => config.isLocal); + +const AppErrorBoundaryFallback = ({ error, resetErrorBoundary }: Props) => { + const { t } = useTranslation(); + const isLocal = useAppSelector(selectIsLocal); + + const handleCopy = useCallback(() => { + const text = JSON.stringify(serializeError(error), null, 2); + navigator.clipboard.writeText(`\`\`\`\n${text}\n\`\`\``); + toast({ + id: 'ERROR_COPIED', + title: t('toast.errorCopied'), + }); + }, [error, t]); + + const url = useMemo(() => { + if (isLocal) { + return newGithubIssueUrl({ + user: 'invoke-ai', + repo: 'InvokeAI', + template: 'BUG_REPORT.yml', + title: `[bug]: ${error.name}: ${error.message}`, + }); + } else { + return 'https://support.invoke.ai/support/tickets/new'; + } + }, [error.message, error.name, isLocal]); + + return ( + + + + invoke-logo + {t('common.somethingWentWrong')} + + + + + {error.name}: {error.message} + + + + + + + + + + + + ); +}; + +export default memo(AppErrorBoundaryFallback); diff --git a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c4826a94419127320b0f5d68fa7e15c71d8ca4b0 --- /dev/null +++ b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx @@ -0,0 +1,84 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { useIsRegionFocused } from 'common/hooks/focus'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { useImageActions } from 'features/gallery/hooks/useImageActions'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { memo } from 'react'; +import type { ImageDTO } from 'services/api/types'; + +export const GlobalImageHotkeys = memo(() => { + useAssertSingleton('GlobalImageHotkeys'); + const imageDTO = useAppSelector(selectLastSelectedImage); + + if (!imageDTO) { + return null; + } + + return ; +}); + +GlobalImageHotkeys.displayName = 'GlobalImageHotkeys'; + +const GlobalImageHotkeysInternal = memo(({ imageDTO }: { imageDTO: ImageDTO }) => { + const isGalleryFocused = useIsRegionFocused('gallery'); + const isViewerFocused = useIsRegionFocused('viewer'); + const imageActions = useImageActions(imageDTO); + const isStaging = useAppSelector(selectIsStaging); + const isUpscalingEnabled = useFeatureStatus('upscaling'); + + useRegisteredHotkeys({ + id: 'loadWorkflow', + category: 'viewer', + callback: imageActions.loadWorkflow, + options: { enabled: isGalleryFocused || isViewerFocused }, + dependencies: [imageActions.loadWorkflow, isGalleryFocused, isViewerFocused], + }); + useRegisteredHotkeys({ + id: 'recallAll', + category: 'viewer', + callback: imageActions.recallAll, + options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) }, + dependencies: [imageActions.recallAll, isStaging, isGalleryFocused, isViewerFocused], + }); + useRegisteredHotkeys({ + id: 'recallSeed', + category: 'viewer', + callback: imageActions.recallSeed, + options: { enabled: isGalleryFocused || isViewerFocused }, + dependencies: [imageActions.recallSeed, isGalleryFocused, isViewerFocused], + }); + useRegisteredHotkeys({ + id: 'recallPrompts', + category: 'viewer', + callback: imageActions.recallPrompts, + options: { enabled: isGalleryFocused || isViewerFocused }, + dependencies: [imageActions.recallPrompts, isGalleryFocused, isViewerFocused], + }); + useRegisteredHotkeys({ + id: 'remix', + category: 'viewer', + callback: imageActions.remix, + options: { enabled: isGalleryFocused || isViewerFocused }, + dependencies: [imageActions.remix, isGalleryFocused, isViewerFocused], + }); + useRegisteredHotkeys({ + id: 'useSize', + category: 'viewer', + callback: imageActions.recallSize, + options: { enabled: !isStaging && (isGalleryFocused || isViewerFocused) }, + dependencies: [imageActions.recallSize, isStaging, isGalleryFocused, isViewerFocused], + }); + useRegisteredHotkeys({ + id: 'runPostprocessing', + category: 'viewer', + callback: imageActions.upscale, + options: { enabled: isUpscalingEnabled && isViewerFocused }, + dependencies: [isUpscalingEnabled, imageDTO, isViewerFocused], + }); + return null; +}); + +GlobalImageHotkeysInternal.displayName = 'GlobalImageHotkeysInternal'; diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx new file mode 100644 index 0000000000000000000000000000000000000000..848ce1a4039f8c52912bd80a09f790c779c1f88d --- /dev/null +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -0,0 +1,247 @@ +import 'i18n'; + +import type { Middleware } from '@reduxjs/toolkit'; +import type { StudioInitAction } from 'app/hooks/useStudioInitAction'; +import type { LoggingOverrides } from 'app/logging/logger'; +import { $loggingOverrides, configureLogging } from 'app/logging/logger'; +import { $authToken } from 'app/store/nanostores/authToken'; +import { $baseUrl } from 'app/store/nanostores/baseUrl'; +import { $customNavComponent } from 'app/store/nanostores/customNavComponent'; +import type { CustomStarUi } from 'app/store/nanostores/customStarUI'; +import { $customStarUI } from 'app/store/nanostores/customStarUI'; +import { $isDebugging } from 'app/store/nanostores/isDebugging'; +import { $logo } from 'app/store/nanostores/logo'; +import { $openAPISchemaUrl } from 'app/store/nanostores/openAPISchemaUrl'; +import { $projectId, $projectName, $projectUrl } from 'app/store/nanostores/projectId'; +import { $queueId, DEFAULT_QUEUE_ID } from 'app/store/nanostores/queueId'; +import { $store } from 'app/store/nanostores/store'; +import { $workflowCategories } from 'app/store/nanostores/workflowCategories'; +import { createStore } from 'app/store/store'; +import type { PartialAppConfig } from 'app/types/invokeai'; +import Loading from 'common/components/Loading/Loading'; +import type { WorkflowCategory } from 'features/nodes/types/workflow'; +import type { PropsWithChildren, ReactNode } from 'react'; +import React, { lazy, memo, useEffect, useLayoutEffect, useMemo } from 'react'; +import { Provider } from 'react-redux'; +import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares'; +import { $socketOptions } from 'services/events/stores'; +import type { ManagerOptions, SocketOptions } from 'socket.io-client'; + +const App = lazy(() => import('./App')); +const ThemeLocaleProvider = lazy(() => import('./ThemeLocaleProvider')); + +interface Props extends PropsWithChildren { + apiUrl?: string; + openAPISchemaUrl?: string; + token?: string; + config?: PartialAppConfig; + customNavComponent?: ReactNode; + middleware?: Middleware[]; + projectId?: string; + projectName?: string; + projectUrl?: string; + queueId?: string; + studioInitAction?: StudioInitAction; + customStarUi?: CustomStarUi; + socketOptions?: Partial; + isDebugging?: boolean; + logo?: ReactNode; + workflowCategories?: WorkflowCategory[]; + loggingOverrides?: LoggingOverrides; +} + +const InvokeAIUI = ({ + apiUrl, + openAPISchemaUrl, + token, + config, + customNavComponent, + middleware, + projectId, + projectName, + projectUrl, + queueId, + studioInitAction, + customStarUi, + socketOptions, + isDebugging = false, + logo, + workflowCategories, + loggingOverrides, +}: Props) => { + useLayoutEffect(() => { + /* + * We need to configure logging before anything else happens - useLayoutEffect ensures we set this at the first + * possible opportunity. + * + * Once redux initializes, we will check the user's settings and update the logging config accordingly. See + * `useSyncLoggingConfig`. + */ + $loggingOverrides.set(loggingOverrides); + + // Until we get the user's settings, we will use the overrides OR default values. + configureLogging( + loggingOverrides?.logIsEnabled ?? true, + loggingOverrides?.logLevel ?? 'debug', + loggingOverrides?.logNamespaces ?? '*' + ); + }, [loggingOverrides]); + + useEffect(() => { + // configure API client token + if (token) { + $authToken.set(token); + } + + // configure API client base url + if (apiUrl) { + $baseUrl.set(apiUrl); + } + + // configure API client project header + if (projectId) { + $projectId.set(projectId); + } + + // configure API client project header + if (queueId) { + $queueId.set(queueId); + } + + // reset dynamically added middlewares + resetMiddlewares(); + + // TODO: at this point, after resetting the middleware, we really ought to clean up the socket + // stuff by calling `dispatch(socketReset())`. but we cannot dispatch from here as we are + // outside the provider. it's not needed until there is the possibility that we will change + // the `apiUrl`/`token` dynamically. + + // rebuild socket middleware with token and apiUrl + if (middleware && middleware.length > 0) { + addMiddleware(...middleware); + } + + return () => { + // Reset the API client token and base url on unmount + $baseUrl.set(undefined); + $authToken.set(undefined); + $projectId.set(undefined); + $queueId.set(DEFAULT_QUEUE_ID); + }; + }, [apiUrl, token, middleware, projectId, queueId, projectName, projectUrl]); + + useEffect(() => { + if (customStarUi) { + $customStarUI.set(customStarUi); + } + + return () => { + $customStarUI.set(undefined); + }; + }, [customStarUi]); + + useEffect(() => { + if (customNavComponent) { + $customNavComponent.set(customNavComponent); + } + + return () => { + $customNavComponent.set(undefined); + }; + }, [customNavComponent]); + + useEffect(() => { + if (openAPISchemaUrl) { + $openAPISchemaUrl.set(openAPISchemaUrl); + } + + return () => { + $openAPISchemaUrl.set(undefined); + }; + }, [openAPISchemaUrl]); + + useEffect(() => { + $projectName.set(projectName); + + return () => { + $projectName.set(undefined); + }; + }, [projectName]); + + useEffect(() => { + $projectUrl.set(projectUrl); + + return () => { + $projectUrl.set(undefined); + }; + }, [projectUrl]); + + useEffect(() => { + if (logo) { + $logo.set(logo); + } + + return () => { + $logo.set(undefined); + }; + }, [logo]); + + useEffect(() => { + if (workflowCategories) { + $workflowCategories.set(workflowCategories); + } + + return () => { + $workflowCategories.set([]); + }; + }, [workflowCategories]); + + useEffect(() => { + if (socketOptions) { + $socketOptions.set(socketOptions); + } + return () => { + $socketOptions.set({}); + }; + }, [socketOptions]); + + useEffect(() => { + if (isDebugging) { + $isDebugging.set(isDebugging); + } + return () => { + $isDebugging.set(false); + }; + }, [isDebugging]); + + const store = useMemo(() => { + return createStore(projectId); + }, [projectId]); + + useEffect(() => { + $store.set(store); + if (import.meta.env.MODE === 'development') { + window.$store = $store; + } + () => { + $store.set(undefined); + if (import.meta.env.MODE === 'development') { + window.$store = undefined; + } + }; + }, [store]); + + return ( + + + }> + + + + + + + ); +}; + +export default memo(InvokeAIUI); diff --git a/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx new file mode 100644 index 0000000000000000000000000000000000000000..325db314d561a6400705c0e13861e262aa49b30c --- /dev/null +++ b/invokeai/frontend/web/src/app/components/ThemeLocaleProvider.tsx @@ -0,0 +1,49 @@ +import '@fontsource-variable/inter'; +import 'overlayscrollbars/overlayscrollbars.css'; + +import { ChakraProvider, DarkMode, extendTheme, theme as _theme, TOAST_OPTIONS } from '@invoke-ai/ui-library'; +import type { ReactNode } from 'react'; +import { memo, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ThemeLocaleProviderProps = { + children: ReactNode; +}; + +function ThemeLocaleProvider({ children }: ThemeLocaleProviderProps) { + const { i18n } = useTranslation(); + + const direction = i18n.dir(); + + const theme = useMemo(() => { + return extendTheme({ + ..._theme, + direction, + shadows: { + ..._theme.shadows, + selected: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + hoverSelected: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + hoverUnselected: + 'inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-800)', + selectedForCompare: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + hoverSelectedForCompare: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, + }); + }, [direction]); + + useEffect(() => { + document.body.dir = direction; + }, [direction]); + + return ( + + {children} + + ); +} + +export default memo(ThemeLocaleProvider); diff --git a/invokeai/frontend/web/src/app/constants.ts b/invokeai/frontend/web/src/app/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8fab16c1ccfb0e61cd3d104c84fd71f317da2ae --- /dev/null +++ b/invokeai/frontend/web/src/app/constants.ts @@ -0,0 +1,2 @@ +export const NUMPY_RAND_MIN = 0; +export const NUMPY_RAND_MAX = 4294967295; diff --git a/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts new file mode 100644 index 0000000000000000000000000000000000000000..a48311e5f281f00f2380feacd3ae3d99a48a476f --- /dev/null +++ b/invokeai/frontend/web/src/app/hooks/useStudioInitAction.ts @@ -0,0 +1,208 @@ +import { useAppStore } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { withResultAsync } from 'common/util/result'; +import { canvasReset } from 'features/controlLayers/store/actions'; +import { settingsSendToCanvasChanged } from 'features/controlLayers/store/canvasSettingsSlice'; +import { rasterLayerAdded } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasRasterLayerState } from 'features/controlLayers/store/types'; +import { imageDTOToImageObject } from 'features/controlLayers/store/util'; +import { $imageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { sentImageToCanvas } from 'features/gallery/store/actions'; +import { parseAndRecallAllMetadata } from 'features/metadata/util/handlers'; +import { $isWorkflowListMenuIsOpen } from 'features/nodes/store/workflowListMenu'; +import { $isStylePresetsMenuOpen, activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice'; +import { toast } from 'features/toast/toast'; +import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; +import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow'; +import { useCallback, useEffect, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { getImageDTO, getImageMetadata } from 'services/api/endpoints/images'; +import { getStylePreset } from 'services/api/endpoints/stylePresets'; + +type _StudioInitAction = { type: T; data: U }; + +type LoadWorkflowAction = _StudioInitAction<'loadWorkflow', { workflowId: string }>; +type SelectStylePresetAction = _StudioInitAction<'selectStylePreset', { stylePresetId: string }>; +type SendToCanvasAction = _StudioInitAction<'sendToCanvas', { imageName: string }>; +type UseAllParametersAction = _StudioInitAction<'useAllParameters', { imageName: string }>; +type StudioDestinationAction = _StudioInitAction< + 'goToDestination', + { destination: 'generation' | 'canvas' | 'workflows' | 'upscaling' | 'viewAllWorkflows' | 'viewAllStylePresets' } +>; + +export type StudioInitAction = + | LoadWorkflowAction + | SelectStylePresetAction + | SendToCanvasAction + | UseAllParametersAction + | StudioDestinationAction; + +/** + * A hook that performs an action when the studio is initialized. This is useful for deep linking into the studio. + * + * The action is performed only once, when the hook is first run. + * + * In this hook, we prefer to use imperative APIs over hooks to avoid re-rendering the parent component. For example: + * - Use `getImageDTO` helper instead of `useGetImageDTO` + * - Usee the `$imageViewer` atom instead of `useImageViewer` + */ +export const useStudioInitAction = (action?: StudioInitAction) => { + useAssertSingleton('useStudioInitAction'); + const { t } = useTranslation(); + // Use a ref to ensure that we only perform the action once + const didInit = useRef(false); + const store = useAppStore(); + const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow(); + + const handleSendToCanvas = useCallback( + async (imageName: string) => { + // Try to the image DTO - use an imperative helper, rather than `useGetImageDTO`, so that we aren't re-rendering + // the parent of this hook whenever the image name changes + const getImageDTOResult = await withResultAsync(() => getImageDTO(imageName)); + if (getImageDTOResult.isErr()) { + toast({ + title: t('toast.unableToLoadImage'), + status: 'error', + }); + return; + } + const imageDTO = getImageDTOResult.value; + const imageObject = imageDTOToImageObject(imageDTO); + const overrides: Partial = { + objects: [imageObject], + }; + store.dispatch(canvasReset()); + store.dispatch(rasterLayerAdded({ overrides, isSelected: true })); + store.dispatch(settingsSendToCanvasChanged(true)); + store.dispatch(setActiveTab('canvas')); + store.dispatch(sentImageToCanvas()); + $imageViewer.set(false); + toast({ + title: t('toast.sentToCanvas'), + status: 'info', + }); + }, + [store, t] + ); + + const handleUseAllMetadata = useCallback( + async (imageName: string) => { + // Try to the image metadata - use an imperative helper, rather than `useGetImageMetadata`, so that we aren't + // re-rendering the parent of this hook whenever the image name changes + const getImageMetadataResult = await withResultAsync(() => getImageMetadata(imageName)); + if (getImageMetadataResult.isErr()) { + toast({ + title: t('toast.unableToLoadImageMetadata'), + status: 'error', + }); + return; + } + const metadata = getImageMetadataResult.value; + // This shows a toast + parseAndRecallAllMetadata(metadata, true); + store.dispatch(setActiveTab('canvas')); + }, + [store, t] + ); + + const handleLoadWorkflow = useCallback( + (workflowId: string) => { + // This shows a toast + getAndLoadWorkflow(workflowId); + store.dispatch(setActiveTab('workflows')); + }, + [getAndLoadWorkflow, store] + ); + + const handleSelectStylePreset = useCallback( + async (stylePresetId: string) => { + const getStylePresetResult = await withResultAsync(() => getStylePreset(stylePresetId)); + if (getStylePresetResult.isErr()) { + toast({ + title: t('toast.unableToLoadStylePreset'), + status: 'error', + }); + return; + } + store.dispatch(activeStylePresetIdChanged(stylePresetId)); + store.dispatch(setActiveTab('canvas')); + toast({ + title: t('toast.stylePresetLoaded'), + status: 'info', + }); + }, + [store, t] + ); + + const handleGoToDestination = useCallback( + (destination: StudioDestinationAction['data']['destination']) => { + switch (destination) { + case 'generation': + // Go to the canvas tab, open the image viewer, and enable send-to-gallery mode + store.dispatch(setActiveTab('canvas')); + store.dispatch(activeTabCanvasRightPanelChanged('gallery')); + store.dispatch(settingsSendToCanvasChanged(false)); + $imageViewer.set(true); + break; + case 'canvas': + // Go to the canvas tab, close the image viewer, and disable send-to-gallery mode + store.dispatch(setActiveTab('canvas')); + store.dispatch(settingsSendToCanvasChanged(true)); + $imageViewer.set(false); + break; + case 'workflows': + // Go to the workflows tab + store.dispatch(setActiveTab('workflows')); + break; + case 'upscaling': + // Go to the upscaling tab + store.dispatch(setActiveTab('upscaling')); + break; + case 'viewAllWorkflows': + // Go to the workflows tab and open the workflow library modal + store.dispatch(setActiveTab('workflows')); + $isWorkflowListMenuIsOpen.set(true); + break; + case 'viewAllStylePresets': + // Go to the canvas tab and open the style presets menu + store.dispatch(setActiveTab('canvas')); + $isStylePresetsMenuOpen.set(true); + break; + } + }, + [store] + ); + + useEffect(() => { + if (didInit.current || !action) { + return; + } + + didInit.current = true; + + switch (action.type) { + case 'loadWorkflow': + handleLoadWorkflow(action.data.workflowId); + break; + case 'selectStylePreset': + handleSelectStylePreset(action.data.stylePresetId); + break; + case 'sendToCanvas': + handleSendToCanvas(action.data.imageName); + break; + case 'useAllParameters': + handleUseAllMetadata(action.data.imageName); + break; + case 'goToDestination': + handleGoToDestination(action.data.destination); + break; + } + }, [ + handleSendToCanvas, + handleUseAllMetadata, + action, + handleLoadWorkflow, + handleSelectStylePreset, + handleGoToDestination, + ]); +}; diff --git a/invokeai/frontend/web/src/app/hooks/useSyncQueueStatus.ts b/invokeai/frontend/web/src/app/hooks/useSyncQueueStatus.ts new file mode 100644 index 0000000000000000000000000000000000000000..d6874c3bb5e9e7b8f6d6d0a336197164a21c5d35 --- /dev/null +++ b/invokeai/frontend/web/src/app/hooks/useSyncQueueStatus.ts @@ -0,0 +1,25 @@ +import { useEffect } from 'react'; +import { useGetQueueStatusQuery } from 'services/api/endpoints/queue'; + +const baseTitle = document.title; +const invokeLogoSVG = 'assets/images/invoke-favicon.svg'; +const invokeAlertLogoSVG = 'assets/images/invoke-alert-favicon.svg'; + +/** + * This hook synchronizes the queue status with the page's title and favicon. + * It should be considered a singleton and only used once in the component tree. + */ +export const useSyncQueueStatus = () => { + const { queueSize } = useGetQueueStatusQuery(undefined, { + selectFromResult: (res) => ({ + queueSize: res.data ? res.data.queue.pending + res.data.queue.in_progress : 0, + }), + }); + useEffect(() => { + document.title = queueSize > 0 ? `(${queueSize}) ${baseTitle}` : baseTitle; + const faviconEl = document.getElementById('invoke-favicon'); + if (faviconEl instanceof HTMLLinkElement) { + faviconEl.href = queueSize > 0 ? invokeAlertLogoSVG : invokeLogoSVG; + } + }, [queueSize]); +}; diff --git a/invokeai/frontend/web/src/app/logging/logger.ts b/invokeai/frontend/web/src/app/logging/logger.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b0cb1a298b0f7ef17873273ec46318f353758a0 --- /dev/null +++ b/invokeai/frontend/web/src/app/logging/logger.ts @@ -0,0 +1,97 @@ +import { createLogWriter } from '@roarr/browser-log-writer'; +import { atom } from 'nanostores'; +import type { Logger, MessageSerializer } from 'roarr'; +import { ROARR, Roarr } from 'roarr'; +import { z } from 'zod'; + +const serializeMessage: MessageSerializer = (message) => { + return JSON.stringify(message); +}; + +ROARR.serializeMessage = serializeMessage; + +const BASE_CONTEXT = {}; + +const $logger = atom(Roarr.child(BASE_CONTEXT)); + +export const zLogNamespace = z.enum([ + 'canvas', + 'config', + 'dnd', + 'events', + 'gallery', + 'generation', + 'metadata', + 'models', + 'system', + 'queue', + 'workflows', +]); +export type LogNamespace = z.infer; + +export const logger = (namespace: LogNamespace) => $logger.get().child({ namespace }); + +export const zLogLevel = z.enum(['trace', 'debug', 'info', 'warn', 'error', 'fatal']); +export type LogLevel = z.infer; +export const isLogLevel = (v: unknown): v is LogLevel => zLogLevel.safeParse(v).success; + +/** + * Override logging settings. + * @property logIsEnabled Override the enabled log state. Omit to use the user's settings. + * @property logNamespaces Override the enabled log namespaces. Use `"*"` for all namespaces. Omit to use the user's settings. + * @property logLevel Override the log level. Omit to use the user's settings. + */ +export type LoggingOverrides = { + logIsEnabled?: boolean; + logNamespaces?: LogNamespace[] | '*'; + logLevel?: LogLevel; +}; + +export const $loggingOverrides = atom(); + +// Translate human-readable log levels to numbers, used for log filtering +const LOG_LEVEL_MAP: Record = { + trace: 10, + debug: 20, + info: 30, + warn: 40, + error: 50, + fatal: 60, +}; + +/** + * Configure logging, pushing settings to local storage. + * + * @param logIsEnabled Whether logging is enabled + * @param logLevel The log level + * @param logNamespaces A list of log namespaces to enable, or '*' to enable all + */ +export const configureLogging = ( + logIsEnabled: boolean = true, + logLevel: LogLevel = 'warn', + logNamespaces: LogNamespace[] | '*' +): void => { + if (!logIsEnabled) { + // Disable console log output + localStorage.setItem('ROARR_LOG', 'false'); + } else { + // Enable console log output + localStorage.setItem('ROARR_LOG', 'true'); + + // Use a filter to show only logs of the given level + let filter = `context.logLevel:>=${LOG_LEVEL_MAP[logLevel]}`; + + const namespaces = logNamespaces === '*' ? zLogNamespace.options : logNamespaces; + + if (namespaces.length > 0) { + filter += ` AND (${namespaces.map((ns) => `context.namespace:${ns}`).join(' OR ')})`; + } else { + // This effectively hides all logs because we use namespaces for all logs + filter += ' AND context.namespace:undefined'; + } + + localStorage.setItem('ROARR_FILTER', filter); + } + + ROARR.write = createLogWriter(); +}; diff --git a/invokeai/frontend/web/src/app/logging/useLogger.ts b/invokeai/frontend/web/src/app/logging/useLogger.ts new file mode 100644 index 0000000000000000000000000000000000000000..ac2a05cadbc19e7a8ddc7d560347603f36b756ca --- /dev/null +++ b/invokeai/frontend/web/src/app/logging/useLogger.ts @@ -0,0 +1,10 @@ +import { useMemo } from 'react'; + +import type { LogNamespace } from './logger'; +import { logger } from './logger'; + +export const useLogger = (namespace: LogNamespace) => { + const log = useMemo(() => logger(namespace), [namespace]); + + return log; +}; diff --git a/invokeai/frontend/web/src/app/logging/useSyncLoggingConfig.ts b/invokeai/frontend/web/src/app/logging/useSyncLoggingConfig.ts new file mode 100644 index 0000000000000000000000000000000000000000..fb4b2a7b8ee1346a6d4f78759d9031cff06630d6 --- /dev/null +++ b/invokeai/frontend/web/src/app/logging/useSyncLoggingConfig.ts @@ -0,0 +1,43 @@ +import { useStore } from '@nanostores/react'; +import { $loggingOverrides, configureLogging } from 'app/logging/logger'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { + selectSystemLogIsEnabled, + selectSystemLogLevel, + selectSystemLogNamespaces, +} from 'features/system/store/systemSlice'; +import { useLayoutEffect } from 'react'; + +/** + * This hook synchronizes the logging configuration stored in Redux with the logging system, which uses localstorage. + * + * The sync is one-way: from Redux to localstorage. This means that changes made in the UI will be reflected in the + * logging system, but changes made directly to localstorage will not be reflected in the UI. + * + * See {@link configureLogging} + */ +export const useSyncLoggingConfig = () => { + useAssertSingleton('useSyncLoggingConfig'); + + const loggingOverrides = useStore($loggingOverrides); + + const logLevel = useAppSelector(selectSystemLogLevel); + const logNamespaces = useAppSelector(selectSystemLogNamespaces); + const logIsEnabled = useAppSelector(selectSystemLogIsEnabled); + + useLayoutEffect(() => { + configureLogging( + loggingOverrides?.logIsEnabled ?? logIsEnabled, + loggingOverrides?.logLevel ?? logLevel, + loggingOverrides?.logNamespaces ?? logNamespaces + ); + }, [ + logIsEnabled, + logLevel, + logNamespaces, + loggingOverrides?.logIsEnabled, + loggingOverrides?.logLevel, + loggingOverrides?.logNamespaces, + ]); +}; diff --git a/invokeai/frontend/web/src/app/store/actions.ts b/invokeai/frontend/web/src/app/store/actions.ts new file mode 100644 index 0000000000000000000000000000000000000000..6b7475d1b684db6bfe3365382c81dcc6c24d7a8d --- /dev/null +++ b/invokeai/frontend/web/src/app/store/actions.ts @@ -0,0 +1,7 @@ +import { createAction } from '@reduxjs/toolkit'; +import type { TabName } from 'features/ui/store/uiTypes'; + +export const enqueueRequested = createAction<{ + tabName: TabName; + prepend: boolean; +}>('app/enqueueRequested'); diff --git a/invokeai/frontend/web/src/app/store/constants.ts b/invokeai/frontend/web/src/app/store/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..58989b4a54a878f9e5db14ebd7634c3a7cdb0e97 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/constants.ts @@ -0,0 +1,3 @@ +export const STORAGE_PREFIX = '@@invokeai-'; +export const EMPTY_ARRAY = []; +export const EMPTY_OBJECT = {}; diff --git a/invokeai/frontend/web/src/app/store/createMemoizedSelector.ts b/invokeai/frontend/web/src/app/store/createMemoizedSelector.ts new file mode 100644 index 0000000000000000000000000000000000000000..bd6428db519110e7b3eb092fe5f8ef1367d26643 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/createMemoizedSelector.ts @@ -0,0 +1,24 @@ +import { createDraftSafeSelectorCreator, createSelectorCreator, lruMemoize } from '@reduxjs/toolkit'; +import type { GetSelectorsOptions } from '@reduxjs/toolkit/dist/entities/state_selectors'; +import type { RootState } from 'app/store/store'; +import { isEqual } from 'lodash-es'; + +/** + * A memoized selector creator that uses LRU cache and lodash's isEqual for equality check. + */ +export const createMemoizedSelector = createSelectorCreator({ + memoize: lruMemoize, + memoizeOptions: { + resultEqualityCheck: isEqual, + }, + argsMemoize: lruMemoize, +}); + +export const getSelectorsOptions: GetSelectorsOptions = { + createSelector: createDraftSafeSelectorCreator({ + memoize: lruMemoize, + argsMemoize: lruMemoize, + }), +}; + +export const createMemoizedAppSelector = createMemoizedSelector.withTypes(); diff --git a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts new file mode 100644 index 0000000000000000000000000000000000000000..7196e1fceac1b7847f9b84e08aa8b65032cbfa41 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/driver.ts @@ -0,0 +1,40 @@ +import { StorageError } from 'app/store/enhancers/reduxRemember/errors'; +import { $projectId } from 'app/store/nanostores/projectId'; +import type { UseStore } from 'idb-keyval'; +import { clear, createStore as createIDBKeyValStore, get, set } from 'idb-keyval'; +import { atom } from 'nanostores'; +import type { Driver } from 'redux-remember'; + +// Create a custom idb-keyval store (just needed to customize the name) +const $idbKeyValStore = atom(createIDBKeyValStore('invoke', 'invoke-store')); + +export const clearIdbKeyValStore = () => { + clear($idbKeyValStore.get()); +}; + +// Create redux-remember driver, wrapping idb-keyval +export const idbKeyValDriver: Driver = { + getItem: (key) => { + try { + return get(key, $idbKeyValStore.get()); + } catch (originalError) { + throw new StorageError({ + key, + projectId: $projectId.get(), + originalError, + }); + } + }, + setItem: (key, value) => { + try { + return set(key, value, $idbKeyValStore.get()); + } catch (originalError) { + throw new StorageError({ + key, + value, + projectId: $projectId.get(), + originalError, + }); + } + }, +}; diff --git a/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/errors.ts b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/errors.ts new file mode 100644 index 0000000000000000000000000000000000000000..f6a2129a3cf5425a7c8c48966086a7c8d76157f2 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/enhancers/reduxRemember/errors.ts @@ -0,0 +1,45 @@ +import { logger } from 'app/logging/logger'; +import { PersistError, RehydrateError } from 'redux-remember'; +import { serializeError } from 'serialize-error'; + +type StorageErrorArgs = { + key: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ // any is correct + value?: any; + originalError?: unknown; + projectId?: string; +}; + +export class StorageError extends Error { + key: string; + /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ // any is correct + value?: any; + originalError?: Error; + projectId?: string; + + constructor({ key, value, originalError, projectId }: StorageErrorArgs) { + super(`Error setting ${key}`); + this.name = 'StorageSetError'; + this.key = key; + if (value !== undefined) { + this.value = value; + } + if (projectId !== undefined) { + this.projectId = projectId; + } + if (originalError instanceof Error) { + this.originalError = originalError; + } + } +} + +export const errorHandler = (err: PersistError | RehydrateError) => { + const log = logger('system'); + if (err instanceof PersistError) { + log.error({ error: serializeError(err) }, 'Problem persisting state'); + } else if (err instanceof RehydrateError) { + log.error({ error: serializeError(err) }, 'Problem rehydrating state'); + } else { + log.error({ error: serializeError(err) }, 'Problem in persistence layer'); + } +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/debugLoggerMiddleware.ts b/invokeai/frontend/web/src/app/store/middleware/debugLoggerMiddleware.ts new file mode 100644 index 0000000000000000000000000000000000000000..c112f2dd97d226732e3fac6b8f9b2f91438d7259 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/debugLoggerMiddleware.ts @@ -0,0 +1,26 @@ +/* eslint-disable no-console */ +// This is only enabled manually for debugging, console is allowed. + +import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit'; +import { diff } from 'jsondiffpatch'; + +/** + * Super simple logger middleware. Useful for debugging when the redux devtools are awkward. + */ +export const getDebugLoggerMiddleware = + (options?: { withDiff?: boolean; withNextState?: boolean }): Middleware => + (api: MiddlewareAPI) => + (next) => + (action) => { + const originalState = api.getState(); + console.log('REDUX: dispatching', action); + const result = next(action); + const nextState = api.getState(); + if (options?.withNextState) { + console.log('REDUX: next state', nextState); + } + if (options?.withDiff) { + console.log('REDUX: diff', diff(originalState, nextState)); + } + return result; + }; diff --git a/invokeai/frontend/web/src/app/store/middleware/devtools/actionSanitizer.ts b/invokeai/frontend/web/src/app/store/middleware/devtools/actionSanitizer.ts new file mode 100644 index 0000000000000000000000000000000000000000..d130a0895b8f5e1f73e2e865725bdbb3cc441e8d --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/devtools/actionSanitizer.ts @@ -0,0 +1,26 @@ +import type { UnknownAction } from '@reduxjs/toolkit'; +import { isAnyGraphBuilt } from 'features/nodes/store/actions'; +import { appInfoApi } from 'services/api/endpoints/appInfo'; +import type { Graph } from 'services/api/types'; + +export const actionSanitizer = (action: A): A => { + if (isAnyGraphBuilt(action)) { + if (action.payload.nodes) { + const sanitizedNodes: Graph['nodes'] = {}; + + return { + ...action, + payload: { ...action.payload, nodes: sanitizedNodes }, + }; + } + } + + if (appInfoApi.endpoints.getOpenAPISchema.matchFulfilled(action)) { + return { + ...action, + payload: '', + }; + } + + return action; +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/devtools/actionsDenylist.ts b/invokeai/frontend/web/src/app/store/middleware/devtools/actionsDenylist.ts new file mode 100644 index 0000000000000000000000000000000000000000..defb98b64c821c2e352c2c0f87539b64a4b21675 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/devtools/actionsDenylist.ts @@ -0,0 +1,16 @@ +/** + * This is a list of actions that should be excluded in the Redux DevTools. + */ +export const actionsDenylist: string[] = [ + // very spammy canvas actions + // 'canvas/setStageCoordinates', + // 'canvas/setStageScale', + // 'canvas/setBoundingBoxCoordinates', + // 'canvas/setBoundingBoxDimensions', + // 'canvas/addPointToCurrentLine', + // bazillions during generation + // 'socket/socketGeneratorProgress', + // 'socket/appSocketGeneratorProgress', + // this happens after every state change + // '@@REMEMBER_PERSISTED', +]; diff --git a/invokeai/frontend/web/src/app/store/middleware/devtools/stateSanitizer.ts b/invokeai/frontend/web/src/app/store/middleware/devtools/stateSanitizer.ts new file mode 100644 index 0000000000000000000000000000000000000000..312b4db1897767e69209f5a7ece9cd459092e6e2 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/devtools/stateSanitizer.ts @@ -0,0 +1,3 @@ +export const stateSanitizer = (state: S): S => { + return state; +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts new file mode 100644 index 0000000000000000000000000000000000000000..554a274cf958c3f6e14f2b9c8d7863a9edf9fc10 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -0,0 +1,109 @@ +import type { TypedStartListening } from '@reduxjs/toolkit'; +import { addListener, createListenerMiddleware } from '@reduxjs/toolkit'; +import { addAdHocPostProcessingRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener'; +import { addStagingListeners } from 'app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener'; +import { addAnyEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/anyEnqueued'; +import { addAppConfigReceivedListener } from 'app/store/middleware/listenerMiddleware/listeners/appConfigReceived'; +import { addAppStartedListener } from 'app/store/middleware/listenerMiddleware/listeners/appStarted'; +import { addBatchEnqueuedListener } from 'app/store/middleware/listenerMiddleware/listeners/batchEnqueued'; +import { addDeleteBoardAndImagesFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted'; +import { addBoardIdSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/boardIdSelected'; +import { addBulkDownloadListeners } from 'app/store/middleware/listenerMiddleware/listeners/bulkDownload'; +import { addEnqueueRequestedLinear } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear'; +import { addEnqueueRequestedNodes } from 'app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes'; +import { addGalleryImageClickedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryImageClicked'; +import { addGalleryOffsetChangedListener } from 'app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged'; +import { addGetOpenAPISchemaListener } from 'app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema'; +import { addImageAddedToBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard'; +import { addImageDeletionListeners } from 'app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners'; +import { addImageRemovedFromBoardFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard'; +import { addImagesStarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesStarred'; +import { addImagesUnstarredListener } from 'app/store/middleware/listenerMiddleware/listeners/imagesUnstarred'; +import { addImageToDeleteSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected'; +import { addImageUploadedFulfilledListener } from 'app/store/middleware/listenerMiddleware/listeners/imageUploaded'; +import { addModelSelectedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelSelected'; +import { addModelsLoadedListener } from 'app/store/middleware/listenerMiddleware/listeners/modelsLoaded'; +import { addDynamicPromptsListener } from 'app/store/middleware/listenerMiddleware/listeners/promptChanged'; +import { addSetDefaultSettingsListener } from 'app/store/middleware/listenerMiddleware/listeners/setDefaultSettings'; +import { addSocketConnectedEventListener } from 'app/store/middleware/listenerMiddleware/listeners/socketConnected'; +import { addUpdateAllNodesRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested'; +import { addWorkflowLoadRequestedListener } from 'app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested'; +import type { AppDispatch, RootState } from 'app/store/store'; + +import { addArchivedOrDeletedBoardListener } from './listeners/addArchivedOrDeletedBoardListener'; +import { addEnqueueRequestedUpscale } from './listeners/enqueueRequestedUpscale'; + +export const listenerMiddleware = createListenerMiddleware(); + +export type AppStartListening = TypedStartListening; + +const startAppListening = listenerMiddleware.startListening as AppStartListening; + +export const addAppListener = addListener.withTypes(); + +/** + * The RTK listener middleware is a lightweight alternative sagas/observables. + * + * Most side effect logic should live in a listener. + */ + +// Image uploaded +addImageUploadedFulfilledListener(startAppListening); + +// Image deleted +addImageDeletionListeners(startAppListening); +addDeleteBoardAndImagesFulfilledListener(startAppListening); +addImageToDeleteSelectedListener(startAppListening); + +// Image starred +addImagesStarredListener(startAppListening); +addImagesUnstarredListener(startAppListening); + +// Gallery +addGalleryImageClickedListener(startAppListening); +addGalleryOffsetChangedListener(startAppListening); + +// User Invoked +addEnqueueRequestedNodes(startAppListening); +addEnqueueRequestedLinear(startAppListening); +addEnqueueRequestedUpscale(startAppListening); +addAnyEnqueuedListener(startAppListening); +addBatchEnqueuedListener(startAppListening); + +// Canvas actions +addStagingListeners(startAppListening); + +// Socket.IO +addSocketConnectedEventListener(startAppListening); + +// Gallery bulk download +addBulkDownloadListeners(startAppListening); + +// Boards +addImageAddedToBoardFulfilledListener(startAppListening); +addImageRemovedFromBoardFulfilledListener(startAppListening); +addBoardIdSelectedListener(startAppListening); +addArchivedOrDeletedBoardListener(startAppListening); + +// Node schemas +addGetOpenAPISchemaListener(startAppListening); + +// Workflows +addWorkflowLoadRequestedListener(startAppListening); +addUpdateAllNodesRequestedListener(startAppListening); + +// Models +addModelSelectedListener(startAppListening); + +// app startup +addAppStartedListener(startAppListening); +addModelsLoadedListener(startAppListening); +addAppConfigReceivedListener(startAppListening); + +// Ad-hoc upscale workflwo +addAdHocPostProcessingRequestedListener(startAppListening); + +// Prompts +addDynamicPromptsListener(startAppListening); + +addSetDefaultSettingsListener(startAppListening); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts new file mode 100644 index 0000000000000000000000000000000000000000..cc1d2cbbaa65643a1ab98df1e5c153d0e89cb954 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addAdHocPostProcessingRequestedListener.ts @@ -0,0 +1,58 @@ +import { createAction } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { buildAdHocPostProcessingGraph } from 'features/nodes/util/graph/buildAdHocPostProcessingGraph'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { queueApi } from 'services/api/endpoints/queue'; +import type { BatchConfig, ImageDTO } from 'services/api/types'; +import type { JsonObject } from 'type-fest'; + +const log = logger('queue'); + +export const adHocPostProcessingRequested = createAction<{ imageDTO: ImageDTO }>(`upscaling/postProcessingRequested`); + +export const addAdHocPostProcessingRequestedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: adHocPostProcessingRequested, + effect: async (action, { dispatch, getState }) => { + const { imageDTO } = action.payload; + const state = getState(); + + const enqueueBatchArg: BatchConfig = { + prepend: true, + batch: { + graph: await buildAdHocPostProcessingGraph({ + image: imageDTO, + state, + }), + runs: 1, + }, + }; + + try { + const req = dispatch( + queueApi.endpoints.enqueueBatch.initiate(enqueueBatchArg, { + fixedCacheKey: 'enqueueBatch', + }) + ); + + const enqueueResult = await req.unwrap(); + req.reset(); + log.debug({ enqueueResult } as JsonObject, t('queue.graphQueued')); + } catch (error) { + log.error({ enqueueBatchArg } as JsonObject, t('queue.graphFailedToQueue')); + + if (error instanceof Object && 'status' in error && error.status === 403) { + return; + } else { + toast({ + id: 'GRAPH_QUEUE_FAILED', + title: t('queue.graphFailedToQueue'), + status: 'error', + }); + } + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts new file mode 100644 index 0000000000000000000000000000000000000000..a3831664c4a10cd2e7545ed557e2aea1393237eb --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener.ts @@ -0,0 +1,120 @@ +import { isAnyOf } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { + autoAddBoardIdChanged, + boardIdSelected, + galleryViewChanged, + shouldShowArchivedBoardsChanged, +} from 'features/gallery/store/gallerySlice'; +import { boardsApi } from 'services/api/endpoints/boards'; +import { imagesApi } from 'services/api/endpoints/images'; + +// Type inference doesn't work for this if you inline it in the listener for some reason +const matchAnyBoardDeleted = isAnyOf( + imagesApi.endpoints.deleteBoard.matchFulfilled, + imagesApi.endpoints.deleteBoardAndImages.matchFulfilled +); + +export const addArchivedOrDeletedBoardListener = (startAppListening: AppStartListening) => { + /** + * The auto-add board shouldn't be set to an archived board or deleted board. When we archive a board, delete + * a board, or change a the archived board visibility flag, we may need to reset the auto-add board. + */ + startAppListening({ + matcher: matchAnyBoardDeleted, + effect: (action, { dispatch, getState }) => { + const state = getState(); + const deletedBoardId = action.meta.arg.originalArgs; + const { autoAddBoardId, selectedBoardId } = state.gallery; + + // If the deleted board was currently selected, we should reset the selected board to uncategorized + if (selectedBoardId !== 'none' && deletedBoardId === selectedBoardId) { + dispatch(boardIdSelected({ boardId: 'none' })); + dispatch(galleryViewChanged('images')); + } + + // If the deleted board was selected for auto-add, we should reset the auto-add board to uncategorized + if (autoAddBoardId !== 'none' && deletedBoardId === autoAddBoardId) { + dispatch(autoAddBoardIdChanged('none')); + } + }, + }); + + // If we archived a board, it may end up hidden. If it's selected or the auto-add board, we should reset those. + startAppListening({ + matcher: boardsApi.endpoints.updateBoard.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const state = getState(); + const { shouldShowArchivedBoards, selectedBoardId, autoAddBoardId } = state.gallery; + + const wasArchived = action.meta.arg.originalArgs.changes.archived === true; + + if (selectedBoardId !== 'none' && autoAddBoardId !== 'none' && wasArchived && !shouldShowArchivedBoards) { + dispatch(autoAddBoardIdChanged('none')); + dispatch(boardIdSelected({ boardId: 'none' })); + dispatch(galleryViewChanged('images')); + } + }, + }); + + // When we hide archived boards, if the selected or the auto-add board is archived, we should reset those. + startAppListening({ + actionCreator: shouldShowArchivedBoardsChanged, + effect: (action, { dispatch, getState }) => { + const shouldShowArchivedBoards = action.payload; + + // We only need to take action if we have just hidden archived boards. + if (shouldShowArchivedBoards) { + return; + } + + const state = getState(); + const queryArgs = selectListBoardsQueryArgs(state); + const queryResult = boardsApi.endpoints.listAllBoards.select(queryArgs)(state); + const { selectedBoardId, autoAddBoardId } = state.gallery; + + if (!queryResult.data) { + return; + } + + // Handle the case where selected board is archived + const selectedBoard = queryResult.data.find((b) => b.board_id === selectedBoardId); + if (selectedBoardId !== 'none' && (!selectedBoard || selectedBoard.archived)) { + // If we can't find the selected board or it's archived, we should reset the selected board to uncategorized + dispatch(boardIdSelected({ boardId: 'none' })); + dispatch(galleryViewChanged('images')); + } + + // Handle the case where auto-add board is archived + const autoAddBoard = queryResult.data.find((b) => b.board_id === autoAddBoardId); + if (autoAddBoardId !== 'none' && (!autoAddBoard || autoAddBoard.archived)) { + // If we can't find the auto-add board or it's archived, we should reset the selected board to uncategorized + dispatch(autoAddBoardIdChanged('none')); + } + }, + }); + + /** + * When listing boards, if the selected or auto-add boards are no longer in the list, we should reset them. + */ + startAppListening({ + matcher: boardsApi.endpoints.listAllBoards.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const boards = action.payload; + const state = getState(); + const { selectedBoardId, autoAddBoardId } = state.gallery; + + // Handle the case where selected board isn't in the list of boards + if (selectedBoardId !== 'none' && !boards.find((b) => b.board_id === selectedBoardId)) { + dispatch(boardIdSelected({ boardId: 'none' })); + dispatch(galleryViewChanged('images')); + } + + // Handle the case where auto-add board isn't in the list of boards + if (autoAddBoardId !== 'none' && !boards.find((b) => b.board_id === autoAddBoardId)) { + dispatch(autoAddBoardIdChanged('none')); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts new file mode 100644 index 0000000000000000000000000000000000000000..11dd39d8674bbf99b9fcdf8f3285558087fc5eb6 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/addCommitStagingAreaImageListener.ts @@ -0,0 +1,46 @@ +import { isAnyOf } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { canvasReset, newSessionRequested } from 'features/controlLayers/store/actions'; +import { stagingAreaReset } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { queueApi } from 'services/api/endpoints/queue'; + +const log = logger('canvas'); + +const matchCanvasOrStagingAreaReset = isAnyOf(stagingAreaReset, canvasReset, newSessionRequested); + +export const addStagingListeners = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: matchCanvasOrStagingAreaReset, + effect: async (_, { dispatch }) => { + try { + const req = dispatch( + queueApi.endpoints.cancelByBatchDestination.initiate( + { destination: 'canvas' }, + { fixedCacheKey: 'cancelByBatchOrigin' } + ) + ); + const { canceled } = await req.unwrap(); + req.reset(); + + if (canceled > 0) { + log.debug(`Canceled ${canceled} canvas batches`); + toast({ + id: 'CANCEL_BATCH_SUCCEEDED', + title: t('queue.cancelBatchSucceeded'), + status: 'success', + }); + } + } catch { + log.error('Failed to cancel canvas batches'); + toast({ + id: 'CANCEL_BATCH_FAILED', + title: t('queue.cancelBatchFailed'), + status: 'error', + }); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/anyEnqueued.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/anyEnqueued.ts new file mode 100644 index 0000000000000000000000000000000000000000..b312005ef68451011c3ec0588b28bbd1929f5189 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/anyEnqueued.ts @@ -0,0 +1,21 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { queueApi, selectQueueStatus } from 'services/api/endpoints/queue'; + +export const addAnyEnqueuedListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: queueApi.endpoints.enqueueBatch.matchFulfilled, + effect: (_, { dispatch, getState }) => { + const { data } = selectQueueStatus(getState()); + + if (!data || data.processor.is_started) { + return; + } + + dispatch( + queueApi.endpoints.resumeProcessor.initiate(undefined, { + fixedCacheKey: 'resumeProcessor', + }) + ); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts new file mode 100644 index 0000000000000000000000000000000000000000..a8f3602440b76c71e02b600c33d8e886c5e86742 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appConfigReceived.ts @@ -0,0 +1,29 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { setInfillMethod } from 'features/controlLayers/store/paramsSlice'; +import { shouldUseNSFWCheckerChanged, shouldUseWatermarkerChanged } from 'features/system/store/systemSlice'; +import { appInfoApi } from 'services/api/endpoints/appInfo'; + +export const addAppConfigReceivedListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: appInfoApi.endpoints.getAppConfig.matchFulfilled, + effect: (action, { getState, dispatch }) => { + const { infill_methods = [], nsfw_methods = [], watermarking_methods = [] } = action.payload; + const infillMethod = getState().params.infillMethod; + + if (!infill_methods.includes(infillMethod)) { + // If the selected infill method does not exist, prefer 'lama' if it's in the list, otherwise 'tile'. + // TODO(psyche): lama _should_ always be in the list, but the API doesn't guarantee it... + const infillMethod = infill_methods.includes('lama') ? 'lama' : 'tile'; + dispatch(setInfillMethod(infillMethod)); + } + + if (!nsfw_methods.includes('nsfw_checker')) { + dispatch(shouldUseNSFWCheckerChanged(false)); + } + + if (!watermarking_methods.includes('invisible_watermark')) { + dispatch(shouldUseWatermarkerChanged(false)); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts new file mode 100644 index 0000000000000000000000000000000000000000..60a5310fcd563a14b45be9bfc104e1894f692715 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -0,0 +1,15 @@ +import { createAction } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; + +export const appStarted = createAction('app/appStarted'); + +export const addAppStartedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: appStarted, + effect: (action, { unsubscribe, cancelActiveListeners }) => { + // this should only run once + cancelActiveListeners(); + unsubscribe(); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts new file mode 100644 index 0000000000000000000000000000000000000000..e2fd33ecf30b62c1bfee4856aca8a85c2e2074dd --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/batchEnqueued.ts @@ -0,0 +1,77 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { zPydanticValidationError } from 'features/system/store/zodSchemas'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { truncate, upperFirst } from 'lodash-es'; +import { serializeError } from 'serialize-error'; +import { queueApi } from 'services/api/endpoints/queue'; +import type { JsonObject } from 'type-fest'; + +const log = logger('queue'); + +export const addBatchEnqueuedListener = (startAppListening: AppStartListening) => { + // success + startAppListening({ + matcher: queueApi.endpoints.enqueueBatch.matchFulfilled, + effect: (action) => { + const enqueueResult = action.payload; + const arg = action.meta.arg.originalArgs; + log.debug({ enqueueResult } as JsonObject, 'Batch enqueued'); + + toast({ + id: 'QUEUE_BATCH_SUCCEEDED', + title: t('queue.batchQueued'), + status: 'success', + description: t('queue.batchQueuedDesc', { + count: enqueueResult.enqueued, + direction: arg.prepend ? t('queue.front') : t('queue.back'), + }), + }); + }, + }); + + // error + startAppListening({ + matcher: queueApi.endpoints.enqueueBatch.matchRejected, + effect: (action) => { + const response = action.payload; + const batchConfig = action.meta.arg.originalArgs; + + if (!response) { + toast({ + id: 'QUEUE_BATCH_FAILED', + title: t('queue.batchFailedToQueue'), + status: 'error', + description: t('common.unknownError'), + }); + log.error({ batchConfig } as JsonObject, t('queue.batchFailedToQueue')); + return; + } + + const result = zPydanticValidationError.safeParse(response); + if (result.success) { + result.data.data.detail.map((e) => { + toast({ + id: 'QUEUE_BATCH_FAILED', + title: truncate(upperFirst(e.msg), { length: 128 }), + status: 'error', + description: truncate( + `Path: + ${e.loc.join('.')}`, + { length: 128 } + ), + }); + }); + } else if (response.status !== 403) { + toast({ + id: 'QUEUE_BATCH_FAILED', + title: t('queue.batchFailedToQueue'), + status: 'error', + description: t('common.unknownError'), + }); + } + log.error({ batchConfig, error: serializeError(response) } as JsonObject, t('queue.batchFailedToQueue')); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts new file mode 100644 index 0000000000000000000000000000000000000000..4a0c79c72e30f7eb649b783cda145c1df137ce95 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardAndImagesDeleted.ts @@ -0,0 +1,34 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { getImageUsage } from 'features/deleteImageModal/store/selectors'; +import { nodeEditorReset } from 'features/nodes/store/nodesSlice'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; +import { imagesApi } from 'services/api/endpoints/images'; + +export const addDeleteBoardAndImagesFulfilledListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: imagesApi.endpoints.deleteBoardAndImages.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const { deleted_images } = action.payload; + + // Remove all deleted images from the UI + + let wasNodeEditorReset = false; + + const state = getState(); + const nodes = selectNodesSlice(state); + const canvas = selectCanvasSlice(state); + const upscale = selectUpscaleSlice(state); + + deleted_images.forEach((image_name) => { + const imageUsage = getImageUsage(nodes, canvas, upscale, image_name); + + if (imageUsage.isNodesImage && !wasNodeEditorReset) { + dispatch(nodeEditorReset()); + wasNodeEditorReset = true; + } + }); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts new file mode 100644 index 0000000000000000000000000000000000000000..4ec0075daca6641010a1cfc954b83591dac0cc9f --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -0,0 +1,46 @@ +import { isAnyOf } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; + +export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: isAnyOf(boardIdSelected, galleryViewChanged), + effect: async (action, { getState, dispatch, condition, cancelActiveListeners }) => { + // Cancel any in-progress instances of this listener, we don't want to select an image from a previous board + cancelActiveListeners(); + + const state = getState(); + + const queryArgs = selectListImagesQueryArgs(state); + + // wait until the board has some images - maybe it already has some from a previous fetch + // must use getState() to ensure we do not have stale state + const isSuccess = await condition( + () => imagesApi.endpoints.listImages.select(queryArgs)(getState()).isSuccess, + 5000 + ); + + if (isSuccess) { + // the board was just changed - we can select the first image + const { data: boardImagesData } = imagesApi.endpoints.listImages.select(queryArgs)(getState()); + + if (boardImagesData && boardIdSelected.match(action) && action.payload.selectedImageName) { + const selectedImage = boardImagesData.items.find( + (item) => item.image_name === action.payload.selectedImageName + ); + dispatch(imageSelected(selectedImage || null)); + } else if (boardImagesData) { + dispatch(imageSelected(boardImagesData.items[0] || null)); + } else { + // board has no images - deselect + dispatch(imageSelected(null)); + } + } else { + // fallback - deselect + dispatch(imageSelected(null)); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.tsx b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.tsx new file mode 100644 index 0000000000000000000000000000000000000000..738bd3af131f7ed04ef06b96d3063e6571470a9f --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/bulkDownload.tsx @@ -0,0 +1,41 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { imagesApi } from 'services/api/endpoints/images'; + +const log = logger('gallery'); + +export const addBulkDownloadListeners = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: imagesApi.endpoints.bulkDownloadImages.matchFulfilled, + effect: (action) => { + log.debug(action.payload, 'Bulk download requested'); + + // If we have an item name, we are processing the bulk download locally and should use it as the toast id to + // prevent multiple toasts for the same item. + toast({ + id: action.payload.bulk_download_item_name ?? undefined, + title: t('gallery.bulkDownloadRequested'), + status: 'success', + // Show the response message if it exists, otherwise show the default message + description: action.payload.response || t('gallery.bulkDownloadRequestedDesc'), + duration: null, + }); + }, + }); + + startAppListening({ + matcher: imagesApi.endpoints.bulkDownloadImages.matchRejected, + effect: () => { + log.debug('Bulk download request failed'); + + // There isn't any toast to update if we get this event. + toast({ + id: 'BULK_DOWNLOAD_REQUEST_FAILED', + title: t('gallery.bulkDownloadRequestFailed'), + status: 'error', + }); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts new file mode 100644 index 0000000000000000000000000000000000000000..f5831f4ebaf010db15a813a3432280f69a7251b8 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedLinear.ts @@ -0,0 +1,110 @@ +import { logger } from 'app/logging/logger'; +import { enqueueRequested } from 'app/store/actions'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; +import type { Result } from 'common/util/result'; +import { withResult, withResultAsync } from 'common/util/result'; +import { $canvasManager } from 'features/controlLayers/store/ephemeral'; +import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; +import { buildFLUXGraph } from 'features/nodes/util/graph/generation/buildFLUXGraph'; +import { buildSD1Graph } from 'features/nodes/util/graph/generation/buildSD1Graph'; +import { buildSD3Graph } from 'features/nodes/util/graph/generation/buildSD3Graph'; +import { buildSDXLGraph } from 'features/nodes/util/graph/generation/buildSDXLGraph'; +import type { Graph } from 'features/nodes/util/graph/generation/Graph'; +import { toast } from 'features/toast/toast'; +import { serializeError } from 'serialize-error'; +import { queueApi } from 'services/api/endpoints/queue'; +import type { Invocation } from 'services/api/types'; +import { assert, AssertionError } from 'tsafe'; +import type { JsonObject } from 'type-fest'; + +const log = logger('generation'); + +export const addEnqueueRequestedLinear = (startAppListening: AppStartListening) => { + startAppListening({ + predicate: (action): action is ReturnType => + enqueueRequested.match(action) && action.payload.tabName === 'canvas', + effect: async (action, { getState, dispatch }) => { + const state = getState(); + const model = state.params.model; + const { prepend } = action.payload; + + const manager = $canvasManager.get(); + assert(manager, 'No model found in state'); + + let buildGraphResult: Result< + { + g: Graph; + noise: Invocation<'noise' | 'flux_denoise' | 'sd3_denoise'>; + posCond: Invocation<'compel' | 'sdxl_compel_prompt' | 'flux_text_encoder' | 'sd3_text_encoder'>; + }, + Error + >; + + assert(model, 'No model found in state'); + const base = model.base; + + switch (base) { + case 'sdxl': + buildGraphResult = await withResultAsync(() => buildSDXLGraph(state, manager)); + break; + case 'sd-1': + case `sd-2`: + buildGraphResult = await withResultAsync(() => buildSD1Graph(state, manager)); + break; + case `sd-3`: + buildGraphResult = await withResultAsync(() => buildSD3Graph(state, manager)); + break; + case `flux`: + buildGraphResult = await withResultAsync(() => buildFLUXGraph(state, manager)); + break; + default: + assert(false, `No graph builders for base ${base}`); + } + + if (buildGraphResult.isErr()) { + let description: string | null = null; + if (buildGraphResult.error instanceof AssertionError) { + description = extractMessageFromAssertionError(buildGraphResult.error); + } + const error = serializeError(buildGraphResult.error); + log.error({ error }, 'Failed to build graph'); + toast({ + status: 'error', + title: 'Failed to build graph', + description, + }); + return; + } + + const { g, noise, posCond } = buildGraphResult.value; + + const destination = state.canvasSettings.sendToCanvas ? 'canvas' : 'gallery'; + + const prepareBatchResult = withResult(() => + prepareLinearUIBatch(state, g, prepend, noise, posCond, 'canvas', destination) + ); + + if (prepareBatchResult.isErr()) { + log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch'); + return; + } + + const req = dispatch( + queueApi.endpoints.enqueueBatch.initiate(prepareBatchResult.value, { + fixedCacheKey: 'enqueueBatch', + }) + ); + req.reset(); + + const enqueueResult = await withResultAsync(() => req.unwrap()); + + if (enqueueResult.isErr()) { + log.error({ error: serializeError(enqueueResult.error) }, 'Failed to enqueue batch'); + return; + } + + log.debug({ batchConfig: prepareBatchResult.value } as JsonObject, 'Enqueued batch'); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts new file mode 100644 index 0000000000000000000000000000000000000000..42cd591e0cb1d34ee55f2ec8ceb59f8716db96ba --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedNodes.ts @@ -0,0 +1,52 @@ +import { enqueueRequested } from 'app/store/actions'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectNodesSlice } from 'features/nodes/store/selectors'; +import { buildNodesGraph } from 'features/nodes/util/graph/buildNodesGraph'; +import { buildWorkflowWithValidation } from 'features/nodes/util/workflow/buildWorkflow'; +import { queueApi } from 'services/api/endpoints/queue'; +import type { BatchConfig } from 'services/api/types'; + +export const addEnqueueRequestedNodes = (startAppListening: AppStartListening) => { + startAppListening({ + predicate: (action): action is ReturnType => + enqueueRequested.match(action) && action.payload.tabName === 'workflows', + effect: async (action, { getState, dispatch }) => { + const state = getState(); + const nodes = selectNodesSlice(state); + const workflow = state.workflow; + const graph = buildNodesGraph(nodes); + const builtWorkflow = buildWorkflowWithValidation({ + nodes: nodes.nodes, + edges: nodes.edges, + workflow, + }); + + if (builtWorkflow) { + // embedded workflows don't have an id + delete builtWorkflow.id; + } + + const batchConfig: BatchConfig = { + batch: { + graph, + workflow: builtWorkflow, + runs: state.params.iterations, + origin: 'workflows', + destination: 'gallery', + }, + prepend: action.payload.prepend, + }; + + const req = dispatch( + queueApi.endpoints.enqueueBatch.initiate(batchConfig, { + fixedCacheKey: 'enqueueBatch', + }) + ); + try { + await req.unwrap(); + } finally { + req.reset(); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts new file mode 100644 index 0000000000000000000000000000000000000000..624e9e54b304099e42724ef2e4e223aa901cfb2f --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/enqueueRequestedUpscale.ts @@ -0,0 +1,31 @@ +import { enqueueRequested } from 'app/store/actions'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; +import { buildMultidiffusionUpscaleGraph } from 'features/nodes/util/graph/buildMultidiffusionUpscaleGraph'; +import { queueApi } from 'services/api/endpoints/queue'; + +export const addEnqueueRequestedUpscale = (startAppListening: AppStartListening) => { + startAppListening({ + predicate: (action): action is ReturnType => + enqueueRequested.match(action) && action.payload.tabName === 'upscaling', + effect: async (action, { getState, dispatch }) => { + const state = getState(); + const { prepend } = action.payload; + + const { g, noise, posCond } = await buildMultidiffusionUpscaleGraph(state); + + const batchConfig = prepareLinearUIBatch(state, g, prepend, noise, posCond, 'upscaling', 'gallery'); + + const req = dispatch( + queueApi.endpoints.enqueueBatch.initiate(batchConfig, { + fixedCacheKey: 'enqueueBatch', + }) + ); + try { + await req.unwrap(); + } finally { + req.reset(); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts new file mode 100644 index 0000000000000000000000000000000000000000..5271d655d9d7b3f72b70b1a046d3aed343ebcd10 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryImageClicked.ts @@ -0,0 +1,73 @@ +import { createAction } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +export const galleryImageClicked = createAction<{ + imageDTO: ImageDTO; + shiftKey: boolean; + ctrlKey: boolean; + metaKey: boolean; + altKey: boolean; +}>('gallery/imageClicked'); + +/** + * This listener handles the logic for selecting images in the gallery. + * + * Previously, this logic was in a `useCallback` with the whole gallery selection as a dependency. Every time + * the selection changed, the callback got recreated and all images rerendered. This could easily block for + * hundreds of ms, more for lower end devices. + * + * Moving this logic into a listener means we don't need to recalculate anything dynamically and the gallery + * is much more responsive. + */ + +export const addGalleryImageClickedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: galleryImageClicked, + effect: (action, { dispatch, getState }) => { + const { imageDTO, shiftKey, ctrlKey, metaKey, altKey } = action.payload; + const state = getState(); + const queryArgs = selectListImagesQueryArgs(state); + const queryResult = imagesApi.endpoints.listImages.select(queryArgs)(state); + + if (!queryResult.data) { + // Should never happen if we have clicked a gallery image + return; + } + + const imageDTOs = queryResult.data.items; + const selection = state.gallery.selection; + + if (altKey) { + if (state.gallery.imageToCompare?.image_name === imageDTO.image_name) { + dispatch(imageToCompareChanged(null)); + } else { + dispatch(imageToCompareChanged(imageDTO)); + } + } else if (shiftKey) { + const rangeEndImageName = imageDTO.image_name; + const lastSelectedImage = selection[selection.length - 1]?.image_name; + const lastClickedIndex = imageDTOs.findIndex((n) => n.image_name === lastSelectedImage); + const currentClickedIndex = imageDTOs.findIndex((n) => n.image_name === rangeEndImageName); + if (lastClickedIndex > -1 && currentClickedIndex > -1) { + // We have a valid range! + const start = Math.min(lastClickedIndex, currentClickedIndex); + const end = Math.max(lastClickedIndex, currentClickedIndex); + const imagesToSelect = imageDTOs.slice(start, end + 1); + dispatch(selectionChanged(selection.concat(imagesToSelect))); + } + } else if (ctrlKey || metaKey) { + if (selection.some((i) => i.image_name === imageDTO.image_name) && selection.length > 1) { + dispatch(selectionChanged(selection.filter((n) => n.image_name !== imageDTO.image_name))); + } else { + dispatch(selectionChanged(selection.concat(imageDTO))); + } + } else { + dispatch(selectionChanged([imageDTO])); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts new file mode 100644 index 0000000000000000000000000000000000000000..51095700e3f80d20803572f91b60957f4d2f103d --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/galleryOffsetChanged.ts @@ -0,0 +1,119 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { imageToCompareChanged, offsetChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; + +export const addGalleryOffsetChangedListener = (startAppListening: AppStartListening) => { + /** + * When the user changes pages in the gallery, we need to wait until the next page of images is loaded, then maybe + * update the selection. + * + * There are a three scenarios: + * + * 1. The page is changed by clicking the pagination buttons. No changes to selection are needed. + * + * 2. The page is changed by using the arrow keys (without alt). + * - When going backwards, select the last image. + * - When going forwards, select the first image. + * + * 3. The page is changed by using the arrows keys with alt. This means the user is changing the comparison image. + * - When going backwards, select the last image _as the comparison image_. + * - When going forwards, select the first image _as the comparison image_. + */ + startAppListening({ + actionCreator: offsetChanged, + effect: async (action, { dispatch, getState, getOriginalState, take, cancelActiveListeners }) => { + // Cancel any active listeners to prevent the selection from changing without user input + cancelActiveListeners(); + + const { withHotkey } = action.payload; + + if (!withHotkey) { + // User changed pages by clicking the pagination buttons - no changes to selection + return; + } + + const originalState = getOriginalState(); + const prevOffset = originalState.gallery.offset; + const offset = getState().gallery.offset; + + if (offset === prevOffset) { + // The page didn't change - bail + return; + } + + /** + * We need to wait until the next page of images is loaded before updating the selection, so we use the correct + * page of images. + * + * The simplest way to do it would be to use `take` to wait for the next fulfilled action, but RTK-Q doesn't + * dispatch an action on cache hits. This means the `take` will only return if the cache is empty. If the user + * changes to a cached page - a common situation - the `take` will never resolve. + * + * So we need to take a two-step approach. First, check if we have data in the cache for the page of images. If + * we have data cached, use it to update the selection. If we don't have data cached, wait for the next fulfilled + * action, which updates the cache, then use the cache to update the selection. + */ + + // Check if we have data in the cache for the page of images + const queryArgs = selectListImagesQueryArgs(getState()); + let { data } = imagesApi.endpoints.listImages.select(queryArgs)(getState()); + + // No data yet - wait for the network request to complete + if (!data) { + const takeResult = await take(imagesApi.endpoints.listImages.matchFulfilled, 5000); + if (!takeResult) { + // The request didn't complete in time - bail + return; + } + data = takeResult[0].payload; + } + + // We awaited a network request - state could have changed, get fresh state + const state = getState(); + const { selection, imageToCompare } = state.gallery; + const imageDTOs = data?.items; + + if (!imageDTOs) { + // The page didn't load - bail + return; + } + + if (withHotkey === 'arrow') { + // User changed pages by using the arrow keys - selection changes to first or last image depending + if (offset < prevOffset) { + // We've gone backwards + const lastImage = imageDTOs[imageDTOs.length - 1]; + if (!selection.some((selectedImage) => selectedImage.image_name === lastImage?.image_name)) { + dispatch(selectionChanged(lastImage ? [lastImage] : [])); + } + } else { + // We've gone forwards + const firstImage = imageDTOs[0]; + if (!selection.some((selectedImage) => selectedImage.image_name === firstImage?.image_name)) { + dispatch(selectionChanged(firstImage ? [firstImage] : [])); + } + } + return; + } + + if (withHotkey === 'alt+arrow') { + // User changed pages by using the arrow keys with alt - comparison image changes to first or last depending + if (offset < prevOffset) { + // We've gone backwards + const lastImage = imageDTOs[imageDTOs.length - 1]; + if (lastImage && imageToCompare?.image_name !== lastImage.image_name) { + dispatch(imageToCompareChanged(lastImage)); + } + } else { + // We've gone forwards + const firstImage = imageDTOs[0]; + if (firstImage && imageToCompare?.image_name !== firstImage.image_name) { + dispatch(imageToCompareChanged(firstImage)); + } + } + return; + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema.ts new file mode 100644 index 0000000000000000000000000000000000000000..bbf2a09b6a729323da03f85ca4a58cec41da7eff --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/getOpenAPISchema.ts @@ -0,0 +1,40 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { parseify } from 'common/util/serialize'; +import { $templates } from 'features/nodes/store/nodesSlice'; +import { parseSchema } from 'features/nodes/util/schema/parseSchema'; +import { size } from 'lodash-es'; +import { serializeError } from 'serialize-error'; +import { appInfoApi } from 'services/api/endpoints/appInfo'; +import type { JsonObject } from 'type-fest'; + +const log = logger('system'); + +export const addGetOpenAPISchemaListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: appInfoApi.endpoints.getOpenAPISchema.matchFulfilled, + effect: (action, { getState }) => { + const schemaJSON = action.payload; + + log.debug({ schemaJSON: parseify(schemaJSON) } as JsonObject, 'Received OpenAPI schema'); + const { nodesAllowlist, nodesDenylist } = getState().config; + + const nodeTemplates = parseSchema(schemaJSON, nodesAllowlist, nodesDenylist); + + log.debug({ nodeTemplates } as JsonObject, `Built ${size(nodeTemplates)} node templates`); + + $templates.set(nodeTemplates); + }, + }); + + startAppListening({ + matcher: appInfoApi.endpoints.getOpenAPISchema.matchRejected, + effect: (action) => { + // If action.meta.condition === true, the request was canceled/skipped because another request was in flight or + // the value was already in the cache. We don't want to log these errors. + if (!action.meta.condition) { + log.error({ error: serializeError(action.error) }, 'Problem retrieving OpenAPI Schema'); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts new file mode 100644 index 0000000000000000000000000000000000000000..38e2127c0d1b949c11cc0851d0686d2393647632 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageAddedToBoard.ts @@ -0,0 +1,23 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { imagesApi } from 'services/api/endpoints/images'; + +const log = logger('gallery'); + +export const addImageAddedToBoardFulfilledListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: imagesApi.endpoints.addImageToBoard.matchFulfilled, + effect: (action) => { + const { board_id, imageDTO } = action.meta.arg.originalArgs; + log.debug({ board_id, imageDTO }, 'Image added to board'); + }, + }); + + startAppListening({ + matcher: imagesApi.endpoints.addImageToBoard.matchRejected, + effect: (action) => { + const { board_id, imageDTO } = action.meta.arg.originalArgs; + log.debug({ board_id, imageDTO }, 'Problem adding image to board'); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts new file mode 100644 index 0000000000000000000000000000000000000000..68d7595bd9a7bfe6d294359d7be1483715516c6d --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageDeletionListeners.ts @@ -0,0 +1,207 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import type { AppDispatch, RootState } from 'app/store/store'; +import { entityDeleted, referenceImageIPAdapterImageChanged } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; +import { isModalOpenChanged } from 'features/deleteImageModal/store/slice'; +import { selectListImagesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; +import { isImageFieldInputInstance } from 'features/nodes/types/field'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { forEach, intersectionBy } from 'lodash-es'; +import { imagesApi } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +const log = logger('gallery'); + +//TODO(psyche): handle image deletion (canvas staging area?) + +// Some utils to delete images from different parts of the app +const deleteNodesImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + state.nodes.present.nodes.forEach((node) => { + if (!isInvocationNode(node)) { + return; + } + + forEach(node.data.inputs, (input) => { + if (isImageFieldInputInstance(input) && input.value?.image_name === imageDTO.image_name) { + dispatch( + fieldImageValueChanged({ + nodeId: node.data.id, + fieldName: input.name, + value: undefined, + }) + ); + } + }); + }); +}; + +const deleteControlLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + selectCanvasSlice(state).controlLayers.entities.forEach(({ id, objects }) => { + let shouldDelete = false; + for (const obj of objects) { + if (obj.type === 'image' && obj.image.image_name === imageDTO.image_name) { + shouldDelete = true; + break; + } + } + if (shouldDelete) { + dispatch(entityDeleted({ entityIdentifier: { id, type: 'control_layer' } })); + } + }); +}; + +const deleteReferenceImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + selectCanvasSlice(state).referenceImages.entities.forEach((entity) => { + if (entity.ipAdapter.image?.image_name === imageDTO.image_name) { + dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier: getEntityIdentifier(entity), imageDTO: null })); + } + }); +}; + +const deleteRasterLayerImages = (state: RootState, dispatch: AppDispatch, imageDTO: ImageDTO) => { + selectCanvasSlice(state).rasterLayers.entities.forEach(({ id, objects }) => { + let shouldDelete = false; + for (const obj of objects) { + if (obj.type === 'image' && obj.image.image_name === imageDTO.image_name) { + shouldDelete = true; + break; + } + } + if (shouldDelete) { + dispatch(entityDeleted({ entityIdentifier: { id, type: 'raster_layer' } })); + } + }); +}; + +export const addImageDeletionListeners = (startAppListening: AppStartListening) => { + // Handle single image deletion + startAppListening({ + actionCreator: imageDeletionConfirmed, + effect: async (action, { dispatch, getState }) => { + const { imageDTOs, imagesUsage } = action.payload; + + if (imageDTOs.length !== 1 || imagesUsage.length !== 1) { + // handle multiples in separate listener + return; + } + + const imageDTO = imageDTOs[0]; + const imageUsage = imagesUsage[0]; + + if (!imageDTO || !imageUsage) { + // satisfy noUncheckedIndexedAccess + return; + } + + try { + const state = getState(); + await dispatch(imagesApi.endpoints.deleteImage.initiate(imageDTO)).unwrap(); + + if (state.gallery.selection.some((i) => i.image_name === imageDTO.image_name)) { + // The deleted image was a selected image, we need to select the next image + const newSelection = state.gallery.selection.filter((i) => i.image_name !== imageDTO.image_name); + + if (newSelection.length > 0) { + return; + } + + // Get the current list of images and select the same index + const baseQueryArgs = selectListImagesQueryArgs(state); + const data = imagesApi.endpoints.listImages.select(baseQueryArgs)(state).data; + + if (data) { + const deletedImageIndex = data.items.findIndex((i) => i.image_name === imageDTO.image_name); + const nextImage = data.items[deletedImageIndex + 1] ?? data.items[0] ?? null; + if (nextImage?.image_name === imageDTO.image_name) { + // If the next image is the same as the deleted one, it means it was the last image, reset selection + dispatch(imageSelected(null)); + } else { + dispatch(imageSelected(nextImage)); + } + } + } + + deleteNodesImages(state, dispatch, imageDTO); + deleteReferenceImages(state, dispatch, imageDTO); + deleteRasterLayerImages(state, dispatch, imageDTO); + deleteControlLayerImages(state, dispatch, imageDTO); + } catch { + // no-op + } finally { + dispatch(isModalOpenChanged(false)); + } + }, + }); + + // Handle multiple image deletion + startAppListening({ + actionCreator: imageDeletionConfirmed, + effect: async (action, { dispatch, getState }) => { + const { imageDTOs, imagesUsage } = action.payload; + + if (imageDTOs.length <= 1 || imagesUsage.length <= 1) { + // handle singles in separate listener + return; + } + + try { + const state = getState(); + await dispatch(imagesApi.endpoints.deleteImages.initiate({ imageDTOs })).unwrap(); + + if (intersectionBy(state.gallery.selection, imageDTOs, 'image_name').length > 0) { + // Some selected images were deleted, need to select the next image + const queryArgs = selectListImagesQueryArgs(state); + const { data } = imagesApi.endpoints.listImages.select(queryArgs)(state); + if (data) { + // When we delete multiple images, we clear the selection. Then, the the next time we load images, we will + // select the first one. This is handled below in the listener for `imagesApi.endpoints.listImages.matchFulfilled`. + dispatch(imageSelected(null)); + } + } + + // We need to reset the features where the image is in use - none of these work if their image(s) don't exist + + imageDTOs.forEach((imageDTO) => { + deleteNodesImages(state, dispatch, imageDTO); + deleteControlLayerImages(state, dispatch, imageDTO); + deleteReferenceImages(state, dispatch, imageDTO); + deleteRasterLayerImages(state, dispatch, imageDTO); + }); + } catch { + // no-op + } finally { + dispatch(isModalOpenChanged(false)); + } + }, + }); + + // When we list images, if no images is selected, select the first one. + startAppListening({ + matcher: imagesApi.endpoints.listImages.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const selection = getState().gallery.selection; + if (selection.length === 0) { + dispatch(imageSelected(action.payload.items[0] ?? null)); + } + }, + }); + + startAppListening({ + matcher: imagesApi.endpoints.deleteImage.matchFulfilled, + effect: (action) => { + log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Image deleted'); + }, + }); + + startAppListening({ + matcher: imagesApi.endpoints.deleteImage.matchRejected, + effect: (action) => { + log.debug({ imageDTO: action.meta.arg.originalArgs }, 'Unable to delete image'); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts new file mode 100644 index 0000000000000000000000000000000000000000..c25f5216097f0614726a4090d19300445f10dab7 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageRemovedFromBoard.ts @@ -0,0 +1,23 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { imagesApi } from 'services/api/endpoints/images'; + +const log = logger('gallery'); + +export const addImageRemovedFromBoardFulfilledListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: imagesApi.endpoints.removeImageFromBoard.matchFulfilled, + effect: (action) => { + const imageDTO = action.meta.arg.originalArgs; + log.debug({ imageDTO }, 'Image removed from board'); + }, + }); + + startAppListening({ + matcher: imagesApi.endpoints.removeImageFromBoard.matchRejected, + effect: (action) => { + const imageDTO = action.meta.arg.originalArgs; + log.debug({ imageDTO }, 'Problem removing image from board'); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts new file mode 100644 index 0000000000000000000000000000000000000000..c8f80ef451e950887d09d63f0f89652ffba42be3 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageToDeleteSelected.ts @@ -0,0 +1,32 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { imageDeletionConfirmed } from 'features/deleteImageModal/store/actions'; +import { selectImageUsage } from 'features/deleteImageModal/store/selectors'; +import { imagesToDeleteSelected, isModalOpenChanged } from 'features/deleteImageModal/store/slice'; + +export const addImageToDeleteSelectedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: imagesToDeleteSelected, + effect: (action, { dispatch, getState }) => { + const imageDTOs = action.payload; + const state = getState(); + const { shouldConfirmOnDelete } = state.system; + const imagesUsage = selectImageUsage(getState()); + + const isImageInUse = + imagesUsage.some((i) => i.isRasterLayerImage) || + imagesUsage.some((i) => i.isControlLayerImage) || + imagesUsage.some((i) => i.isReferenceImage) || + imagesUsage.some((i) => i.isInpaintMaskImage) || + imagesUsage.some((i) => i.isUpscaleImage) || + imagesUsage.some((i) => i.isNodesImage) || + imagesUsage.some((i) => i.isRegionalGuidanceImage); + + if (shouldConfirmOnDelete || isImageInUse) { + dispatch(isModalOpenChanged(true)); + return; + } + + dispatch(imageDeletionConfirmed({ imageDTOs, imagesUsage })); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts new file mode 100644 index 0000000000000000000000000000000000000000..daeab9e3a521a26fe52a0eb2fcd19c0cfaa19cc5 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imageUploaded.ts @@ -0,0 +1,108 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import type { RootState } from 'app/store/store'; +import { selectListBoardsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected, galleryViewChanged } from 'features/gallery/store/gallerySlice'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { omit } from 'lodash-es'; +import { boardsApi } from 'services/api/endpoints/boards'; +import { imagesApi } from 'services/api/endpoints/images'; + +const log = logger('gallery'); + +/** + * Gets the description for the toast that is shown when an image is uploaded. + * @param boardId The board id of the uploaded image + * @param state The current state of the app + * @returns + */ +const getUploadedToastDescription = (boardId: string, state: RootState) => { + if (boardId === 'none') { + return t('toast.addedToUncategorized'); + } + // Attempt to get the board's name for the toast + const queryArgs = selectListBoardsQueryArgs(state); + const { data } = boardsApi.endpoints.listAllBoards.select(queryArgs)(state); + // Fall back to just the board id if we can't find the board for some reason + const board = data?.find((b) => b.board_id === boardId); + + return t('toast.addedToBoard', { name: board?.board_name ?? boardId }); +}; + +let lastUploadedToastTimeout: number | null = null; + +export const addImageUploadedFulfilledListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: imagesApi.endpoints.uploadImage.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const imageDTO = action.payload; + const state = getState(); + + log.debug({ imageDTO }, 'Image uploaded'); + + if (action.meta.arg.originalArgs.silent || imageDTO.is_intermediate) { + // When a "silent" upload is requested, or the image is intermediate, we can skip all post-upload actions, + // like toasts and switching the gallery view + return; + } + + const boardId = imageDTO.board_id ?? 'none'; + + const DEFAULT_UPLOADED_TOAST = { + id: 'IMAGE_UPLOADED', + title: t('toast.imageUploaded'), + status: 'success', + } as const; + + // default action - just upload and alert user + if (lastUploadedToastTimeout !== null) { + window.clearTimeout(lastUploadedToastTimeout); + } + const toastApi = toast({ + ...DEFAULT_UPLOADED_TOAST, + title: DEFAULT_UPLOADED_TOAST.title, + description: getUploadedToastDescription(boardId, state), + duration: null, // we will close the toast manually + }); + lastUploadedToastTimeout = window.setTimeout(() => { + toastApi.close(); + }, 3000); + + /** + * We only want to change the board and view if this is the first upload of a batch, else we end up hijacking + * the user's gallery board and view selection: + * - User uploads multiple images + * - A couple uploads finish, but others are pending still + * - User changes the board selection + * - Pending uploads finish and change the board back to the original board + * - User is confused as to why the board changed + * + * Default to true to not require _all_ image upload handlers to set this value + */ + const isFirstUploadOfBatch = action.meta.arg.originalArgs.isFirstUploadOfBatch ?? true; + if (isFirstUploadOfBatch) { + dispatch(boardIdSelected({ boardId })); + dispatch(galleryViewChanged('assets')); + } + }, + }); + + startAppListening({ + matcher: imagesApi.endpoints.uploadImage.matchRejected, + effect: (action) => { + const sanitizedData = { + arg: { + ...omit(action.meta.arg.originalArgs, ['file', 'postUploadAction']), + file: '', + }, + }; + log.error({ ...sanitizedData }, 'Image upload failed'); + toast({ + title: t('toast.imageUploadFailed'), + description: action.error.message, + status: 'error', + }); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts new file mode 100644 index 0000000000000000000000000000000000000000..0337b995f58d0c651c632bf552311bce8b3c7f1a --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesStarred.ts @@ -0,0 +1,30 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectionChanged } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +export const addImagesStarredListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: imagesApi.endpoints.starImages.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const { updated_image_names: starredImages } = action.payload; + + const state = getState(); + + const { selection } = state.gallery; + const updatedSelection: ImageDTO[] = []; + + selection.forEach((selectedImageDTO) => { + if (starredImages.includes(selectedImageDTO.image_name)) { + updatedSelection.push({ + ...selectedImageDTO, + starred: true, + }); + } else { + updatedSelection.push(selectedImageDTO); + } + }); + dispatch(selectionChanged(updatedSelection)); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts new file mode 100644 index 0000000000000000000000000000000000000000..ad6c26fd0c35cb878b610736b1f87fdd4ab267e4 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/imagesUnstarred.ts @@ -0,0 +1,30 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { selectionChanged } from 'features/gallery/store/gallerySlice'; +import { imagesApi } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +export const addImagesUnstarredListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher: imagesApi.endpoints.unstarImages.matchFulfilled, + effect: (action, { dispatch, getState }) => { + const { updated_image_names: unstarredImages } = action.payload; + + const state = getState(); + + const { selection } = state.gallery; + const updatedSelection: ImageDTO[] = []; + + selection.forEach((selectedImageDTO) => { + if (unstarredImages.includes(selectedImageDTO.image_name)) { + updatedSelection.push({ + ...selectedImageDTO, + starred: false, + }); + } else { + updatedSelection.push(selectedImageDTO); + } + }); + dispatch(selectionChanged(updatedSelection)); + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts new file mode 100644 index 0000000000000000000000000000000000000000..1ce0ab602b0a85d4bc959cfa235429631eb4cb0d --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelSelected.ts @@ -0,0 +1,80 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { bboxSyncedToOptimalDimension } from 'features/controlLayers/store/canvasSlice'; +import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { loraDeleted } from 'features/controlLayers/store/lorasSlice'; +import { modelChanged, vaeSelected } from 'features/controlLayers/store/paramsSlice'; +import { selectBboxModelBase } from 'features/controlLayers/store/selectors'; +import { modelSelected } from 'features/parameters/store/actions'; +import { zParameterModel } from 'features/parameters/types/parameterSchemas'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; + +const log = logger('models'); + +export const addModelSelectedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: modelSelected, + effect: (action, { getState, dispatch }) => { + const state = getState(); + const result = zParameterModel.safeParse(action.payload); + + if (!result.success) { + log.error({ error: result.error.format() }, 'Failed to parse main model'); + return; + } + + const newModel = result.data; + + const newBaseModel = newModel.base; + const didBaseModelChange = state.params.model?.base !== newBaseModel; + + if (didBaseModelChange) { + // we may need to reset some incompatible submodels + let modelsCleared = 0; + + // handle incompatible loras + state.loras.loras.forEach((lora) => { + if (lora.model.base !== newBaseModel) { + dispatch(loraDeleted({ id: lora.id })); + modelsCleared += 1; + } + }); + + // handle incompatible vae + const { vae } = state.params; + if (vae && vae.base !== newBaseModel) { + dispatch(vaeSelected(null)); + modelsCleared += 1; + } + + // handle incompatible controlnets + // state.canvas.present.controlAdapters.entities.forEach((ca) => { + // if (ca.model?.base !== newBaseModel) { + // modelsCleared += 1; + // if (ca.isEnabled) { + // dispatch(entityIsEnabledToggled({ entityIdentifier: { id: ca.id, type: 'control_adapter' } })); + // } + // } + // }); + + if (modelsCleared > 0) { + toast({ + id: 'BASE_MODEL_CHANGED', + title: t('toast.baseModelChanged'), + description: t('toast.baseModelChangedCleared', { + count: modelsCleared, + }), + status: 'warning', + }); + } + } + + dispatch(modelChanged({ model: newModel, previousModel: state.params.model })); + const modelBase = selectBboxModelBase(state); + if (!selectIsStaging(state) && modelBase !== state.params.model?.base) { + dispatch(bboxSyncedToOptimalDimension()); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts new file mode 100644 index 0000000000000000000000000000000000000000..770e376687907964523fd67b3079913245f213b4 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/modelsLoaded.ts @@ -0,0 +1,380 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import type { AppDispatch, RootState } from 'app/store/store'; +import { + controlLayerModelChanged, + referenceImageIPAdapterModelChanged, + rgIPAdapterModelChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { loraDeleted } from 'features/controlLayers/store/lorasSlice'; +import { + clipEmbedModelSelected, + fluxVAESelected, + modelChanged, + refinerModelChanged, + t5EncoderModelSelected, + vaeSelected, +} from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { modelSelected } from 'features/parameters/store/actions'; +import { postProcessingModelChanged, upscaleModelChanged } from 'features/parameters/store/upscaleSlice'; +import { + zParameterCLIPEmbedModel, + zParameterSpandrelImageToImageModel, + zParameterT5EncoderModel, + zParameterVAEModel, +} from 'features/parameters/types/parameterSchemas'; +import type { Logger } from 'roarr'; +import { modelConfigsAdapterSelectors, modelsApi } from 'services/api/endpoints/models'; +import type { AnyModelConfig } from 'services/api/types'; +import { + isCLIPEmbedModelConfig, + isControlNetOrT2IAdapterModelConfig, + isFluxVAEModelConfig, + isIPAdapterModelConfig, + isLoRAModelConfig, + isNonFluxVAEModelConfig, + isNonRefinerMainModelConfig, + isRefinerMainModelModelConfig, + isSpandrelImageToImageModelConfig, + isT5EncoderModelConfig, +} from 'services/api/types'; +import type { JsonObject } from 'type-fest'; + +const log = logger('models'); + +/** + * This listener handles resetting or selecting models as we receive the big list of models from the API. + * + * For example, if a selected model is no longer available, it resets that models selection in redux. + * + * Or, if the model selection is one that should always be populated if possible, like main models, the listener + * attempts to populate it. + * + * Some models, like VAEs, are optional and can be `null` - this listener will only clear the selection if the model is + * no longer available, it will not attempt to select a new model. + */ +export const addModelsLoadedListener = (startAppListening: AppStartListening) => { + startAppListening({ + predicate: modelsApi.endpoints.getModelConfigs.matchFulfilled, + effect: (action, { getState, dispatch }) => { + // models loaded, we need to ensure the selected model is available and if not, select the first one + log.info({ models: action.payload.entities }, `Models loaded (${action.payload.ids.length})`); + + const state = getState(); + + const models = modelConfigsAdapterSelectors.selectAll(action.payload); + + handleMainModels(models, state, dispatch, log); + handleRefinerModels(models, state, dispatch, log); + handleVAEModels(models, state, dispatch, log); + handleLoRAModels(models, state, dispatch, log); + handleControlAdapterModels(models, state, dispatch, log); + handlePostProcessingModel(models, state, dispatch, log); + handleUpscaleModel(models, state, dispatch, log); + handleIPAdapterModels(models, state, dispatch, log); + handleT5EncoderModels(models, state, dispatch, log); + handleCLIPEmbedModels(models, state, dispatch, log); + handleFLUXVAEModels(models, state, dispatch, log); + }, + }); +}; + +type ModelHandler = ( + models: AnyModelConfig[], + state: RootState, + dispatch: AppDispatch, + log: Logger +) => undefined; + +const handleMainModels: ModelHandler = (models, state, dispatch, log) => { + const selectedMainModel = state.params.model; + const allMainModels = models.filter(isNonRefinerMainModelConfig).sort((a) => (a.base === 'sdxl' ? -1 : 1)); + + const firstModel = allMainModels[0]; + + // If we have no models, we may need to clear the selected model + if (!firstModel) { + // Only clear the model if we have one currently selected + if (selectedMainModel !== null) { + log.debug({ selectedMainModel }, 'No main models available, clearing'); + dispatch(modelChanged({ model: null })); + } + return; + } + + // If the current model is available, we don't need to do anything + if (allMainModels.some((m) => m.key === selectedMainModel?.key)) { + return; + } + + // If we have a default model, try to use it + if (state.config.sd.defaultModel) { + const defaultModel = allMainModels.find((m) => m.key === state.config.sd.defaultModel); + if (defaultModel) { + log.debug( + { selectedMainModel, defaultModel }, + 'No selected main model or selected main model is not available, selecting default model' + ); + dispatch(modelSelected(defaultModel)); + return; + } + } + + log.debug( + { selectedMainModel, firstModel }, + 'No selected main model or selected main model is not available, selecting first available model' + ); + dispatch(modelSelected(firstModel)); +}; + +const handleRefinerModels: ModelHandler = (models, state, dispatch, log) => { + const selectedRefinerModel = state.params.refinerModel; + + // `null` is a valid refiner model - no need to do anything. + if (selectedRefinerModel === null) { + return; + } + + // We have a refiner model selected, need to check if it is available + + // Grab just the refiner models + const allRefinerModels = models.filter(isRefinerMainModelModelConfig); + + // If the current refiner model is available, we don't need to do anything + if (allRefinerModels.some((m) => m.key === selectedRefinerModel.key)) { + return; + } + + // Else, we need to clear the refiner model + log.debug({ selectedRefinerModel }, 'Selected refiner model is not available, clearing'); + dispatch(refinerModelChanged(null)); + return; +}; + +const handleVAEModels: ModelHandler = (models, state, dispatch, log) => { + const selectedVAEModel = state.params.vae; + + // `null` is a valid VAE - it means "use the VAE baked into the currently-selected main model" + if (selectedVAEModel === null) { + return; + } + + // We have a VAE selected, need to check if it is available + + // Grab just the VAE models + const vaeModels = models.filter((m) => isNonFluxVAEModelConfig(m)); + + // If the current VAE model is available, we don't need to do anything + if (vaeModels.some((m) => m.key === selectedVAEModel.key)) { + return; + } + + // Else, we need to clear the VAE model + log.debug({ selectedVAEModel }, 'Selected VAE model is not available, clearing'); + dispatch(vaeSelected(null)); + return; +}; + +const handleLoRAModels: ModelHandler = (models, state, dispatch, log) => { + const loraModels = models.filter(isLoRAModelConfig); + state.loras.loras.forEach((lora) => { + const isLoRAAvailable = loraModels.some((m) => m.key === lora.model.key); + if (isLoRAAvailable) { + return; + } + log.debug({ model: lora.model }, 'LoRA model is not available, clearing'); + dispatch(loraDeleted({ id: lora.id })); + }); +}; + +const handleControlAdapterModels: ModelHandler = (models, state, dispatch, log) => { + const caModels = models.filter(isControlNetOrT2IAdapterModelConfig); + selectCanvasSlice(state).controlLayers.entities.forEach((entity) => { + const selectedControlAdapterModel = entity.controlAdapter.model; + // `null` is a valid control adapter model - no need to do anything. + if (!selectedControlAdapterModel) { + return; + } + const isModelAvailable = caModels.some((m) => m.key === selectedControlAdapterModel.key); + if (isModelAvailable) { + return; + } + log.debug({ selectedControlAdapterModel }, 'Selected control adapter model is not available, clearing'); + dispatch(controlLayerModelChanged({ entityIdentifier: getEntityIdentifier(entity), modelConfig: null })); + }); +}; + +const handleIPAdapterModels: ModelHandler = (models, state, dispatch, log) => { + const ipaModels = models.filter(isIPAdapterModelConfig); + selectCanvasSlice(state).referenceImages.entities.forEach((entity) => { + const selectedIPAdapterModel = entity.ipAdapter.model; + // `null` is a valid IP adapter model - no need to do anything. + if (!selectedIPAdapterModel) { + return; + } + const isModelAvailable = ipaModels.some((m) => m.key === selectedIPAdapterModel.key); + if (isModelAvailable) { + return; + } + log.debug({ selectedIPAdapterModel }, 'Selected IP adapter model is not available, clearing'); + dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), modelConfig: null })); + }); + + selectCanvasSlice(state).regionalGuidance.entities.forEach((entity) => { + entity.referenceImages.forEach(({ id: referenceImageId, ipAdapter }) => { + const selectedIPAdapterModel = ipAdapter.model; + // `null` is a valid IP adapter model - no need to do anything. + if (!selectedIPAdapterModel) { + return; + } + const isModelAvailable = ipaModels.some((m) => m.key === selectedIPAdapterModel.key); + if (isModelAvailable) { + return; + } + log.debug({ selectedIPAdapterModel }, 'Selected IP adapter model is not available, clearing'); + dispatch( + rgIPAdapterModelChanged({ entityIdentifier: getEntityIdentifier(entity), referenceImageId, modelConfig: null }) + ); + }); + }); +}; + +const handlePostProcessingModel: ModelHandler = (models, state, dispatch, log) => { + const selectedPostProcessingModel = state.upscale.postProcessingModel; + const allSpandrelModels = models.filter(isSpandrelImageToImageModelConfig); + + // If the currently selected model is available, we don't need to do anything + if (selectedPostProcessingModel && allSpandrelModels.some((m) => m.key === selectedPostProcessingModel.key)) { + return; + } + + // Else we should select the first available model + const firstModel = allSpandrelModels[0] || null; + if (firstModel) { + log.debug( + { selectedPostProcessingModel, firstModel }, + 'No selected post-processing model or selected post-processing model is not available, selecting first available model' + ); + dispatch(postProcessingModelChanged(zParameterSpandrelImageToImageModel.parse(firstModel))); + return; + } + + // No available models, we should clear the selected model - but only if we have one selected + if (selectedPostProcessingModel) { + log.debug({ selectedPostProcessingModel }, 'Selected post-processing model is not available, clearing'); + dispatch(postProcessingModelChanged(null)); + } +}; + +const handleUpscaleModel: ModelHandler = (models, state, dispatch, log) => { + const selectedUpscaleModel = state.upscale.upscaleModel; + const allSpandrelModels = models.filter(isSpandrelImageToImageModelConfig); + + // If the currently selected model is available, we don't need to do anything + if (selectedUpscaleModel && allSpandrelModels.some((m) => m.key === selectedUpscaleModel.key)) { + return; + } + + // Else we should select the first available model + const firstModel = allSpandrelModels[0] || null; + if (firstModel) { + log.debug( + { selectedUpscaleModel, firstModel }, + 'No selected upscale model or selected upscale model is not available, selecting first available model' + ); + dispatch(upscaleModelChanged(zParameterSpandrelImageToImageModel.parse(firstModel))); + return; + } + + // No available models, we should clear the selected model - but only if we have one selected + if (selectedUpscaleModel) { + log.debug({ selectedUpscaleModel }, 'Selected upscale model is not available, clearing'); + dispatch(upscaleModelChanged(null)); + } +}; + +const handleT5EncoderModels: ModelHandler = (models, state, dispatch, log) => { + const selectedT5EncoderModel = state.params.t5EncoderModel; + const t5EncoderModels = models.filter((m) => isT5EncoderModelConfig(m)); + + // If the currently selected model is available, we don't need to do anything + if (selectedT5EncoderModel && t5EncoderModels.some((m) => m.key === selectedT5EncoderModel.key)) { + return; + } + + // Else we should select the first available model + const firstModel = t5EncoderModels[0] || null; + if (firstModel) { + log.debug( + { selectedT5EncoderModel, firstModel }, + 'No selected T5 encoder model or selected T5 encoder model is not available, selecting first available model' + ); + dispatch(t5EncoderModelSelected(zParameterT5EncoderModel.parse(firstModel))); + return; + } + + // No available models, we should clear the selected model - but only if we have one selected + if (selectedT5EncoderModel) { + log.debug({ selectedT5EncoderModel }, 'Selected T5 encoder model is not available, clearing'); + dispatch(t5EncoderModelSelected(null)); + return; + } +}; + +const handleCLIPEmbedModels: ModelHandler = (models, state, dispatch, log) => { + const selectedCLIPEmbedModel = state.params.clipEmbedModel; + const CLIPEmbedModels = models.filter((m) => isCLIPEmbedModelConfig(m)); + + // If the currently selected model is available, we don't need to do anything + if (selectedCLIPEmbedModel && CLIPEmbedModels.some((m) => m.key === selectedCLIPEmbedModel.key)) { + return; + } + + // Else we should select the first available model + const firstModel = CLIPEmbedModels[0] || null; + if (firstModel) { + log.debug( + { selectedCLIPEmbedModel, firstModel }, + 'No selected CLIP embed model or selected CLIP embed model is not available, selecting first available model' + ); + dispatch(clipEmbedModelSelected(zParameterCLIPEmbedModel.parse(firstModel))); + return; + } + + // No available models, we should clear the selected model - but only if we have one selected + if (selectedCLIPEmbedModel) { + log.debug({ selectedCLIPEmbedModel }, 'Selected CLIP embed model is not available, clearing'); + dispatch(clipEmbedModelSelected(null)); + return; + } +}; + +const handleFLUXVAEModels: ModelHandler = (models, state, dispatch, log) => { + const selectedFLUXVAEModel = state.params.fluxVAE; + const fluxVAEModels = models.filter((m) => isFluxVAEModelConfig(m)); + + // If the currently selected model is available, we don't need to do anything + if (selectedFLUXVAEModel && fluxVAEModels.some((m) => m.key === selectedFLUXVAEModel.key)) { + return; + } + + // Else we should select the first available model + const firstModel = fluxVAEModels[0] || null; + if (firstModel) { + log.debug( + { selectedFLUXVAEModel, firstModel }, + 'No selected FLUX VAE model or selected FLUX VAE model is not available, selecting first available model' + ); + dispatch(fluxVAESelected(zParameterVAEModel.parse(firstModel))); + return; + } + + // No available models, we should clear the selected model - but only if we have one selected + if (selectedFLUXVAEModel) { + log.debug({ selectedFLUXVAEModel }, 'Selected FLUX VAE model is not available, clearing'); + dispatch(fluxVAESelected(null)); + return; + } +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts new file mode 100644 index 0000000000000000000000000000000000000000..0be242f49d358421bc8e491b9fdcb99097cd76d4 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts @@ -0,0 +1,89 @@ +import { isAnyOf } from '@reduxjs/toolkit'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { positivePromptChanged } from 'features/controlLayers/store/paramsSlice'; +import { + combinatorialToggled, + isErrorChanged, + isLoadingChanged, + maxPromptsChanged, + maxPromptsReset, + parsingErrorChanged, + promptsChanged, +} from 'features/dynamicPrompts/store/dynamicPromptsSlice'; +import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt'; +import { getPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; +import { activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice'; +import { stylePresetsApi } from 'services/api/endpoints/stylePresets'; +import { utilitiesApi } from 'services/api/endpoints/utilities'; + +import { socketConnected } from './socketConnected'; + +const matcher = isAnyOf( + positivePromptChanged, + combinatorialToggled, + maxPromptsChanged, + maxPromptsReset, + socketConnected, + activeStylePresetIdChanged, + stylePresetsApi.endpoints.listStylePresets.matchFulfilled +); + +export const addDynamicPromptsListener = (startAppListening: AppStartListening) => { + startAppListening({ + matcher, + effect: async (action, { dispatch, getState, cancelActiveListeners, delay }) => { + cancelActiveListeners(); + const state = getState(); + const { positivePrompt } = getPresetModifiedPrompts(state); + const { maxPrompts } = state.dynamicPrompts; + + if (state.config.disabledFeatures.includes('dynamicPrompting')) { + return; + } + + const cachedPrompts = utilitiesApi.endpoints.dynamicPrompts.select({ + prompt: positivePrompt, + max_prompts: maxPrompts, + })(state).data; + + if (cachedPrompts) { + dispatch(promptsChanged(cachedPrompts.prompts)); + dispatch(parsingErrorChanged(cachedPrompts.error)); + return; + } + + if (!getShouldProcessPrompt(positivePrompt)) { + dispatch(promptsChanged([positivePrompt])); + dispatch(parsingErrorChanged(undefined)); + dispatch(isErrorChanged(false)); + return; + } + + if (!state.dynamicPrompts.isLoading) { + dispatch(isLoadingChanged(true)); + } + + // debounce request + await delay(1000); + + try { + const req = dispatch( + utilitiesApi.endpoints.dynamicPrompts.initiate({ + prompt: positivePrompt, + max_prompts: maxPrompts, + }) + ); + + const res = await req.unwrap(); + req.unsubscribe(); + + dispatch(promptsChanged(res.prompts)); + dispatch(parsingErrorChanged(res.error)); + dispatch(isErrorChanged(false)); + } catch { + dispatch(isErrorChanged(true)); + dispatch(isLoadingChanged(false)); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts new file mode 100644 index 0000000000000000000000000000000000000000..333d98812b652d209f3469680ce67277d61ce71d --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/setDefaultSettings.ts @@ -0,0 +1,126 @@ +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { bboxHeightChanged, bboxWidthChanged } from 'features/controlLayers/store/canvasSlice'; +import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { + setCfgRescaleMultiplier, + setCfgScale, + setGuidance, + setScheduler, + setSteps, + vaePrecisionChanged, + vaeSelected, +} from 'features/controlLayers/store/paramsSlice'; +import { setDefaultSettings } from 'features/parameters/store/actions'; +import { + isParameterCFGRescaleMultiplier, + isParameterCFGScale, + isParameterGuidance, + isParameterHeight, + isParameterPrecision, + isParameterScheduler, + isParameterSteps, + isParameterWidth, + zParameterVAEModel, +} from 'features/parameters/types/parameterSchemas'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { modelConfigsAdapterSelectors, modelsApi } from 'services/api/endpoints/models'; +import { isNonRefinerMainModelConfig } from 'services/api/types'; + +export const addSetDefaultSettingsListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: setDefaultSettings, + effect: async (action, { dispatch, getState }) => { + const state = getState(); + + const currentModel = state.params.model; + + if (!currentModel) { + return; + } + + const request = dispatch(modelsApi.endpoints.getModelConfigs.initiate()); + const data = await request.unwrap(); + request.unsubscribe(); + const models = modelConfigsAdapterSelectors.selectAll(data); + + const modelConfig = models.find((model) => model.key === currentModel.key); + + if (!modelConfig) { + return; + } + + if (isNonRefinerMainModelConfig(modelConfig) && modelConfig.default_settings) { + const { vae, vae_precision, cfg_scale, cfg_rescale_multiplier, steps, scheduler, width, height, guidance } = + modelConfig.default_settings; + + if (vae) { + // we store this as "default" within default settings + // to distinguish it from no default set + if (vae === 'default') { + dispatch(vaeSelected(null)); + } else { + const vaeModel = models.find((model) => model.key === vae); + const result = zParameterVAEModel.safeParse(vaeModel); + if (!result.success) { + return; + } + dispatch(vaeSelected(result.data)); + } + } + + if (vae_precision) { + if (isParameterPrecision(vae_precision)) { + dispatch(vaePrecisionChanged(vae_precision)); + } + } + + if (guidance) { + if (isParameterGuidance(guidance)) { + dispatch(setGuidance(guidance)); + } + } + + if (cfg_scale) { + if (isParameterCFGScale(cfg_scale)) { + dispatch(setCfgScale(cfg_scale)); + } + } + + if (cfg_rescale_multiplier) { + if (isParameterCFGRescaleMultiplier(cfg_rescale_multiplier)) { + dispatch(setCfgRescaleMultiplier(cfg_rescale_multiplier)); + } + } + + if (steps) { + if (isParameterSteps(steps)) { + dispatch(setSteps(steps)); + } + } + + if (scheduler) { + if (isParameterScheduler(scheduler)) { + dispatch(setScheduler(scheduler)); + } + } + const setSizeOptions = { updateAspectRatio: true, clamp: true }; + + const isStaging = selectIsStaging(getState()); + if (!isStaging && width) { + if (isParameterWidth(width)) { + dispatch(bboxWidthChanged({ width, ...setSizeOptions })); + } + } + + if (!isStaging && height) { + if (isParameterHeight(height)) { + dispatch(bboxHeightChanged({ height, ...setSizeOptions })); + } + } + + toast({ id: 'PARAMETER_SET', title: t('toast.parameterSet', { parameter: 'Default settings' }) }); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketConnected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketConnected.ts new file mode 100644 index 0000000000000000000000000000000000000000..0398aacb6e3fd6b1da335bf75a667d032e13c9d2 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketConnected.ts @@ -0,0 +1,85 @@ +import { createAction } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { $baseUrl } from 'app/store/nanostores/baseUrl'; +import { isEqual } from 'lodash-es'; +import { atom } from 'nanostores'; +import { api } from 'services/api'; +import { modelsApi } from 'services/api/endpoints/models'; +import { queueApi, selectQueueStatus } from 'services/api/endpoints/queue'; + +const log = logger('events'); + +const $isFirstConnection = atom(true); +export const socketConnected = createAction('socket/connected'); + +export const addSocketConnectedEventListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: socketConnected, + effect: async (action, { dispatch, getState, cancelActiveListeners, delay }) => { + log.debug('Connected'); + + /** + * The rest of this listener has recovery logic for when the socket disconnects and reconnects. + * + * We need to re-fetch if something has changed while we were disconnected. In practice, the only + * thing that could change while disconnected is a queue item finishes processing. + * + * The queue status is a proxy for this - if the queue status has changed, we need to re-fetch + * the queries that may have changed while we were disconnected. + */ + + // Bail on the recovery logic if this is the first connection - we don't need to recover anything + if ($isFirstConnection.get()) { + // Populate the model configs on first connection. This query cache has a 24hr timeout, so we can immediately + // unsubscribe. + const request = dispatch(modelsApi.endpoints.getModelConfigs.initiate()); + request.unsubscribe(); + + $isFirstConnection.set(false); + return; + } + + // If we are in development mode, reset the whole API state. In this scenario, reconnects will + // typically be caused by reloading the server, in which case we do want to reset the whole API. + if (import.meta.env.MODE === 'development') { + dispatch(api.util.resetApiState()); + } + + // Else, we need to compare the last-known queue status with the current queue status, re-fetching + // everything if it has changed. + + if ($baseUrl.get()) { + // If we have a baseUrl (e.g. not localhost), we need to debounce the re-fetch to not hammer server + cancelActiveListeners(); + // Add artificial jitter to the debounce + await delay(1000 + Math.random() * 1000); + } + + const prevQueueStatusData = selectQueueStatus(getState()).data; + + try { + // Fetch the queue status again + const queueStatusRequest = dispatch( + await queueApi.endpoints.getQueueStatus.initiate(undefined, { + forceRefetch: true, + }) + ); + const nextQueueStatusData = await queueStatusRequest.unwrap(); + queueStatusRequest.unsubscribe(); + + // If the queue hasn't changed, we don't need to do anything. + if (isEqual(prevQueueStatusData?.queue, nextQueueStatusData.queue)) { + return; + } + + //The queue has changed. We need to re-fetch everything that may have changed while we were + // disconnected. + dispatch(api.util.invalidateTags(['FetchOnReconnect'])); + } catch { + // no-op + log.debug('Unable to get current queue status on reconnect'); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested.ts new file mode 100644 index 0000000000000000000000000000000000000000..690bb77fa124dd435e6ad001fedf016e9b5979e7 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/updateAllNodesRequested.ts @@ -0,0 +1,69 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { updateAllNodesRequested } from 'features/nodes/store/actions'; +import { $templates, nodesChanged } from 'features/nodes/store/nodesSlice'; +import { selectNodes } from 'features/nodes/store/selectors'; +import { NodeUpdateError } from 'features/nodes/types/error'; +import { isInvocationNode } from 'features/nodes/types/invocation'; +import { getNeedsUpdate, updateNode } from 'features/nodes/util/node/nodeUpdate'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; + +const log = logger('workflows'); + +export const addUpdateAllNodesRequestedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: updateAllNodesRequested, + effect: (action, { dispatch, getState }) => { + const nodes = selectNodes(getState()); + const templates = $templates.get(); + + let unableToUpdateCount = 0; + + nodes.filter(isInvocationNode).forEach((node) => { + const template = templates[node.data.type]; + if (!template) { + unableToUpdateCount++; + return; + } + if (!getNeedsUpdate(node.data, template)) { + // No need to increment the count here, since we're not actually updating + return; + } + try { + const updatedNode = updateNode(node, template); + dispatch( + nodesChanged([ + { type: 'remove', id: updatedNode.id }, + { type: 'add', item: updatedNode }, + ]) + ); + } catch (e) { + if (e instanceof NodeUpdateError) { + unableToUpdateCount++; + } + } + }); + + if (unableToUpdateCount) { + log.warn( + t('nodes.unableToUpdateNodes', { + count: unableToUpdateCount, + }) + ); + toast({ + id: 'UNABLE_TO_UPDATE_NODES', + title: t('nodes.unableToUpdateNodes', { + count: unableToUpdateCount, + }), + }); + } else { + toast({ + id: 'ALL_NODES_UPDATED', + title: t('nodes.allNodesUpdated'), + status: 'success', + }); + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts new file mode 100644 index 0000000000000000000000000000000000000000..8d39448ad2028c95ff8174c7cd7b3ae247d6aecf --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoadRequested.ts @@ -0,0 +1,116 @@ +import { logger } from 'app/logging/logger'; +import type { AppStartListening } from 'app/store/middleware/listenerMiddleware'; +import { $nodeExecutionStates } from 'features/nodes/hooks/useExecutionState'; +import { workflowLoaded, workflowLoadRequested } from 'features/nodes/store/actions'; +import { $templates } from 'features/nodes/store/nodesSlice'; +import { $needsFit } from 'features/nodes/store/reactFlowInstance'; +import type { Templates } from 'features/nodes/store/types'; +import { WorkflowMigrationError, WorkflowVersionError } from 'features/nodes/types/error'; +import { graphToWorkflow } from 'features/nodes/util/workflow/graphToWorkflow'; +import { validateWorkflow } from 'features/nodes/util/workflow/validateWorkflow'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { serializeError } from 'serialize-error'; +import { checkBoardAccess, checkImageAccess, checkModelAccess } from 'services/api/hooks/accessChecks'; +import type { GraphAndWorkflowResponse, NonNullableGraph } from 'services/api/types'; +import { z } from 'zod'; +import { fromZodError } from 'zod-validation-error'; + +const log = logger('workflows'); + +const getWorkflow = async (data: GraphAndWorkflowResponse, templates: Templates) => { + if (data.workflow) { + // Prefer to load the workflow if it's available - it has more information + const parsed = JSON.parse(data.workflow); + return await validateWorkflow(parsed, templates, checkImageAccess, checkBoardAccess, checkModelAccess); + } else if (data.graph) { + // Else we fall back on the graph, using the graphToWorkflow function to convert and do layout + const parsed = JSON.parse(data.graph); + const workflow = graphToWorkflow(parsed as NonNullableGraph, true); + return await validateWorkflow(workflow, templates, checkImageAccess, checkBoardAccess, checkModelAccess); + } else { + throw new Error('No workflow or graph provided'); + } +}; + +export const addWorkflowLoadRequestedListener = (startAppListening: AppStartListening) => { + startAppListening({ + actionCreator: workflowLoadRequested, + effect: async (action, { dispatch }) => { + const { data, asCopy } = action.payload; + const nodeTemplates = $templates.get(); + + try { + const { workflow, warnings } = await getWorkflow(data, nodeTemplates); + + if (asCopy) { + // If we're loading a copy, we need to remove the ID so that the backend will create a new workflow + delete workflow.id; + } + + $nodeExecutionStates.set({}); + dispatch(workflowLoaded(workflow)); + if (!warnings.length) { + toast({ + id: 'WORKFLOW_LOADED', + title: t('toast.workflowLoaded'), + status: 'success', + }); + } else { + toast({ + id: 'WORKFLOW_LOADED', + title: t('toast.loadedWithWarnings'), + status: 'warning', + }); + + warnings.forEach(({ message, ...rest }) => { + log.warn(rest, message); + }); + } + + $needsFit.set(true); + } catch (e) { + if (e instanceof WorkflowVersionError) { + // The workflow version was not recognized in the valid list of versions + log.error({ error: serializeError(e) }, e.message); + toast({ + id: 'UNABLE_TO_VALIDATE_WORKFLOW', + title: t('nodes.unableToValidateWorkflow'), + status: 'error', + description: e.message, + }); + } else if (e instanceof WorkflowMigrationError) { + // There was a problem migrating the workflow to the latest version + log.error({ error: serializeError(e) }, e.message); + toast({ + id: 'UNABLE_TO_VALIDATE_WORKFLOW', + title: t('nodes.unableToValidateWorkflow'), + status: 'error', + description: e.message, + }); + } else if (e instanceof z.ZodError) { + // There was a problem validating the workflow itself + const { message } = fromZodError(e, { + prefix: t('nodes.workflowValidation'), + }); + log.error({ error: serializeError(e) }, message); + toast({ + id: 'UNABLE_TO_VALIDATE_WORKFLOW', + title: t('nodes.unableToValidateWorkflow'), + status: 'error', + description: message, + }); + } else { + // Some other error occurred + log.error({ error: serializeError(e) }, t('nodes.unknownErrorValidatingWorkflow')); + toast({ + id: 'UNABLE_TO_VALIDATE_WORKFLOW', + title: t('nodes.unableToValidateWorkflow'), + status: 'error', + description: t('nodes.unknownErrorValidatingWorkflow'), + }); + } + } + }, + }); +}; diff --git a/invokeai/frontend/web/src/app/store/nanostores/authToken.ts b/invokeai/frontend/web/src/app/store/nanostores/authToken.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f07e3535e8d9309c07b575c244a25717fb00552 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/authToken.ts @@ -0,0 +1,6 @@ +import { atom } from 'nanostores'; + +/** + * The user's auth token. + */ +export const $authToken = atom(); diff --git a/invokeai/frontend/web/src/app/store/nanostores/baseUrl.ts b/invokeai/frontend/web/src/app/store/nanostores/baseUrl.ts new file mode 100644 index 0000000000000000000000000000000000000000..19bebab0ef87926f524f054ab1ba32479402425b --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/baseUrl.ts @@ -0,0 +1,6 @@ +import { atom } from 'nanostores'; + +/** + * The OpenAPI base url. + */ +export const $baseUrl = atom(); diff --git a/invokeai/frontend/web/src/app/store/nanostores/bulkDownloadId.ts b/invokeai/frontend/web/src/app/store/nanostores/bulkDownloadId.ts new file mode 100644 index 0000000000000000000000000000000000000000..4f7118e2ebc01a9c056b477e9145c3911dafa626 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/bulkDownloadId.ts @@ -0,0 +1,9 @@ +import { atom } from 'nanostores'; + +const DEFAULT_BULK_DOWNLOAD_ID = 'default'; + +/** + * The download id for a bulk download. Used for socket subscriptions. + */ + +export const $bulkDownloadId = atom(DEFAULT_BULK_DOWNLOAD_ID); diff --git a/invokeai/frontend/web/src/app/store/nanostores/customNavComponent.ts b/invokeai/frontend/web/src/app/store/nanostores/customNavComponent.ts new file mode 100644 index 0000000000000000000000000000000000000000..1a6a5571a033c44498812c13b756cacfe5efe1df --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/customNavComponent.ts @@ -0,0 +1,4 @@ +import { atom } from 'nanostores'; +import type { ReactNode } from 'react'; + +export const $customNavComponent = atom(undefined); diff --git a/invokeai/frontend/web/src/app/store/nanostores/customStarUI.ts b/invokeai/frontend/web/src/app/store/nanostores/customStarUI.ts new file mode 100644 index 0000000000000000000000000000000000000000..9f6628ac9cd078c21230730cb440391e3ea9dc71 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/customStarUI.ts @@ -0,0 +1,14 @@ +import type { MenuItemProps } from '@invoke-ai/ui-library'; +import { atom } from 'nanostores'; + +export type CustomStarUi = { + on: { + icon: MenuItemProps['icon']; + text: string; + }; + off: { + icon: MenuItemProps['icon']; + text: string; + }; +}; +export const $customStarUI = atom(undefined); diff --git a/invokeai/frontend/web/src/app/store/nanostores/isDebugging.ts b/invokeai/frontend/web/src/app/store/nanostores/isDebugging.ts new file mode 100644 index 0000000000000000000000000000000000000000..b71cab53088004b1d3f1f22bc0028110eea6bc60 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/isDebugging.ts @@ -0,0 +1,3 @@ +import { atom } from 'nanostores'; + +export const $isDebugging = atom(false); diff --git a/invokeai/frontend/web/src/app/store/nanostores/logo.ts b/invokeai/frontend/web/src/app/store/nanostores/logo.ts new file mode 100644 index 0000000000000000000000000000000000000000..5fd94ebd9014cfe1f8b6644724f140e3432458e9 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/logo.ts @@ -0,0 +1,4 @@ +import { atom } from 'nanostores'; +import type { ReactNode } from 'react'; + +export const $logo = atom(undefined); diff --git a/invokeai/frontend/web/src/app/store/nanostores/openAPISchemaUrl.ts b/invokeai/frontend/web/src/app/store/nanostores/openAPISchemaUrl.ts new file mode 100644 index 0000000000000000000000000000000000000000..124815f7ead512923364d82fbb6673b89977988c --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/openAPISchemaUrl.ts @@ -0,0 +1,3 @@ +import { atom } from 'nanostores'; + +export const $openAPISchemaUrl = atom(undefined); diff --git a/invokeai/frontend/web/src/app/store/nanostores/projectId.ts b/invokeai/frontend/web/src/app/store/nanostores/projectId.ts new file mode 100644 index 0000000000000000000000000000000000000000..c2b14e91acb501404094fc4cbe5c0bfcbed3583e --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/projectId.ts @@ -0,0 +1,9 @@ +import { atom } from 'nanostores'; + +/** + * The optional project-id header. + */ +export const $projectId = atom(); + +export const $projectName = atom(); +export const $projectUrl = atom(); diff --git a/invokeai/frontend/web/src/app/store/nanostores/queueId.ts b/invokeai/frontend/web/src/app/store/nanostores/queueId.ts new file mode 100644 index 0000000000000000000000000000000000000000..462cf69d0a6c8f2bc061545fbc5765759118266e --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/queueId.ts @@ -0,0 +1,5 @@ +import { atom } from 'nanostores'; + +export const DEFAULT_QUEUE_ID = 'default'; + +export const $queueId = atom(DEFAULT_QUEUE_ID); diff --git a/invokeai/frontend/web/src/app/store/nanostores/store.ts b/invokeai/frontend/web/src/app/store/nanostores/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..00adc9a34f3e72c2deaf4092b96c87151688123d --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/store.ts @@ -0,0 +1,42 @@ +import { useStore } from '@nanostores/react'; +import type { AppStore } from 'app/store/store'; +import { atom } from 'nanostores'; + +// Inject socket options and url into window for debugging +declare global { + interface Window { + $store?: typeof $store; + } +} + +/** + * Raised when the redux store is unable to be retrieved. + */ +class ReduxStoreNotInitialized extends Error { + /** + * Create ReduxStoreNotInitialized + * @param {String} message + */ + constructor(message = 'Redux store not initialized') { + super(message); + this.name = this.constructor.name; + } +} + +export const $store = atom>(); + +export const getStore = () => { + const store = $store.get(); + if (!store) { + throw new ReduxStoreNotInitialized(); + } + return store; +}; + +export const useAppStore = () => { + const store = useStore($store); + if (!store) { + throw new ReduxStoreNotInitialized(); + } + return store; +}; diff --git a/invokeai/frontend/web/src/app/store/nanostores/util.ts b/invokeai/frontend/web/src/app/store/nanostores/util.ts new file mode 100644 index 0000000000000000000000000000000000000000..ab333ae85b7b5a4b8cb8f59aadeb1972f6c03193 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/util.ts @@ -0,0 +1,15 @@ +import type { ReadableAtom } from 'nanostores'; +import { atom } from 'nanostores'; + +/** + * A fallback non-writable atom that always returns `false`, used when a nanostores atom is only conditionally available + * in a hook or component. + * + * @knipignore + */ +export const $false: ReadableAtom = atom(false); +/** + * A fallback non-writable atom that always returns `true`, used when a nanostores atom is only conditionally available + * in a hook or component. + */ +export const $true: ReadableAtom = atom(true); diff --git a/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts b/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts new file mode 100644 index 0000000000000000000000000000000000000000..e0d610712948f4b61bccdeda63fbbcc1ed483e6f --- /dev/null +++ b/invokeai/frontend/web/src/app/store/nanostores/workflowCategories.ts @@ -0,0 +1,4 @@ +import type { WorkflowCategory } from 'features/nodes/types/workflow'; +import { atom } from 'nanostores'; + +export const $workflowCategories = atom(['user', 'default']); diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts new file mode 100644 index 0000000000000000000000000000000000000000..a36300cca98824790441823f9dd03918d3a9d2b3 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -0,0 +1,208 @@ +import type { ThunkDispatch, UnknownAction } from '@reduxjs/toolkit'; +import { autoBatchEnhancer, combineReducers, configureStore } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; +import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver'; +import { errorHandler } from 'app/store/enhancers/reduxRemember/errors'; +import { deepClone } from 'common/util/deepClone'; +import { changeBoardModalSlice } from 'features/changeBoardModal/store/slice'; +import { canvasSettingsPersistConfig, canvasSettingsSlice } from 'features/controlLayers/store/canvasSettingsSlice'; +import { canvasPersistConfig, canvasSlice, canvasUndoableConfig } from 'features/controlLayers/store/canvasSlice'; +import { + canvasStagingAreaPersistConfig, + canvasStagingAreaSlice, +} from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { lorasPersistConfig, lorasSlice } from 'features/controlLayers/store/lorasSlice'; +import { paramsPersistConfig, paramsSlice } from 'features/controlLayers/store/paramsSlice'; +import { deleteImageModalSlice } from 'features/deleteImageModal/store/slice'; +import { dynamicPromptsPersistConfig, dynamicPromptsSlice } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; +import { galleryPersistConfig, gallerySlice } from 'features/gallery/store/gallerySlice'; +import { hrfPersistConfig, hrfSlice } from 'features/hrf/store/hrfSlice'; +import { modelManagerV2PersistConfig, modelManagerV2Slice } from 'features/modelManagerV2/store/modelManagerV2Slice'; +import { nodesPersistConfig, nodesSlice, nodesUndoableConfig } from 'features/nodes/store/nodesSlice'; +import { workflowSettingsPersistConfig, workflowSettingsSlice } from 'features/nodes/store/workflowSettingsSlice'; +import { workflowPersistConfig, workflowSlice } from 'features/nodes/store/workflowSlice'; +import { upscalePersistConfig, upscaleSlice } from 'features/parameters/store/upscaleSlice'; +import { queueSlice } from 'features/queue/store/queueSlice'; +import { stylePresetPersistConfig, stylePresetSlice } from 'features/stylePresets/store/stylePresetSlice'; +import { configSlice } from 'features/system/store/configSlice'; +import { systemPersistConfig, systemSlice } from 'features/system/store/systemSlice'; +import { uiPersistConfig, uiSlice } from 'features/ui/store/uiSlice'; +import { diff } from 'jsondiffpatch'; +import { keys, mergeWith, omit, pick } from 'lodash-es'; +import dynamicMiddlewares from 'redux-dynamic-middlewares'; +import type { SerializeFunction, UnserializeFunction } from 'redux-remember'; +import { rememberEnhancer, rememberReducer } from 'redux-remember'; +import undoable from 'redux-undo'; +import { serializeError } from 'serialize-error'; +import { api } from 'services/api'; +import { authToastMiddleware } from 'services/api/authToastMiddleware'; +import type { JsonObject } from 'type-fest'; + +import { STORAGE_PREFIX } from './constants'; +import { actionSanitizer } from './middleware/devtools/actionSanitizer'; +import { actionsDenylist } from './middleware/devtools/actionsDenylist'; +import { stateSanitizer } from './middleware/devtools/stateSanitizer'; +import { listenerMiddleware } from './middleware/listenerMiddleware'; + +const log = logger('system'); + +const allReducers = { + [api.reducerPath]: api.reducer, + [gallerySlice.name]: gallerySlice.reducer, + [nodesSlice.name]: undoable(nodesSlice.reducer, nodesUndoableConfig), + [systemSlice.name]: systemSlice.reducer, + [configSlice.name]: configSlice.reducer, + [uiSlice.name]: uiSlice.reducer, + [dynamicPromptsSlice.name]: dynamicPromptsSlice.reducer, + [deleteImageModalSlice.name]: deleteImageModalSlice.reducer, + [changeBoardModalSlice.name]: changeBoardModalSlice.reducer, + [modelManagerV2Slice.name]: modelManagerV2Slice.reducer, + [queueSlice.name]: queueSlice.reducer, + [workflowSlice.name]: workflowSlice.reducer, + [hrfSlice.name]: hrfSlice.reducer, + [canvasSlice.name]: undoable(canvasSlice.reducer, canvasUndoableConfig), + [workflowSettingsSlice.name]: workflowSettingsSlice.reducer, + [upscaleSlice.name]: upscaleSlice.reducer, + [stylePresetSlice.name]: stylePresetSlice.reducer, + [paramsSlice.name]: paramsSlice.reducer, + [canvasSettingsSlice.name]: canvasSettingsSlice.reducer, + [canvasStagingAreaSlice.name]: canvasStagingAreaSlice.reducer, + [lorasSlice.name]: lorasSlice.reducer, +}; + +const rootReducer = combineReducers(allReducers); + +const rememberedRootReducer = rememberReducer(rootReducer); + +/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ +export type PersistConfig = { + /** + * The name of the slice. + */ + name: keyof typeof allReducers; + /** + * The initial state of the slice. + */ + initialState: T; + /** + * Migrate the state to the current version during rehydration. + * @param state The rehydrated state. + * @returns A correctly-shaped state. + */ + migrate: (state: unknown) => T; + /** + * Keys to omit from the persisted state. + */ + persistDenylist: (keyof T)[]; +}; + +const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = { + [galleryPersistConfig.name]: galleryPersistConfig, + [nodesPersistConfig.name]: nodesPersistConfig, + [systemPersistConfig.name]: systemPersistConfig, + [workflowPersistConfig.name]: workflowPersistConfig, + [uiPersistConfig.name]: uiPersistConfig, + [dynamicPromptsPersistConfig.name]: dynamicPromptsPersistConfig, + [modelManagerV2PersistConfig.name]: modelManagerV2PersistConfig, + [hrfPersistConfig.name]: hrfPersistConfig, + [canvasPersistConfig.name]: canvasPersistConfig, + [workflowSettingsPersistConfig.name]: workflowSettingsPersistConfig, + [upscalePersistConfig.name]: upscalePersistConfig, + [stylePresetPersistConfig.name]: stylePresetPersistConfig, + [paramsPersistConfig.name]: paramsPersistConfig, + [canvasSettingsPersistConfig.name]: canvasSettingsPersistConfig, + [canvasStagingAreaPersistConfig.name]: canvasStagingAreaPersistConfig, + [lorasPersistConfig.name]: lorasPersistConfig, +}; + +const unserialize: UnserializeFunction = (data, key) => { + const persistConfig = persistConfigs[key as keyof typeof persistConfigs]; + if (!persistConfig) { + throw new Error(`No persist config for slice "${key}"`); + } + try { + const { initialState, migrate } = persistConfig; + const parsed = JSON.parse(data); + + // strip out old keys + const stripped = pick(deepClone(parsed), keys(initialState)); + // run (additive) migrations + const migrated = migrate(stripped); + /* + * Merge in initial state as default values, covering any missing keys. You might be tempted to use _.defaultsDeep, + * but that merges arrays by index and partial objects by key. Using an identity function as the customizer results + * in behaviour like defaultsDeep, but doesn't overwrite any values that are not undefined in the migrated state. + */ + const transformed = mergeWith(migrated, initialState, (objVal) => objVal); + + log.debug( + { + persistedData: parsed, + rehydratedData: transformed, + diff: diff(parsed, transformed) as JsonObject, // this is always serializable + }, + `Rehydrated slice "${key}"` + ); + return transformed; + } catch (err) { + log.warn({ error: serializeError(err) }, `Error rehydrating slice "${key}", falling back to default initial state`); + return persistConfig.initialState; + } +}; + +const serialize: SerializeFunction = (data, key) => { + const persistConfig = persistConfigs[key as keyof typeof persistConfigs]; + if (!persistConfig) { + throw new Error(`No persist config for slice "${key}"`); + } + // Heuristic to determine if the slice is undoable - could just hardcode it in the persistConfig + const isUndoable = 'present' in data && 'past' in data && 'future' in data && '_latestUnfiltered' in data; + const result = omit(isUndoable ? data.present : data, persistConfig.persistDenylist); + return JSON.stringify(result); +}; + +export const createStore = (uniqueStoreKey?: string, persist = true) => + configureStore({ + reducer: rememberedRootReducer, + middleware: (getDefaultMiddleware) => + getDefaultMiddleware({ + serializableCheck: import.meta.env.MODE === 'development', + immutableCheck: import.meta.env.MODE === 'development', + }) + .concat(api.middleware) + .concat(dynamicMiddlewares) + .concat(authToastMiddleware) + .prepend(listenerMiddleware.middleware), + enhancers: (getDefaultEnhancers) => { + const _enhancers = getDefaultEnhancers().concat(autoBatchEnhancer()); + if (persist) { + _enhancers.push( + rememberEnhancer(idbKeyValDriver, keys(persistConfigs), { + persistDebounce: 300, + serialize, + unserialize, + prefix: uniqueStoreKey ? `${STORAGE_PREFIX}${uniqueStoreKey}-` : STORAGE_PREFIX, + errorHandler, + }) + ); + } + return _enhancers; + }, + devTools: { + actionSanitizer, + stateSanitizer, + trace: true, + predicate: (state, action) => { + if (actionsDenylist.includes(action.type)) { + return false; + } + return true; + }, + }, + }); + +export type AppStore = ReturnType; +export type RootState = ReturnType; +// eslint-disable-next-line @typescript-eslint/no-explicit-any +export type AppThunkDispatch = ThunkDispatch; +export type AppDispatch = ReturnType['dispatch']; diff --git a/invokeai/frontend/web/src/app/store/storeHooks.ts b/invokeai/frontend/web/src/app/store/storeHooks.ts new file mode 100644 index 0000000000000000000000000000000000000000..6bc904acb31616a1a9ad08b1495f0edd816eebde --- /dev/null +++ b/invokeai/frontend/web/src/app/store/storeHooks.ts @@ -0,0 +1,8 @@ +import type { AppThunkDispatch, RootState } from 'app/store/store'; +import type { TypedUseSelectorHook } from 'react-redux'; +import { useDispatch, useSelector, useStore } from 'react-redux'; + +// Use throughout your app instead of plain `useDispatch` and `useSelector` +export const useAppDispatch = () => useDispatch(); +export const useAppSelector: TypedUseSelectorHook = useSelector; +export const useAppStore = () => useStore(); diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts new file mode 100644 index 0000000000000000000000000000000000000000..8ec968e26988e802059f9b2171fc19e20dcedab7 --- /dev/null +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -0,0 +1,123 @@ +import type { FilterType } from 'features/controlLayers/store/filters'; +import type { ParameterPrecision, ParameterScheduler } from 'features/parameters/types/parameterSchemas'; +import type { TabName } from 'features/ui/store/uiTypes'; +import type { PartialDeep } from 'type-fest'; + +/** + * A disable-able application feature + */ +export type AppFeature = + | 'faceRestore' + | 'upscaling' + | 'lightbox' + | 'modelManager' + | 'githubLink' + | 'discordLink' + | 'bugLink' + | 'localization' + | 'consoleLogging' + | 'dynamicPrompting' + | 'batches' + | 'syncModels' + | 'multiselect' + | 'pauseQueue' + | 'resumeQueue' + | 'invocationCache' + | 'bulkDownload' + | 'starterModels' + | 'hfToken' + | 'invocationProgressAlert'; + +/** + * A disable-able Stable Diffusion feature + */ +export type SDFeature = + | 'controlNet' + | 'noise' + | 'perlinNoise' + | 'noiseThreshold' + | 'variation' + | 'symmetry' + | 'seamless' + | 'hires' + | 'lora' + | 'embedding' + | 'vae' + | 'hrf'; + +export type NumericalParameterConfig = { + initial: number; + sliderMin: number; + sliderMax: number; + numberInputMin: number; + numberInputMax: number; + fineStep: number; + coarseStep: number; +}; + +/** + * Configuration options for the InvokeAI UI. + * Distinct from system settings which may be changed inside the app. + */ +export type AppConfig = { + /** + * Whether or not we should update image urls when image loading errors + */ + shouldUpdateImagesOnConnect: boolean; + shouldFetchMetadataFromApi: boolean; + /** + * Sets a size limit for outputs on the upscaling tab. This is a maximum dimension, so the actual max number of pixels + * will be the square of this value. + */ + maxUpscaleDimension?: number; + allowPrivateBoards: boolean; + allowPrivateStylePresets: boolean; + disabledTabs: TabName[]; + disabledFeatures: AppFeature[]; + disabledSDFeatures: SDFeature[]; + nodesAllowlist: string[] | undefined; + nodesDenylist: string[] | undefined; + metadataFetchDebounce?: number; + workflowFetchDebounce?: number; + isLocal?: boolean; + maxImageUploadCount?: number; + sd: { + defaultModel?: string; + disabledControlNetModels: string[]; + disabledControlNetProcessors: FilterType[]; + // Core parameters + iterations: NumericalParameterConfig; + width: NumericalParameterConfig; // initial value comes from model + height: NumericalParameterConfig; // initial value comes from model + steps: NumericalParameterConfig; + guidance: NumericalParameterConfig; + cfgRescaleMultiplier: NumericalParameterConfig; + img2imgStrength: NumericalParameterConfig; + scheduler?: ParameterScheduler; + vaePrecision?: ParameterPrecision; + // Canvas + boundingBoxHeight: NumericalParameterConfig; // initial value comes from model + boundingBoxWidth: NumericalParameterConfig; // initial value comes from model + scaledBoundingBoxHeight: NumericalParameterConfig; // initial value comes from model + scaledBoundingBoxWidth: NumericalParameterConfig; // initial value comes from model + canvasCoherenceStrength: NumericalParameterConfig; + canvasCoherenceEdgeSize: NumericalParameterConfig; + infillTileSize: NumericalParameterConfig; + infillPatchmatchDownscaleSize: NumericalParameterConfig; + // Misc advanced + clipSkip: NumericalParameterConfig; // slider and input max are ignored for this, because the values depend on the model + maskBlur: NumericalParameterConfig; + hrfStrength: NumericalParameterConfig; + dynamicPrompts: { + maxPrompts: NumericalParameterConfig; + }; + ca: { + weight: NumericalParameterConfig; + }; + }; + flux: { + guidance: NumericalParameterConfig; + }; +}; + +export type PartialAppConfig = PartialDeep; diff --git a/invokeai/frontend/web/src/common/components/ColorPicker/RgbColorPicker.tsx b/invokeai/frontend/web/src/common/components/ColorPicker/RgbColorPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1361039b7567a67c033259275e6ca2c51e2b799b --- /dev/null +++ b/invokeai/frontend/web/src/common/components/ColorPicker/RgbColorPicker.tsx @@ -0,0 +1,105 @@ +import type { ChakraProps } from '@invoke-ai/ui-library'; +import { Box, CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { RGB_COLOR_SWATCHES } from 'common/components/ColorPicker/swatches'; +import { rgbColorToString } from 'common/util/colorCodeTransformers'; +import type { CSSProperties } from 'react'; +import { memo, useCallback } from 'react'; +import { RgbColorPicker as ColorfulRgbColorPicker } from 'react-colorful'; +import type { RgbColor } from 'react-colorful/dist/types'; +import { useTranslation } from 'react-i18next'; + +type Props = { + color: RgbColor; + onChange: (color: RgbColor) => void; + withNumberInput?: boolean; + withSwatches?: boolean; +}; +const colorPickerPointerStyles: NonNullable = { + width: 6, + height: 6, + borderColor: 'base.100', +}; + +const sx: ChakraProps['sx'] = { + '.react-colorful__hue-pointer': colorPickerPointerStyles, + '.react-colorful__saturation-pointer': colorPickerPointerStyles, + '.react-colorful__alpha-pointer': colorPickerPointerStyles, + gap: 4, + flexDir: 'column', +}; + +const colorPickerStyles: CSSProperties = { width: '100%' }; + +const numberInputWidth: ChakraProps['w'] = '3.5rem'; + +const RgbColorPicker = (props: Props) => { + const { color, onChange, withNumberInput = false, withSwatches = false } = props; + const { t } = useTranslation(); + const handleChangeR = useCallback((r: number) => onChange({ ...color, r }), [color, onChange]); + const handleChangeG = useCallback((g: number) => onChange({ ...color, g }), [color, onChange]); + const handleChangeB = useCallback((b: number) => onChange({ ...color, b }), [color, onChange]); + return ( + + + {withNumberInput && ( + + + {t('common.red')[0]} + + + + {t('common.green')[0]} + + + + {t('common.blue')[0]} + + + + )} + {withSwatches && ( + + {RGB_COLOR_SWATCHES.map((color, i) => ( + + ))} + + )} + + ); +}; + +export default memo(RgbColorPicker); + +const ColorSwatch = ({ color, onChange }: { color: RgbColor; onChange: (color: RgbColor) => void }) => { + const onClick = useCallback(() => { + onChange(color); + }, [color, onChange]); + return ; +}; diff --git a/invokeai/frontend/web/src/common/components/ColorPicker/RgbaColorPicker.tsx b/invokeai/frontend/web/src/common/components/ColorPicker/RgbaColorPicker.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fb3b1da2a019fc6f8cbb87d3e696da1fee6a3d4f --- /dev/null +++ b/invokeai/frontend/web/src/common/components/ColorPicker/RgbaColorPicker.tsx @@ -0,0 +1,116 @@ +import type { ChakraProps } from '@invoke-ai/ui-library'; +import { Box, CompositeNumberInput, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { RGBA_COLOR_SWATCHES } from 'common/components/ColorPicker/swatches'; +import { rgbaColorToString } from 'common/util/colorCodeTransformers'; +import type { CSSProperties } from 'react'; +import { memo, useCallback } from 'react'; +import { RgbaColorPicker as ColorfulRgbaColorPicker } from 'react-colorful'; +import type { RgbaColor } from 'react-colorful/dist/types'; +import { useTranslation } from 'react-i18next'; + +type Props = { + color: RgbaColor; + onChange: (color: RgbaColor) => void; + withNumberInput?: boolean; + withSwatches?: boolean; +}; + +const colorPickerPointerStyles: NonNullable = { + width: 6, + height: 6, + borderColor: 'base.100', +}; + +const sx: ChakraProps['sx'] = { + '.react-colorful__hue-pointer': colorPickerPointerStyles, + '.react-colorful__saturation-pointer': colorPickerPointerStyles, + '.react-colorful__alpha-pointer': colorPickerPointerStyles, + gap: 4, + flexDir: 'column', +}; + +const colorPickerStyles: CSSProperties = { width: '100%' }; + +const numberInputWidth: ChakraProps['w'] = '3.5rem'; + +const RgbaColorPicker = (props: Props) => { + const { color, onChange, withNumberInput = false, withSwatches = false } = props; + const { t } = useTranslation(); + const handleChangeR = useCallback((r: number) => onChange({ ...color, r }), [color, onChange]); + const handleChangeG = useCallback((g: number) => onChange({ ...color, g }), [color, onChange]); + const handleChangeB = useCallback((b: number) => onChange({ ...color, b }), [color, onChange]); + const handleChangeA = useCallback((a: number) => onChange({ ...color, a }), [color, onChange]); + return ( + + + {withNumberInput && ( + + + {t('common.red')[0]} + + + + {t('common.green')[0]} + + + + {t('common.blue')[0]} + + + + {t('common.alpha')[0]} + + + + )} + {withSwatches && ( + + {RGBA_COLOR_SWATCHES.map((color, i) => ( + + ))} + + )} + + ); +}; + +export default memo(RgbaColorPicker); + +const ColorSwatch = ({ color, onChange }: { color: RgbaColor; onChange: (color: RgbaColor) => void }) => { + const onClick = useCallback(() => { + onChange(color); + }, [color, onChange]); + return ; +}; diff --git a/invokeai/frontend/web/src/common/components/ColorPicker/swatches.ts b/invokeai/frontend/web/src/common/components/ColorPicker/swatches.ts new file mode 100644 index 0000000000000000000000000000000000000000..0bbbcfe3da3c1edf9116e2fad8111a8f41a531af --- /dev/null +++ b/invokeai/frontend/web/src/common/components/ColorPicker/swatches.ts @@ -0,0 +1,16 @@ +const SWATCHES = [ + { r: 0, g: 0, b: 0, a: 1 }, // black + { r: 255, g: 255, b: 255, a: 1 }, // white + { r: 255, g: 90, b: 94, a: 1 }, // red + { r: 255, g: 146, b: 75, a: 1 }, // orange + { r: 255, g: 202, b: 59, a: 1 }, // yellow + { r: 197, g: 202, b: 48, a: 1 }, // lime + { r: 138, g: 201, b: 38, a: 1 }, // green + { r: 83, g: 165, b: 117, a: 1 }, // teal + { r: 23, g: 130, b: 196, a: 1 }, // blue + { r: 66, g: 103, b: 172, a: 1 }, // indigo + { r: 107, g: 76, b: 147, a: 1 }, // purple +]; + +export const RGBA_COLOR_SWATCHES = SWATCHES; +export const RGB_COLOR_SWATCHES = SWATCHES.map(({ r, g, b }) => ({ r, g, b })); diff --git a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f9e4e11f085b756ef701156588ddc7162578dd5b --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx @@ -0,0 +1,90 @@ +import type { As, ChakraProps, FlexProps } from '@invoke-ai/ui-library'; +import { Flex, Icon, Skeleton, Spinner, Text } from '@invoke-ai/ui-library'; +import { memo, useMemo } from 'react'; +import { PiImageBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +type Props = { image: ImageDTO | undefined }; + +const IAILoadingImageFallback = memo((props: Props) => { + if (props.image) { + return ( + + ); + } + + return ( + + + + ); +}); +IAILoadingImageFallback.displayName = 'IAILoadingImageFallback'; + +type IAINoImageFallbackProps = FlexProps & { + label?: string; + icon?: As | null; + boxSize?: ChakraProps['boxSize']; +}; + +export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => { + const { icon = PiImageBold, boxSize = 16, ...rest } = props; + + return ( + + {icon && } + {props.label && {props.label}} + + ); +}); +IAINoContentFallback.displayName = 'IAINoContentFallback'; + +type IAINoImageFallbackWithSpinnerProps = FlexProps & { + label?: string; +}; + +export const IAINoContentFallbackWithSpinner = memo((props: IAINoImageFallbackWithSpinnerProps) => { + const { sx, ...rest } = props; + const styles = useMemo( + () => ({ + w: 'full', + h: 'full', + alignItems: 'center', + justifyContent: 'center', + borderRadius: 'base', + flexDir: 'column', + gap: 2, + userSelect: 'none', + opacity: 0.7, + color: 'base.500', + ...sx, + }), + [sx] + ); + + return ( + + + {props.label && {props.label}} + + ); +}); +IAINoContentFallbackWithSpinner.displayName = 'IAINoContentFallbackWithSpinner'; diff --git a/invokeai/frontend/web/src/common/components/IconMenuItem.tsx b/invokeai/frontend/web/src/common/components/IconMenuItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6b58d5a6112b36a2733f283f8eee30f259a5ff1f --- /dev/null +++ b/invokeai/frontend/web/src/common/components/IconMenuItem.tsx @@ -0,0 +1,34 @@ +import type { MenuItemProps } from '@invoke-ai/ui-library'; +import { Flex, MenuItem, Tooltip } from '@invoke-ai/ui-library'; +import type { ReactNode } from 'react'; + +type Props = MenuItemProps & { + tooltip?: ReactNode; + icon: ReactNode; +}; + +export const IconMenuItem = ({ tooltip, icon, ...props }: Props) => { + return ( + + + {icon} + + + ); +}; + +export const IconMenuItemGroup = ({ children }: { children: ReactNode }) => { + return ( + + {children} + + ); +}; diff --git a/invokeai/frontend/web/src/common/components/InformationalPopover/InformationalPopover.tsx b/invokeai/frontend/web/src/common/components/InformationalPopover/InformationalPopover.tsx new file mode 100644 index 0000000000000000000000000000000000000000..37cd40d3320d54e74b8409ed0735f22a24fc0237 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/InformationalPopover/InformationalPopover.tsx @@ -0,0 +1,153 @@ +import { + Button, + Divider, + Flex, + Heading, + Image, + Popover, + PopoverBody, + PopoverCloseButton, + PopoverContent, + PopoverTrigger, + Portal, + Spacer, + Text, +} from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectSystemSlice, setShouldEnableInformationalPopovers } from 'features/system/store/systemSlice'; +import { toast } from 'features/toast/toast'; +import { merge, omit } from 'lodash-es'; +import type { ReactElement } from 'react'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowSquareOutBold } from 'react-icons/pi'; + +import type { Feature, PopoverData } from './constants'; +import { OPEN_DELAY, POPOVER_DATA, POPPER_MODIFIERS } from './constants'; + +type Props = { + feature: Feature; + inPortal?: boolean; + hideDisable?: boolean; + children: ReactElement; +}; + +const selectShouldEnableInformationalPopovers = createSelector( + selectSystemSlice, + (system) => system.shouldEnableInformationalPopovers +); + +export const InformationalPopover = memo( + ({ feature, children, inPortal = true, hideDisable = false, ...rest }: Props) => { + const shouldEnableInformationalPopovers = useAppSelector(selectShouldEnableInformationalPopovers); + + const data = useMemo(() => POPOVER_DATA[feature], [feature]); + + const popoverProps = useMemo(() => merge(omit(data, ['image', 'href', 'buttonLabel']), rest), [data, rest]); + + if (!hideDisable && !shouldEnableInformationalPopovers) { + return children; + } + + return ( + + {children} + {inPortal ? ( + + + + ) : ( + + )} + + ); + } +); + +InformationalPopover.displayName = 'InformationalPopover'; + +type ContentProps = { + data?: PopoverData; + feature: Feature; + hideDisable: boolean; +}; + +const Content = ({ data, feature, hideDisable }: ContentProps) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const heading = useMemo(() => t(`popovers.${feature}.heading`), [feature, t]); + + const paragraphs = useMemo( + () => + t(`popovers.${feature}.paragraphs`, { + returnObjects: true, + }) ?? [], + [feature, t] + ); + + const onClickLearnMore = useCallback(() => { + if (!data?.href) { + return; + } + window.open(data.href); + }, [data?.href]); + + const onClickDontShowMeThese = useCallback(() => { + dispatch(setShouldEnableInformationalPopovers(false)); + toast({ + title: t('settings.informationalPopoversDisabled'), + description: t('settings.informationalPopoversDisabledDesc'), + status: 'info', + }); + }, [dispatch, t]); + + return ( + + + + + {heading && ( + <> + {heading} + + + )} + {data?.image && ( + <> + Optional Image + + + )} + {paragraphs.map((p) => ( + {p} + ))} + + + + {!hideDisable && ( + + )} + + {data?.href && ( + + )} + + + + + ); +}; diff --git a/invokeai/frontend/web/src/common/components/InformationalPopover/constants.ts b/invokeai/frontend/web/src/common/components/InformationalPopover/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..0d804d99fa09452b959b28605c98e4cc4036417f --- /dev/null +++ b/invokeai/frontend/web/src/common/components/InformationalPopover/constants.ts @@ -0,0 +1,226 @@ +import type { PopoverProps } from '@invoke-ai/ui-library'; +import commercialLicenseBg from 'public/assets/images/commercial-license-bg.png'; +import denoisingStrength from 'public/assets/images/denoising-strength.png'; + +export type Feature = + | 'clipSkip' + | 'hrf' + | 'paramNegativeConditioning' + | 'paramPositiveConditioning' + | 'paramScheduler' + | 'compositingMaskBlur' + | 'compositingBlurMethod' + | 'compositingCoherencePass' + | 'compositingCoherenceMode' + | 'compositingCoherenceEdgeSize' + | 'compositingCoherenceMinDenoise' + | 'compositingMaskAdjustments' + | 'controlNet' + | 'controlNetBeginEnd' + | 'controlNetControlMode' + | 'controlNetProcessor' + | 'controlNetResizeMode' + | 'controlNetWeight' + | 'dynamicPrompts' + | 'dynamicPromptsMaxPrompts' + | 'dynamicPromptsSeedBehaviour' + | 'globalReferenceImage' + | 'imageFit' + | 'infillMethod' + | 'inpainting' + | 'ipAdapterMethod' + | 'lora' + | 'loraWeight' + | 'noiseUseCPU' + | 'paramAspect' + | 'paramCFGScale' + | 'paramGuidance' + | 'paramCFGRescaleMultiplier' + | 'paramDenoisingStrength' + | 'paramHeight' + | 'paramHrf' + | 'paramIterations' + | 'paramModel' + | 'paramRatio' + | 'paramSeed' + | 'paramSteps' + | 'paramUpscaleMethod' + | 'paramVAE' + | 'paramVAEPrecision' + | 'paramWidth' + | 'patchmatchDownScaleSize' + | 'rasterLayer' + | 'refinerModel' + | 'refinerNegativeAestheticScore' + | 'refinerPositiveAestheticScore' + | 'refinerScheduler' + | 'refinerStart' + | 'refinerSteps' + | 'refinerCfgScale' + | 'regionalGuidance' + | 'regionalGuidanceAndReferenceImage' + | 'regionalReferenceImage' + | 'scaleBeforeProcessing' + | 'seamlessTilingXAxis' + | 'seamlessTilingYAxis' + | 'upscaleModel' + | 'scale' + | 'creativity' + | 'structure' + | 'optimizedDenoising' + | 'fluxDevLicense'; + +export type PopoverData = PopoverProps & { + image?: string; + href?: string; + buttonLabel?: string; +}; + +export const POPOVER_DATA: { [key in Feature]?: PopoverData } = { + paramNegativeConditioning: { + placement: 'right', + }, + clipSkip: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178161-advanced-settings', + }, + inpainting: { + href: 'https://support.invoke.ai/support/solutions/articles/151000096702-inpainting-outpainting-and-bounding-box', + }, + rasterLayer: { + href: 'https://support.invoke.ai/support/solutions/articles/151000094998-raster-layers-and-initial-images', + }, + regionalGuidance: { + href: 'https://support.invoke.ai/support/solutions/articles/151000165024-regional-guidance-layers', + }, + regionalGuidanceAndReferenceImage: { + href: 'https://support.invoke.ai/support/solutions/articles/151000165024-regional-guidance-layers', + }, + globalReferenceImage: { + href: 'https://support.invoke.ai/support/solutions/articles/151000159340-global-and-regional-reference-images-ip-adapters-', + }, + regionalReferenceImage: { + href: 'https://support.invoke.ai/support/solutions/articles/151000159340-global-and-regional-reference-images-ip-adapters-', + }, + controlNet: { + href: 'https://support.invoke.ai/support/solutions/articles/151000105880', + }, + controlNetBeginEnd: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178148', + }, + controlNetWeight: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178148', + }, + lora: { + href: 'https://support.invoke.ai/support/solutions/articles/151000159072', + }, + loraWeight: { + href: 'https://support.invoke.ai/support/solutions/articles/151000159072-concepts-low-rank-adaptations-loras-', + }, + compositingMaskBlur: { + href: 'https://support.invoke.ai/support/solutions/articles/151000158838-compositing-settings', + }, + compositingBlurMethod: { + href: 'https://support.invoke.ai/support/solutions/articles/151000158838-compositing-settings', + }, + compositingCoherenceMode: { + href: 'https://support.invoke.ai/support/solutions/articles/151000158838-compositing-settings', + }, + infillMethod: { + href: 'https://support.invoke.ai/support/solutions/articles/151000158838-compositing-settings', + }, + scaleBeforeProcessing: { + href: 'https://support.invoke.ai/support/solutions/articles/151000158841', + }, + paramCFGScale: { + href: 'https://www.youtube.com/watch?v=1OeHEJrsTpI', + }, + paramCFGRescaleMultiplier: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178161-advanced-settings', + }, + paramDenoisingStrength: { + href: 'https://support.invoke.ai/support/solutions/articles/151000094998-image-to-image', + image: denoisingStrength, + }, + paramHrf: { + href: 'https://support.invoke.ai/support/solutions/articles/151000096700-how-can-i-get-larger-images-what-does-upscaling-do-', + }, + paramIterations: { + href: 'https://support.invoke.ai/support/solutions/articles/151000159073', + }, + paramPositiveConditioning: { + href: 'https://support.invoke.ai/support/solutions/articles/151000096606-tips-on-crafting-prompts', + placement: 'right', + }, + paramScheduler: { + placement: 'right', + href: 'https://www.youtube.com/watch?v=1OeHEJrsTpI', + }, + paramSeed: { + href: 'https://support.invoke.ai/support/solutions/articles/151000096684-what-is-a-seed-how-do-i-use-it-to-recreate-the-same-image-', + }, + paramModel: { + placement: 'right', + href: 'https://support.invoke.ai/support/solutions/articles/151000096601-what-is-a-model-which-should-i-use-', + }, + paramRatio: { + gutter: 16, + }, + controlNetControlMode: { + placement: 'right', + href: 'https://support.invoke.ai/support/solutions/articles/151000178148', + }, + controlNetProcessor: { + placement: 'right', + href: 'https://support.invoke.ai/support/solutions/articles/151000105880-using-controlnet', + }, + controlNetResizeMode: { + placement: 'right', + href: 'https://support.invoke.ai/support/solutions/articles/151000178148', + }, + paramVAE: { + placement: 'right', + href: 'https://support.invoke.ai/support/solutions/articles/151000178161-advanced-settings', + }, + paramVAEPrecision: { + placement: 'right', + href: 'https://support.invoke.ai/support/solutions/articles/151000178161-advanced-settings', + }, + paramUpscaleMethod: { + href: 'https://support.invoke.ai/support/solutions/articles/151000096700-how-can-i-get-larger-images-what-does-upscaling-do-', + }, + refinerModel: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178333-using-the-refiner', + }, + refinerNegativeAestheticScore: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178333-using-the-refiner', + }, + refinerPositiveAestheticScore: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178333-using-the-refiner', + }, + refinerScheduler: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178333-using-the-refiner', + }, + refinerStart: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178333-using-the-refiner', + }, + refinerSteps: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178333-using-the-refiner', + }, + refinerCfgScale: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178333-using-the-refiner', + }, + seamlessTilingXAxis: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178161-advanced-settings', + }, + seamlessTilingYAxis: { + href: 'https://support.invoke.ai/support/solutions/articles/151000178161-advanced-settings', + }, + fluxDevLicense: { + href: 'https://www.invoke.com/get-a-commercial-license-for-flux', + image: commercialLicenseBg, + }, +} as const; + +export const OPEN_DELAY = 1000; // in milliseconds + +export const POPPER_MODIFIERS: PopoverProps['modifiers'] = [{ name: 'preventOverflow', options: { padding: 10 } }]; diff --git a/invokeai/frontend/web/src/common/components/InvokeLogoIcon.tsx b/invokeai/frontend/web/src/common/components/InvokeLogoIcon.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e744e1a5c85ca572e3e9280be2cffa53063ea1b2 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/InvokeLogoIcon.tsx @@ -0,0 +1,13 @@ +import type { IconProps } from '@invoke-ai/ui-library'; +import { Icon } from '@invoke-ai/ui-library'; +import { memo } from 'react'; + +export const InvokeLogoIcon = memo((props: IconProps) => { + return ( + + + + ); +}); + +InvokeLogoIcon.displayName = 'InvokeLogoIcon'; diff --git a/invokeai/frontend/web/src/common/components/Loading/Loading.tsx b/invokeai/frontend/web/src/common/components/Loading/Loading.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d5d07d538e1354236f828acc301195262ea539ba --- /dev/null +++ b/invokeai/frontend/web/src/common/components/Loading/Loading.tsx @@ -0,0 +1,25 @@ +import { Flex, Image, Spinner } from '@invoke-ai/ui-library'; +import InvokeLogoWhite from 'public/assets/images/invoke-symbol-wht-lrg.svg'; +import { memo } from 'react'; + +// This component loads before the theme so we cannot use theme tokens here + +const Loading = () => { + return ( + + + + + ); +}; + +export default memo(Loading); diff --git a/invokeai/frontend/web/src/common/components/NodeSelectionOverlay.tsx b/invokeai/frontend/web/src/common/components/NodeSelectionOverlay.tsx new file mode 100644 index 0000000000000000000000000000000000000000..31ea415c90918c00958d499ef01831be9b9a6431 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/NodeSelectionOverlay.tsx @@ -0,0 +1,39 @@ +import { Box } from '@invoke-ai/ui-library'; +import { memo, useMemo } from 'react'; + +type Props = { + isSelected: boolean; + isHovered: boolean; +}; +const SelectionOverlay = ({ isSelected, isHovered }: Props) => { + const shadow = useMemo(() => { + if (isSelected && isHovered) { + return 'nodeHoveredSelected'; + } + if (isSelected) { + return 'nodeSelected'; + } + if (isHovered) { + return 'nodeHovered'; + } + return undefined; + }, [isHovered, isSelected]); + return ( + + ); +}; + +export default memo(SelectionOverlay); diff --git a/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx b/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..370c85959e0c2ac66624e981514c0a2e4b3e6a4b --- /dev/null +++ b/invokeai/frontend/web/src/common/components/OverlayScrollbars/ScrollableContent.tsx @@ -0,0 +1,57 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { autoScrollForElements } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/element'; +import { autoScrollForExternal } from '@atlaskit/pragmatic-drag-and-drop-auto-scroll/external'; +import type { ChakraProps } from '@invoke-ai/ui-library'; +import { Box, Flex } from '@invoke-ai/ui-library'; +import { getOverlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; +import type { OverlayScrollbarsComponentRef } from 'overlayscrollbars-react'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import type { CSSProperties, PropsWithChildren } from 'react'; +import { memo, useEffect, useMemo, useState } from 'react'; + +type Props = PropsWithChildren & { + maxHeight?: ChakraProps['maxHeight']; + overflowX?: 'hidden' | 'scroll'; + overflowY?: 'hidden' | 'scroll'; +}; + +const styles: CSSProperties = { position: 'absolute', top: 0, left: 0, right: 0, bottom: 0 }; + +const ScrollableContent = ({ children, maxHeight, overflowX = 'hidden', overflowY = 'scroll' }: Props) => { + const overlayscrollbarsOptions = useMemo( + () => getOverlayScrollbarsParams(overflowX, overflowY).options, + [overflowX, overflowY] + ); + const [os, osRef] = useState(null); + useEffect(() => { + const osInstance = os?.osInstance(); + + if (!osInstance) { + return; + } + + const element = osInstance.elements().viewport; + + // `pragmatic-drag-and-drop-auto-scroll` requires the element to have `overflow-y: scroll` or `overflow-y: auto` + // else it logs an ugly warning. In our case, using a custom scrollbar library, it will be 'hidden' by default. + // To prevent the erroneous warning, we temporarily set the overflow-y to 'scroll' and then revert it back. + const overflowY = element.style.overflowY; // starts 'hidden' + element.style.setProperty('overflow-y', 'scroll', 'important'); + const cleanup = combine(autoScrollForElements({ element }), autoScrollForExternal({ element })); + element.style.setProperty('overflow-y', overflowY); + + return cleanup; + }, [os]); + + return ( + + + + {children} + + + + ); +}; + +export default memo(ScrollableContent); diff --git a/invokeai/frontend/web/src/common/components/OverlayScrollbars/constants.ts b/invokeai/frontend/web/src/common/components/OverlayScrollbars/constants.ts new file mode 100644 index 0000000000000000000000000000000000000000..d72d20e8465fe65ee71a6da8a4e307290f8a1327 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/OverlayScrollbars/constants.ts @@ -0,0 +1,29 @@ +import { deepClone } from 'common/util/deepClone'; +import { merge } from 'lodash-es'; +import { ClickScrollPlugin, OverlayScrollbars } from 'overlayscrollbars'; +import type { UseOverlayScrollbarsParams } from 'overlayscrollbars-react'; + +OverlayScrollbars.plugin(ClickScrollPlugin); + +export const overlayScrollbarsParams: UseOverlayScrollbarsParams = { + defer: true, + options: { + scrollbars: { + visibility: 'auto', + autoHide: 'scroll', + autoHideDelay: 1300, + theme: 'os-theme-dark', + clickScroll: true, + }, + overflow: { x: 'hidden' }, + }, +}; + +export const getOverlayScrollbarsParams = ( + overflowX: 'hidden' | 'scroll' = 'hidden', + overflowY: 'hidden' | 'scroll' = 'scroll' +) => { + const params = deepClone(overlayScrollbarsParams); + merge(params, { options: { overflow: { y: overflowY, x: overflowX } } }); + return params; +}; diff --git a/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css b/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css new file mode 100644 index 0000000000000000000000000000000000000000..8827987401429cf03dfcc94cad2910c780f292bc --- /dev/null +++ b/invokeai/frontend/web/src/common/components/OverlayScrollbars/overlayscrollbars.css @@ -0,0 +1,56 @@ +.os-scrollbar { + /* The size of the scrollbar */ + --os-size: 9px; + /* The axis-perpedicular padding of the scrollbar (horizontal: padding-y, vertical: padding-x) */ + /* --os-padding-perpendicular: 0; */ + /* The axis padding of the scrollbar (horizontal: padding-x, vertical: padding-y) */ + /* --os-padding-axis: 0; */ + /* The border radius of the scrollbar track */ + /* --os-track-border-radius: 0; */ + /* The background of the scrollbar track */ + /* --os-track-bg: rgba(0, 0, 0, 0.3); */ + /* The :hover background of the scrollbar track */ + /* --os-track-bg-hover: rgba(0, 0, 0, 0.3); */ + /* The :active background of the scrollbar track */ + /* --os-track-bg-active: rgba(0, 0, 0, 0.3); */ + /* The border of the scrollbar track */ + /* --os-track-border: none; */ + /* The :hover background of the scrollbar track */ + /* --os-track-border-hover: none; */ + /* The :active background of the scrollbar track */ + /* --os-track-border-active: none; */ + /* The border radius of the scrollbar handle */ + /* --os-handle-border-radius: 2px; */ + /* The background of the scrollbar handle */ + /* --os-handle-bg: var(--invokeai-colors-accentAlpha-500); */ + /* The :hover background of the scrollbar handle */ + /* --os-handle-bg-hover: var(--invokeai-colors-accentAlpha-700); */ + /* The :active background of the scrollbar handle */ + /* --os-handle-bg-active: var(--invokeai-colors-accentAlpha-800); */ + /* The border of the scrollbar handle */ + /* --os-handle-border: none; */ + /* The :hover border of the scrollbar handle */ + /* --os-handle-border-hover: none; */ + /* The :active border of the scrollbar handle */ + /* --os-handle-border-active: none; */ + /* The min size of the scrollbar handle */ + --os-handle-min-size: 50px; + /* The max size of the scrollbar handle */ + /* --os-handle-max-size: none; */ + /* The axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */ + /* --os-handle-perpendicular-size: 100%; */ + /* The :hover axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */ + /* --os-handle-perpendicular-size-hover: 100%; */ + /* The :active axis-perpedicular size of the scrollbar handle (horizontal: height, vertical: width) */ + /* --os-handle-perpendicular-size-active: 100%; */ + /* Increases the interactive area of the scrollbar handle. */ + /* --os-handle-interactive-area-offset: 0; */ +} + +.os-scrollbar-handle { + cursor: grab; +} + +.os-scrollbar-handle:active { + cursor: grabbing; +} diff --git a/invokeai/frontend/web/src/common/components/WavyLine.tsx b/invokeai/frontend/web/src/common/components/WavyLine.tsx new file mode 100644 index 0000000000000000000000000000000000000000..35acd789079e1e7f99684b71d1e3b37a0f9fd8d6 --- /dev/null +++ b/invokeai/frontend/web/src/common/components/WavyLine.tsx @@ -0,0 +1,57 @@ +type Props = { + /** + * The amplitude of the wave. 0 is a straight line, higher values create more pronounced waves. + */ + amplitude: number; + /** + * The number of segments in the line. More segments create a smoother wave. + */ + segments?: number; + /** + * The color of the wave. + */ + stroke: string; + /** + * The width of the wave. + */ + strokeWidth: number; + /** + * The width of the SVG. + */ + width: number; + /** + * The height of the SVG. + */ + height: number; +}; + +const WavyLine = ({ amplitude, stroke, strokeWidth, width, height, segments = 5 }: Props) => { + // Calculate the path dynamically based on waviness + const generatePath = () => { + if (amplitude === 0) { + // If waviness is 0, return a straight line + return `M0,${height / 2} L${width},${height / 2}`; + } + + const clampedAmplitude = Math.min(height / 2, amplitude); // Cap amplitude to half the height + const segmentWidth = width / segments; + let path = `M0,${height / 2}`; // Start in the middle of the left edge + + // Loop through each segment and alternate the y position to create waves + for (let i = 1; i <= segments; i++) { + const x = i * segmentWidth; + const y = height / 2 + (i % 2 === 0 ? clampedAmplitude : -clampedAmplitude); + path += ` Q${x - segmentWidth / 2},${y} ${x},${height / 2}`; + } + + return path; + }; + + return ( + + + + ); +}; + +export default WavyLine; diff --git a/invokeai/frontend/web/src/common/hooks/focus.ts b/invokeai/frontend/web/src/common/hooks/focus.ts new file mode 100644 index 0000000000000000000000000000000000000000..a5d4e1de443bc6d10dfb8b9e56624fd1bbb73ad5 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/focus.ts @@ -0,0 +1,182 @@ +import { useStore } from '@nanostores/react'; +import { logger } from 'app/logging/logger'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import type { Atom } from 'nanostores'; +import { atom, computed } from 'nanostores'; +import type { RefObject } from 'react'; +import { useEffect } from 'react'; +import { objectKeys } from 'tsafe'; + +/** + * We need to manage focus regions to conditionally enable hotkeys: + * - Some hotkeys should only be enabled when a specific region is focused. + * - Some hotkeys may conflict with other regions, so we need to disable them when a specific region is focused. For + * example, `esc` is used to clear the gallery selection, but it is also used to cancel a filter or transform on the + * canvas. + * + * To manage focus regions, we use a system of hooks and stores: + * - `useFocusRegion` is a hook that registers an element as part of a focus region. When that element is focused, by + * click or any other action, that region is set as the focused region. Optionally, focus can be set on mount. This + * is useful for components like the image viewer. + * - `useIsRegionFocused` is a hook that returns a boolean indicating if a specific region is focused. + * - `useFocusRegionWatcher` is a hook that listens for focus events on the window. When an element is focused, it + * checks if it is part of a focus region and sets that region as the focused region. + */ + +// + +const log = logger('system'); + +/** + * The names of the focus regions. + */ +type FocusRegionName = 'gallery' | 'layers' | 'canvas' | 'workflows' | 'viewer'; + +/** + * A map of focus regions to the elements that are part of that region. + */ +const REGION_TARGETS: Record> = { + gallery: new Set(), + layers: new Set(), + canvas: new Set(), + workflows: new Set(), + viewer: new Set(), +} as const; + +/** + * The currently-focused region or `null` if no region is focused. + */ +const $focusedRegion = atom(null); + +/** + * A map of focus regions to atoms that indicate if that region is focused. + */ +const FOCUS_REGIONS = objectKeys(REGION_TARGETS).reduce( + (acc, region) => { + acc[`$${region}`] = computed($focusedRegion, (focusedRegion) => focusedRegion === region); + return acc; + }, + {} as Record<`$${FocusRegionName}`, Atom> +); + +/** + * Sets the focused region, logging a trace level message. + */ +const setFocus = (region: FocusRegionName | null) => { + $focusedRegion.set(region); + log.trace(`Focus changed: ${region}`); +}; + +type UseFocusRegionOptions = { + focusOnMount?: boolean; +}; + +/** + * Registers an element as part of a focus region. When that element is focused, by click or any other action, that + * region is set as the focused region. Optionally, focus can be set on mount. + * + * On unmount, if the element is the last element in the region and the region is focused, the focused region is set to + * `null`. + * + * @param region The focus region name. + * @param ref The ref of the element to register. + * @param options The options. + */ +export const useFocusRegion = ( + region: FocusRegionName, + ref: RefObject, + options?: UseFocusRegionOptions +) => { + useEffect(() => { + if (!ref.current) { + return; + } + + const { focusOnMount = false } = { focusOnMount: false, ...options }; + + const element = ref.current; + + REGION_TARGETS[region].add(element); + + if (focusOnMount) { + setFocus(region); + } + + return () => { + REGION_TARGETS[region].delete(element); + + if (REGION_TARGETS[region].size === 0 && $focusedRegion.get() === region) { + setFocus(null); + } + }; + }, [options, ref, region]); +}; + +/** + * Returns a boolean indicating if a specific region is focused. + * @param region The focus region name. + */ +export const useIsRegionFocused = (region: FocusRegionName) => { + return useStore(FOCUS_REGIONS[`$${region}`]); +}; + +/** + * Listens for focus events on the window. When an element is focused, it checks if it is part of a focus region and sets + * that region as the focused region. The region corresponding to the deepest element is set. + */ +const onFocus = (_: FocusEvent) => { + const activeElement = document.activeElement; + if (!(activeElement instanceof HTMLElement)) { + return; + } + + const regionCandidates: { region: FocusRegionName; element: HTMLElement }[] = []; + + for (const region of objectKeys(REGION_TARGETS)) { + for (const element of REGION_TARGETS[region]) { + if (element.contains(activeElement)) { + regionCandidates.push({ region, element }); + } + } + } + + if (regionCandidates.length === 0) { + return; + } + + // Sort by the shallowest element + regionCandidates.sort((a, b) => { + if (b.element.contains(a.element)) { + return -1; + } + if (a.element.contains(b.element)) { + return 1; + } + return 0; + }); + + // Set the region of the deepest element + const focusedRegion = regionCandidates[0]?.region; + + if (!focusedRegion) { + log.warn('No focused region found'); + return; + } + + setFocus(focusedRegion); +}; + +/** + * Listens for focus events on the window. When an element is focused, it checks if it is part of a focus region and sets + * that region as the focused region. This is a singleton. + */ +export const useFocusRegionWatcher = () => { + useAssertSingleton('useFocusRegionWatcher'); + + useEffect(() => { + window.addEventListener('focus', onFocus, { capture: true }); + return () => { + window.removeEventListener('focus', onFocus, { capture: true }); + }; + }, []); +}; diff --git a/invokeai/frontend/web/src/common/hooks/useAssertSingleton.ts b/invokeai/frontend/web/src/common/hooks/useAssertSingleton.ts new file mode 100644 index 0000000000000000000000000000000000000000..0f7cc9db6f565bd6e818e9621e482660d805776b --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useAssertSingleton.ts @@ -0,0 +1,18 @@ +import { useEffect } from 'react'; +import { assert } from 'tsafe'; + +const IDS = new Set(); + +/** + * Asserts that there is only one instance of a singleton entity. It can be a hook or a component. + * @param id The ID of the singleton entity. + */ +export function useAssertSingleton(id: string) { + useEffect(() => { + assert(!IDS.has(id), `There should be only one instance of ${id}`); + IDS.add(id); + return () => { + IDS.delete(id); + }; + }, [id]); +} diff --git a/invokeai/frontend/web/src/common/hooks/useBoolean.ts b/invokeai/frontend/web/src/common/hooks/useBoolean.ts new file mode 100644 index 0000000000000000000000000000000000000000..ec68457ecdd6af84ebe6029483e427042724985a --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useBoolean.ts @@ -0,0 +1,151 @@ +import { useStore } from '@nanostores/react'; +import type { WritableAtom } from 'nanostores'; +import { atom } from 'nanostores'; +import { useCallback, useState } from 'react'; + +type UseBoolean = { + isTrue: boolean; + setTrue: () => void; + setFalse: () => void; + set: (value: boolean) => void; + toggle: () => void; +}; + +/** + * Creates a hook to manage a boolean state. The boolean is stored in a nanostores atom. + * Returns a tuple containing the hook and the atom. Use this for global boolean state. + * @param initialValue Initial value of the boolean + */ +export const buildUseBoolean = (initialValue: boolean): [() => UseBoolean, WritableAtom] => { + const $boolean = atom(initialValue); + + const setTrue = () => { + $boolean.set(true); + }; + const setFalse = () => { + $boolean.set(false); + }; + const set = (value: boolean) => { + $boolean.set(value); + }; + const toggle = () => { + $boolean.set(!$boolean.get()); + }; + + const useBoolean = () => { + const isTrue = useStore($boolean); + + return { + isTrue, + setTrue, + setFalse, + set, + toggle, + }; + }; + + return [useBoolean, $boolean] as const; +}; + +/** + * Hook to manage a boolean state. Use this for a local boolean state. + * @param initialValue Initial value of the boolean + */ +export const useBoolean = (initialValue: boolean): UseBoolean => { + const [isTrue, set] = useState(initialValue); + + const setTrue = useCallback(() => { + set(true); + }, [set]); + const setFalse = useCallback(() => { + set(false); + }, [set]); + const toggle = useCallback(() => { + set((val) => !val); + }, [set]); + + return { + isTrue, + setTrue, + setFalse, + set, + toggle, + }; +}; + +type UseDisclosure = { + isOpen: boolean; + open: () => void; + close: () => void; + set: (isOpen: boolean) => void; + toggle: () => void; +}; + +/** + * This is the same as `buildUseBoolean`, but the method names are more descriptive, + * serving the semantics of a disclosure state. + * + * Creates a hook to manage a boolean state. The boolean is stored in a nanostores atom. + * Returns a tuple containing the hook and the atom. Use this for global boolean state. + * + * @param defaultIsOpen Initial state of the disclosure + */ +export const buildUseDisclosure = (defaultIsOpen: boolean): [() => UseDisclosure, WritableAtom] => { + const $isOpen = atom(defaultIsOpen); + + const open = () => { + $isOpen.set(true); + }; + const close = () => { + $isOpen.set(false); + }; + const set = (isOpen: boolean) => { + $isOpen.set(isOpen); + }; + const toggle = () => { + $isOpen.set(!$isOpen.get()); + }; + + const useDisclosure = () => { + const isOpen = useStore($isOpen); + + return { + isOpen, + open, + close, + set, + toggle, + }; + }; + + return [useDisclosure, $isOpen] as const; +}; + +/** + * This is the same as `useBoolean`, but the method names are more descriptive, + * serving the semantics of a disclosure state. + * + * Hook to manage a boolean state. Use this for a local boolean state. + * @param defaultIsOpen Initial state of the disclosure + */ +export const useDisclosure = (defaultIsOpen: boolean): UseDisclosure => { + const [isOpen, set] = useState(defaultIsOpen); + + const open = useCallback(() => { + set(true); + }, [set]); + const close = useCallback(() => { + set(false); + }, [set]); + const toggle = useCallback(() => { + set((val) => !val); + }, [set]); + + return { + isOpen, + open, + close, + set, + toggle, + }; +}; diff --git a/invokeai/frontend/web/src/common/hooks/useChakraThemeTokens.ts b/invokeai/frontend/web/src/common/hooks/useChakraThemeTokens.ts new file mode 100644 index 0000000000000000000000000000000000000000..93345f6a4cb628f6410865c2a778b2f81421d530 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useChakraThemeTokens.ts @@ -0,0 +1,238 @@ +import { useToken } from '@invoke-ai/ui-library'; + +export const useChakraThemeTokens = () => { + const [ + base50, + base100, + base150, + base200, + base250, + base300, + base350, + base400, + base450, + base500, + base550, + base600, + base650, + base700, + base750, + base800, + base850, + base900, + base950, + accent50, + accent100, + accent150, + accent200, + accent250, + accent300, + accent350, + accent400, + accent450, + accent500, + accent550, + accent600, + accent650, + accent700, + accent750, + accent800, + accent850, + accent900, + accent950, + baseAlpha50, + baseAlpha100, + baseAlpha150, + baseAlpha200, + baseAlpha250, + baseAlpha300, + baseAlpha350, + baseAlpha400, + baseAlpha450, + baseAlpha500, + baseAlpha550, + baseAlpha600, + baseAlpha650, + baseAlpha700, + baseAlpha750, + baseAlpha800, + baseAlpha850, + baseAlpha900, + baseAlpha950, + accentAlpha50, + accentAlpha100, + accentAlpha150, + accentAlpha200, + accentAlpha250, + accentAlpha300, + accentAlpha350, + accentAlpha400, + accentAlpha450, + accentAlpha500, + accentAlpha550, + accentAlpha600, + accentAlpha650, + accentAlpha700, + accentAlpha750, + accentAlpha800, + accentAlpha850, + accentAlpha900, + accentAlpha950, + ] = useToken('colors', [ + 'base.50', + 'base.100', + 'base.150', + 'base.200', + 'base.250', + 'base.300', + 'base.350', + 'base.400', + 'base.450', + 'base.500', + 'base.550', + 'base.600', + 'base.650', + 'base.700', + 'base.750', + 'base.800', + 'base.850', + 'base.900', + 'base.950', + 'accent.50', + 'accent.100', + 'accent.150', + 'accent.200', + 'accent.250', + 'accent.300', + 'accent.350', + 'accent.400', + 'accent.450', + 'accent.500', + 'accent.550', + 'accent.600', + 'accent.650', + 'accent.700', + 'accent.750', + 'accent.800', + 'accent.850', + 'accent.900', + 'accent.950', + 'baseAlpha.50', + 'baseAlpha.100', + 'baseAlpha.150', + 'baseAlpha.200', + 'baseAlpha.250', + 'baseAlpha.300', + 'baseAlpha.350', + 'baseAlpha.400', + 'baseAlpha.450', + 'baseAlpha.500', + 'baseAlpha.550', + 'baseAlpha.600', + 'baseAlpha.650', + 'baseAlpha.700', + 'baseAlpha.750', + 'baseAlpha.800', + 'baseAlpha.850', + 'baseAlpha.900', + 'baseAlpha.950', + 'accentAlpha.50', + 'accentAlpha.100', + 'accentAlpha.150', + 'accentAlpha.200', + 'accentAlpha.250', + 'accentAlpha.300', + 'accentAlpha.350', + 'accentAlpha.400', + 'accentAlpha.450', + 'accentAlpha.500', + 'accentAlpha.550', + 'accentAlpha.600', + 'accentAlpha.650', + 'accentAlpha.700', + 'accentAlpha.750', + 'accentAlpha.800', + 'accentAlpha.850', + 'accentAlpha.900', + 'accentAlpha.950', + ]); + + return { + base50, + base100, + base150, + base200, + base250, + base300, + base350, + base400, + base450, + base500, + base550, + base600, + base650, + base700, + base750, + base800, + base850, + base900, + base950, + accent50, + accent100, + accent150, + accent200, + accent250, + accent300, + accent350, + accent400, + accent450, + accent500, + accent550, + accent600, + accent650, + accent700, + accent750, + accent800, + accent850, + accent900, + accent950, + baseAlpha50, + baseAlpha100, + baseAlpha150, + baseAlpha200, + baseAlpha250, + baseAlpha300, + baseAlpha350, + baseAlpha400, + baseAlpha450, + baseAlpha500, + baseAlpha550, + baseAlpha600, + baseAlpha650, + baseAlpha700, + baseAlpha750, + baseAlpha800, + baseAlpha850, + baseAlpha900, + baseAlpha950, + accentAlpha50, + accentAlpha100, + accentAlpha150, + accentAlpha200, + accentAlpha250, + accentAlpha300, + accentAlpha350, + accentAlpha400, + accentAlpha450, + accentAlpha500, + accentAlpha550, + accentAlpha600, + accentAlpha650, + accentAlpha700, + accentAlpha750, + accentAlpha800, + accentAlpha850, + accentAlpha900, + accentAlpha950, + }; +}; diff --git a/invokeai/frontend/web/src/common/hooks/useClearStorage.ts b/invokeai/frontend/web/src/common/hooks/useClearStorage.ts new file mode 100644 index 0000000000000000000000000000000000000000..b8338829021f15007589496294231ac35e0f96cf --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useClearStorage.ts @@ -0,0 +1,11 @@ +import { clearIdbKeyValStore } from 'app/store/enhancers/reduxRemember/driver'; +import { useCallback } from 'react'; + +export const useClearStorage = () => { + const clearStorage = useCallback(() => { + clearIdbKeyValStore(); + localStorage.clear(); + }, []); + + return clearStorage; +}; diff --git a/invokeai/frontend/web/src/common/hooks/useCopyImageToClipboard.ts b/invokeai/frontend/web/src/common/hooks/useCopyImageToClipboard.ts new file mode 100644 index 0000000000000000000000000000000000000000..345ea98e136ce797409c7ff49e73068a7196c57f --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useCopyImageToClipboard.ts @@ -0,0 +1,51 @@ +import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; +import { copyBlobToClipboard } from 'features/system/util/copyBlobToClipboard'; +import { toast } from 'features/toast/toast'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useCopyImageToClipboard = () => { + const { t } = useTranslation(); + + const isClipboardAPIAvailable = useMemo(() => { + return Boolean(navigator.clipboard) && Boolean(window.ClipboardItem); + }, []); + + const copyImageToClipboard = useCallback( + async (image_url: string) => { + if (!isClipboardAPIAvailable) { + toast({ + id: 'PROBLEM_COPYING_IMAGE', + title: t('toast.problemCopyingImage'), + description: "Your browser doesn't support the Clipboard API.", + status: 'error', + }); + } + try { + const blob = await convertImageUrlToBlob(image_url); + + if (!blob) { + throw new Error('Unable to create Blob'); + } + + copyBlobToClipboard(blob); + + toast({ + id: 'IMAGE_COPIED', + title: t('toast.imageCopied'), + status: 'success', + }); + } catch (err) { + toast({ + id: 'PROBLEM_COPYING_IMAGE', + title: t('toast.problemCopyingImage'), + description: String(err), + status: 'error', + }); + } + }, + [isClipboardAPIAvailable, t] + ); + + return { isClipboardAPIAvailable, copyImageToClipboard }; +}; diff --git a/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts new file mode 100644 index 0000000000000000000000000000000000000000..ede247b9fbe54340d95943dbcc6cab839a0cc4f2 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts @@ -0,0 +1,51 @@ +import { useStore } from '@nanostores/react'; +import { $authToken } from 'app/store/nanostores/authToken'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { imageDownloaded } from 'features/gallery/store/actions'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const useDownloadImage = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const authToken = useStore($authToken); + + const downloadImage = useCallback( + async (image_url: string, image_name: string) => { + try { + const requestOpts = authToken + ? { + headers: { + Authorization: `Bearer ${authToken}`, + }, + } + : {}; + const blob = await fetch(image_url, requestOpts).then((resp) => resp.blob()); + if (!blob) { + throw new Error('Unable to create Blob'); + } + + const url = window.URL.createObjectURL(blob); + const a = document.createElement('a'); + a.style.display = 'none'; + a.href = url; + a.download = image_name; + document.body.appendChild(a); + a.click(); + window.URL.revokeObjectURL(url); + dispatch(imageDownloaded()); + } catch (err) { + toast({ + id: 'PROBLEM_DOWNLOADING_IMAGE', + title: t('toast.problemDownloadingImage'), + description: String(err), + status: 'error', + }); + } + }, + [t, dispatch, authToken] + ); + + return { downloadImage }; +}; diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts new file mode 100644 index 0000000000000000000000000000000000000000..f2ed90588519d9ee5b61225cbdde49a9c9e96cfc --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -0,0 +1,115 @@ +import { useAppDispatch } from 'app/store/storeHooks'; +import { useClearQueue } from 'features/queue/components/ClearQueueConfirmationAlertDialog'; +import { useCancelCurrentQueueItem } from 'features/queue/hooks/useCancelCurrentQueueItem'; +import { useInvoke } from 'features/queue/hooks/useInvoke'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { setActiveTab } from 'features/ui/store/uiSlice'; + +export const useGlobalHotkeys = () => { + const dispatch = useAppDispatch(); + const isModelManagerEnabled = useFeatureStatus('modelManager'); + const queue = useInvoke(); + + useRegisteredHotkeys({ + id: 'invoke', + category: 'app', + callback: queue.queueBack, + options: { + enabled: !queue.isDisabled && !queue.isLoading, + preventDefault: true, + enableOnFormTags: ['input', 'textarea', 'select'], + }, + dependencies: [queue], + }); + + useRegisteredHotkeys({ + id: 'invokeFront', + category: 'app', + callback: queue.queueFront, + options: { + enabled: !queue.isDisabled && !queue.isLoading, + preventDefault: true, + enableOnFormTags: ['input', 'textarea', 'select'], + }, + dependencies: [queue], + }); + + const { + cancelQueueItem, + isDisabled: isDisabledCancelQueueItem, + isLoading: isLoadingCancelQueueItem, + } = useCancelCurrentQueueItem(); + + useRegisteredHotkeys({ + id: 'cancelQueueItem', + category: 'app', + callback: cancelQueueItem, + options: { + enabled: !isDisabledCancelQueueItem && !isLoadingCancelQueueItem, + preventDefault: true, + }, + dependencies: [cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem], + }); + + const { clearQueue, isDisabled: isDisabledClearQueue, isLoading: isLoadingClearQueue } = useClearQueue(); + + useRegisteredHotkeys({ + id: 'clearQueue', + category: 'app', + callback: clearQueue, + options: { + enabled: !isDisabledClearQueue && !isLoadingClearQueue, + preventDefault: true, + }, + dependencies: [clearQueue, isDisabledClearQueue, isLoadingClearQueue], + }); + + useRegisteredHotkeys({ + id: 'selectCanvasTab', + category: 'app', + callback: () => { + dispatch(setActiveTab('canvas')); + }, + dependencies: [dispatch], + }); + + useRegisteredHotkeys({ + id: 'selectUpscalingTab', + category: 'app', + callback: () => { + dispatch(setActiveTab('upscaling')); + }, + dependencies: [dispatch], + }); + + useRegisteredHotkeys({ + id: 'selectWorkflowsTab', + category: 'app', + callback: () => { + dispatch(setActiveTab('workflows')); + }, + dependencies: [dispatch], + }); + + useRegisteredHotkeys({ + id: 'selectModelsTab', + category: 'app', + callback: () => { + dispatch(setActiveTab('models')); + }, + options: { + enabled: isModelManagerEnabled, + }, + dependencies: [dispatch, isModelManagerEnabled], + }); + + useRegisteredHotkeys({ + id: 'selectQueueTab', + category: 'app', + callback: () => { + dispatch(setActiveTab('queue')); + }, + dependencies: [dispatch, isModelManagerEnabled], + }); +}; diff --git a/invokeai/frontend/web/src/common/hooks/useGroupedModelCombobox.ts b/invokeai/frontend/web/src/common/hooks/useGroupedModelCombobox.ts new file mode 100644 index 0000000000000000000000000000000000000000..5751c823a16f90c2bf420a4e5c6bdf4841513dbe --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useGroupedModelCombobox.ts @@ -0,0 +1,105 @@ +import type { ComboboxOnChange, ComboboxOption } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import type { GroupBase } from 'chakra-react-select'; +import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import type { ModelIdentifierField } from 'features/nodes/types/common'; +import { selectSystemShouldEnableModelDescriptions } from 'features/system/store/systemSlice'; +import { groupBy, reduce } from 'lodash-es'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import type { AnyModelConfig } from 'services/api/types'; + +type UseGroupedModelComboboxArg = { + modelConfigs: T[]; + selectedModel?: ModelIdentifierField | null; + onChange: (value: T | null) => void; + getIsDisabled?: (model: T) => boolean; + isLoading?: boolean; + groupByType?: boolean; +}; + +type UseGroupedModelComboboxReturn = { + value: ComboboxOption | undefined | null; + options: GroupBase[]; + onChange: ComboboxOnChange; + placeholder: string; + noOptionsMessage: () => string; +}; + +const groupByBaseFunc = (model: T) => model.base.toUpperCase(); +const groupByBaseAndTypeFunc = (model: T) => + `${model.base.toUpperCase()} / ${model.type.replaceAll('_', ' ').toUpperCase()}`; + +const selectBaseWithSDXLFallback = createSelector(selectParamsSlice, (params) => params.model?.base ?? 'sdxl'); + +export const useGroupedModelCombobox = ( + arg: UseGroupedModelComboboxArg +): UseGroupedModelComboboxReturn => { + const { t } = useTranslation(); + const base = useAppSelector(selectBaseWithSDXLFallback); + const shouldShowModelDescriptions = useAppSelector(selectSystemShouldEnableModelDescriptions); + const { modelConfigs, selectedModel, getIsDisabled, onChange, isLoading, groupByType = false } = arg; + const options = useMemo[]>(() => { + if (!modelConfigs) { + return []; + } + const groupedModels = groupBy(modelConfigs, groupByType ? groupByBaseAndTypeFunc : groupByBaseFunc); + const _options = reduce( + groupedModels, + (acc, val, label) => { + acc.push({ + label, + options: val.map((model) => ({ + label: model.name, + value: model.key, + description: (shouldShowModelDescriptions && model.description) || undefined, + isDisabled: getIsDisabled ? getIsDisabled(model) : false, + })), + }); + return acc; + }, + [] as GroupBase[] + ); + _options.sort((a) => (a.label?.split('/')[0]?.toLowerCase().includes(base) ? -1 : 1)); + return _options; + }, [modelConfigs, groupByType, getIsDisabled, base, shouldShowModelDescriptions]); + + const value = useMemo( + () => + options.flatMap((o) => o.options).find((m) => (selectedModel ? m.value === selectedModel.key : false)) ?? null, + [options, selectedModel] + ); + + const _onChange = useCallback( + (v) => { + if (!v) { + onChange(null); + return; + } + const model = modelConfigs.find((m) => m.key === v.value); + if (!model) { + onChange(null); + return; + } + onChange(model); + }, + [modelConfigs, onChange] + ); + + const placeholder = useMemo(() => { + if (isLoading) { + return t('common.loading'); + } + + if (options.length === 0) { + return t('models.noModelsAvailable'); + } + + return t('models.selectModel'); + }, [isLoading, options, t]); + + const noOptionsMessage = useCallback(() => t('models.noMatchingModels'), [t]); + + return { options, value, onChange: _onChange, placeholder, noOptionsMessage }; +}; diff --git a/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5c69f2d7450c18207f18c49df4ca0a2c50ba8315 --- /dev/null +++ b/invokeai/frontend/web/src/common/hooks/useImageUploadButton.tsx @@ -0,0 +1,178 @@ +import type { IconButtonProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import { IconButton } from '@invoke-ai/ui-library'; +import { logger } from 'app/logging/logger'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectAutoAddBoardId } from 'features/gallery/store/gallerySelectors'; +import { selectMaxImageUploadCount } from 'features/system/store/configSlice'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import type { FileRejection } from 'react-dropzone'; +import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; +import { PiUploadBold } from 'react-icons/pi'; +import { uploadImages, useUploadImageMutation } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; +import { assert } from 'tsafe'; +import type { SetOptional } from 'type-fest'; + +type UseImageUploadButtonArgs = + | { + isDisabled?: boolean; + allowMultiple: false; + onUpload?: (imageDTO: ImageDTO) => void; + } + | { + isDisabled?: boolean; + allowMultiple: true; + onUpload?: (imageDTOs: ImageDTO[]) => void; + }; + +const log = logger('gallery'); + +/** + * Provides image uploader functionality to any component. + * + * @example + * const { getUploadButtonProps, getUploadInputProps, openUploader } = useImageUploadButton({ + * postUploadAction: { + * type: 'SET_CONTROL_ADAPTER_IMAGE', + * controlNetId: '12345', + * }, + * isDisabled: getIsUploadDisabled(), + * }); + * + * // open the uploaded directly + * const handleSomething = () => { openUploader() } + * + * // in the render function + * + + + + {t('controlLayers.regional')} + + + + + + + + + + + + {t('controlLayers.layer_other')} + + + + + + + + + + ); +}); + +CanvasAddEntityButtons.displayName = 'CanvasAddEntityButtons'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca09b10cff2db8b0b3b6ec60b767aac0207b3a1f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress.tsx @@ -0,0 +1,68 @@ +import { Alert, AlertDescription, AlertIcon, AlertTitle } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useDeferredModelLoadingInvocationProgressMessage } from 'features/controlLayers/hooks/useDeferredModelLoadingInvocationProgressMessage'; +import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; +import { selectIsLocal } from 'features/system/store/configSlice'; +import { selectSystemShouldShowInvocationProgressDetail } from 'features/system/store/systemSlice'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { $invocationProgressMessage } from 'services/events/stores'; + +const CanvasAlertsInvocationProgressContentLocal = memo(() => { + const { t } = useTranslation(); + const invocationProgressMessage = useStore($invocationProgressMessage); + + if (!invocationProgressMessage) { + return null; + } + + return ( + + + {t('common.generating')} + {invocationProgressMessage} + + ); +}); +CanvasAlertsInvocationProgressContentLocal.displayName = 'CanvasAlertsInvocationProgressContentLocal'; + +const CanvasAlertsInvocationProgressContentCommercial = memo(() => { + const message = useDeferredModelLoadingInvocationProgressMessage(); + + if (!message) { + return null; + } + + return ( + + + {message} + + ); +}); +CanvasAlertsInvocationProgressContentCommercial.displayName = 'CanvasAlertsInvocationProgressContentCommercial'; + +export const CanvasAlertsInvocationProgress = memo(() => { + const isProgressMessageAlertEnabled = useFeatureStatus('invocationProgressAlert'); + const shouldShowInvocationProgressDetail = useAppSelector(selectSystemShouldShowInvocationProgressDetail); + const isLocal = useAppSelector(selectIsLocal); + + // The alert is disabled at the system level + if (!isProgressMessageAlertEnabled) { + return null; + } + + if (!isLocal) { + return ; + } + + // The alert is disabled at the user level + if (!shouldShowInvocationProgressDetail) { + return null; + } + + return ; +}); + +CanvasAlertsInvocationProgress.displayName = 'CanvasAlertsInvocationProgress'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7178c3d123b1b16cbf857340c4d01eeb7ca8c709 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask.tsx @@ -0,0 +1,23 @@ +import { Alert, AlertIcon, AlertTitle } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectPreserveMask } from 'features/controlLayers/store/canvasSettingsSlice'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const CanvasAlertsPreserveMask = memo(() => { + const { t } = useTranslation(); + const preserveMask = useAppSelector(selectPreserveMask); + + if (!preserveMask) { + return null; + } + + return ( + + + {t('controlLayers.settings.preserveMask.alert')} + + ); +}); + +CanvasAlertsPreserveMask.displayName = 'CanvasAlertsPreserveMask'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus.tsx new file mode 100644 index 0000000000000000000000000000000000000000..47bbf36ebd8cc89206913cd386fb293a9dd5ca9f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus.tsx @@ -0,0 +1,121 @@ +import type { AlertStatus } from '@invoke-ai/ui-library'; +import { Alert, AlertIcon, AlertTitle } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext'; +import { useEntityTitle } from 'features/controlLayers/hooks/useEntityTitle'; +import { useEntityTypeIsHidden } from 'features/controlLayers/hooks/useEntityTypeIsHidden'; +import type { CanvasEntityAdapter } from 'features/controlLayers/konva/CanvasEntity/types'; +import { + selectCanvasSlice, + selectEntityOrThrow, + selectSelectedEntityIdentifier, +} from 'features/controlLayers/store/selectors'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { atom } from 'nanostores'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ContentProps = { + entityIdentifier: CanvasEntityIdentifier; + adapter: CanvasEntityAdapter; +}; + +const $isFilteringFallback = atom(false); + +type AlertData = { + status: AlertStatus; + title: string; +}; + +const CanvasAlertsSelectedEntityStatusContent = memo(({ entityIdentifier, adapter }: ContentProps) => { + const { t } = useTranslation(); + const title = useEntityTitle(entityIdentifier); + const selectIsEnabled = useMemo( + () => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).isEnabled), + [entityIdentifier] + ); + const selectIsLocked = useMemo( + () => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).isLocked), + [entityIdentifier] + ); + const isEnabled = useAppSelector(selectIsEnabled); + const isLocked = useAppSelector(selectIsLocked); + const isHidden = useEntityTypeIsHidden(entityIdentifier.type); + const isFiltering = useStore(adapter.filterer?.$isFiltering ?? $isFilteringFallback); + const isTransforming = useStore(adapter.transformer.$isTransforming); + const isEmpty = useStore(adapter.$isEmpty); + + const alert = useMemo(() => { + if (isFiltering) { + return { + status: 'info', + title: t('controlLayers.HUD.entityStatus.isFiltering', { title }), + }; + } + + if (isTransforming) { + return { + status: 'info', + title: t('controlLayers.HUD.entityStatus.isTransforming', { title }), + }; + } + + if (isEmpty) { + return { + status: 'info', + title: t('controlLayers.HUD.entityStatus.isEmpty', { title }), + }; + } + + if (isHidden) { + return { + status: 'warning', + title: t('controlLayers.HUD.entityStatus.isHidden', { title }), + }; + } + + if (isLocked) { + return { + status: 'warning', + title: t('controlLayers.HUD.entityStatus.isLocked', { title }), + }; + } + + if (!isEnabled) { + return { + status: 'warning', + title: t('controlLayers.HUD.entityStatus.isDisabled', { title }), + }; + } + + return null; + }, [isFiltering, isTransforming, isEmpty, isHidden, isLocked, isEnabled, title, t]); + + if (!alert) { + return null; + } + + return ( + + + {alert.title} + + ); +}); + +CanvasAlertsSelectedEntityStatusContent.displayName = 'CanvasAlertsSelectedEntityStatusContent'; + +export const CanvasAlertsSelectedEntityStatus = memo(() => { + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const adapter = useEntityAdapterSafe(selectedEntityIdentifier); + + if (!selectedEntityIdentifier || !adapter) { + return null; + } + + return ; +}); + +CanvasAlertsSelectedEntityStatus.displayName = 'CanvasAlertsSelectedEntityStatus'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo.tsx new file mode 100644 index 0000000000000000000000000000000000000000..96d388c70af00f05858027927468f809c8bfc96e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo.tsx @@ -0,0 +1,146 @@ +import { Alert, AlertDescription, AlertIcon, AlertTitle, Button, Flex } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useBoolean } from 'common/hooks/useBoolean'; +import { selectIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { useCurrentDestination } from 'features/queue/hooks/useCurrentDestination'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; +import { activeTabCanvasRightPanelChanged, setActiveTab } from 'features/ui/store/uiSlice'; +import { AnimatePresence, motion } from 'framer-motion'; +import type { PropsWithChildren, ReactNode } from 'react'; +import { useCallback, useMemo } from 'react'; +import { Trans, useTranslation } from 'react-i18next'; + +const ActivateImageViewerButton = (props: PropsWithChildren) => { + const imageViewer = useImageViewer(); + const dispatch = useAppDispatch(); + const onClick = useCallback(() => { + imageViewer.open(); + dispatch(activeTabCanvasRightPanelChanged('gallery')); + }, [imageViewer, dispatch]); + return ( + + ); +}; + +export const CanvasAlertsSendingToGallery = () => { + const { t } = useTranslation(); + const destination = useCurrentDestination(); + const tab = useAppSelector(selectActiveTab); + const isVisible = useMemo(() => { + // This alert should only be visible when the destination is gallery and the tab is canvas + if (tab !== 'canvas') { + return false; + } + if (!destination) { + return false; + } + + return destination === 'gallery'; + }, [destination, tab]); + + return ( + }} /> + } + isVisible={isVisible} + /> + ); +}; + +const ActivateCanvasButton = (props: PropsWithChildren) => { + const dispatch = useAppDispatch(); + const imageViewer = useImageViewer(); + const onClick = useCallback(() => { + dispatch(setActiveTab('canvas')); + dispatch(activeTabCanvasRightPanelChanged('layers')); + imageViewer.close(); + }, [dispatch, imageViewer]); + return ( + + ); +}; + +export const CanvasAlertsSendingToCanvas = () => { + const { t } = useTranslation(); + const destination = useCurrentDestination(); + const isStaging = useAppSelector(selectIsStaging); + const tab = useAppSelector(selectActiveTab); + const isVisible = useMemo(() => { + // When we are on a non-canvas tab, and the current generation's destination is not the canvas, we don't show the alert + // For example, on the workflows tab, when the destinatin is gallery, we don't show the alert + if (tab !== 'canvas' && destination !== 'canvas') { + return false; + } + if (isStaging) { + return true; + } + + if (!destination) { + return false; + } + + return destination === 'canvas'; + }, [destination, isStaging, tab]); + + return ( + }} /> + } + isVisible={isVisible} + /> + ); +}; + +const AlertWrapper = ({ + title, + description, + isVisible, +}: { + title: ReactNode; + description: ReactNode; + isVisible: boolean; +}) => { + const isHovered = useBoolean(false); + + return ( + + {(isVisible || isHovered.isTrue) && ( + + + + + {title} + + {description} + + + )} + + ); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasAutoProcessSwitch.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAutoProcessSwitch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7137fb3b6de937963e3efefabc7b728be4d5f8a9 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasAutoProcessSwitch.tsx @@ -0,0 +1,24 @@ +import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectAutoProcess, settingsAutoProcessToggled } from 'features/controlLayers/store/canvasSettingsSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const CanvasAutoProcessSwitch = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const autoProcess = useAppSelector(selectAutoProcess); + + const onChange = useCallback(() => { + dispatch(settingsAutoProcessToggled()); + }, [dispatch]); + + return ( + + {t('controlLayers.filter.autoProcess')} + + + ); +}); + +CanvasAutoProcessSwitch.displayName = 'CanvasAutoProcessSwitch'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..11f0b0f95022f5f32dc4a2a7f3df4d757916d6dc --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems.tsx @@ -0,0 +1,75 @@ +import { Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { CanvasContextMenuItemsCropCanvasToBbox } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItemsCropCanvasToBbox'; +import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; +import { + useNewControlLayerFromBbox, + useNewGlobalReferenceImageFromBbox, + useNewRasterLayerFromBbox, + useNewRegionalReferenceImageFromBbox, + useSaveBboxToGallery, + useSaveCanvasToGallery, +} from 'features/controlLayers/hooks/saveCanvasHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFloppyDiskBold } from 'react-icons/pi'; + +export const CanvasContextMenuGlobalMenuItems = memo(() => { + const { t } = useTranslation(); + const saveSubMenu = useSubMenu(); + const newSubMenu = useSubMenu(); + const isBusy = useCanvasIsBusy(); + const saveCanvasToGallery = useSaveCanvasToGallery(); + const saveBboxToGallery = useSaveBboxToGallery(); + const newRegionalReferenceImageFromBbox = useNewRegionalReferenceImageFromBbox(); + const newGlobalReferenceImageFromBbox = useNewGlobalReferenceImageFromBbox(); + const newRasterLayerFromBbox = useNewRasterLayerFromBbox(); + const newControlLayerFromBbox = useNewControlLayerFromBbox(); + + return ( + <> + + + }> + + + + + + } isDisabled={isBusy} onClick={saveCanvasToGallery}> + {t('controlLayers.canvasContextMenu.saveCanvasToGallery')} + + } isDisabled={isBusy} onClick={saveBboxToGallery}> + {t('controlLayers.canvasContextMenu.saveBboxToGallery')} + + + + + }> + + + + + + } isDisabled={isBusy} onClick={newGlobalReferenceImageFromBbox}> + {t('controlLayers.canvasContextMenu.newGlobalReferenceImage')} + + } isDisabled={isBusy} onClick={newRegionalReferenceImageFromBbox}> + {t('controlLayers.canvasContextMenu.newRegionalReferenceImage')} + + } isDisabled={isBusy} onClick={newControlLayerFromBbox}> + {t('controlLayers.canvasContextMenu.newControlLayer')} + + } isDisabled={isBusy} onClick={newRasterLayerFromBbox}> + {t('controlLayers.canvasContextMenu.newRasterLayer')} + + + + + + + ); +}); + +CanvasContextMenuGlobalMenuItems.displayName = 'CanvasContextMenuGlobalMenuItems'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItemsCropCanvasToBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItemsCropCanvasToBbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f034ed7976a5182accb00ad6403d0fd3a94b74a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuItemsCropCanvasToBbox.tsx @@ -0,0 +1,26 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useCanvasManager } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCropBold } from 'react-icons/pi'; + +export const CanvasContextMenuItemsCropCanvasToBbox = memo(() => { + const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); + const canvasManager = useCanvasManager(); + const cropCanvasToBbox = useCallback(async () => { + const adapters = canvasManager.getAllAdapters(); + for (const adapter of adapters) { + await adapter.cropToBbox(); + } + }, [canvasManager]); + + return ( + } isDisabled={isBusy} onClick={cropCanvasToBbox}> + {t('controlLayers.canvasContextMenu.cropCanvasToBbox')} + + ); +}); + +CanvasContextMenuItemsCropCanvasToBbox.displayName = 'CanvasContextMenuItemsCropCanvasToBbox'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..57dac5cbb5996cc06ce1b3492b6e7992679d6f89 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems.tsx @@ -0,0 +1,68 @@ +import { MenuGroup } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { ControlLayerMenuItems } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItems'; +import { InpaintMaskMenuItems } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItems'; +import { IPAdapterMenuItems } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItems'; +import { RasterLayerMenuItems } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItems'; +import { RegionalGuidanceMenuItems } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems'; +import { + EntityIdentifierContext, + useEntityIdentifierContext, +} from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useEntityTypeString } from 'features/controlLayers/hooks/useEntityTypeString'; +import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import type { PropsWithChildren } from 'react'; +import { memo } from 'react'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; + +const CanvasContextMenuSelectedEntityMenuItemsContent = memo(() => { + const entityIdentifier = useEntityIdentifierContext(); + + if (entityIdentifier.type === 'raster_layer') { + return ; + } + if (entityIdentifier.type === 'control_layer') { + return ; + } + if (entityIdentifier.type === 'inpaint_mask') { + return ; + } + if (entityIdentifier.type === 'regional_guidance') { + return ; + } + if (entityIdentifier.type === 'reference_image') { + return ; + } + + assert>(false); +}); + +CanvasContextMenuSelectedEntityMenuItemsContent.displayName = 'CanvasContextMenuSelectedEntityMenuItemsContent'; + +export const CanvasContextMenuSelectedEntityMenuItems = memo(() => { + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + + if (!selectedEntityIdentifier) { + return null; + } + + return ( + + + + + + ); +}); + +CanvasContextMenuSelectedEntityMenuItems.displayName = 'CanvasContextMenuSelectedEntityMenuItems'; + +const CanvasContextMenuSelectedEntityMenuGroup = memo((props: PropsWithChildren) => { + const entityIdentifier = useEntityIdentifierContext(); + const title = useEntityTypeString(entityIdentifier.type); + + return {props.children}; +}); + +CanvasContextMenuSelectedEntityMenuGroup.displayName = 'CanvasContextMenuSelectedEntityMenuGroup'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e988ecce6837fdaae0b4d540afb7a4ed004574ae --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasDropArea.tsx @@ -0,0 +1,79 @@ +import { Grid, GridItem } from '@invoke-ai/ui-library'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { newCanvasEntityFromImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const addRasterLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ type: 'raster_layer' }); +const addControlLayerFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ + type: 'control_layer', +}); +const addRegionalGuidanceReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ + type: 'regional_guidance_with_reference_image', +}); +const addGlobalReferenceImageFromImageDndTargetData = newCanvasEntityFromImageDndTarget.getData({ + type: 'reference_image', +}); + +export const CanvasDropArea = memo(() => { + const { t } = useTranslation(); + const imageViewer = useImageViewer(); + const isBusy = useCanvasIsBusy(); + + if (imageViewer.isOpen) { + return null; + } + + return ( + <> + + + + + + + + + + + + + + + + + ); +}); + +CanvasDropArea.displayName = 'CanvasDropArea'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f38e78b44480e5f3b31890a5487432fb1b4e3682 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityContainer.tsx @@ -0,0 +1,59 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, Flex } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useCanvasEntityListDnd } from 'features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useEntityIsSelected } from 'features/controlLayers/hooks/useEntityIsSelected'; +import { entitySelected } from 'features/controlLayers/store/canvasSlice'; +import { DndListDropIndicator } from 'features/dnd/DndListDropIndicator'; +import type { PropsWithChildren } from 'react'; +import { memo, useCallback, useRef } from 'react'; + +const sx = { + position: 'relative', + flexDir: 'column', + w: 'full', + bg: 'base.850', + borderRadius: 'base', + '&[data-selected=true]': { + bg: 'base.800', + }, + '&[data-is-dragging=true]': { + opacity: 0.3, + }, + transitionProperty: 'common', +} satisfies SystemStyleObject; + +export const CanvasEntityContainer = memo((props: PropsWithChildren) => { + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext(); + const isSelected = useEntityIsSelected(entityIdentifier); + const onClick = useCallback(() => { + if (isSelected) { + return; + } + dispatch(entitySelected({ entityIdentifier })); + }, [dispatch, entityIdentifier, isSelected]); + const ref = useRef(null); + + const [dndListState, isDragging] = useCanvasEntityListDnd(ref, entityIdentifier); + + return ( + + + {props.children} + + + + ); +}); + +CanvasEntityContainer.displayName = 'CanvasEntityContainer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..07ca2093a5278c4de828fb4df1822e02018900b8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList.tsx @@ -0,0 +1,181 @@ +import { monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import { reorderWithEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/util/reorder-with-edge'; +import { Button, Collapse, Flex, Icon, Spacer, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import { useBoolean } from 'common/hooks/useBoolean'; +import { colorTokenToCssVar } from 'common/util/colorTokenToCssVar'; +import { fixTooltipCloseOnScrollStyles } from 'common/util/fixTooltipCloseOnScrollStyles'; +import { CanvasEntityAddOfTypeButton } from 'features/controlLayers/components/common/CanvasEntityAddOfTypeButton'; +import { CanvasEntityMergeVisibleButton } from 'features/controlLayers/components/common/CanvasEntityMergeVisibleButton'; +import { CanvasEntityTypeIsHiddenToggle } from 'features/controlLayers/components/common/CanvasEntityTypeIsHiddenToggle'; +import { useEntityTypeInformationalPopover } from 'features/controlLayers/hooks/useEntityTypeInformationalPopover'; +import { useEntityTypeTitle } from 'features/controlLayers/hooks/useEntityTypeTitle'; +import { entitiesReordered } from 'features/controlLayers/store/canvasSlice'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { isRenderableEntityType } from 'features/controlLayers/store/types'; +import { singleCanvasEntityDndSource } from 'features/dnd/dnd'; +import { triggerPostMoveFlash } from 'features/dnd/util'; +import type { PropsWithChildren } from 'react'; +import { memo, useEffect } from 'react'; +import { flushSync } from 'react-dom'; +import { PiCaretDownBold } from 'react-icons/pi'; + +type Props = PropsWithChildren<{ + isSelected: boolean; + type: CanvasEntityIdentifier['type']; + entityIdentifiers: CanvasEntityIdentifier[]; +}>; + +export const CanvasEntityGroupList = memo(({ isSelected, type, children, entityIdentifiers }: Props) => { + const title = useEntityTypeTitle(type); + const informationalPopoverFeature = useEntityTypeInformationalPopover(type); + const collapse = useBoolean(true); + const dispatch = useAppDispatch(); + + useEffect(() => { + return monitorForElements({ + canMonitor({ source }) { + if (!singleCanvasEntityDndSource.typeGuard(source.data)) { + return false; + } + if (source.data.payload.entityIdentifier.type !== type) { + return false; + } + return true; + }, + onDrop({ location, source }) { + const target = location.current.dropTargets[0]; + if (!target) { + return; + } + + const sourceData = source.data; + const targetData = target.data; + + if (!singleCanvasEntityDndSource.typeGuard(sourceData) || !singleCanvasEntityDndSource.typeGuard(targetData)) { + return; + } + + const indexOfSource = entityIdentifiers.findIndex( + (entityIdentifier) => entityIdentifier.id === sourceData.payload.entityIdentifier.id + ); + const indexOfTarget = entityIdentifiers.findIndex( + (entityIdentifier) => entityIdentifier.id === targetData.payload.entityIdentifier.id + ); + + if (indexOfTarget < 0 || indexOfSource < 0) { + return; + } + + // Don't move if the source and target are the same index, meaning same position in the list + if (indexOfSource === indexOfTarget) { + return; + } + + const closestEdgeOfTarget = extractClosestEdge(targetData); + + // It's possible that the indices are different, but refer to the same position. For example, if the source is + // at 2 and the target is at 3, but the target edge is 'top', then the entity is already in the correct position. + // We should bail if this is the case. + let edgeIndexDelta = 0; + + if (closestEdgeOfTarget === 'bottom') { + edgeIndexDelta = 1; + } else if (closestEdgeOfTarget === 'top') { + edgeIndexDelta = -1; + } + + // If the source is already in the correct position, we don't need to move it. + if (indexOfSource === indexOfTarget + edgeIndexDelta) { + return; + } + + // Using `flushSync` so we can query the DOM straight after this line + flushSync(() => { + dispatch( + entitiesReordered({ + type, + entityIdentifiers: reorderWithEdge({ + list: entityIdentifiers, + startIndex: indexOfSource, + indexOfTarget, + closestEdgeOfTarget, + axis: 'vertical', + }), + }) + ); + }); + + // Flash the element that was moved + const element = document.querySelector(`[data-entity-id="${sourceData.payload.entityIdentifier.id}"]`); + if (element instanceof HTMLElement) { + triggerPostMoveFlash(element, colorTokenToCssVar('base.700')); + } + }, + }); + }, [dispatch, entityIdentifiers, type]); + + return ( + + + + + {informationalPopoverFeature ? ( + + + {title} + + + ) : ( + + {title} + + )} + + + + {isRenderableEntityType(type) && } + {isRenderableEntityType(type) && } + + + + + {children} + + + + ); +}); + +CanvasEntityGroupList.displayName = 'CanvasEntityGroupList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e63641dab35969ac9c01ce3a1b3d9515e9b363ab --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/CanvasEntityList.tsx @@ -0,0 +1,24 @@ +import { Flex } from '@invoke-ai/ui-library'; +import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; +import { ControlLayerEntityList } from 'features/controlLayers/components/ControlLayer/ControlLayerEntityList'; +import { InpaintMaskList } from 'features/controlLayers/components/InpaintMask/InpaintMaskList'; +import { IPAdapterList } from 'features/controlLayers/components/IPAdapter/IPAdapterList'; +import { RasterLayerEntityList } from 'features/controlLayers/components/RasterLayer/RasterLayerEntityList'; +import { RegionalGuidanceEntityList } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList'; +import { memo } from 'react'; + +export const CanvasEntityList = memo(() => { + return ( + + + + + + + + + + ); +}); + +CanvasEntityList.displayName = 'CanvasEntityList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..70623f54b753f0c3abe7fc9b55b156da32fe6598 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu.tsx @@ -0,0 +1,72 @@ +import { IconButton, Menu, MenuButton, MenuGroup, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { + useAddControlLayer, + useAddGlobalReferenceImage, + useAddInpaintMask, + useAddRasterLayer, + useAddRegionalGuidance, + useAddRegionalReferenceImage, +} from 'features/controlLayers/hooks/addLayerHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { selectIsFLUX, selectIsSD3 } from 'features/controlLayers/store/paramsSlice'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiPlusBold } from 'react-icons/pi'; + +export const EntityListGlobalActionBarAddLayerMenu = memo(() => { + const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); + const addGlobalReferenceImage = useAddGlobalReferenceImage(); + const addInpaintMask = useAddInpaintMask(); + const addRegionalGuidance = useAddRegionalGuidance(); + const addRegionalReferenceImage = useAddRegionalReferenceImage(); + const addRasterLayer = useAddRasterLayer(); + const addControlLayer = useAddControlLayer(); + const isFLUX = useAppSelector(selectIsFLUX); + const isSD3 = useAppSelector(selectIsSD3); + + return ( + + } + data-testid="control-layers-add-layer-menu-button" + isDisabled={isBusy} + /> + + + } onClick={addGlobalReferenceImage} isDisabled={isSD3}> + {t('controlLayers.globalReferenceImage')} + + + + } onClick={addInpaintMask}> + {t('controlLayers.inpaintMask')} + + } onClick={addRegionalGuidance} isDisabled={isFLUX || isSD3}> + {t('controlLayers.regionalGuidance')} + + } onClick={addRegionalReferenceImage} isDisabled={isFLUX || isSD3}> + {t('controlLayers.regionalReferenceImage')} + + + + } onClick={addControlLayer} isDisabled={isSD3}> + {t('controlLayers.controlLayer')} + + } onClick={addRasterLayer}> + {t('controlLayers.rasterLayer')} + + + + + ); +}); + +EntityListGlobalActionBarAddLayerMenu.displayName = 'EntityListGlobalActionBarAddLayerMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2ec01041e29de0925c58e18f2ccc110d4e60fa77 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar.tsx @@ -0,0 +1,31 @@ +import { Flex, Spacer } from '@invoke-ai/ui-library'; +import { EntityListGlobalActionBarAddLayerMenu } from 'features/controlLayers/components/CanvasEntityList/EntityListGlobalActionBarAddLayerMenu'; +import { EntityListSelectedEntityActionBarDuplicateButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton'; +import { EntityListSelectedEntityActionBarFill } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill'; +import { EntityListSelectedEntityActionBarFilterButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton'; +import { EntityListSelectedEntityActionBarOpacity } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity'; +import { EntityListSelectedEntityActionBarSelectObjectButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton'; +import { EntityListSelectedEntityActionBarTransformButton } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton'; +import { memo } from 'react'; + +import { EntityListSelectedEntityActionBarSaveToAssetsButton } from './EntityListSelectedEntityActionBarSaveToAssetsButton'; + +export const EntityListSelectedEntityActionBar = memo(() => { + return ( + + + + + + + + + + + + + + ); +}); + +EntityListSelectedEntityActionBar.displayName = 'EntityListSelectedEntityActionBar'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2e2f5fa20a43a88095d92d4319fc38273ed046e2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarDuplicateButton.tsx @@ -0,0 +1,36 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { entityDuplicated } from 'features/controlLayers/store/canvasSlice'; +import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCopyFill } from 'react-icons/pi'; + +export const EntityListSelectedEntityActionBarDuplicateButton = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const isBusy = useCanvasIsBusy(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const onClick = useCallback(() => { + if (!selectedEntityIdentifier) { + return; + } + dispatch(entityDuplicated({ entityIdentifier: selectedEntityIdentifier })); + }, [dispatch, selectedEntityIdentifier]); + + return ( + } + /> + ); +}); + +EntityListSelectedEntityActionBarDuplicateButton.displayName = 'EntityListSelectedEntityActionBarDuplicateButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3004ddc0b8ebf39698c9697cc665164ccfd94a02 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFill.tsx @@ -0,0 +1,77 @@ +import { Box, Flex, Popover, PopoverBody, PopoverContent, PopoverTrigger, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import RgbColorPicker from 'common/components/ColorPicker/RgbColorPicker'; +import { rgbColorToString } from 'common/util/colorCodeTransformers'; +import { MaskFillStyle } from 'features/controlLayers/components/common/MaskFillStyle'; +import { entityFillColorChanged, entityFillStyleChanged } from 'features/controlLayers/store/canvasSlice'; +import { selectSelectedEntityFill, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { type FillStyle, isMaskEntityIdentifier, type RgbColor } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const EntityListSelectedEntityActionBarFill = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const fill = useAppSelector(selectSelectedEntityFill); + + const onChangeFillColor = useCallback( + (color: RgbColor) => { + if (!selectedEntityIdentifier) { + return; + } + if (!isMaskEntityIdentifier(selectedEntityIdentifier)) { + return; + } + dispatch(entityFillColorChanged({ entityIdentifier: selectedEntityIdentifier, color })); + }, + [dispatch, selectedEntityIdentifier] + ); + const onChangeFillStyle = useCallback( + (style: FillStyle) => { + if (!selectedEntityIdentifier) { + return; + } + if (!isMaskEntityIdentifier(selectedEntityIdentifier)) { + return; + } + dispatch(entityFillStyleChanged({ entityIdentifier: selectedEntityIdentifier, style })); + }, + [dispatch, selectedEntityIdentifier] + ); + + if (!selectedEntityIdentifier || !fill) { + return null; + } + + return ( + + + + + + + + + + + + + + + + + + + + ); +}); + +EntityListSelectedEntityActionBarFill.displayName = 'EntityListSelectedEntityActionBarFill'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bb4e809b4d1994421d249391381848df247c521b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarFilterButton.tsx @@ -0,0 +1,37 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityFilter } from 'features/controlLayers/hooks/useEntityFilter'; +import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { isFilterableEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiShootingStarFill } from 'react-icons/pi'; + +export const EntityListSelectedEntityActionBarFilterButton = memo(() => { + const { t } = useTranslation(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const filter = useEntityFilter(selectedEntityIdentifier); + + if (!selectedEntityIdentifier) { + return null; + } + + if (!isFilterableEntityIdentifier(selectedEntityIdentifier)) { + return null; + } + + return ( + } + /> + ); +}); + +EntityListSelectedEntityActionBarFilterButton.displayName = 'EntityListSelectedEntityActionBarFilterButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e59e09dfa1429880f5266aaca1726ece879cb919 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarOpacity.tsx @@ -0,0 +1,196 @@ +import { + $shift, + CompositeSlider, + FormControl, + FormLabel, + IconButton, + NumberInput, + NumberInputField, + Popover, + PopoverAnchor, + PopoverArrow, + PopoverBody, + PopoverContent, + PopoverTrigger, +} from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { snapToNearest } from 'features/controlLayers/konva/util'; +import { entityOpacityChanged } from 'features/controlLayers/store/canvasSlice'; +import { + selectCanvasSlice, + selectEntity, + selectSelectedEntityIdentifier, +} from 'features/controlLayers/store/selectors'; +import { isRenderableEntity } from 'features/controlLayers/store/types'; +import { clamp, round } from 'lodash-es'; +import type { KeyboardEvent } from 'react'; +import { memo, useCallback, useEffect, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretDownBold } from 'react-icons/pi'; + +function formatPct(v: number | string) { + if (isNaN(Number(v))) { + return ''; + } + + return `${round(Number(v), 2).toLocaleString()}%`; +} + +function mapSliderValueToRawValue(value: number) { + return value / 100; +} + +function mapRawValueToSliderValue(opacity: number) { + return opacity * 100; +} + +function formatSliderValue(value: number) { + return String(value); +} + +const marks = [ + mapRawValueToSliderValue(0), + mapRawValueToSliderValue(0.25), + mapRawValueToSliderValue(0.5), + mapRawValueToSliderValue(0.75), + mapRawValueToSliderValue(1), +]; + +const sliderDefaultValue = mapRawValueToSliderValue(1); + +const snapCandidates = marks.slice(1, marks.length - 1); + +const selectOpacity = createSelector(selectCanvasSlice, (canvas) => { + const selectedEntityIdentifier = canvas.selectedEntityIdentifier; + if (!selectedEntityIdentifier) { + return 1; // fallback to 100% opacity + } + const selectedEntity = selectEntity(canvas, selectedEntityIdentifier); + if (!selectedEntity) { + return 1; // fallback to 100% opacity + } + if (!isRenderableEntity(selectedEntity)) { + return 1; // fallback to 100% opacity + } + // Opacity is a float from 0-1, but we want to display it as a percentage + return selectedEntity.opacity; +}); + +export const EntityListSelectedEntityActionBarOpacity = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const opacity = useAppSelector(selectOpacity); + + const [localOpacity, setLocalOpacity] = useState(opacity * 100); + + const onChangeSlider = useCallback( + (opacity: number) => { + if (!selectedEntityIdentifier) { + return; + } + let snappedOpacity = opacity; + // Do not snap if shift key is held + if (!$shift.get()) { + snappedOpacity = snapToNearest(opacity, snapCandidates, 2); + } + const mappedOpacity = mapSliderValueToRawValue(snappedOpacity); + + dispatch(entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: mappedOpacity })); + }, + [dispatch, selectedEntityIdentifier] + ); + + const onBlur = useCallback(() => { + if (!selectedEntityIdentifier) { + return; + } + if (isNaN(Number(localOpacity))) { + setLocalOpacity(100); + return; + } + dispatch( + entityOpacityChanged({ entityIdentifier: selectedEntityIdentifier, opacity: clamp(localOpacity / 100, 0, 1) }) + ); + }, [dispatch, localOpacity, selectedEntityIdentifier]); + + const onChangeNumberInput = useCallback((valueAsString: string, valueAsNumber: number) => { + setLocalOpacity(valueAsNumber); + }, []); + + const onKeyDown = useCallback( + (e: KeyboardEvent) => { + if (e.key === 'Enter') { + onBlur(); + } + }, + [onBlur] + ); + + useEffect(() => { + setLocalOpacity((opacity ?? 1) * 100); + }, [opacity]); + + return ( + + + {t('controlLayers.opacity')} + + + + + } + size="sm" + variant="link" + position="absolute" + insetInlineEnd={0} + h="full" + isDisabled={selectedEntityIdentifier === null || selectedEntityIdentifier.type === 'reference_image'} + /> + + + + + + + + + + + + ); +}); + +EntityListSelectedEntityActionBarOpacity.displayName = 'EntityListSelectedEntityActionBarOpacity'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSaveToAssetsButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSaveToAssetsButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bf222a5f5d071ba2623b4f7bebaa1d5d4711a576 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSaveToAssetsButton.tsx @@ -0,0 +1,44 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityAdapterSafe } from 'features/controlLayers/contexts/EntityAdapterContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useSaveLayerToAssets } from 'features/controlLayers/hooks/useSaveLayerToAssets'; +import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { isSaveableEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFloppyDiskBold } from 'react-icons/pi'; + +export const EntityListSelectedEntityActionBarSaveToAssetsButton = memo(() => { + const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const adapter = useEntityAdapterSafe(selectedEntityIdentifier); + const saveLayerToAssets = useSaveLayerToAssets(); + const onClick = useCallback(() => { + saveLayerToAssets(adapter); + }, [saveLayerToAssets, adapter]); + + if (!selectedEntityIdentifier) { + return null; + } + + if (!isSaveableEntityIdentifier(selectedEntityIdentifier)) { + return null; + } + + return ( + } + /> + ); +}); + +EntityListSelectedEntityActionBarSaveToAssetsButton.displayName = 'EntityListSelectedEntityActionBarSaveToAssetsButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ca053c704d375223a57311bc212cf1868df886ff --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarSelectObjectButton.tsx @@ -0,0 +1,37 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntitySegmentAnything } from 'features/controlLayers/hooks/useEntitySegmentAnything'; +import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { isSegmentableEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiShapesFill } from 'react-icons/pi'; + +export const EntityListSelectedEntityActionBarSelectObjectButton = memo(() => { + const { t } = useTranslation(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const segment = useEntitySegmentAnything(selectedEntityIdentifier); + + if (!selectedEntityIdentifier) { + return null; + } + + if (!isSegmentableEntityIdentifier(selectedEntityIdentifier)) { + return null; + } + + return ( + } + /> + ); +}); + +EntityListSelectedEntityActionBarSelectObjectButton.displayName = 'EntityListSelectedEntityActionBarSelectObjectButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0b1009ea0e96b70d686fa18e448a4f30e184d6d5 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBarTransformButton.tsx @@ -0,0 +1,37 @@ +import { IconButton } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityTransform } from 'features/controlLayers/hooks/useEntityTransform'; +import { selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { isTransformableEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFrameCornersBold } from 'react-icons/pi'; + +export const EntityListSelectedEntityActionBarTransformButton = memo(() => { + const { t } = useTranslation(); + const selectedEntityIdentifier = useAppSelector(selectSelectedEntityIdentifier); + const transform = useEntityTransform(selectedEntityIdentifier); + + if (!selectedEntityIdentifier) { + return null; + } + + if (!isTransformableEntityIdentifier(selectedEntityIdentifier)) { + return null; + } + + return ( + } + /> + ); +}); + +EntityListSelectedEntityActionBarTransformButton.displayName = 'EntityListSelectedEntityActionBarTransformButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts new file mode 100644 index 0000000000000000000000000000000000000000..a036448cd19e2a622406194611763ac2f5a4843b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasEntityList/useCanvasEntityListDnd.ts @@ -0,0 +1,85 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, dropTargetForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { attachClosestEdge, extractClosestEdge } from '@atlaskit/pragmatic-drag-and-drop-hitbox/closest-edge'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { singleCanvasEntityDndSource } from 'features/dnd/dnd'; +import { type DndListTargetState, idle } from 'features/dnd/types'; +import { firefoxDndFix } from 'features/dnd/util'; +import type { RefObject } from 'react'; +import { useEffect, useState } from 'react'; + +export const useCanvasEntityListDnd = (ref: RefObject, entityIdentifier: CanvasEntityIdentifier) => { + const [dndListState, setDndListState] = useState(idle); + const [isDragging, setIsDragging] = useState(false); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return combine( + firefoxDndFix(element), + draggable({ + element, + getInitialData() { + return singleCanvasEntityDndSource.getData({ entityIdentifier }); + }, + onDragStart() { + setDndListState({ type: 'is-dragging' }); + setIsDragging(true); + }, + onDrop() { + setDndListState(idle); + setIsDragging(false); + }, + }), + dropTargetForElements({ + element, + canDrop({ source }) { + if (!singleCanvasEntityDndSource.typeGuard(source.data)) { + return false; + } + if (source.data.payload.entityIdentifier.type !== entityIdentifier.type) { + return false; + } + return true; + }, + getData({ input }) { + const data = singleCanvasEntityDndSource.getData({ entityIdentifier }); + return attachClosestEdge(data, { + element, + input, + allowedEdges: ['top', 'bottom'], + }); + }, + getIsSticky() { + return true; + }, + onDragEnter({ self }) { + const closestEdge = extractClosestEdge(self.data); + setDndListState({ type: 'is-dragging-over', closestEdge }); + }, + onDrag({ self }) { + const closestEdge = extractClosestEdge(self.data); + + // Only need to update react state if nothing has changed. + // Prevents re-rendering. + setDndListState((current) => { + if (current.type === 'is-dragging-over' && current.closestEdge === closestEdge) { + return current; + } + return { type: 'is-dragging-over', closestEdge }; + }); + }, + onDragLeave() { + setDndListState(idle); + }, + onDrop() { + setDndListState(idle); + }, + }) + ); + }, [entityIdentifier, ref]); + + return [dndListState, isDragging] as const; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bed685d642f64fd83b0bc60674df82ecce932c42 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasLayersPanelContent.tsx @@ -0,0 +1,29 @@ +import { Divider, Flex } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useFocusRegion } from 'common/hooks/focus'; +import { CanvasAddEntityButtons } from 'features/controlLayers/components/CanvasAddEntityButtons'; +import { CanvasEntityList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityList'; +import { EntityListSelectedEntityActionBar } from 'features/controlLayers/components/CanvasEntityList/EntityListSelectedEntityActionBar'; +import { selectHasEntities } from 'features/controlLayers/store/selectors'; +import { memo, useRef } from 'react'; + +import { ParamDenoisingStrength } from './ParamDenoisingStrength'; + +export const CanvasLayersPanelContent = memo(() => { + const hasEntities = useAppSelector(selectHasEntities); + const layersPanelFocusRef = useRef(null); + useFocusRegion('layers', layersPanelFocusRef); + + return ( + + + + + + {!hasEntities && } + {hasEntities && } + + ); +}); + +CanvasLayersPanelContent.displayName = 'CanvasLayersPanelContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..18fd8d983db5c7fd0d7c383292d938e9bbca7dc3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasMainPanelContent.tsx @@ -0,0 +1,123 @@ +import { ContextMenu, Flex, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useFocusRegion } from 'common/hooks/focus'; +import { CanvasAlertsPreserveMask } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsPreserveMask'; +import { CanvasAlertsSelectedEntityStatus } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSelectedEntityStatus'; +import { CanvasAlertsSendingToGallery } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsSendingTo'; +import { CanvasContextMenuGlobalMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuGlobalMenuItems'; +import { CanvasContextMenuSelectedEntityMenuItems } from 'features/controlLayers/components/CanvasContextMenu/CanvasContextMenuSelectedEntityMenuItems'; +import { CanvasDropArea } from 'features/controlLayers/components/CanvasDropArea'; +import { Filter } from 'features/controlLayers/components/Filters/Filter'; +import { CanvasHUD } from 'features/controlLayers/components/HUD/CanvasHUD'; +import { InvokeCanvasComponent } from 'features/controlLayers/components/InvokeCanvasComponent'; +import { SelectObject } from 'features/controlLayers/components/SelectObject/SelectObject'; +import { StagingAreaIsStagingGate } from 'features/controlLayers/components/StagingArea/StagingAreaIsStagingGate'; +import { StagingAreaToolbar } from 'features/controlLayers/components/StagingArea/StagingAreaToolbar'; +import { CanvasToolbar } from 'features/controlLayers/components/Toolbar/CanvasToolbar'; +import { Transform } from 'features/controlLayers/components/Transform/Transform'; +import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { selectDynamicGrid, selectShowHUD } from 'features/controlLayers/store/canvasSettingsSlice'; +import { GatedImageViewer } from 'features/gallery/components/ImageViewer/ImageViewer'; +import { memo, useCallback, useRef } from 'react'; +import { PiDotsThreeOutlineVerticalFill } from 'react-icons/pi'; + +import { CanvasAlertsInvocationProgress } from './CanvasAlerts/CanvasAlertsInvocationProgress'; + +const MenuContent = () => { + return ( + + + + + + + ); +}; + +export const CanvasMainPanelContent = memo(() => { + const ref = useRef(null); + const dynamicGrid = useAppSelector(selectDynamicGrid); + const showHUD = useAppSelector(selectShowHUD); + + const renderMenu = useCallback(() => { + return ; + }, []); + + useFocusRegion('canvas', ref); + + return ( + + + + + renderMenu={renderMenu} withLongPress={false}> + {(ref) => ( + + + + + {showHUD && } + + + + + + + + } colorScheme="base" /> + + + + + + )} + + + + + + + + + + + + + + + + + + + + + ); +}); + +CanvasMainPanelContent.displayName = 'CanvasMainPanelContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasOperationIsolatedLayerPreviewSwitch.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasOperationIsolatedLayerPreviewSwitch.tsx new file mode 100644 index 0000000000000000000000000000000000000000..13a153634863282a3fc5cbe8a572c90e5eb941ec --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasOperationIsolatedLayerPreviewSwitch.tsx @@ -0,0 +1,28 @@ +import { FormControl, FormLabel, Switch, Tooltip } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + selectIsolatedLayerPreview, + settingsIsolatedLayerPreviewToggled, +} from 'features/controlLayers/store/canvasSettingsSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const CanvasOperationIsolatedLayerPreviewSwitch = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const isolatedLayerPreview = useAppSelector(selectIsolatedLayerPreview); + const onChangeIsolatedPreview = useCallback(() => { + dispatch(settingsIsolatedLayerPreviewToggled()); + }, [dispatch]); + + return ( + + + {t('controlLayers.settings.isolatedPreview')} + + + + ); +}); + +CanvasOperationIsolatedLayerPreviewSwitch.displayName = 'CanvasOperationIsolatedLayerPreviewSwitch'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ac0c6690057c480557325db8aedaf896ddd0e4b7 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/CanvasRightPanel.tsx @@ -0,0 +1,272 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { dropTargetForElements, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { dropTargetForExternal, monitorForExternal } from '@atlaskit/pragmatic-drag-and-drop/external/adapter'; +import { Box, Button, Spacer, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { CanvasLayersPanelContent } from 'features/controlLayers/components/CanvasLayersPanelContent'; +import { CanvasManagerProviderGate } from 'features/controlLayers/contexts/CanvasManagerProviderGate'; +import { selectEntityCountActive } from 'features/controlLayers/store/selectors'; +import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; +import { DndDropOverlay } from 'features/dnd/DndDropOverlay'; +import type { DndTargetState } from 'features/dnd/types'; +import GalleryPanelContent from 'features/gallery/components/GalleryPanelContent'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { selectActiveTabCanvasRightPanel } from 'features/ui/store/uiSelectors'; +import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const CanvasRightPanel = memo(() => { + const { t } = useTranslation(); + const activeTab = useAppSelector(selectActiveTabCanvasRightPanel); + const imageViewer = useImageViewer(); + const dispatch = useAppDispatch(); + + const tabIndex = useMemo(() => { + if (activeTab === 'gallery') { + return 1; + } else { + return 0; + } + }, [activeTab]); + + const onClickViewerToggleButton = useCallback(() => { + if (activeTab !== 'gallery') { + dispatch(activeTabCanvasRightPanelChanged('gallery')); + } + imageViewer.toggle(); + }, [imageViewer, activeTab, dispatch]); + + const onChangeTab = useCallback( + (index: number) => { + if (index === 0) { + dispatch(activeTabCanvasRightPanelChanged('layers')); + } else { + dispatch(activeTabCanvasRightPanelChanged('gallery')); + } + }, + [dispatch] + ); + + useRegisteredHotkeys({ + id: 'toggleViewer', + category: 'viewer', + callback: imageViewer.toggle, + dependencies: [imageViewer], + }); + + return ( + + + + + + + + + + + + + + + + + + ); +}); + +CanvasRightPanel.displayName = 'CanvasRightPanel'; + +const PanelTabs = memo(() => { + const { t } = useTranslation(); + const store = useAppStore(); + const activeEntityCount = useAppSelector(selectEntityCountActive); + const [layersTabDndState, setLayersTabDndState] = useState('idle'); + const [galleryTabDndState, setGalleryTabDndState] = useState('idle'); + const layersTabRef = useRef(null); + const galleryTabRef = useRef(null); + const timeoutRef = useRef(null); + + const layersTabLabel = useMemo(() => { + if (activeEntityCount === 0) { + return t('controlLayers.layer_other'); + } + return `${t('controlLayers.layer_other')} (${activeEntityCount})`; + }, [activeEntityCount, t]); + + useEffect(() => { + if (!layersTabRef.current) { + return; + } + + const getIsOnLayersTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'layers'; + + const onDragEnter = () => { + // If we are already on the layers tab, do nothing + if (getIsOnLayersTab()) { + return; + } + + // Else set the state to active and switch to the layers tab after a timeout + setLayersTabDndState('over'); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + store.dispatch(activeTabCanvasRightPanelChanged('layers')); + // When we switch tabs, the other tab should be pending + setLayersTabDndState('idle'); + setGalleryTabDndState('potential'); + }, 300); + }; + const onDragLeave = () => { + // Set the state to idle or pending depending on the current tab + if (getIsOnLayersTab()) { + setLayersTabDndState('idle'); + } else { + setLayersTabDndState('potential'); + } + // Abort the tab switch if it hasn't happened yet + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + const onDragStart = () => { + // Set the state to pending when a drag starts + setLayersTabDndState('potential'); + }; + return combine( + dropTargetForElements({ + element: layersTabRef.current, + onDragEnter, + onDragLeave, + }), + monitorForElements({ + canMonitor: ({ source }) => { + if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { + return false; + } + // Only monitor if we are not already on the gallery tab + return !getIsOnLayersTab(); + }, + onDragStart, + }), + dropTargetForExternal({ + element: layersTabRef.current, + onDragEnter, + onDragLeave, + }), + monitorForExternal({ + canMonitor: () => !getIsOnLayersTab(), + onDragStart, + }) + ); + }, [store]); + + useEffect(() => { + if (!galleryTabRef.current) { + return; + } + + const getIsOnGalleryTab = () => selectActiveTabCanvasRightPanel(store.getState()) === 'gallery'; + + const onDragEnter = () => { + // If we are already on the gallery tab, do nothing + if (getIsOnGalleryTab()) { + return; + } + + // Else set the state to active and switch to the gallery tab after a timeout + setGalleryTabDndState('over'); + timeoutRef.current = window.setTimeout(() => { + timeoutRef.current = null; + store.dispatch(activeTabCanvasRightPanelChanged('gallery')); + // When we switch tabs, the other tab should be pending + setGalleryTabDndState('idle'); + setLayersTabDndState('potential'); + }, 300); + }; + + const onDragLeave = () => { + // Set the state to idle or pending depending on the current tab + if (getIsOnGalleryTab()) { + setGalleryTabDndState('idle'); + } else { + setGalleryTabDndState('potential'); + } + // Abort the tab switch if it hasn't happened yet + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + + const onDragStart = () => { + // Set the state to pending when a drag starts + setGalleryTabDndState('potential'); + }; + + return combine( + dropTargetForElements({ + element: galleryTabRef.current, + onDragEnter, + onDragLeave, + }), + monitorForElements({ + canMonitor: ({ source }) => { + if (!singleImageDndSource.typeGuard(source.data) && !multipleImageDndSource.typeGuard(source.data)) { + return false; + } + // Only monitor if we are not already on the gallery tab + return !getIsOnGalleryTab(); + }, + onDragStart, + }), + dropTargetForExternal({ + element: galleryTabRef.current, + onDragEnter, + onDragLeave, + }), + monitorForExternal({ + canMonitor: () => !getIsOnGalleryTab(), + onDragStart, + }) + ); + }, [store]); + + useEffect(() => { + const onDrop = () => { + // Reset the dnd state when a drop happens + setGalleryTabDndState('idle'); + setLayersTabDndState('idle'); + }; + const cleanup = combine(monitorForElements({ onDrop }), monitorForExternal({ onDrop })); + + return () => { + cleanup(); + if (timeoutRef.current !== null) { + clearTimeout(timeoutRef.current); + } + }; + }, []); + + return ( + <> + + + {layersTabLabel} + + + + + + {t('gallery.gallery')} + + + + + ); +}); + +PanelTabs.displayName = 'PanelTabs'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9c40863166d248f3af15df871000751637a7f446 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayer.tsx @@ -0,0 +1,62 @@ +import { Spacer } from '@invoke-ai/ui-library'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; +import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; +import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; +import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; +import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper'; +import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; +import { ControlLayerBadges } from 'features/controlLayers/components/ControlLayer/ControlLayerBadges'; +import { ControlLayerSettings } from 'features/controlLayers/components/ControlLayer/ControlLayerSettings'; +import { ControlLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; +import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { ReplaceCanvasEntityObjectsWithImageDndTargetData } from 'features/dnd/dnd'; +import { replaceCanvasEntityObjectsWithImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { + id: string; +}; + +export const ControlLayer = memo(({ id }: Props) => { + const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); + const entityIdentifier = useMemo>( + () => ({ id, type: 'control_layer' }), + [id] + ); + const dndTargetData = useMemo( + () => replaceCanvasEntityObjectsWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), + [entityIdentifier] + ); + + return ( + + + + + + + + + + + + + + + + + + ); +}); + +ControlLayer.displayName = 'ControlLayer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerBadges.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerBadges.tsx new file mode 100644 index 0000000000000000000000000000000000000000..126d8fabbbfa919a4da62b2b6fe22463da04fcbc --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerBadges.tsx @@ -0,0 +1,26 @@ +import { Badge } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const ControlLayerBadges = memo(() => { + const entityIdentifier = useEntityIdentifierContext('control_layer'); + const { t } = useTranslation(); + const withTransparencyEffect = useAppSelector( + (s) => selectEntityOrThrow(selectCanvasSlice(s), entityIdentifier).withTransparencyEffect + ); + + return ( + <> + {withTransparencyEffect && ( + + {t('controlLayers.transparency')} + + )} + + ); +}); + +ControlLayerBadges.displayName = 'ControlLayerBadges'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b8ba7173c767a99ff533a3ce7a800934635f35fe --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapter.tsx @@ -0,0 +1,178 @@ +import { Flex, IconButton } from '@invoke-ai/ui-library'; +import { createMemoizedAppSelector } from 'app/store/createMemoizedSelector'; +import { useAppStore } from 'app/store/nanostores/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; +import { Weight } from 'features/controlLayers/components/common/Weight'; +import { ControlLayerControlAdapterControlMode } from 'features/controlLayers/components/ControlLayer/ControlLayerControlAdapterControlMode'; +import { ControlLayerControlAdapterModel } from 'features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel'; +import { useEntityAdapterContext } from 'features/controlLayers/contexts/EntityAdapterContext'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { usePullBboxIntoLayer } from 'features/controlLayers/hooks/saveCanvasHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityFilter } from 'features/controlLayers/hooks/useEntityFilter'; +import { + controlLayerBeginEndStepPctChanged, + controlLayerControlModeChanged, + controlLayerModelChanged, + controlLayerWeightChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { getFilterForModel } from 'features/controlLayers/store/filters'; +import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import type { CanvasEntityIdentifier, ControlModeV2 } from 'features/controlLayers/store/types'; +import { replaceCanvasEntityObjectsWithImage } from 'features/imageActions/actions'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiBoundingBoxBold, PiShootingStarFill, PiUploadBold } from 'react-icons/pi'; +import type { ControlNetModelConfig, ImageDTO, T2IAdapterModelConfig } from 'services/api/types'; + +const useControlLayerControlAdapter = (entityIdentifier: CanvasEntityIdentifier<'control_layer'>) => { + const selectControlAdapter = useMemo( + () => + createMemoizedAppSelector(selectCanvasSlice, (canvas) => { + const layer = selectEntityOrThrow(canvas, entityIdentifier); + return layer.controlAdapter; + }), + [entityIdentifier] + ); + const controlAdapter = useAppSelector(selectControlAdapter); + return controlAdapter; +}; + +export const ControlLayerControlAdapter = memo(() => { + const { t } = useTranslation(); + const { dispatch, getState } = useAppStore(); + const entityIdentifier = useEntityIdentifierContext('control_layer'); + const controlAdapter = useControlLayerControlAdapter(entityIdentifier); + const filter = useEntityFilter(entityIdentifier); + const isFLUX = useAppSelector(selectIsFLUX); + const adapter = useEntityAdapterContext('control_layer'); + + const onChangeBeginEndStepPct = useCallback( + (beginEndStepPct: [number, number]) => { + dispatch(controlLayerBeginEndStepPctChanged({ entityIdentifier, beginEndStepPct })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeControlMode = useCallback( + (controlMode: ControlModeV2) => { + dispatch(controlLayerControlModeChanged({ entityIdentifier, controlMode })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeWeight = useCallback( + (weight: number) => { + dispatch(controlLayerWeightChanged({ entityIdentifier, weight })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeModel = useCallback( + (modelConfig: ControlNetModelConfig | T2IAdapterModelConfig) => { + dispatch(controlLayerModelChanged({ entityIdentifier, modelConfig })); + // When we change the model, we need may need to start filtering w/ the simplified filter mode, and/or change the + // filter config. + const isFiltering = adapter.filterer.$isFiltering.get(); + const isSimple = adapter.filterer.$simple.get(); + // If we are filtering and _not_ in simple mode, that means the user has clicked Advanced. They want to be in control + // of the settings. Bail early without doing anything else. + if (isFiltering && !isSimple) { + return; + } + + // Else, we are in simple mode and will take care of some things for the user. + + // First, check if the newly-selected model has a default filter. It may not - for example, Tile controlnet models + // don't have a default filter. + const defaultFilterForNewModel = getFilterForModel(modelConfig); + + if (!defaultFilterForNewModel) { + // The user has chosen a model that doesn't have a default filter - cancel any in-progress filtering and bail. + if (isFiltering) { + adapter.filterer.cancel(); + } + return; + } + + // At this point, we know the user has selected a model that has a default filter. We need to either start filtering + // with that default filter, or update the existing filter config to match the new model's default filter. + const filterConfig = defaultFilterForNewModel.buildDefaults(); + if (isFiltering) { + adapter.filterer.$filterConfig.set(filterConfig); + } else { + adapter.filterer.start(filterConfig); + } + // The user may have disabled auto-processing, so we should process the filter manually. This is essentially a + // no-op if auto-processing is already enabled, because the process method is debounced. + adapter.filterer.process(); + }, + [adapter.filterer, dispatch, entityIdentifier] + ); + + const pullBboxIntoLayer = usePullBboxIntoLayer(entityIdentifier); + const isBusy = useCanvasIsBusy(); + const uploadOptions = useMemo( + () => + ({ + onUpload: (imageDTO: ImageDTO) => { + replaceCanvasEntityObjectsWithImage({ entityIdentifier, imageDTO, dispatch, getState }); + }, + allowMultiple: false, + }) as const, + [dispatch, entityIdentifier, getState] + ); + const uploadApi = useImageUploadButton(uploadOptions); + + return ( + + + + } + /> + } + /> + } + {...uploadApi.getUploadButtonProps()} + /> + + + + + {controlAdapter.type === 'controlnet' && !isFLUX && ( + + )} + + ); +}); + +ControlLayerControlAdapter.displayName = 'ControlLayerControlAdapter'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterControlMode.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterControlMode.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c80a6ef037dd355e0eab6bd78ecaf6b8e6579cc0 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterControlMode.tsx @@ -0,0 +1,60 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import type { ControlModeV2 } from 'features/controlLayers/store/types'; +import { isControlModeV2 } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { assert } from 'tsafe'; + +type Props = { + controlMode: ControlModeV2; + onChange: (controlMode: ControlModeV2) => void; +}; + +export const ControlLayerControlAdapterControlMode = memo(({ controlMode, onChange }: Props) => { + const { t } = useTranslation(); + const CONTROL_MODE_DATA = useMemo( + () => [ + { label: t('controlLayers.controlMode.balanced'), value: 'balanced' }, + { label: t('controlLayers.controlMode.prompt'), value: 'more_prompt' }, + { label: t('controlLayers.controlMode.control'), value: 'more_control' }, + { label: t('controlLayers.controlMode.megaControl'), value: 'unbalanced' }, + ], + [t] + ); + + const handleControlModeChange = useCallback( + (v) => { + assert(isControlModeV2(v?.value)); + onChange(v.value); + }, + [onChange] + ); + + const value = useMemo( + () => CONTROL_MODE_DATA.filter((o) => o.value === controlMode)[0], + [CONTROL_MODE_DATA, controlMode] + ); + + if (!controlMode) { + return null; + } + + return ( + + + {t('controlLayers.controlMode.controlMode')} + + + + ); +}); + +ControlLayerControlAdapterControlMode.displayName = 'ControlLayerControlAdapterControlMode'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..aaed9031ede1021431ce2f399eeb2fe0af4fd60e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerControlAdapterModel.tsx @@ -0,0 +1,64 @@ +import { Combobox, FormControl, Tooltip } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox'; +import { selectBase } from 'features/controlLayers/store/paramsSlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useControlNetAndT2IAdapterModels } from 'services/api/hooks/modelsByType'; +import type { AnyModelConfig, ControlNetModelConfig, T2IAdapterModelConfig } from 'services/api/types'; + +type Props = { + modelKey: string | null; + onChange: (modelConfig: ControlNetModelConfig | T2IAdapterModelConfig) => void; +}; + +export const ControlLayerControlAdapterModel = memo(({ modelKey, onChange: onChangeModel }: Props) => { + const { t } = useTranslation(); + const currentBaseModel = useAppSelector(selectBase); + const [modelConfigs, { isLoading }] = useControlNetAndT2IAdapterModels(); + const selectedModel = useMemo(() => modelConfigs.find((m) => m.key === modelKey), [modelConfigs, modelKey]); + + const _onChange = useCallback( + (modelConfig: ControlNetModelConfig | T2IAdapterModelConfig | null) => { + if (!modelConfig) { + return; + } + onChangeModel(modelConfig); + }, + [onChangeModel] + ); + + const getIsDisabled = useCallback( + (model: AnyModelConfig): boolean => { + const isCompatible = currentBaseModel === model.base; + const hasMainModel = Boolean(currentBaseModel); + return !hasMainModel || !isCompatible; + }, + [currentBaseModel] + ); + + const { options, value, onChange, noOptionsMessage } = useGroupedModelCombobox({ + modelConfigs, + onChange: _onChange, + selectedModel, + getIsDisabled, + isLoading, + groupByType: true, + }); + + return ( + + + + + + ); +}); + +ControlLayerControlAdapterModel.displayName = 'ControlLayerControlAdapterModel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a353ee59f1999c3e490385e5aa5972100db0fb0c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerEntityList.tsx @@ -0,0 +1,37 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; +import { ControlLayer } from 'features/controlLayers/components/ControlLayer/ControlLayer'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; + +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.controlLayers.entities.map(getEntityIdentifier).toReversed(); +}); + +const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { + return selectedEntityIdentifier?.type === 'control_layer'; +}); + +export const ControlLayerEntityList = memo(() => { + const isSelected = useAppSelector(selectIsSelected); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); + + if (entityIdentifiers.length === 0) { + return null; + } + + if (entityIdentifiers.length > 0) { + return ( + + {entityIdentifiers.map((entityIdentifier) => ( + + ))} + + ); + } +}); + +ControlLayerEntityList.displayName = 'ControlLayerEntityList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..7a1ecac7de804983b51c5ef2c6c55effd0f34e12 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItems.tsx @@ -0,0 +1,40 @@ +import { MenuDivider } from '@invoke-ai/ui-library'; +import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; +import { CanvasEntityMenuItemsCropToBbox } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCropToBbox'; +import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete'; +import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate'; +import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter'; +import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown'; +import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; +import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSelectObject'; +import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; +import { ControlLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItemsConvertToSubMenu'; +import { ControlLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItemsCopyToSubMenu'; +import { ControlLayerMenuItemsTransparencyEffect } from 'features/controlLayers/components/ControlLayer/ControlLayerMenuItemsTransparencyEffect'; +import { memo } from 'react'; + +export const ControlLayerMenuItems = memo(() => { + return ( + <> + + + + + + + + + + + + + + + + + + ); +}); + +ControlLayerMenuItems.displayName = 'ControlLayerMenuItems'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsConvertToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsConvertToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..24c3d0d87a106202e7e9d6d7cbd42f5df21df454 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsConvertToSubMenu.tsx @@ -0,0 +1,58 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; +import { + controlLayerConvertedToInpaintMask, + controlLayerConvertedToRasterLayer, + controlLayerConvertedToRegionalGuidance, +} from 'features/controlLayers/store/canvasSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSwapBold } from 'react-icons/pi'; + +export const ControlLayerMenuItemsConvertToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('control_layer'); + const isBusy = useCanvasIsBusy(); + const isLocked = useEntityIsLocked(entityIdentifier); + + const convertToInpaintMask = useCallback(() => { + dispatch(controlLayerConvertedToInpaintMask({ entityIdentifier, replace: true })); + }, [dispatch, entityIdentifier]); + + const convertToRegionalGuidance = useCallback(() => { + dispatch(controlLayerConvertedToRegionalGuidance({ entityIdentifier, replace: true })); + }, [dispatch, entityIdentifier]); + + const convertToRasterLayer = useCallback(() => { + dispatch(controlLayerConvertedToRasterLayer({ entityIdentifier, replace: true })); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isLocked || isBusy}> + + + + + + } isDisabled={isLocked || isBusy}> + {t('controlLayers.inpaintMask')} + + } isDisabled={isLocked || isBusy}> + {t('controlLayers.regionalGuidance')} + + } isDisabled={isLocked || isBusy}> + {t('controlLayers.rasterLayer')} + + + + + ); +}); + +ControlLayerMenuItemsConvertToSubMenu.displayName = 'ControlLayerMenuItemsConvertToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsCopyToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsCopyToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5e757b0951ffe3c2cdaaf9230a344d19dfd47486 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsCopyToSubMenu.tsx @@ -0,0 +1,58 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { + controlLayerConvertedToInpaintMask, + controlLayerConvertedToRasterLayer, + controlLayerConvertedToRegionalGuidance, +} from 'features/controlLayers/store/canvasSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCopyBold } from 'react-icons/pi'; + +export const ControlLayerMenuItemsCopyToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('control_layer'); + const isBusy = useCanvasIsBusy(); + + const copyToInpaintMask = useCallback(() => { + dispatch(controlLayerConvertedToInpaintMask({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + const copyToRegionalGuidance = useCallback(() => { + dispatch(controlLayerConvertedToRegionalGuidance({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + const copyToRasterLayer = useCallback(() => { + dispatch(controlLayerConvertedToRasterLayer({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isBusy}> + + + + + + + } isDisabled={isBusy}> + {t('controlLayers.newInpaintMask')} + + } isDisabled={isBusy}> + {t('controlLayers.newRegionalGuidance')} + + } isDisabled={isBusy}> + {t('controlLayers.newRasterLayer')} + + + + + ); +}); + +ControlLayerMenuItemsCopyToSubMenu.displayName = 'ControlLayerMenuItemsCopyToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsTransparencyEffect.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsTransparencyEffect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..28ad0923cf70a4c0c89a06b5a41d708713c31055 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerMenuItemsTransparencyEffect.tsx @@ -0,0 +1,39 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; +import { controlLayerWithTransparencyEffectToggled } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiDropHalfBold } from 'react-icons/pi'; + +export const ControlLayerMenuItemsTransparencyEffect = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('control_layer'); + const isLocked = useEntityIsLocked(entityIdentifier); + const selectWithTransparencyEffect = useMemo( + () => + createSelector(selectCanvasSlice, (canvas) => { + const entity = selectEntityOrThrow(canvas, entityIdentifier); + return entity.withTransparencyEffect; + }), + [entityIdentifier] + ); + const withTransparencyEffect = useAppSelector(selectWithTransparencyEffect); + const onToggle = useCallback(() => { + dispatch(controlLayerWithTransparencyEffectToggled({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isLocked}> + {withTransparencyEffect + ? t('controlLayers.disableTransparencyEffect') + : t('controlLayers.enableTransparencyEffect')} + + ); +}); + +ControlLayerMenuItemsTransparencyEffect.displayName = 'ControlLayerMenuItemsTransparencyEffect'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d4fa3220202d58fde4c664c5e391d10f10216a72 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettings.tsx @@ -0,0 +1,18 @@ +import { ControlLayerControlAdapter } from 'features/controlLayers/components/ControlLayer/ControlLayerControlAdapter'; +import { ControlLayerSettingsEmptyState } from 'features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useEntityIsEmpty } from 'features/controlLayers/hooks/useEntityIsEmpty'; +import { memo } from 'react'; + +export const ControlLayerSettings = memo(() => { + const entityIdentifier = useEntityIdentifierContext(); + const isEmpty = useEntityIsEmpty(entityIdentifier); + + if (isEmpty) { + return ; + } + + return ; +}); + +ControlLayerSettings.displayName = 'ControlLayerSettings'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e8f0bbaeece17c01d775e104aa9abae81629644b --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ControlLayer/ControlLayerSettingsEmptyState.tsx @@ -0,0 +1,53 @@ +import { Button, Flex, Text } from '@invoke-ai/ui-library'; +import { useAppStore } from 'app/store/nanostores/store'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { replaceCanvasEntityObjectsWithImage } from 'features/imageActions/actions'; +import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; +import { memo, useCallback } from 'react'; +import { Trans } from 'react-i18next'; +import type { ImageDTO } from 'services/api/types'; + +export const ControlLayerSettingsEmptyState = memo(() => { + const entityIdentifier = useEntityIdentifierContext('control_layer'); + const { dispatch, getState } = useAppStore(); + const isBusy = useCanvasIsBusy(); + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + replaceCanvasEntityObjectsWithImage({ imageDTO, entityIdentifier, dispatch, getState }); + }, + [dispatch, entityIdentifier, getState] + ); + const uploadApi = useImageUploadButton({ onUpload, allowMultiple: false }); + const onClickGalleryButton = useCallback(() => { + dispatch(activeTabCanvasRightPanelChanged('gallery')); + }, [dispatch]); + + return ( + + + + ), + GalleryButton: ( + + + + + + } + > + {t('controlLayers.selectObject.saveAs')} + + + + {t('controlLayers.newInpaintMask')} + + + {t('controlLayers.newRegionalGuidance')} + + + {t('controlLayers.newControlLayer')} + + + {t('controlLayers.newRasterLayer')} + + + + + + + ); + } +); + +FilterContentAdvanced.displayName = 'FilterContentAdvanced'; + +const FilterContentSimple = memo( + ({ adapter }: { adapter: CanvasEntityAdapterRasterLayer | CanvasEntityAdapterControlLayer }) => { + const { t } = useTranslation(); + const config = useStore(adapter.filterer.$filterConfig); + const isProcessing = useStore(adapter.filterer.$isProcessing); + const hasImageState = useStore(adapter.filterer.$hasImageState); + + const isValid = useMemo(() => { + return IMAGE_FILTERS[config.type].validateConfig?.(config as never) ?? true; + }, [config]); + + const onClickAdvanced = useCallback(() => { + adapter.filterer.$simple.set(false); + }, [adapter.filterer.$simple]); + + return ( + <> + + + {t('controlLayers.filter.filter')} + + + + + + {t('controlLayers.filter.processingLayerWith', { type: t(`controlLayers.filter.${config.type}.label`) })} + + + {t('controlLayers.filter.forMoreControl')} + + + + + + + + + + ); + } +); + +FilterContentSimple.displayName = 'FilterContentSimple'; + +export const Filter = () => { + const canvasManager = useCanvasManager(); + const adapter = useStore(canvasManager.stateApi.$filteringAdapter); + if (!adapter) { + return null; + } + return ; +}; + +Filter.displayName = 'Filter'; + +const FilterContent = memo( + ({ adapter }: { adapter: CanvasEntityAdapterRasterLayer | CanvasEntityAdapterControlLayer }) => { + const simplified = useStore(adapter.filterer.$simple); + const isCanvasFocused = useIsRegionFocused('canvas'); + const isProcessing = useStore(adapter.filterer.$isProcessing); + const ref = useRef(null); + useFocusRegion('canvas', ref, { focusOnMount: true }); + + useRegisteredHotkeys({ + id: 'applyFilter', + category: 'canvas', + callback: adapter.filterer.apply, + options: { enabled: !isProcessing && isCanvasFocused, enableOnFormTags: true }, + dependencies: [adapter.filterer, isProcessing, isCanvasFocused], + }); + + useRegisteredHotkeys({ + id: 'cancelFilter', + category: 'canvas', + callback: adapter.filterer.cancel, + options: { enabled: !isProcessing && isCanvasFocused, enableOnFormTags: true }, + dependencies: [adapter.filterer, isProcessing, isCanvasFocused], + }); + + return ( + + {simplified && } + {!simplified && } + + ); + } +); + +FilterContent.displayName = 'FilterContent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterCannyEdgeDetection.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterCannyEdgeDetection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f799caaff785753d559618d632c314dec53ef924 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterCannyEdgeDetection.tsx @@ -0,0 +1,67 @@ +import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { CannyEdgeDetectionFilterConfig } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS } from 'features/controlLayers/store/filters'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.canny_edge_detection.buildDefaults(); + +export const FilterCannyEdgeDetection = ({ onChange, config }: Props) => { + const { t } = useTranslation(); + const handleLowThresholdChanged = useCallback( + (v: number) => { + onChange({ ...config, low_threshold: v }); + }, + [onChange, config] + ); + const handleHighThresholdChanged = useCallback( + (v: number) => { + onChange({ ...config, high_threshold: v }); + }, + [onChange, config] + ); + + return ( + <> + + {t('controlLayers.filter.canny_edge_detection.low_threshold')} + + + + + {t('controlLayers.filter.canny_edge_detection.high_threshold')} + + + + + ); +}; + +FilterCannyEdgeDetection.displayName = 'FilterCannyEdgeDetection'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterColorMap.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterColorMap.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9c56bc4e7ea3b6f49033a2b013d285b92110248d --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterColorMap.tsx @@ -0,0 +1,46 @@ +import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { type ColorMapFilterConfig, IMAGE_FILTERS } from 'features/controlLayers/store/filters'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.color_map.buildDefaults(); + +export const FilterColorMap = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + const handleColorMapTileSizeChanged = useCallback( + (v: number) => { + onChange({ ...config, tile_size: v }); + }, + [config, onChange] + ); + + return ( + <> + + {t('controlLayers.filter.color_map.tile_size')} + + + + + ); +}); + +FilterColorMap.displayName = 'FilterColorMap'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterContentShuffle.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterContentShuffle.tsx new file mode 100644 index 0000000000000000000000000000000000000000..e7154018eb2417a48f760280fe3e57bb0a1f57ba --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterContentShuffle.tsx @@ -0,0 +1,46 @@ +import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { ContentShuffleFilterConfig } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS } from 'features/controlLayers/store/filters'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.content_shuffle.buildDefaults(); + +export const FilterContentShuffle = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const handleScaleFactorChanged = useCallback( + (v: number) => { + onChange({ ...config, scale_factor: v }); + }, + [config, onChange] + ); + + return ( + <> + + {t('controlLayers.filter.content_shuffle.scale_factor')} + + + + + ); +}); + +FilterContentShuffle.displayName = 'FilterContentShuffle'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterDWOpenposeDetection.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterDWOpenposeDetection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..9522823b6563eec5c68b425be37f8208b810452f --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterDWOpenposeDetection.tsx @@ -0,0 +1,60 @@ +import { Flex, FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; +import { type DWOpenposeDetectionFilterConfig, IMAGE_FILTERS } from 'features/controlLayers/store/filters'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS['dw_openpose_detection'].buildDefaults(); + +export const FilterDWOpenposeDetection = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const handleDrawBodyChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, draw_body: e.target.checked }); + }, + [config, onChange] + ); + + const handleDrawFaceChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, draw_face: e.target.checked }); + }, + [config, onChange] + ); + + const handleDrawHandsChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, draw_hands: e.target.checked }); + }, + [config, onChange] + ); + + return ( + <> + + + {t('controlLayers.filter.dw_openpose_detection.draw_body')} + + + + {t('controlLayers.filter.dw_openpose_detection.draw_face')} + + + + {t('controlLayers.filter.dw_openpose_detection.draw_hands')} + + + + + ); +}); + +FilterDWOpenposeDetection.displayName = 'FilterDWOpenposeDetection'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterDepthAnythingDepthEstimation.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterDepthAnythingDepthEstimation.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8a06512f722679856d32808eee2fbfb26e22c106 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterDepthAnythingDepthEstimation.tsx @@ -0,0 +1,46 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { DepthAnythingFilterConfig, DepthAnythingModelSize } from 'features/controlLayers/store/filters'; +import { isDepthAnythingModelSize } from 'features/controlLayers/store/filters'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; + +export const FilterDepthAnythingDepthEstimation = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + const handleModelSizeChange = useCallback( + (v) => { + if (!isDepthAnythingModelSize(v?.value)) { + return; + } + onChange({ ...config, model_size: v.value }); + }, + [config, onChange] + ); + + const options: { label: string; value: DepthAnythingModelSize }[] = useMemo( + () => [ + { label: t('controlLayers.filter.depth_anything_depth_estimation.model_size_small_v2'), value: 'small_v2' }, + { label: t('controlLayers.filter.depth_anything_depth_estimation.model_size_small'), value: 'small' }, + { label: t('controlLayers.filter.depth_anything_depth_estimation.model_size_base'), value: 'base' }, + { label: t('controlLayers.filter.depth_anything_depth_estimation.model_size_large'), value: 'large' }, + ], + [t] + ); + + const value = useMemo(() => options.filter((o) => o.value === config.model_size)[0], [options, config.model_size]); + + return ( + <> + + {t('controlLayers.filter.depth_anything_depth_estimation.model_size')} + + + + ); +}); + +FilterDepthAnythingDepthEstimation.displayName = 'FilterDepthAnythingDepthEstimation'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterHEDEdgeDetection.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterHEDEdgeDetection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..810da9a4c7aa0c35065f77bc73ab8c122e0813d4 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterHEDEdgeDetection.tsx @@ -0,0 +1,31 @@ +import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; +import type { HEDEdgeDetectionFilterConfig } from 'features/controlLayers/store/filters'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; + +export const FilterHEDEdgeDetection = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const handleScribbleChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, scribble: e.target.checked }); + }, + [config, onChange] + ); + + return ( + <> + + {t('controlLayers.filter.hed_edge_detection.scribble')} + + + + ); +}); + +FilterHEDEdgeDetection.displayName = 'FilterHEDEdgeDetection'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterLineartEdgeDetection.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterLineartEdgeDetection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a840b65d273d7684b0c087feda71b431b6325394 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterLineartEdgeDetection.tsx @@ -0,0 +1,31 @@ +import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; +import type { LineartEdgeDetectionFilterConfig } from 'features/controlLayers/store/filters'; +import type { ChangeEvent } from 'react'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; + +export const FilterLineartEdgeDetection = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const handleCoarseChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, coarse: e.target.checked }); + }, + [config, onChange] + ); + + return ( + <> + + {t('controlLayers.filter.lineart_edge_detection.coarse')} + + + + ); +}); + +FilterLineartEdgeDetection.displayName = 'FilterLineartEdgeDetection'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterMLSDDetection.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterMLSDDetection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3248008d7d2280f777dbda05a60ec5bcb445aa7c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterMLSDDetection.tsx @@ -0,0 +1,75 @@ +import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { MLSDDetectionFilterConfig } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS } from 'features/controlLayers/store/filters'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.mlsd_detection.buildDefaults(); + +export const FilterMLSDDetection = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const onDistanceThresholdChanged = useCallback( + (v: number) => { + onChange({ ...config, distance_threshold: v }); + }, + [config, onChange] + ); + + const onScoreThresholdChanged = useCallback( + (v: number) => { + onChange({ ...config, score_threshold: v }); + }, + [config, onChange] + ); + + return ( + <> + + {t('controlLayers.filter.mlsd_detection.score_threshold')} + + + + + {t('controlLayers.filter.mlsd_detection.distance_threshold')} + + + + + ); +}); + +FilterMLSDDetection.displayName = 'FilterMLSDDetection'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterMediaPipeFaceDetection.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterMediaPipeFaceDetection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..6c20817644b4a53836998cd659584a73f66735c1 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterMediaPipeFaceDetection.tsx @@ -0,0 +1,73 @@ +import { CompositeNumberInput, CompositeSlider, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import type { MediaPipeFaceDetectionFilterConfig } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS } from 'features/controlLayers/store/filters'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.mediapipe_face_detection.buildDefaults(); + +export const FilterMediaPipeFaceDetection = memo(({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const handleMaxFacesChanged = useCallback( + (v: number) => { + onChange({ ...config, max_faces: v }); + }, + [config, onChange] + ); + + const handleMinConfidenceChanged = useCallback( + (v: number) => { + onChange({ ...config, min_confidence: v }); + }, + [config, onChange] + ); + + return ( + <> + + {t('controlLayers.filter.mediapipe_face_detection.max_faces')} + + + + + {t('controlLayers.filter.mediapipe_face_detection.min_confidence')} + + + + + ); +}); + +FilterMediaPipeFaceDetection.displayName = 'FilterMediaPipeFaceDetection'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterPiDiNetEdgeDetection.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterPiDiNetEdgeDetection.tsx new file mode 100644 index 0000000000000000000000000000000000000000..05812a3f2e4f40342e64596897243f69c0b5182a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterPiDiNetEdgeDetection.tsx @@ -0,0 +1,42 @@ +import { FormControl, FormLabel, Switch } from '@invoke-ai/ui-library'; +import type { PiDiNetEdgeDetectionFilterConfig } from 'features/controlLayers/store/filters'; +import type { ChangeEvent } from 'react'; +import { useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; + +export const FilterPiDiNetEdgeDetection = ({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const onScribbleChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, scribble: e.target.checked }); + }, + [config, onChange] + ); + + const onQuantizeEdgesChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, quantize_edges: e.target.checked }); + }, + [config, onChange] + ); + + return ( + <> + + {t('controlLayers.filter.pidi_edge_detection.scribble')} + + + + {t('controlLayers.filter.pidi_edge_detection.quantize_edges')} + + + + ); +}; + +FilterPiDiNetEdgeDetection.displayName = 'FilterPiDiNetEdgeDetection'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..411571fe09c248892a62cc951d02e6e82bf5cf61 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSettings.tsx @@ -0,0 +1,75 @@ +import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { FilterCannyEdgeDetection } from 'features/controlLayers/components/Filters/FilterCannyEdgeDetection'; +import { FilterColorMap } from 'features/controlLayers/components/Filters/FilterColorMap'; +import { FilterContentShuffle } from 'features/controlLayers/components/Filters/FilterContentShuffle'; +import { FilterDepthAnythingDepthEstimation } from 'features/controlLayers/components/Filters/FilterDepthAnythingDepthEstimation'; +import { FilterDWOpenposeDetection } from 'features/controlLayers/components/Filters/FilterDWOpenposeDetection'; +import { FilterHEDEdgeDetection } from 'features/controlLayers/components/Filters/FilterHEDEdgeDetection'; +import { FilterLineartEdgeDetection } from 'features/controlLayers/components/Filters/FilterLineartEdgeDetection'; +import { FilterMediaPipeFaceDetection } from 'features/controlLayers/components/Filters/FilterMediaPipeFaceDetection'; +import { FilterMLSDDetection } from 'features/controlLayers/components/Filters/FilterMLSDDetection'; +import { FilterPiDiNetEdgeDetection } from 'features/controlLayers/components/Filters/FilterPiDiNetEdgeDetection'; +import { FilterSpandrel } from 'features/controlLayers/components/Filters/FilterSpandrel'; +import type { FilterConfig } from 'features/controlLayers/store/filters'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { filterConfig: FilterConfig; onChange: (filterConfig: FilterConfig) => void }; + +export const FilterSettings = memo(({ filterConfig, onChange }: Props) => { + const { t } = useTranslation(); + + if (filterConfig.type === 'canny_edge_detection') { + return ; + } + + if (filterConfig.type === 'color_map') { + return ; + } + + if (filterConfig.type === 'content_shuffle') { + return ; + } + + if (filterConfig.type === 'depth_anything_depth_estimation') { + return ; + } + + if (filterConfig.type === 'dw_openpose_detection') { + return ; + } + + if (filterConfig.type === 'hed_edge_detection') { + return ; + } + + if (filterConfig.type === 'lineart_edge_detection') { + return ; + } + + if (filterConfig.type === 'mediapipe_face_detection') { + return ; + } + + if (filterConfig.type === 'mlsd_detection') { + return ; + } + + if (filterConfig.type === 'pidi_edge_detection') { + return ; + } + + if (filterConfig.type === 'spandrel_filter') { + return ; + } + + return ( + + ); +}); + +FilterSettings.displayName = 'Filter'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSpandrel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSpandrel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..5f6497079994cd00857b66851bb22ceedbb282a5 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterSpandrel.tsx @@ -0,0 +1,126 @@ +import { + Box, + Combobox, + CompositeNumberInput, + CompositeSlider, + Flex, + FormControl, + FormHelperText, + FormLabel, + Switch, + Tooltip, +} from '@invoke-ai/ui-library'; +import { useModelCombobox } from 'common/hooks/useModelCombobox'; +import type { SpandrelFilterConfig } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS } from 'features/controlLayers/store/filters'; +import type { ChangeEvent } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useSpandrelImageToImageModels } from 'services/api/hooks/modelsByType'; +import type { SpandrelImageToImageModelConfig } from 'services/api/types'; + +import type { FilterComponentProps } from './types'; + +type Props = FilterComponentProps; +const DEFAULTS = IMAGE_FILTERS.spandrel_filter.buildDefaults(); + +export const FilterSpandrel = ({ onChange, config }: Props) => { + const { t } = useTranslation(); + + const [modelConfigs, { isLoading }] = useSpandrelImageToImageModels(); + + const tooltipLabel = useMemo(() => { + if (!modelConfigs.length || !config.model) { + return; + } + return modelConfigs.find((m) => m.key === config.model?.key)?.description; + }, [modelConfigs, config.model]); + + const _onChange = useCallback( + (v: SpandrelImageToImageModelConfig | null) => { + onChange({ ...config, model: v }); + }, + [config, onChange] + ); + + const { + options, + value, + onChange: onChangeModel, + placeholder, + noOptionsMessage, + } = useModelCombobox({ + modelConfigs, + onChange: _onChange, + selectedModel: config.model, + isLoading, + }); + + const onScaleChanged = useCallback( + (v: number) => { + onChange({ ...config, scale: v }); + }, + [onChange, config] + ); + const onAutoscaleChanged = useCallback( + (e: ChangeEvent) => { + onChange({ ...config, autoScale: e.target.checked }); + }, + [onChange, config] + ); + + useEffect(() => { + const firstModel = options[0]; + if (!config.model && firstModel) { + onChangeModel(firstModel); + } + }, [config.model, onChangeModel, options]); + + return ( + <> + + + + {t('controlLayers.filter.spandrel_filter.autoScale')} + + + + {t('controlLayers.filter.spandrel_filter.autoScaleDesc')} + + + {t('controlLayers.filter.spandrel_filter.scale')} + + + + + {t('controlLayers.filter.spandrel_filter.model')} + + + + + + + + ); +}; + +FilterSpandrel.displayName = 'FilterSpandrel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterTypeSelect.tsx b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterTypeSelect.tsx new file mode 100644 index 0000000000000000000000000000000000000000..3e9da8c8421154e6df2f76db8d814c98127fd5d3 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/FilterTypeSelect.tsx @@ -0,0 +1,54 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, Flex, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import type { FilterConfig } from 'features/controlLayers/store/filters'; +import { IMAGE_FILTERS, isFilterType } from 'features/controlLayers/store/filters'; +import { selectConfigSlice } from 'features/system/store/configSlice'; +import { includes, map } from 'lodash-es'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { assert } from 'tsafe'; + +const selectDisabledProcessors = createSelector(selectConfigSlice, (config) => config.sd.disabledControlNetProcessors); + +type Props = { + filterType: FilterConfig['type']; + onChange: (filterType: FilterConfig['type']) => void; +}; + +export const FilterTypeSelect = memo(({ filterType, onChange }: Props) => { + const { t } = useTranslation(); + const disabledProcessors = useAppSelector(selectDisabledProcessors); + const options = useMemo(() => { + return map(IMAGE_FILTERS, (data, type) => ({ value: type, label: t(`controlLayers.filter.${type}.label`) })).filter( + (o) => !includes(disabledProcessors, o.value) + ); + }, [disabledProcessors, t]); + + const _onChange = useCallback( + (v) => { + if (!v) { + return; + } + assert(isFilterType(v.value)); + onChange(v.value); + }, + [onChange] + ); + const value = useMemo(() => options.find((o) => o.value === filterType) ?? null, [options, filterType]); + + return ( + + + + {t('controlLayers.filter.filterType')} + + + + + ); +}); + +FilterTypeSelect.displayName = 'FilterTypeSelect'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/Filters/types.ts b/invokeai/frontend/web/src/features/controlLayers/components/Filters/types.ts new file mode 100644 index 0000000000000000000000000000000000000000..9ad420a0cf72a5d4ec41b2386b69631e174dad16 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/Filters/types.ts @@ -0,0 +1,6 @@ +import type { FilterConfig } from 'features/controlLayers/store/filters'; + +export type FilterComponentProps = { + onChange: (config: T) => void; + config: T; +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUD.tsx b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUD.tsx new file mode 100644 index 0000000000000000000000000000000000000000..450613d3a5f2b422fab9e007300ee3a70f78f1a7 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUD.tsx @@ -0,0 +1,24 @@ +import { Grid } from '@invoke-ai/ui-library'; +import { CanvasHUDItemBbox } from 'features/controlLayers/components/HUD/CanvasHUDItemBbox'; +import { CanvasHUDItemScaledBbox } from 'features/controlLayers/components/HUD/CanvasHUDItemScaledBbox'; +import { memo } from 'react'; + +export const CanvasHUD = memo(() => { + return ( + + + + + ); +}); + +CanvasHUD.displayName = 'CanvasHUD'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItem.tsx b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItem.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8119eb51cfa17bfa741716e1eb4c72c619fc751e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItem.tsx @@ -0,0 +1,26 @@ +import { GridItem, Text } from '@invoke-ai/ui-library'; +import type { Property } from 'csstype'; +import { memo } from 'react'; + +type Props = { + label: string; + value: string | number; + color?: Property.Color; +}; + +export const CanvasHUDItem = memo(({ label, value, color }: Props) => { + return ( + <> + + {label}: + + + + {value} + + + + ); +}); + +CanvasHUDItem.displayName = 'CanvasHUDItem'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItemBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItemBbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..27d21b128141f1264a932d3df0e4512f7c84f6b9 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItemBbox.tsx @@ -0,0 +1,17 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasHUDItem } from 'features/controlLayers/components/HUD/CanvasHUDItem'; +import { selectBbox } from 'features/controlLayers/store/selectors'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +const selectBboxRect = createSelector(selectBbox, (bbox) => bbox.rect); + +export const CanvasHUDItemBbox = memo(() => { + const { t } = useTranslation(); + const rect = useAppSelector(selectBboxRect); + + return ; +}); + +CanvasHUDItemBbox.displayName = 'CanvasHUDItemBbox'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItemScaledBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItemScaledBbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c576636949a68da44c776d700c1144f0e7090efb --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/HUD/CanvasHUDItemScaledBbox.tsx @@ -0,0 +1,21 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasHUDItem } from 'features/controlLayers/components/HUD/CanvasHUDItem'; +import { selectScaledSize, selectScaleMethod } from 'features/controlLayers/store/selectors'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const CanvasHUDItemScaledBbox = memo(() => { + const { t } = useTranslation(); + const scaleMethod = useAppSelector(selectScaleMethod); + const scaledSize = useAppSelector(selectScaledSize); + + if (scaleMethod === 'none') { + return null; + } + + return ( + + ); +}); + +CanvasHUDItemScaledBbox.displayName = 'CanvasHUDItemScaledBbox'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..d0fbc6a105d9ad0fcb461b26a256f552e8da0ff2 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapter.tsx @@ -0,0 +1,32 @@ +import { Spacer } from '@invoke-ai/ui-library'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; +import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; +import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; +import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; +import { IPAdapterSettings } from 'features/controlLayers/components/IPAdapter/IPAdapterSettings'; +import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo, useMemo } from 'react'; + +type Props = { + id: string; +}; + +export const IPAdapter = memo(({ id }: Props) => { + const entityIdentifier = useMemo(() => ({ id, type: 'reference_image' }), [id]); + + return ( + + + + + + + + + + + ); +}); + +IPAdapter.displayName = 'IPAdapter'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx new file mode 100644 index 0000000000000000000000000000000000000000..da24bfc93676909d434a07bab1a163c8613b8789 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterImagePreview.tsx @@ -0,0 +1,80 @@ +import { Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { UploadImageButton } from 'common/hooks/useImageUploadButton'; +import type { ImageWithDims } from 'features/controlLayers/store/types'; +import type { setGlobalReferenceImageDndTarget, setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { DndImage } from 'features/dnd/DndImage'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import { memo, useCallback, useEffect } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; +import { useGetImageDTOQuery } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; +import { $isConnected } from 'services/events/stores'; + +type Props = { + image: ImageWithDims | null; + onChangeImage: (imageDTO: ImageDTO | null) => void; + dndTarget: T; + dndTargetData: ReturnType; +}; + +export const IPAdapterImagePreview = memo( + ({ + image, + onChangeImage, + dndTarget, + dndTargetData, + }: Props) => { + const { t } = useTranslation(); + const isConnected = useStore($isConnected); + const { currentData: imageDTO, isError } = useGetImageDTOQuery(image?.image_name ?? skipToken); + const handleResetControlImage = useCallback(() => { + onChangeImage(null); + }, [onChangeImage]); + + useEffect(() => { + if (isConnected && isError) { + handleResetControlImage(); + } + }, [handleResetControlImage, isError, isConnected]); + + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + onChangeImage(imageDTO); + }, + [onChangeImage] + ); + + return ( + + {!imageDTO && ( + + )} + {imageDTO && ( + <> + + + } + tooltip={t('common.reset')} + /> + + + )} + + + ); + } +); + +IPAdapterImagePreview.displayName = 'IPAdapterImagePreview'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..38cdbde8c7cac22383fdccf607fadb5c2dc89862 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterList.tsx @@ -0,0 +1,37 @@ +/* eslint-disable i18next/no-literal-string */ +import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; +import { IPAdapter } from 'features/controlLayers/components/IPAdapter/IPAdapter'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; + +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.referenceImages.entities.map(getEntityIdentifier).toReversed(); +}); +const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { + return selectedEntityIdentifier?.type === 'reference_image'; +}); + +export const IPAdapterList = memo(() => { + const isSelected = useAppSelector(selectIsSelected); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); + + if (entityIdentifiers.length === 0) { + return null; + } + + if (entityIdentifiers.length > 0) { + return ( + + {entityIdentifiers.map((entityIdentifiers) => ( + + ))} + + ); + } +}); + +IPAdapterList.displayName = 'IPAdapterList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx new file mode 100644 index 0000000000000000000000000000000000000000..504ac54f27ef8e596c4b1725b2c31254dae4e152 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox.tsx @@ -0,0 +1,22 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiBoundingBoxBold } from 'react-icons/pi'; + +export const IPAdapterMenuItemPullBbox = memo(() => { + const { t } = useTranslation(); + const entityIdentifier = useEntityIdentifierContext('reference_image'); + const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); + const isBusy = useCanvasIsBusy(); + + return ( + } isDisabled={isBusy}> + {t('controlLayers.pullBboxIntoReferenceImage')} + + ); +}); + +IPAdapterMenuItemPullBbox.displayName = 'IPAdapterMenuItemPullBbox'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..f174bdf004b2d74d65bf65221b190c3efa3208cf --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMenuItems.tsx @@ -0,0 +1,23 @@ +import { MenuDivider } from '@invoke-ai/ui-library'; +import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; +import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete'; +import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate'; +import { IPAdapterMenuItemPullBbox } from 'features/controlLayers/components/IPAdapter/IPAdapterMenuItemPullBbox'; +import { memo } from 'react'; + +export const IPAdapterMenuItems = memo(() => { + return ( + <> + + + + + + + + + ); +}); + +IPAdapterMenuItems.displayName = 'IPAdapterMenuItems'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMethod.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMethod.tsx new file mode 100644 index 0000000000000000000000000000000000000000..79244eece63ecb448fdeac8a2afacb323f0da8d9 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterMethod.tsx @@ -0,0 +1,44 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import type { IPMethodV2 } from 'features/controlLayers/store/types'; +import { isIPMethodV2 } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { assert } from 'tsafe'; + +type Props = { + method: IPMethodV2; + onChange: (method: IPMethodV2) => void; +}; + +export const IPAdapterMethod = memo(({ method, onChange }: Props) => { + const { t } = useTranslation(); + const options: { label: string; value: IPMethodV2 }[] = useMemo( + () => [ + { label: t('controlLayers.ipAdapterMethod.full'), value: 'full' }, + { label: t('controlLayers.ipAdapterMethod.style'), value: 'style' }, + { label: t('controlLayers.ipAdapterMethod.composition'), value: 'composition' }, + ], + [t] + ); + const _onChange = useCallback( + (v) => { + assert(isIPMethodV2(v?.value)); + onChange(v.value); + }, + [onChange] + ); + const value = useMemo(() => options.find((o) => o.value === method), [options, method]); + + return ( + + + {t('controlLayers.ipAdapterMethod.ipAdapterMethod')} + + + + ); +}); + +IPAdapterMethod.displayName = 'IPAdapterMethod'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterModel.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterModel.tsx new file mode 100644 index 0000000000000000000000000000000000000000..682c272f8926f7ac8a828409ccd762d8e10de928 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterModel.tsx @@ -0,0 +1,111 @@ +import type { ComboboxOnChange } from '@invoke-ai/ui-library'; +import { Combobox, Flex, FormControl, Tooltip } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox'; +import { selectBase, selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; +import type { CLIPVisionModelV2 } from 'features/controlLayers/store/types'; +import { isCLIPVisionModelV2 } from 'features/controlLayers/store/types'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useIPAdapterModels } from 'services/api/hooks/modelsByType'; +import type { AnyModelConfig, IPAdapterModelConfig } from 'services/api/types'; +import { assert } from 'tsafe'; + +// at this time, ViT-L is the only supported clip model for FLUX IP adapter +const FLUX_CLIP_VISION = 'ViT-L'; + +const CLIP_VISION_OPTIONS = [ + { label: 'ViT-H', value: 'ViT-H' }, + { label: 'ViT-G', value: 'ViT-G' }, + { label: FLUX_CLIP_VISION, value: FLUX_CLIP_VISION }, +]; + +type Props = { + modelKey: string | null; + onChangeModel: (modelConfig: IPAdapterModelConfig) => void; + clipVisionModel: CLIPVisionModelV2; + onChangeCLIPVisionModel: (clipVisionModel: CLIPVisionModelV2) => void; +}; + +export const IPAdapterModel = memo(({ modelKey, onChangeModel, clipVisionModel, onChangeCLIPVisionModel }: Props) => { + const { t } = useTranslation(); + const currentBaseModel = useAppSelector(selectBase); + const [modelConfigs, { isLoading }] = useIPAdapterModels(); + const selectedModel = useMemo(() => modelConfigs.find((m) => m.key === modelKey), [modelConfigs, modelKey]); + + const _onChangeModel = useCallback( + (modelConfig: IPAdapterModelConfig | null) => { + if (!modelConfig) { + return; + } + onChangeModel(modelConfig); + }, + [onChangeModel] + ); + + const _onChangeCLIPVisionModel = useCallback( + (v) => { + assert(isCLIPVisionModelV2(v?.value)); + onChangeCLIPVisionModel(v.value); + }, + [onChangeCLIPVisionModel] + ); + + const isFLUX = useAppSelector(selectIsFLUX); + + const getIsDisabled = useCallback( + (model: AnyModelConfig): boolean => { + const isCompatible = currentBaseModel === model.base; + const hasMainModel = Boolean(currentBaseModel); + return !hasMainModel || !isCompatible; + }, + [currentBaseModel] + ); + + const { options, value, onChange, noOptionsMessage } = useGroupedModelCombobox({ + modelConfigs, + onChange: _onChangeModel, + selectedModel, + getIsDisabled, + isLoading, + }); + + const clipVisionOptions = useMemo(() => { + return CLIP_VISION_OPTIONS.map((option) => ({ + ...option, + isDisabled: isFLUX && option.value !== FLUX_CLIP_VISION, + })); + }, [isFLUX]); + + const clipVisionModelValue = useMemo(() => { + return CLIP_VISION_OPTIONS.find((o) => o.value === clipVisionModel); + }, [clipVisionModel]); + + return ( + + + + + + + {selectedModel?.format === 'checkpoint' && ( + + + + )} + + ); +}); + +IPAdapterModel.displayName = 'IPAdapterModel'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..218e7571d250846f3a2069a390d37cae65a39ba9 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/IPAdapter/IPAdapterSettings.tsx @@ -0,0 +1,134 @@ +import { Box, Flex, IconButton } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; +import { CanvasEntitySettingsWrapper } from 'features/controlLayers/components/common/CanvasEntitySettingsWrapper'; +import { Weight } from 'features/controlLayers/components/common/Weight'; +import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { usePullBboxIntoGlobalReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { + referenceImageIPAdapterBeginEndStepPctChanged, + referenceImageIPAdapterCLIPVisionModelChanged, + referenceImageIPAdapterImageChanged, + referenceImageIPAdapterMethodChanged, + referenceImageIPAdapterModelChanged, + referenceImageIPAdapterWeightChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { selectIsFLUX } from 'features/controlLayers/store/paramsSlice'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; +import type { SetGlobalReferenceImageDndTargetData } from 'features/dnd/dnd'; +import { setGlobalReferenceImageDndTarget } from 'features/dnd/dnd'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiBoundingBoxBold } from 'react-icons/pi'; +import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types'; + +import { IPAdapterImagePreview } from './IPAdapterImagePreview'; +import { IPAdapterModel } from './IPAdapterModel'; + +export const IPAdapterSettings = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('reference_image'); + const selectIPAdapter = useMemo( + () => createSelector(selectCanvasSlice, (s) => selectEntityOrThrow(s, entityIdentifier).ipAdapter), + [entityIdentifier] + ); + const ipAdapter = useAppSelector(selectIPAdapter); + + const onChangeBeginEndStepPct = useCallback( + (beginEndStepPct: [number, number]) => { + dispatch(referenceImageIPAdapterBeginEndStepPctChanged({ entityIdentifier, beginEndStepPct })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeWeight = useCallback( + (weight: number) => { + dispatch(referenceImageIPAdapterWeightChanged({ entityIdentifier, weight })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeIPMethod = useCallback( + (method: IPMethodV2) => { + dispatch(referenceImageIPAdapterMethodChanged({ entityIdentifier, method })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeModel = useCallback( + (modelConfig: IPAdapterModelConfig) => { + dispatch(referenceImageIPAdapterModelChanged({ entityIdentifier, modelConfig })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeCLIPVisionModel = useCallback( + (clipVisionModel: CLIPVisionModelV2) => { + dispatch(referenceImageIPAdapterCLIPVisionModelChanged({ entityIdentifier, clipVisionModel })); + }, + [dispatch, entityIdentifier] + ); + + const onChangeImage = useCallback( + (imageDTO: ImageDTO | null) => { + dispatch(referenceImageIPAdapterImageChanged({ entityIdentifier, imageDTO })); + }, + [dispatch, entityIdentifier] + ); + + const dndTargetData = useMemo( + () => setGlobalReferenceImageDndTarget.getData({ entityIdentifier }, ipAdapter.image?.image_name), + [entityIdentifier, ipAdapter.image?.image_name] + ); + const pullBboxIntoIPAdapter = usePullBboxIntoGlobalReferenceImage(entityIdentifier); + const isBusy = useCanvasIsBusy(); + + const isFLUX = useAppSelector(selectIsFLUX); + + return ( + + + + + + + } + /> + + + + {!isFLUX && } + + + + + + + + + + ); +}); + +IPAdapterSettings.displayName = 'IPAdapterSettings'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx new file mode 100644 index 0000000000000000000000000000000000000000..cb5bcb8950fa5d76a3624f2ded79807ec26e62b5 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMask.tsx @@ -0,0 +1,35 @@ +import { Spacer } from '@invoke-ai/ui-library'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; +import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; +import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; +import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; +import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; +import { InpaintMaskAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; +import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo, useMemo } from 'react'; + +type Props = { + id: string; +}; + +export const InpaintMask = memo(({ id }: Props) => { + const entityIdentifier = useMemo>(() => ({ id, type: 'inpaint_mask' }), [id]); + + return ( + + + + + + + + + + + + + ); +}); + +InpaintMask.displayName = 'InpaintMask'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8bbb49a9865fbdbc3be6ac4502fcde3a184b0540 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskList.tsx @@ -0,0 +1,37 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; +import { InpaintMask } from 'features/controlLayers/components/InpaintMask/InpaintMask'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; + +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.inpaintMasks.entities.map(getEntityIdentifier).toReversed(); +}); + +const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { + return selectedEntityIdentifier?.type === 'inpaint_mask'; +}); + +export const InpaintMaskList = memo(() => { + const isSelected = useAppSelector(selectIsSelected); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); + + if (entityIdentifiers.length === 0) { + return null; + } + + if (entityIdentifiers.length > 0) { + return ( + + {entityIdentifiers.map((entityIdentifier) => ( + + ))} + + ); + } +}); + +InpaintMaskList.displayName = 'InpaintMaskList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ae99388bd796ea7355a9a8b72d24cb453ce66f2c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItems.tsx @@ -0,0 +1,34 @@ +import { MenuDivider } from '@invoke-ai/ui-library'; +import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; +import { CanvasEntityMenuItemsCropToBbox } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCropToBbox'; +import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete'; +import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate'; +import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown'; +import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; +import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; +import { InpaintMaskMenuItemsConvertToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu'; +import { InpaintMaskMenuItemsCopyToSubMenu } from 'features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu'; +import { memo } from 'react'; + +export const InpaintMaskMenuItems = memo(() => { + return ( + <> + + + + + + + + + + + + + + + ); +}); + +InpaintMaskMenuItems.displayName = 'InpaintMaskMenuItems'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0a161b787fb650753b593861f6650f6c6679347a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsConvertToSubMenu.tsx @@ -0,0 +1,40 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; +import { inpaintMaskConvertedToRegionalGuidance } from 'features/controlLayers/store/canvasSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSwapBold } from 'react-icons/pi'; + +export const InpaintMaskMenuItemsConvertToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('inpaint_mask'); + const isBusy = useCanvasIsBusy(); + const isLocked = useEntityIsLocked(entityIdentifier); + + const convertToRegionalGuidance = useCallback(() => { + dispatch(inpaintMaskConvertedToRegionalGuidance({ entityIdentifier, replace: true })); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isBusy || isLocked}> + + + + + + } isDisabled={isBusy || isLocked}> + {t('controlLayers.regionalGuidance')} + + + + + ); +}); + +InpaintMaskMenuItemsConvertToSubMenu.displayName = 'InpaintMaskMenuItemsConvertToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1390da3dde38c1bf8347e92623a6d9e3790bb8ca --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InpaintMask/InpaintMaskMenuItemsCopyToSubMenu.tsx @@ -0,0 +1,40 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { inpaintMaskConvertedToRegionalGuidance } from 'features/controlLayers/store/canvasSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCopyBold } from 'react-icons/pi'; + +export const InpaintMaskMenuItemsCopyToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('inpaint_mask'); + const isBusy = useCanvasIsBusy(); + + const copyToRegionalGuidance = useCallback(() => { + dispatch(inpaintMaskConvertedToRegionalGuidance({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isBusy}> + + + + + + + } isDisabled={isBusy}> + {t('controlLayers.newRegionalGuidance')} + + + + + ); +}); + +InpaintMaskMenuItemsCopyToSubMenu.displayName = 'InpaintMaskMenuItemsCopyToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/InvokeCanvasComponent.tsx b/invokeai/frontend/web/src/features/controlLayers/components/InvokeCanvasComponent.tsx new file mode 100644 index 0000000000000000000000000000000000000000..871b9e055f105d805854f1b9e172373ac9be694e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/InvokeCanvasComponent.tsx @@ -0,0 +1,23 @@ +import { Box } from '@invoke-ai/ui-library'; +import { useInvokeCanvas } from 'features/controlLayers/hooks/useInvokeCanvas'; +import { memo } from 'react'; + +export const InvokeCanvasComponent = memo(() => { + const ref = useInvokeCanvas(); + + return ( + + ); +}); + +InvokeCanvasComponent.displayName = 'InvokeCanvasComponent'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b365abb8077a7dd05d306ddc8ff5d296548aadd6 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/NewSessionConfirmationAlertDialog.tsx @@ -0,0 +1,136 @@ +import { Checkbox, ConfirmationAlertDialog, Flex, FormControl, FormLabel, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import { buildUseBoolean } from 'common/hooks/useBoolean'; +import { newCanvasSessionRequested, newGallerySessionRequested } from 'features/controlLayers/store/actions'; +import { useImageViewer } from 'features/gallery/components/ImageViewer/useImageViewer'; +import { + selectSystemShouldConfirmOnNewSession, + shouldConfirmOnNewSessionToggled, +} from 'features/system/store/systemSlice'; +import { activeTabCanvasRightPanelChanged } from 'features/ui/store/uiSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +const [useNewGallerySessionDialog] = buildUseBoolean(false); +const [useNewCanvasSessionDialog] = buildUseBoolean(false); + +export const useNewGallerySession = () => { + const dispatch = useAppDispatch(); + const imageViewer = useImageViewer(); + const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); + const newSessionDialog = useNewGallerySessionDialog(); + + const newGallerySessionImmediate = useCallback(() => { + dispatch(newGallerySessionRequested()); + imageViewer.open(); + dispatch(activeTabCanvasRightPanelChanged('gallery')); + }, [dispatch, imageViewer]); + + const newGallerySessionWithDialog = useCallback(() => { + if (shouldConfirmOnNewSession) { + newSessionDialog.setTrue(); + return; + } + newGallerySessionImmediate(); + }, [newGallerySessionImmediate, newSessionDialog, shouldConfirmOnNewSession]); + + return { newGallerySessionImmediate, newGallerySessionWithDialog }; +}; + +export const useNewCanvasSession = () => { + const dispatch = useAppDispatch(); + const imageViewer = useImageViewer(); + const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); + const newSessionDialog = useNewCanvasSessionDialog(); + + const newCanvasSessionImmediate = useCallback(() => { + dispatch(newCanvasSessionRequested()); + imageViewer.close(); + dispatch(activeTabCanvasRightPanelChanged('layers')); + }, [dispatch, imageViewer]); + + const newCanvasSessionWithDialog = useCallback(() => { + if (shouldConfirmOnNewSession) { + newSessionDialog.setTrue(); + return; + } + + newCanvasSessionImmediate(); + }, [newCanvasSessionImmediate, newSessionDialog, shouldConfirmOnNewSession]); + + return { newCanvasSessionImmediate, newCanvasSessionWithDialog }; +}; + +export const NewGallerySessionDialog = memo(() => { + useAssertSingleton('NewGallerySessionDialog'); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const dialog = useNewGallerySessionDialog(); + const { newGallerySessionImmediate } = useNewGallerySession(); + + const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); + const onToggleConfirm = useCallback(() => { + dispatch(shouldConfirmOnNewSessionToggled()); + }, [dispatch]); + + return ( + + + {t('controlLayers.newGallerySessionDesc')} + {t('common.areYouSure')} + + {t('common.dontAskMeAgain')} + + + + + ); +}); + +NewGallerySessionDialog.displayName = 'NewGallerySessionDialog'; + +export const NewCanvasSessionDialog = memo(() => { + useAssertSingleton('NewCanvasSessionDialog'); + const { t } = useTranslation(); + + const dispatch = useAppDispatch(); + + const dialog = useNewCanvasSessionDialog(); + const { newCanvasSessionImmediate } = useNewCanvasSession(); + + const shouldConfirmOnNewSession = useAppSelector(selectSystemShouldConfirmOnNewSession); + const onToggleConfirm = useCallback(() => { + dispatch(shouldConfirmOnNewSessionToggled()); + }, [dispatch]); + + return ( + + + {t('controlLayers.newCanvasSessionDesc')} + {t('common.areYouSure')} + + {t('common.dontAskMeAgain')} + + + + + ); +}); + +NewCanvasSessionDialog.displayName = 'NewCanvasSessionDialog'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/ParamDenoisingStrength.tsx b/invokeai/frontend/web/src/features/controlLayers/components/ParamDenoisingStrength.tsx new file mode 100644 index 0000000000000000000000000000000000000000..4314c47c01c86d517d4abd6b198ec4a738a38f8e --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/ParamDenoisingStrength.tsx @@ -0,0 +1,83 @@ +import { + Badge, + CompositeNumberInput, + CompositeSlider, + Flex, + FormControl, + FormLabel, + useToken, +} from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; +import WavyLine from 'common/components/WavyLine'; +import { selectImg2imgStrength, setImg2imgStrength } from 'features/controlLayers/store/paramsSlice'; +import { selectActiveRasterLayerEntities } from 'features/controlLayers/store/selectors'; +import { selectImg2imgStrengthConfig } from 'features/system/store/configSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; + +const selectHasRasterLayersWithContent = createSelector( + selectActiveRasterLayerEntities, + (entities) => entities.length > 0 +); + +export const ParamDenoisingStrength = memo(() => { + const img2imgStrength = useAppSelector(selectImg2imgStrength); + const dispatch = useAppDispatch(); + const hasRasterLayersWithContent = useAppSelector(selectHasRasterLayersWithContent); + + const onChange = useCallback( + (v: number) => { + dispatch(setImg2imgStrength(v)); + }, + [dispatch] + ); + + const config = useAppSelector(selectImg2imgStrengthConfig); + const { t } = useTranslation(); + + const [invokeBlue300] = useToken('colors', ['invokeBlue.300']); + + return ( + + + + {`${t('parameters.denoisingStrength')}`} + + {hasRasterLayersWithContent && ( + + )} + + {hasRasterLayersWithContent ? ( + <> + + + + ) : ( + + {t('parameters.disabledNoRasterContent')} + + )} + + ); +}); + +ParamDenoisingStrength.displayName = 'ParamDenoisingStrength'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx new file mode 100644 index 0000000000000000000000000000000000000000..bad65ebd92ee3aee4f19beb851aa91a56474d023 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayer.tsx @@ -0,0 +1,52 @@ +import { Spacer } from '@invoke-ai/ui-library'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; +import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; +import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; +import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; +import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; +import { RasterLayerAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; +import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import type { ReplaceCanvasEntityObjectsWithImageDndTargetData } from 'features/dnd/dnd'; +import { replaceCanvasEntityObjectsWithImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type Props = { + id: string; +}; + +export const RasterLayer = memo(({ id }: Props) => { + const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); + const entityIdentifier = useMemo>(() => ({ id, type: 'raster_layer' }), [id]); + const dndTargetData = useMemo( + () => replaceCanvasEntityObjectsWithImageDndTarget.getData({ entityIdentifier }, entityIdentifier.id), + [entityIdentifier] + ); + + return ( + + + + + + + + + + + + + + ); +}); + +RasterLayer.displayName = 'RasterLayer'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..c585a49cc3eb0b95bd717976c556b0c3dc887d72 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerEntityList.tsx @@ -0,0 +1,36 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; +import { RasterLayer } from 'features/controlLayers/components/RasterLayer/RasterLayer'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; + +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.rasterLayers.entities.map(getEntityIdentifier).toReversed(); +}); +const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { + return selectedEntityIdentifier?.type === 'raster_layer'; +}); + +export const RasterLayerEntityList = memo(() => { + const isSelected = useAppSelector(selectIsSelected); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); + + if (entityIdentifiers.length === 0) { + return null; + } + + if (entityIdentifiers.length > 0) { + return ( + + {entityIdentifiers.map((entityIdentifier) => ( + + ))} + + ); + } +}); + +RasterLayerEntityList.displayName = 'RasterLayerEntityList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..65a16a7b4f93c19e0262f723c5c59cd2b748a768 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItems.tsx @@ -0,0 +1,38 @@ +import { MenuDivider } from '@invoke-ai/ui-library'; +import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; +import { CanvasEntityMenuItemsCropToBbox } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCropToBbox'; +import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete'; +import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate'; +import { CanvasEntityMenuItemsFilter } from 'features/controlLayers/components/common/CanvasEntityMenuItemsFilter'; +import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown'; +import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; +import { CanvasEntityMenuItemsSelectObject } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSelectObject'; +import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; +import { RasterLayerMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu'; +import { RasterLayerMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu'; +import { memo } from 'react'; + +export const RasterLayerMenuItems = memo(() => { + return ( + <> + + + + + + + + + + + + + + + + + ); +}); + +RasterLayerMenuItems.displayName = 'RasterLayerMenuItems'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a72f0626b1225319a737ba36e32c1dda923fbc7d --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsConvertToSubMenu.tsx @@ -0,0 +1,67 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { deepClone } from 'common/util/deepClone'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; +import { + rasterLayerConvertedToControlLayer, + rasterLayerConvertedToInpaintMask, + rasterLayerConvertedToRegionalGuidance, +} from 'features/controlLayers/store/canvasSlice'; +import { initialControlNet } from 'features/controlLayers/store/util'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSwapBold } from 'react-icons/pi'; + +export const RasterLayerMenuItemsConvertToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('raster_layer'); + const isBusy = useCanvasIsBusy(); + const isLocked = useEntityIsLocked(entityIdentifier); + + const convertToInpaintMask = useCallback(() => { + dispatch(rasterLayerConvertedToInpaintMask({ entityIdentifier, replace: true })); + }, [dispatch, entityIdentifier]); + + const convertToRegionalGuidance = useCallback(() => { + dispatch(rasterLayerConvertedToRegionalGuidance({ entityIdentifier, replace: true })); + }, [dispatch, entityIdentifier]); + + const convertToControlLayer = useCallback(() => { + dispatch( + rasterLayerConvertedToControlLayer({ + entityIdentifier, + replace: true, + overrides: { controlAdapter: deepClone(initialControlNet) }, + }) + ); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isBusy || isLocked}> + + + + + + } isDisabled={isBusy || isLocked}> + {t('controlLayers.inpaintMask')} + + } isDisabled={isBusy || isLocked}> + {t('controlLayers.regionalGuidance')} + + } isDisabled={isBusy || isLocked}> + {t('controlLayers.controlLayer')} + + + + + ); +}); + +RasterLayerMenuItemsConvertToSubMenu.displayName = 'RasterLayerMenuItemsConvertToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..96b4c01aad79e936ccd81bb1d65d847d9ef91621 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RasterLayer/RasterLayerMenuItemsCopyToSubMenu.tsx @@ -0,0 +1,66 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { deepClone } from 'common/util/deepClone'; +import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { + rasterLayerConvertedToControlLayer, + rasterLayerConvertedToInpaintMask, + rasterLayerConvertedToRegionalGuidance, +} from 'features/controlLayers/store/canvasSlice'; +import { initialControlNet } from 'features/controlLayers/store/util'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCopyBold } from 'react-icons/pi'; + +export const RasterLayerMenuItemsCopyToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('raster_layer'); + const isBusy = useCanvasIsBusy(); + + const copyToInpaintMask = useCallback(() => { + dispatch(rasterLayerConvertedToInpaintMask({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + const copyToRegionalGuidance = useCallback(() => { + dispatch(rasterLayerConvertedToRegionalGuidance({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + const copyToControlLayer = useCallback(() => { + dispatch( + rasterLayerConvertedToControlLayer({ + entityIdentifier, + overrides: { controlAdapter: deepClone(initialControlNet) }, + }) + ); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isBusy}> + + + + + + + } isDisabled={isBusy}> + {t('controlLayers.newInpaintMask')} + + } isDisabled={isBusy}> + {t('controlLayers.newRegionalGuidance')} + + } isDisabled={isBusy}> + {t('controlLayers.newControlLayer')} + + + + + ); +}); + +RasterLayerMenuItemsCopyToSubMenu.displayName = 'RasterLayerMenuItemsCopyToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1d52e1b582b5ac191c48f0d6cba9117d2110433d --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidance.tsx @@ -0,0 +1,42 @@ +import { Spacer } from '@invoke-ai/ui-library'; +import { CanvasEntityContainer } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityContainer'; +import { CanvasEntityHeader } from 'features/controlLayers/components/common/CanvasEntityHeader'; +import { CanvasEntityHeaderCommonActions } from 'features/controlLayers/components/common/CanvasEntityHeaderCommonActions'; +import { CanvasEntityPreviewImage } from 'features/controlLayers/components/common/CanvasEntityPreviewImage'; +import { CanvasEntityEditableTitle } from 'features/controlLayers/components/common/CanvasEntityTitleEdit'; +import { RegionalGuidanceBadges } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges'; +import { RegionalGuidanceSettings } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceSettings'; +import { RegionalGuidanceAdapterGate } from 'features/controlLayers/contexts/EntityAdapterContext'; +import { EntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import type { CanvasEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo, useMemo } from 'react'; + +type Props = { + id: string; +}; + +export const RegionalGuidance = memo(({ id }: Props) => { + const entityIdentifier = useMemo>( + () => ({ id, type: 'regional_guidance' }), + [id] + ); + + return ( + + + + + + + + + + + + + + + ); +}); + +RegionalGuidance.displayName = 'RegionalGuidance'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx new file mode 100644 index 0000000000000000000000000000000000000000..059135b5079dd5b2bcbd91f4d8c8dc384a552ece --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceAddPromptsIPAdapterButtons.tsx @@ -0,0 +1,52 @@ +import { Button, Flex } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { + buildSelectValidRegionalGuidanceActions, + useAddRegionalGuidanceIPAdapter, + useAddRegionalGuidanceNegativePrompt, + useAddRegionalGuidancePositivePrompt, +} from 'features/controlLayers/hooks/addLayerHooks'; +import { useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiPlusBold } from 'react-icons/pi'; + +export const RegionalGuidanceAddPromptsIPAdapterButtons = () => { + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const { t } = useTranslation(); + const addRegionalGuidanceIPAdapter = useAddRegionalGuidanceIPAdapter(entityIdentifier); + const addRegionalGuidancePositivePrompt = useAddRegionalGuidancePositivePrompt(entityIdentifier); + const addRegionalGuidanceNegativePrompt = useAddRegionalGuidanceNegativePrompt(entityIdentifier); + + const selectValidActions = useMemo( + () => buildSelectValidRegionalGuidanceActions(entityIdentifier), + [entityIdentifier] + ); + const validActions = useAppSelector(selectValidActions); + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8e77de7218e64db9c8129c7d43f19af14ae4e548 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceBadges.tsx @@ -0,0 +1,29 @@ +import { Badge } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const RegionalGuidanceBadges = memo(() => { + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const { t } = useTranslation(); + const selectAutoNegative = useMemo( + () => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).autoNegative), + [entityIdentifier] + ); + const autoNegative = useAppSelector(selectAutoNegative); + + return ( + <> + {autoNegative && ( + + {t('controlLayers.autoNegative')} + + )} + + ); +}); + +RegionalGuidanceBadges.displayName = 'RegionalGuidanceBadges'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceDeletePromptButton.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceDeletePromptButton.tsx new file mode 100644 index 0000000000000000000000000000000000000000..2fc7483756d4c8886d26ccf759c78616bb9481c6 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceDeletePromptButton.tsx @@ -0,0 +1,28 @@ +import { IconButton, Tooltip } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiTrashSimpleFill } from 'react-icons/pi'; + +type Props = { + onDelete: () => void; +}; + +export const RegionalGuidanceDeletePromptButton = memo(({ onDelete }: Props) => { + const { t } = useTranslation(); + return ( + + } + onClick={onDelete} + flexGrow={0} + size="sm" + p={0} + colorScheme="error" + /> + + ); +}); + +RegionalGuidanceDeletePromptButton.displayName = 'RegionalGuidanceDeletePromptButton'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx new file mode 100644 index 0000000000000000000000000000000000000000..75224b7689a80db4189e072a6b1cfd0098d30e6a --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceEntityList.tsx @@ -0,0 +1,36 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { CanvasEntityGroupList } from 'features/controlLayers/components/CanvasEntityList/CanvasEntityGroupList'; +import { RegionalGuidance } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidance'; +import { selectCanvasSlice, selectSelectedEntityIdentifier } from 'features/controlLayers/store/selectors'; +import { getEntityIdentifier } from 'features/controlLayers/store/types'; +import { memo } from 'react'; + +const selectEntityIdentifiers = createMemoizedSelector(selectCanvasSlice, (canvas) => { + return canvas.regionalGuidance.entities.map(getEntityIdentifier).toReversed(); +}); +const selectIsSelected = createSelector(selectSelectedEntityIdentifier, (selectedEntityIdentifier) => { + return selectedEntityIdentifier?.type === 'regional_guidance'; +}); + +export const RegionalGuidanceEntityList = memo(() => { + const isSelected = useAppSelector(selectIsSelected); + const entityIdentifiers = useAppSelector(selectEntityIdentifiers); + + if (entityIdentifiers.length === 0) { + return null; + } + + if (entityIdentifiers.length > 0) { + return ( + + {entityIdentifiers.map((entityIdentifier) => ( + + ))} + + ); + } +}); + +RegionalGuidanceEntityList.displayName = 'RegionalGuidanceEntityList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx new file mode 100644 index 0000000000000000000000000000000000000000..8ee97d351d2ebc6be20c169d240f4e45a6864fe1 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings.tsx @@ -0,0 +1,164 @@ +import { Box, Flex, IconButton, Spacer, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { BeginEndStepPct } from 'features/controlLayers/components/common/BeginEndStepPct'; +import { Weight } from 'features/controlLayers/components/common/Weight'; +import { IPAdapterImagePreview } from 'features/controlLayers/components/IPAdapter/IPAdapterImagePreview'; +import { IPAdapterMethod } from 'features/controlLayers/components/IPAdapter/IPAdapterMethod'; +import { IPAdapterModel } from 'features/controlLayers/components/IPAdapter/IPAdapterModel'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { usePullBboxIntoRegionalGuidanceReferenceImage } from 'features/controlLayers/hooks/saveCanvasHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { + rgIPAdapterBeginEndStepPctChanged, + rgIPAdapterCLIPVisionModelChanged, + rgIPAdapterDeleted, + rgIPAdapterImageChanged, + rgIPAdapterMethodChanged, + rgIPAdapterModelChanged, + rgIPAdapterWeightChanged, +} from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectRegionalGuidanceReferenceImage } from 'features/controlLayers/store/selectors'; +import type { CLIPVisionModelV2, IPMethodV2 } from 'features/controlLayers/store/types'; +import type { SetRegionalGuidanceReferenceImageDndTargetData } from 'features/dnd/dnd'; +import { setRegionalGuidanceReferenceImageDndTarget } from 'features/dnd/dnd'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiBoundingBoxBold, PiTrashSimpleFill } from 'react-icons/pi'; +import type { ImageDTO, IPAdapterModelConfig } from 'services/api/types'; +import { assert } from 'tsafe'; + +type Props = { + referenceImageId: string; +}; + +export const RegionalGuidanceIPAdapterSettings = memo(({ referenceImageId }: Props) => { + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const onDeleteIPAdapter = useCallback(() => { + dispatch(rgIPAdapterDeleted({ entityIdentifier, referenceImageId })); + }, [dispatch, entityIdentifier, referenceImageId]); + const selectIPAdapter = useMemo( + () => + createSelector(selectCanvasSlice, (canvas) => { + const referenceImage = selectRegionalGuidanceReferenceImage(canvas, entityIdentifier, referenceImageId); + assert(referenceImage, `Regional Guidance IP Adapter with id ${referenceImageId} not found`); + return referenceImage.ipAdapter; + }), + [entityIdentifier, referenceImageId] + ); + const ipAdapter = useAppSelector(selectIPAdapter); + + const onChangeBeginEndStepPct = useCallback( + (beginEndStepPct: [number, number]) => { + dispatch(rgIPAdapterBeginEndStepPctChanged({ entityIdentifier, referenceImageId, beginEndStepPct })); + }, + [dispatch, entityIdentifier, referenceImageId] + ); + + const onChangeWeight = useCallback( + (weight: number) => { + dispatch(rgIPAdapterWeightChanged({ entityIdentifier, referenceImageId, weight })); + }, + [dispatch, entityIdentifier, referenceImageId] + ); + + const onChangeIPMethod = useCallback( + (method: IPMethodV2) => { + dispatch(rgIPAdapterMethodChanged({ entityIdentifier, referenceImageId, method })); + }, + [dispatch, entityIdentifier, referenceImageId] + ); + + const onChangeModel = useCallback( + (modelConfig: IPAdapterModelConfig) => { + dispatch(rgIPAdapterModelChanged({ entityIdentifier, referenceImageId, modelConfig })); + }, + [dispatch, entityIdentifier, referenceImageId] + ); + + const onChangeCLIPVisionModel = useCallback( + (clipVisionModel: CLIPVisionModelV2) => { + dispatch(rgIPAdapterCLIPVisionModelChanged({ entityIdentifier, referenceImageId, clipVisionModel })); + }, + [dispatch, entityIdentifier, referenceImageId] + ); + + const onChangeImage = useCallback( + (imageDTO: ImageDTO | null) => { + dispatch(rgIPAdapterImageChanged({ entityIdentifier, referenceImageId, imageDTO })); + }, + [dispatch, entityIdentifier, referenceImageId] + ); + + const dndTargetData = useMemo( + () => + setRegionalGuidanceReferenceImageDndTarget.getData( + { entityIdentifier, referenceImageId }, + ipAdapter.image?.image_name + ), + [entityIdentifier, ipAdapter.image?.image_name, referenceImageId] + ); + + const pullBboxIntoIPAdapter = usePullBboxIntoRegionalGuidanceReferenceImage(entityIdentifier, referenceImageId); + const isBusy = useCanvasIsBusy(); + + return ( + + + + {t('controlLayers.referenceImage')} + + + } + tooltip={t('controlLayers.deleteReferenceImage')} + aria-label={t('controlLayers.deleteReferenceImage')} + onClick={onDeleteIPAdapter} + colorScheme="error" + /> + + + + + + + } + /> + + + + + + + + + + + + + + ); +}); + +RegionalGuidanceIPAdapterSettings.displayName = 'RegionalGuidanceIPAdapterSettings'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapters.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapters.tsx new file mode 100644 index 0000000000000000000000000000000000000000..a6f8956cc22dc4b8f9f2c1a5d683a7d5fff3c972 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapters.tsx @@ -0,0 +1,43 @@ +import { Divider } from '@invoke-ai/ui-library'; +import { EMPTY_ARRAY } from 'app/store/constants'; +import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; +import { useAppSelector } from 'app/store/storeHooks'; +import { RegionalGuidanceIPAdapterSettings } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceIPAdapterSettings'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import { Fragment, memo, useMemo } from 'react'; + +export const RegionalGuidanceIPAdapters = memo(() => { + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + + const selectIPAdapterIds = useMemo( + () => + createMemoizedSelector(selectCanvasSlice, (canvas) => { + const ipAdapterIds = selectEntityOrThrow(canvas, entityIdentifier).referenceImages.map(({ id }) => id); + if (ipAdapterIds.length === 0) { + return EMPTY_ARRAY; + } + return ipAdapterIds; + }), + [entityIdentifier] + ); + + const ipAdapterIds = useAppSelector(selectIPAdapterIds); + + if (ipAdapterIds.length === 0) { + return null; + } + + return ( + <> + {ipAdapterIds.map((ipAdapterId, index) => ( + + {index > 0 && } + + + ))} + + ); +}); + +RegionalGuidanceIPAdapters.displayName = 'RegionalGuidanceLayerIPAdapterList'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems.tsx new file mode 100644 index 0000000000000000000000000000000000000000..55c14971ea647c7f2ac13a545f874fd2633abdc8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItems.tsx @@ -0,0 +1,39 @@ +import { MenuDivider } from '@invoke-ai/ui-library'; +import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { CanvasEntityMenuItemsArrange } from 'features/controlLayers/components/common/CanvasEntityMenuItemsArrange'; +import { CanvasEntityMenuItemsCropToBbox } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCropToBbox'; +import { CanvasEntityMenuItemsDelete } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDelete'; +import { CanvasEntityMenuItemsDuplicate } from 'features/controlLayers/components/common/CanvasEntityMenuItemsDuplicate'; +import { CanvasEntityMenuItemsMergeDown } from 'features/controlLayers/components/common/CanvasEntityMenuItemsMergeDown'; +import { CanvasEntityMenuItemsSave } from 'features/controlLayers/components/common/CanvasEntityMenuItemsSave'; +import { CanvasEntityMenuItemsTransform } from 'features/controlLayers/components/common/CanvasEntityMenuItemsTransform'; +import { RegionalGuidanceMenuItemsAddPromptsAndIPAdapter } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter'; +import { RegionalGuidanceMenuItemsAutoNegative } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAutoNegative'; +import { RegionalGuidanceMenuItemsConvertToSubMenu } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsConvertToSubMenu'; +import { RegionalGuidanceMenuItemsCopyToSubMenu } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsCopyToSubMenu'; +import { memo } from 'react'; + +export const RegionalGuidanceMenuItems = memo(() => { + return ( + <> + + + + + + + + + + + + + + + + + + ); +}); + +RegionalGuidanceMenuItems.displayName = 'RegionalGuidanceMenuItems'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx new file mode 100644 index 0000000000000000000000000000000000000000..ecf40338ad7783af646095a7cbe81542ebeca17c --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.tsx @@ -0,0 +1,42 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { + buildSelectValidRegionalGuidanceActions, + useAddRegionalGuidanceIPAdapter, + useAddRegionalGuidanceNegativePrompt, + useAddRegionalGuidancePositivePrompt, +} from 'features/controlLayers/hooks/addLayerHooks'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { memo, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +export const RegionalGuidanceMenuItemsAddPromptsAndIPAdapter = memo(() => { + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const { t } = useTranslation(); + const isBusy = useCanvasIsBusy(); + const addRegionalGuidanceIPAdapter = useAddRegionalGuidanceIPAdapter(entityIdentifier); + const addRegionalGuidancePositivePrompt = useAddRegionalGuidancePositivePrompt(entityIdentifier); + const addRegionalGuidanceNegativePrompt = useAddRegionalGuidanceNegativePrompt(entityIdentifier); + const selectValidActions = useMemo( + () => buildSelectValidRegionalGuidanceActions(entityIdentifier), + [entityIdentifier] + ); + const validActions = useAppSelector(selectValidActions); + + return ( + <> + + {t('controlLayers.addPositivePrompt')} + + + {t('controlLayers.addNegativePrompt')} + + + {t('controlLayers.addReferenceImage')} + + + ); +}); + +RegionalGuidanceMenuItemsAddPromptsAndIPAdapter.displayName = 'RegionalGuidanceMenuItemsExtra'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAutoNegative.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAutoNegative.tsx new file mode 100644 index 0000000000000000000000000000000000000000..0cd3480fd9371b4e13c32e688ba1df2072756253 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsAutoNegative.tsx @@ -0,0 +1,31 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { rgAutoNegativeToggled } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSelectionInverseBold } from 'react-icons/pi'; + +export const RegionalGuidanceMenuItemsAutoNegative = memo(() => { + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selectAutoNegative = useMemo( + () => createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).autoNegative), + [entityIdentifier] + ); + const autoNegative = useAppSelector(selectAutoNegative); + const onClick = useCallback(() => { + dispatch(rgAutoNegativeToggled({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + return ( + } onClick={onClick}> + {autoNegative ? t('controlLayers.disableAutoNegative') : t('controlLayers.enableAutoNegative')} + + ); +}); + +RegionalGuidanceMenuItemsAutoNegative.displayName = 'RegionalGuidanceMenuItemsAutoNegative'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsConvertToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsConvertToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..b0f30f809c684a49bedbe40bcfee67f882948de8 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsConvertToSubMenu.tsx @@ -0,0 +1,40 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { useEntityIsLocked } from 'features/controlLayers/hooks/useEntityIsLocked'; +import { rgConvertedToInpaintMask } from 'features/controlLayers/store/canvasSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSwapBold } from 'react-icons/pi'; + +export const RegionalGuidanceMenuItemsConvertToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const isBusy = useCanvasIsBusy(); + const isLocked = useEntityIsLocked(entityIdentifier); + + const convertToInpaintMask = useCallback(() => { + dispatch(rgConvertedToInpaintMask({ entityIdentifier, replace: true })); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isLocked || isBusy}> + + + + + + } isDisabled={isLocked || isBusy}> + {t('controlLayers.inpaintMask')} + + + + + ); +}); + +RegionalGuidanceMenuItemsConvertToSubMenu.displayName = 'RegionalGuidanceMenuItemsConvertToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsCopyToSubMenu.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsCopyToSubMenu.tsx new file mode 100644 index 0000000000000000000000000000000000000000..fa07d19ad6fb725d0a1d45f51b487d61bced52ca --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceMenuItemsCopyToSubMenu.tsx @@ -0,0 +1,40 @@ +import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; +import { CanvasEntityMenuItemsCopyToClipboard } from 'features/controlLayers/components/common/CanvasEntityMenuItemsCopyToClipboard'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { useCanvasIsBusy } from 'features/controlLayers/hooks/useCanvasIsBusy'; +import { rgConvertedToInpaintMask } from 'features/controlLayers/store/canvasSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCopyBold } from 'react-icons/pi'; + +export const RegionalGuidanceMenuItemsCopyToSubMenu = memo(() => { + const { t } = useTranslation(); + const subMenu = useSubMenu(); + const dispatch = useAppDispatch(); + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const isBusy = useCanvasIsBusy(); + + const copyToInpaintMask = useCallback(() => { + dispatch(rgConvertedToInpaintMask({ entityIdentifier })); + }, [dispatch, entityIdentifier]); + + return ( + } isDisabled={isBusy}> + + + + + + + } isDisabled={isBusy}> + {t('controlLayers.newInpaintMask')} + + + + + ); +}); + +RegionalGuidanceMenuItemsCopyToSubMenu.displayName = 'RegionalGuidanceMenuItemsCopyToSubMenu'; diff --git a/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceNegativePrompt.tsx b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceNegativePrompt.tsx new file mode 100644 index 0000000000000000000000000000000000000000..1fb4d8d324dff07963071b9cc7f272589e531b15 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlLayers/components/RegionalGuidance/RegionalGuidanceNegativePrompt.tsx @@ -0,0 +1,80 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { Box, Flex, Textarea } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { RegionalGuidanceDeletePromptButton } from 'features/controlLayers/components/RegionalGuidance/RegionalGuidanceDeletePromptButton'; +import { useEntityIdentifierContext } from 'features/controlLayers/contexts/EntityIdentifierContext'; +import { rgNegativePromptChanged } from 'features/controlLayers/store/canvasSlice'; +import { selectCanvasSlice, selectEntityOrThrow } from 'features/controlLayers/store/selectors'; +import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton'; +import { PromptPopover } from 'features/prompt/PromptPopover'; +import { usePrompt } from 'features/prompt/usePrompt'; +import { memo, useCallback, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; + +const _focusVisible: SystemStyleObject = { + outline: 'none', +}; + +export const RegionalGuidanceNegativePrompt = memo(() => { + const entityIdentifier = useEntityIdentifierContext('regional_guidance'); + const selectPrompt = useMemo( + () => + createSelector(selectCanvasSlice, (canvas) => selectEntityOrThrow(canvas, entityIdentifier).negativePrompt ?? ''), + [entityIdentifier] + ); + const prompt = useAppSelector(selectPrompt); + const dispatch = useAppDispatch(); + const textareaRef = useRef(null); + const { t } = useTranslation(); + const _onChange = useCallback( + (v: string) => { + dispatch(rgNegativePromptChanged({ entityIdentifier, prompt: v })); + }, + [dispatch, entityIdentifier] + ); + const onDeletePrompt = useCallback(() => { + dispatch(rgNegativePromptChanged({ entityIdentifier, prompt: null })); + }, [dispatch, entityIdentifier]); + const { onChange, isOpen, onClose, onOpen, onSelect, onKeyDown } = usePrompt({ + prompt, + textareaRef, + onChange: _onChange, + }); + + return ( + + +