Merge branch 'main' into checkpoint-ip-adapter

This commit is contained in:
blessedcoolant 2024-03-29 12:39:53 +05:30
commit cd52e99bb9
46 changed files with 2861 additions and 3905 deletions

View File

@ -41,5 +41,5 @@ jobs:
- name: upload installer artifact
uses: actions/upload-artifact@v4
with:
name: ${{ steps.create_installer.outputs.INSTALLER_FILENAME }}
name: installer
path: ${{ steps.create_installer.outputs.INSTALLER_PATH }}

View File

@ -61,11 +61,33 @@ This sets up both python and frontend dependencies and builds the python package
#### Sanity Check & Smoke Test
At this point, the release workflow pauses as the remaining publish jobs require approval.
At this point, the release workflow pauses as the remaining publish jobs require approval. Time to test the installer.
A maintainer should go to the **Summary** tab of the workflow, download the installer and test it. Ensure the app loads and generates.
Because the installer pulls from PyPI, and we haven't published to PyPI yet, you will need to install from the wheel:
> 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 of the `invokeai` package from any of these methods.
- 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
@ -81,6 +103,12 @@ Both jobs require a maintainer to approve them from the workflow's **Summary** t
> **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.
@ -110,11 +138,13 @@ Publishes the distribution on the production PyPI index, using the `pypi` GitHub
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.
2. 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.
3. Upload the zip file created in **`build`** job into the Assets section of the release notes. You can also upload the zip into the body of the release notes, since it can be hard for users to find the Assets section.
4. Check the **Set as a pre-release** and **Create a discussion for this release** checkboxes at the bottom of the release page.
5. Publish the pre-release.
6. Announce the pre-release in Discord.
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.
@ -140,3 +170,4 @@ This functionality is available as a fallback in case something goes wonky. Typi
[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/

View File

@ -840,22 +840,6 @@ and directories at regular intervals when the size of the cache
exceeds the value specified in Invoke's `convert_cache` configuration
variable.
#### List[str]=installer.scan_directory(scan_dir: Path, install: bool)
This method will recursively scan the directory indicated in
`scan_dir` for new models and either install them in the models
directory or register them in place, depending on the setting of
`install` (default False).
The return value is the list of keys of the new installed/registered
models.
#### installer.sync_to_config()
This method synchronizes models in the models directory and autoimport
directory to those in the `ModelConfigRecordService` database. New
models are registered and orphan models are unregistered.
#### installer.start(invoker)
The `start` method is called by the API intialization routines when

View File

@ -137,15 +137,7 @@ Most common algorithms are supported, like `md5`, `sha256`, and `sha512`. These
#### Path Settings
These options set the paths of various directories and files used by
InvokeAI. Relative paths are interpreted relative to the root directory, so
if root is `/home/fred/invokeai` and the path is
`autoimport/main`, then the corresponding directory will be located at
`/home/fred/invokeai/autoimport/main`.
Note that the autoimport directory will be searched recursively,
allowing you to organize the models into folders and subfolders in any
way you wish.
These options set the paths of various directories and files used by InvokeAI. Any user-defined paths should be absolute paths.
#### Logging

View File

@ -20,10 +20,7 @@ are applied to generate imagery. LoRAs may be supplied with a
simply apply their effect without being triggered.
LoRAs are typically stored in .safetensors files, which are the most
secure way to store and transmit these types of weights. You may
install any number of `.safetensors` LoRA files simply by copying them
into the `autoimport/lora` directory of the corresponding InvokeAI models
directory (usually `invokeai` in your home directory).
secure way to store and transmit these types of weights.
To use these when generating, open the LoRA menu item in the options
panel, select the LoRAs you want to apply and ensure that they have

View File

@ -10,7 +10,7 @@ Today, there are thousands of models, fine tuned to excel at specific styles, ge
!!! tip "Model Formats"
We also have two more popular model formats, both created [HuggingFace]:
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.
@ -19,7 +19,7 @@ Today, there are thousands of models, fine tuned to excel at specific styles, ge
## Starter Models
When you first start InvokeAI, you'll see a popup prompting you to install some starter models from the Model Manager.
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.
@ -27,12 +27,15 @@ Some models carry license terms that limit their use in commercial applications
## Other Models
You can install other models using the Model Manager. Supported install sources include:
You can install other models using the Model Manager. You'll find tabs for the following install methods:
- Local path: The file path to the model on your computer.
- URL: A link directly to the model, typically to a model marketplace. Some sites require you to use an API token to download models, which you can [set up in the config file].
- `HuggingFace` repo ID: This points to a HF model. Repo IDs look like this: `XpucT/Deliberate`.
- Folder: Scan a local folder for models. You can install all of the detected models in one click.
- **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
@ -46,10 +49,4 @@ In this situation, you may need to provide some additional information to identi
Add `:v2` to the repo ID and use that when installing the model: `monster-labs/control_v1p_sd15_qrcode_monster:v2`
## Autoimport
In the InvokeAI root directory you will find an `autoimport` directory. On startup, any models in this directory will be installed and copied into the Invoke-managed models directory.
The location of the autoimport directories are controlled by settings in `invokeai.yaml`. See [Configuration](../features/CONFIGURATION.md).
[set up in the config file]: ../features/CONFIGURATION.md#model-marketplace-api-keys
[set up in the config file]: ../../features/CONFIGURATION#model-marketplace-api-keys

View File

@ -7,10 +7,11 @@
PR introduces a schema change.
If you don't need persistent backend storage, you can use an ephemeral in-memory database by setting
`use_memory_db: true` under `Path:` in your `invokeai.yaml` file.
`use_memory_db: true` in your `invokeai.yaml` file. You'll also want to set `scan_models_on_startup: true`
so that your models are registered on startup.
If this is untenable, you should run the application via the official installer or a manual install of the
python package from pypi. These releases will not break your database.
python package from PyPI. These releases will not break your database.
If you have an interest in how InvokeAI works, or you would like to add features or bugfixes, you are encouraged to install the source code for InvokeAI.
@ -22,6 +23,7 @@ If you have an interest in how InvokeAI works, or you would like to add features
1. [Fork and clone] the [InvokeAI repo].
1. Follow the [manual installation] docs to create a new virtual environment for the development install.
- When installing the InvokeAI package, add `-e` to the command so you get an [editable install].
1. Install the [frontend dev toolchain] and do a production build of the UI as described.
1. You can now run the app as described in the [manual installation] docs.
@ -31,3 +33,4 @@ As described in the [frontend dev toolchain] docs, you can run the UI using a de
[InvokeAI repo]: https://github.com/invoke-ai/InvokeAI
[frontend dev toolchain]: ../contributing/frontend/OVERVIEW.md
[manual installation]: installation/020_INSTALL_MANUAL.md
[editable install]: https://pip.pypa.io/en/latest/cli/pip_install/#cmdoption-e

View File

@ -17,6 +17,8 @@ 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
@ -158,6 +160,20 @@ class Installer:
# 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:
"""

View File

@ -441,41 +441,6 @@ async def delete_model_image(
raise HTTPException(status_code=404, detail=str(e))
# @model_manager_router.post(
# "/i/",
# operation_id="add_model_record",
# responses={
# 201: {
# "description": "The model added successfully",
# "content": {"application/json": {"example": example_model_config}},
# },
# 409: {"description": "There is already a model corresponding to this path or repo_id"},
# 415: {"description": "Unrecognized file/folder format"},
# },
# status_code=201,
# )
# async def add_model_record(
# config: Annotated[
# AnyModelConfig, Body(description="Model config", discriminator="type", example=example_model_input)
# ],
# ) -> AnyModelConfig:
# """Add a model using the configuration information appropriate for its type."""
# logger = ApiDependencies.invoker.services.logger
# record_store = ApiDependencies.invoker.services.model_manager.store
# try:
# record_store.add_model(config)
# except DuplicateModelException as e:
# logger.error(str(e))
# raise HTTPException(status_code=409, detail=str(e))
# except InvalidModelException as e:
# logger.error(str(e))
# raise HTTPException(status_code=415)
# # now fetch it out
# result: AnyModelConfig = record_store.get_model(config.key)
# return result
@model_manager_router.post(
"/install",
operation_id="install_model",
@ -627,25 +592,6 @@ async def prune_model_install_jobs() -> Response:
return Response(status_code=204)
@model_manager_router.patch(
"/sync",
operation_id="sync_models_to_config",
responses={
204: {"description": "Model config record database resynced with files on disk"},
400: {"description": "Bad request"},
},
)
async def sync_models_to_config() -> Response:
"""
Traverse the models and autoimport directories.
Model files without a corresponding
record in the database are added. Orphan records without a models file are deleted.
"""
ApiDependencies.invoker.services.model_manager.install.sync_to_config()
return Response(status_code=204)
@model_manager_router.put(
"/convert/{key}",
operation_id="convert_model",
@ -722,71 +668,6 @@ async def convert_model(
return new_config
# @model_manager_router.put(
# "/merge",
# operation_id="merge",
# 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 merge(
# keys: List[str] = Body(description="Keys for two to three models to merge", min_length=2, max_length=3),
# merged_model_name: Optional[str] = Body(description="Name of destination model", default=None),
# alpha: float = Body(description="Alpha weighting strength to apply to 2d and 3d models", default=0.5),
# force: bool = Body(
# description="Force merging of models created with different versions of diffusers",
# default=False,
# ),
# interp: Optional[MergeInterpolationMethod] = Body(description="Interpolation method", default=None),
# merge_dest_directory: Optional[str] = Body(
# description="Save the merged model to the designated directory (with 'merged_model_name' appended)",
# default=None,
# ),
# ) -> AnyModelConfig:
# """
# Merge diffusers models. The process is controlled by a set parameters provided in the body of the request.
# ```
# Argument Description [default]
# -------- ----------------------
# keys List of 2-3 model keys to merge together. All models must use the same base type.
# merged_model_name Name for the merged model [Concat model names]
# alpha Alpha value (0.0-1.0). Higher values give more weight to the second model [0.5]
# force If true, force the merge even if the models were generated by different versions of the diffusers library [False]
# interp Interpolation method. One of "weighted_sum", "sigmoid", "inv_sigmoid" or "add_difference" [weighted_sum]
# merge_dest_directory Specify a directory to store the merged model in [models directory]
# ```
# """
# logger = ApiDependencies.invoker.services.logger
# try:
# logger.info(f"Merging models: {keys} into {merge_dest_directory or '<MODELS>'}/{merged_model_name}")
# dest = pathlib.Path(merge_dest_directory) if merge_dest_directory else None
# installer = ApiDependencies.invoker.services.model_manager.install
# merger = ModelMerger(installer)
# model_names = [installer.record_store.get_model(x).name for x in keys]
# response = merger.merge_diffusion_models_and_save(
# model_keys=keys,
# merged_model_name=merged_model_name or "+".join(model_names),
# alpha=alpha,
# interp=interp,
# force=force,
# merge_dest_directory=dest,
# )
# except UnknownModelException:
# raise HTTPException(
# status_code=404,
# detail=f"One or more of the models '{keys}' not found",
# )
# except ValueError as e:
# raise HTTPException(status_code=400, detail=str(e))
# return response
@model_manager_router.get("/starter_models", operation_id="get_starter_models", response_model=list[StarterModel])
async def get_starter_models() -> list[StarterModel]:
installed_models = ApiDependencies.invoker.services.model_manager.store.search_by_attr()

View File

@ -1,4 +1,5 @@
import asyncio
import logging
import mimetypes
import socket
from contextlib import asynccontextmanager
@ -6,6 +7,7 @@ from inspect import signature
from pathlib import Path
from typing import Any
import torch
import uvicorn
from fastapi import FastAPI
from fastapi.middleware.cors import CORSMiddleware
@ -226,6 +228,22 @@ app.mount(
) # 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"""
@ -252,6 +270,8 @@ def invoke_api() -> None:
if port != app_config.port:
logger.warn(f"Port {app_config.port} in use, using port {port}")
check_cudnn(logger)
# Start our own event loop for eventing usage
loop = asyncio.new_event_loop()
config = uvicorn.Config(

View File

@ -83,7 +83,6 @@ class InvokeAIAppConfig(BaseSettings):
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.
autoimport_dir: Path to a directory of models files to be imported on startup.
models_dir: Path to the models directory.
convert_cache_dir: Path to the converted models cache directory. When loading a non-diffusers model, it will be converted and store on disk at this location.
legacy_conf_dir: Path to directory of legacy checkpoint config files.
@ -117,6 +116,7 @@ class InvokeAIAppConfig(BaseSettings):
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.<br>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)
@ -144,7 +144,6 @@ class InvokeAIAppConfig(BaseSettings):
patchmatch: bool = Field(default=True, description="Enable patchmatch inpaint code.")
# PATHS
autoimport_dir: Path = Field(default=Path("autoimport"), description="Path to a directory of models files to be imported on startup.")
models_dir: Path = Field(default=Path("models"), description="Path to the models directory.")
convert_cache_dir: Path = Field(default=Path("models/.cache"), description="Path to the converted models cache directory. When loading a non-diffusers model, it will be converted and store on disk at this location.")
legacy_conf_dir: Path = Field(default=Path("configs"), description="Path to directory of legacy checkpoint config files.")
@ -193,6 +192,7 @@ class InvokeAIAppConfig(BaseSettings):
# 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
@ -275,11 +275,6 @@ class InvokeAIAppConfig(BaseSettings):
assert resolved_path is not None
return resolved_path
@property
def autoimport_path(self) -> Path:
"""Path to the autoimports directory, resolved to an absolute path.."""
return self._resolve(self.autoimport_dir)
@property
def outputs_path(self) -> Optional[Path]:
"""Path to the outputs directory, resolved to an absolute path.."""

View File

@ -454,20 +454,6 @@ class ModelInstallServiceBase(ABC):
will block indefinitely until the installs complete.
"""
@abstractmethod
def scan_directory(self, scan_dir: Path, install: bool = False) -> List[str]:
"""
Recursively scan directory for new models and register or install them.
:param scan_dir: Path to the directory to scan.
:param install: Install if True, otherwise register in place.
:returns list of IDs: Returns list of IDs of models registered/installed
"""
@abstractmethod
def sync_to_config(self) -> None:
"""Synchronize models on disk to those in the model record database."""
@abstractmethod
def sync_model_path(self, key: str) -> AnyModelConfig:
"""

View File

@ -10,7 +10,7 @@ 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, Set, Union
from typing import Any, Dict, List, Optional, Union
import yaml
from huggingface_hub import HfFolder
@ -25,12 +25,10 @@ from invokeai.app.services.model_records import DuplicateModelException, ModelRe
from invokeai.app.services.model_records.model_records_base import ModelRecordChanges
from invokeai.backend.model_manager.config import (
AnyModelConfig,
BaseModelType,
CheckpointConfigBase,
InvalidModelConfigException,
ModelRepoVariant,
ModelSourceType,
ModelType,
)
from invokeai.backend.model_manager.metadata import (
AnyModelRepoMetadata,
@ -42,7 +40,7 @@ from invokeai.backend.model_manager.metadata import (
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 Chdir, InvokeAILogger
from invokeai.backend.util import InvokeAILogger
from invokeai.backend.util.devices import choose_precision, choose_torch_device
from .model_install_base import (
@ -84,8 +82,6 @@ class ModelInstallService(ModelInstallServiceBase):
self._logger = InvokeAILogger.get_logger(name=self.__class__.__name__)
self._install_jobs: List[ModelInstallJob] = []
self._install_queue: Queue[ModelInstallJob] = Queue()
self._cached_model_paths: Set[Path] = set()
self._models_installed: Set[str] = set()
self._lock = threading.Lock()
self._stop_event = threading.Event()
self._downloads_changed_event = threading.Event()
@ -131,7 +127,16 @@ class ModelInstallService(ModelInstallServiceBase):
self._start_installer_thread()
self._remove_dangling_install_dirs()
self._migrate_yaml()
self.sync_to_config()
# 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:
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."""
@ -306,15 +311,6 @@ class ModelInstallService(ModelInstallServiceBase):
unfinished_jobs = [x for x in self._install_jobs if not x.in_terminal_state]
self._install_jobs = unfinished_jobs
def sync_to_config(self) -> None:
"""Synchronize models on disk to those in the config record store database."""
self._scan_models_directory()
if self._app_config.autoimport_path:
self._logger.info("Scanning autoimport directory for new models")
installed = self.scan_directory(self._app_config.autoimport_path)
self._logger.info(f"{len(installed)} new models registered")
self._logger.info("Model installer (re)initialized")
def _migrate_yaml(self) -> None:
db_models = self.record_store.all_models()
@ -366,14 +362,6 @@ class ModelInstallService(ModelInstallServiceBase):
# Unset the path - we are done with it either way
self._app_config.legacy_models_yaml_path = None
def scan_directory(self, scan_dir: Path, install: bool = False) -> List[str]: # noqa D102
self._cached_model_paths = {Path(x.path).resolve() for x in self.record_store.all_models()}
callback = self._scan_install if install else self._scan_register
search = ModelSearch(on_model_found=callback)
self._models_installed.clear()
search.search(scan_dir)
return list(self._models_installed)
def unregister(self, key: str) -> None: # noqa D102
self.record_store.del_model(key)
@ -509,34 +497,44 @@ class ModelInstallService(ModelInstallServiceBase):
self._logger.info(f"Removing dangling temporary directory {tmpdir}")
rmtree(tmpdir)
def _scan_models_directory(self) -> None:
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 x in self.record_store.all_models():
if not Path(x.path).resolve().exists():
missing_models.append(x)
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.
"""
Scan the models directory for new and missing models.
New models will be added to the storage backend. Missing models
will be deleted.
"""
defunct_models = set()
installed = set()
installed_model_paths = {Path(x.path).resolve() for x in self.record_store.all_models()}
with Chdir(self._app_config.models_path):
self._logger.info("Checking for models that have been moved or deleted from disk")
for model_config in self.record_store.all_models():
path = Path(model_config.path)
if not path.exists():
self._logger.info(f"{model_config.name}: path {path.as_posix()} no longer exists. Unregistering")
defunct_models.add(model_config.key)
for key in defunct_models:
self.unregister(key)
# 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.
if str(resolved_path).startswith(str(self.app_config.models_path / "core")):
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 new and orphaned models")
for cur_base_model in BaseModelType:
for cur_model_type in ModelType:
models_dir = self._app_config.models_path / Path(cur_base_model.value, cur_model_type.value)
if not models_dir.exists():
continue
installed.update(self.scan_directory(models_dir))
self._logger.info(f"{len(installed)} new models registered; {len(defunct_models)} unregistered")
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:
"""
@ -567,29 +565,6 @@ class ModelInstallService(ModelInstallServiceBase):
self.record_store.update_model(key, ModelRecordChanges(path=model.path))
return model
def _scan_register(self, model: Path) -> bool:
if model.resolve() in self._cached_model_paths:
return True
try:
id = self.register_path(model)
self.sync_model_path(id) # possibly move it to right place in `models`
self._logger.info(f"Registered {model.name} with id {id}")
self._models_installed.add(id)
except DuplicateModelException:
pass
return True
def _scan_install(self, model: Path) -> bool:
if model in self._cached_model_paths:
return True
try:
id = self.install_path(model)
self._logger.info(f"Installed {model} with id {id}")
self._models_installed.add(id)
except DuplicateModelException:
pass
return True
def _copy_model(self, old_path: Path, new_path: Path) -> Path:
if old_path == new_path:
return old_path

View File

@ -70,8 +70,18 @@ class DefaultSessionProcessor(SessionProcessorBase):
async def _on_queue_event(self, event: FastAPIEvent) -> None:
event_name = event[1]["event"]
if event_name == "session_canceled" or event_name == "queue_cleared":
# These both mean we should cancel the current session.
if (
event_name == "session_canceled"
and self._queue_item
and self._queue_item.item_id == event[1]["data"]["queue_item_id"]
):
self._cancel_event.set()
self._poll_now()
elif (
event_name == "queue_cleared"
and self._queue_item
and self._queue_item.queue_id == event[1]["data"]["queue_id"]
):
self._cancel_event.set()
self._poll_now()
elif event_name == "batch_enqueued":

View File

@ -11,7 +11,7 @@ class Migration7Callback:
def _drop_old_models_tables(self, cursor: sqlite3.Cursor) -> None:
"""Drops the old model_records, model_metadata, model_tags and tags tables."""
tables = ["model_records", "model_metadata", "model_tags", "tags"]
tables = ["model_config", "model_metadata", "model_tags", "tags"]
for table in tables:
cursor.execute(f"DROP TABLE IF EXISTS {table};")

View File

@ -48,9 +48,8 @@ class ModelSearch:
Usage:
search = ModelSearch()
search.model_found = lambda path : 'anime' in path.as_posix()
found = search.list_models(['/tmp/models1','/tmp/models2'])
# returns all models that have 'anime' in the path
search.on_model_found = lambda path : 'anime' in path.as_posix()
found = search.search(Path('/tmp/models1'))
"""
def __init__(

View File

@ -28,6 +28,10 @@ def _conv_forward_asymmetric(self, input, weight, bias):
@contextmanager
def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL, AutoencoderTiny], seamless_axes: List[str]):
if not seamless_axes:
yield
return
# Callable: (input: Tensor, weight: Tensor, bias: Optional[Tensor]) -> Tensor
to_restore: list[tuple[nn.Conv2d | nn.ConvTranspose2d, Callable]] = []
try:

View File

@ -31,6 +31,9 @@ class ConfigMapper:
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
@ -50,12 +53,18 @@ class ConfigMapper:
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 or outdir is None:
print("The invokeai.yaml file was found but is missing the db_dir and/or outdir setting!")
return False
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)

View File

@ -6,22 +6,10 @@ const config: KnipConfig = {
'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/**',
],
ignoreBinaries: ['only-allow'],
rules: {
files: 'warn',
dependencies: 'warn',
unlisted: 'warn',
binaries: 'warn',
unresolved: 'warn',
exports: 'warn',
types: 'warn',
nsExports: 'warn',
nsTypes: 'warn',
enumMembers: 'warn',
classMembers: 'warn',
duplicates: 'warn',
},
};
export default config;

View File

@ -24,7 +24,7 @@
"build": "pnpm run lint && vite build",
"typegen": "node scripts/typegen.js",
"preview": "vite preview",
"lint:knip": "knip",
"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 .",
@ -52,56 +52,56 @@
},
"dependencies": {
"@chakra-ui/react-use-size": "^2.1.0",
"@dagrejs/graphlib": "^2.1.13",
"@dagrejs/graphlib": "^2.2.1",
"@dnd-kit/core": "^6.1.0",
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.0.16",
"@fontsource-variable/inter": "^5.0.17",
"@invoke-ai/ui-library": "^0.0.21",
"@nanostores/react": "^0.7.2",
"@reduxjs/toolkit": "2.2.1",
"@reduxjs/toolkit": "2.2.2",
"@roarr/browser-log-writer": "^1.3.0",
"chakra-react-select": "^4.7.6",
"compare-versions": "^6.1.0",
"dateformat": "^5.0.3",
"framer-motion": "^11.0.6",
"i18next": "^23.10.0",
"framer-motion": "^11.0.22",
"i18next": "^23.10.1",
"i18next-http-backend": "^2.5.0",
"idb-keyval": "^6.2.1",
"jsondiffpatch": "^0.6.0",
"konva": "^9.3.3",
"konva": "^9.3.6",
"lodash-es": "^4.17.21",
"nanostores": "^0.10.0",
"new-github-issue-url": "^1.0.0",
"overlayscrollbars": "^2.5.0",
"overlayscrollbars-react": "^0.5.4",
"overlayscrollbars": "^2.6.1",
"overlayscrollbars-react": "^0.5.5",
"query-string": "^9.0.0",
"react": "^18.2.0",
"react-colorful": "^5.6.1",
"react-dom": "^18.2.0",
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.12",
"react-hook-form": "^7.50.1",
"react-error-boundary": "^4.0.13",
"react-hook-form": "^7.51.2",
"react-hotkeys-hook": "4.5.0",
"react-i18next": "^14.0.5",
"react-i18next": "^14.1.0",
"react-icons": "^5.0.1",
"react-konva": "^18.2.10",
"react-redux": "9.1.0",
"react-resizable-panels": "^2.0.11",
"react-resizable-panels": "^2.0.16",
"react-select": "5.8.0",
"react-use": "^17.5.0",
"react-virtuoso": "^4.7.1",
"react-virtuoso": "^4.7.5",
"reactflow": "^11.10.4",
"redux-dynamic-middlewares": "^2.2.0",
"redux-remember": "^5.1.0",
"roarr": "^7.21.0",
"roarr": "^7.21.1",
"serialize-error": "^11.0.3",
"socket.io-client": "^4.7.4",
"socket.io-client": "^4.7.5",
"use-debounce": "^10.0.0",
"use-image": "^1.1.1",
"uuid": "^9.0.1",
"zod": "^3.22.4",
"zod-validation-error": "^3.0.2"
"zod-validation-error": "^3.0.3"
},
"peerDependencies": {
"@chakra-ui/react": "^2.8.2",
@ -112,40 +112,40 @@
"devDependencies": {
"@invoke-ai/eslint-config-react": "^0.0.14",
"@invoke-ai/prettier-config-react": "^0.0.7",
"@storybook/addon-essentials": "^7.6.17",
"@storybook/addon-interactions": "^7.6.17",
"@storybook/addon-links": "^7.6.17",
"@storybook/addon-storysource": "^7.6.17",
"@storybook/manager-api": "^7.6.17",
"@storybook/react": "^7.6.17",
"@storybook/react-vite": "^7.6.17",
"@storybook/theming": "^7.6.17",
"@storybook/addon-essentials": "^8.0.4",
"@storybook/addon-interactions": "^8.0.4",
"@storybook/addon-links": "^8.0.4",
"@storybook/addon-storysource": "^8.0.4",
"@storybook/manager-api": "^8.0.4",
"@storybook/react": "^8.0.4",
"@storybook/react-vite": "^8.0.4",
"@storybook/theming": "^8.0.4",
"@types/dateformat": "^5.0.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.11.20",
"@types/react": "^18.2.59",
"@types/react-dom": "^18.2.19",
"@types/node": "^20.11.30",
"@types/react": "^18.2.73",
"@types/react-dom": "^18.2.22",
"@types/uuid": "^9.0.8",
"@vitejs/plugin-react-swc": "^3.6.0",
"concurrently": "^8.2.2",
"dpdm": "^3.14.0",
"eslint": "^8.57.0",
"eslint-plugin-i18next": "^6.0.3",
"eslint-plugin-path": "^1.2.4",
"knip": "^5.0.2",
"eslint-plugin-path": "^1.3.0",
"knip": "^5.6.1",
"openapi-types": "^12.1.3",
"openapi-typescript": "^6.7.4",
"openapi-typescript": "^6.7.5",
"prettier": "^3.2.5",
"rollup-plugin-visualizer": "^5.12.0",
"storybook": "^7.6.17",
"storybook": "^8.0.4",
"ts-toolbelt": "^9.6.0",
"tsafe": "^1.6.6",
"typescript": "^5.3.3",
"vite": "^5.1.4",
"vite-plugin-css-injected-by-js": "^3.4.0",
"vite-plugin-dts": "^3.7.3",
"typescript": "^5.4.3",
"vite": "^5.2.6",
"vite-plugin-css-injected-by-js": "^3.5.0",
"vite-plugin-dts": "^3.8.0",
"vite-plugin-eslint": "^1.8.1",
"vite-tsconfig-paths": "^4.3.1",
"vitest": "^1.3.1"
"vite-tsconfig-paths": "^4.3.2",
"vitest": "^1.4.0"
}
}

File diff suppressed because it is too large Load Diff

View File

@ -1,4 +1,5 @@
import { Flex, Image, Spinner } from '@invoke-ai/ui-library';
/** @knipignore */
import InvokeLogoWhite from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { memo } from 'react';

View File

@ -1,83 +0,0 @@
import type { Item } from '@invoke-ai/ui-library';
import type { ModelIdentifierField } from 'features/nodes/types/common';
import { MODEL_TYPE_SHORT_MAP } from 'features/parameters/types/constants';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import type { AnyModelConfig } from 'services/api/types';
type UseModelCustomSelectArg<T extends AnyModelConfig> = {
modelConfigs: T[];
isLoading: boolean;
selectedModel?: ModelIdentifierField | null;
onChange: (value: T | null) => void;
modelFilter?: (model: T) => boolean;
isModelDisabled?: (model: T) => boolean;
};
type UseModelCustomSelectReturn = {
selectedItem: Item | null;
items: Item[];
onChange: (item: Item | null) => void;
placeholder: string;
};
const modelFilterDefault = () => true;
const isModelDisabledDefault = () => false;
export const useModelCustomSelect = <T extends AnyModelConfig>({
modelConfigs,
isLoading,
selectedModel,
onChange,
modelFilter = modelFilterDefault,
isModelDisabled = isModelDisabledDefault,
}: UseModelCustomSelectArg<T>): UseModelCustomSelectReturn => {
const { t } = useTranslation();
const items: Item[] = useMemo(
() =>
modelConfigs.filter(modelFilter).map<Item>((m) => ({
label: m.name,
value: m.key,
description: m.description,
group: MODEL_TYPE_SHORT_MAP[m.base],
isDisabled: isModelDisabled(m),
})),
[modelConfigs, isModelDisabled, modelFilter]
);
const _onChange = useCallback(
(item: Item | null) => {
if (!item || !modelConfigs) {
return;
}
const model = modelConfigs.find((m) => m.key === item.value);
if (!model) {
return;
}
onChange(model);
},
[modelConfigs, onChange]
);
const selectedItem = useMemo(() => items.find((o) => o.value === selectedModel?.key) ?? null, [selectedModel, items]);
const placeholder = useMemo(() => {
if (isLoading) {
return t('common.loading');
}
if (items.length === 0) {
return t('models.noModelsAvailable');
}
return t('models.selectModel');
}, [isLoading, items, t]);
return {
items,
onChange: _onChange,
selectedItem,
placeholder,
};
};

View File

@ -5,7 +5,6 @@ import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import IAIDndImage from 'common/components/IAIDndImage';
import IAIDndImageIcon from 'common/components/IAIDndImageIcon';
import { roundToMultiple } from 'common/util/roundDownToMultiple';
import { setBoundingBoxDimensions } from 'features/canvas/store/canvasSlice';
import { useControlAdapterControlImage } from 'features/controlAdapters/hooks/useControlAdapterControlImage';
import { useControlAdapterProcessedControlImage } from 'features/controlAdapters/hooks/useControlAdapterProcessedControlImage';
@ -15,6 +14,7 @@ import {
selectControlAdaptersSlice,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import type { TypesafeDraggableData, TypesafeDroppableData } from 'features/dnd/types';
import { calculateNewSize } from 'features/parameters/components/ImageSize/calculateNewSize';
import { heightChanged, selectOptimalDimension, widthChanged } from 'features/parameters/store/generationSlice';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback, useEffect, useMemo, useState } from 'react';
@ -92,12 +92,13 @@ const ControlAdapterImagePreview = ({ isSmall, id }: Props) => {
return;
}
const width = roundToMultiple(controlImage.width, 8);
const height = roundToMultiple(controlImage.height, 8);
if (activeTabName === 'unifiedCanvas') {
dispatch(setBoundingBoxDimensions({ width, height }, optimalDimension));
dispatch(setBoundingBoxDimensions({ width: controlImage.width, height: controlImage.height }, optimalDimension));
} else {
const { width, height } = calculateNewSize(
controlImage.width / controlImage.height,
optimalDimension * optimalDimension
);
dispatch(widthChanged(width));
dispatch(heightChanged(height));
}

View File

@ -6,6 +6,7 @@ import type { RemoveFromBoardDropData } from 'features/dnd/types';
import AutoAddIcon from 'features/gallery/components/Boards/AutoAddIcon';
import BoardContextMenu from 'features/gallery/components/Boards/BoardContextMenu';
import { autoAddBoardIdChanged, boardIdSelected } from 'features/gallery/store/gallerySlice';
/** @knipignore */
import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';

View File

@ -1,33 +0,0 @@
import type { ButtonProps } from '@invoke-ai/ui-library';
import { Button } from '@invoke-ai/ui-library';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { useSyncModels } from './useSyncModels';
export const SyncModelsButton = memo((props: Omit<ButtonProps, 'aria-label'>) => {
const { t } = useTranslation();
const { syncModels, isLoading } = useSyncModels();
const isSyncModelEnabled = useFeatureStatus('syncModels').isFeatureEnabled;
if (!isSyncModelEnabled) {
return null;
}
return (
<Button
leftIcon={<PiArrowsClockwiseBold />}
isLoading={isLoading}
onClick={syncModels}
size="sm"
variant="ghost"
{...props}
>
{t('modelManager.syncModels')}
</Button>
);
});
SyncModelsButton.displayName = 'SyncModelsButton';

View File

@ -1,33 +0,0 @@
import type { IconButtonProps } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { useSyncModels } from './useSyncModels';
export const SyncModelsIconButton = memo((props: Omit<IconButtonProps, 'aria-label'>) => {
const { t } = useTranslation();
const { syncModels, isLoading } = useSyncModels();
const isSyncModelEnabled = useFeatureStatus('syncModels').isFeatureEnabled;
if (!isSyncModelEnabled) {
return null;
}
return (
<IconButton
icon={<PiArrowsClockwiseBold />}
tooltip={t('modelManager.syncModels')}
aria-label={t('modelManager.syncModels')}
isLoading={isLoading}
onClick={syncModels}
size="sm"
variant="ghost"
{...props}
/>
);
});
SyncModelsIconButton.displayName = 'SyncModelsIconButton';

View File

@ -1,40 +0,0 @@
import { useAppDispatch } from 'app/store/storeHooks';
import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { useSyncModelsMutation } from 'services/api/endpoints/models';
export const useSyncModels = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const [_syncModels, { isLoading }] = useSyncModelsMutation();
const syncModels = useCallback(() => {
_syncModels()
.unwrap()
.then((_) => {
dispatch(
addToast(
makeToast({
title: `${t('modelManager.modelsSynced')}`,
status: 'success',
})
)
);
})
.catch((error) => {
if (error) {
dispatch(
addToast(
makeToast({
title: `${t('modelManager.modelSyncFailed')}`,
status: 'error',
})
)
);
}
});
}, [dispatch, _syncModels, t]);
return { syncModels, isLoading };
};

View File

@ -1,6 +1,5 @@
import { Button, Flex, Heading, Spacer } from '@invoke-ai/ui-library';
import { Button, Flex, Heading } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { SyncModelsButton } from 'features/modelManagerV2/components/SyncModels/SyncModelsButton';
import { setSelectedModelKey } from 'features/modelManagerV2/store/modelManagerV2Slice';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
@ -20,8 +19,6 @@ export const ModelManager = () => {
<Flex flexDir="column" layerStyle="first" p={4} gap={4} borderRadius="base" w="50%" h="full">
<Flex w="full" gap={4} justifyContent="space-between" alignItems="center">
<Heading fontSize="xl">{t('common.modelManager')}</Heading>
<Spacer />
<SyncModelsButton size="sm" />
<Button size="sm" colorScheme="invokeYellow" leftIcon={<PiPlusBold />} onClick={handleClickAddModel}>
{t('modelManager.addModels')}
</Button>

View File

@ -1,7 +1,6 @@
import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { SyncModelsIconButton } from 'features/modelManagerV2/components/SyncModels/SyncModelsIconButton';
import { fieldMainModelValueChanged } from 'features/nodes/store/nodesSlice';
import type { MainModelFieldInputInstance, MainModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
@ -49,7 +48,6 @@ const MainModelFieldInputComponent = (props: Props) => {
noOptionsMessage={noOptionsMessage}
/>
</FormControl>
<SyncModelsIconButton className="nodrag" />
</Flex>
);
};

View File

@ -1,7 +1,6 @@
import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { SyncModelsIconButton } from 'features/modelManagerV2/components/SyncModels/SyncModelsIconButton';
import { fieldRefinerModelValueChanged } from 'features/nodes/store/nodesSlice';
import type {
SDXLRefinerModelFieldInputInstance,
@ -52,7 +51,6 @@ const RefinerModelFieldInputComponent = (props: Props) => {
noOptionsMessage={noOptionsMessage}
/>
</FormControl>
<SyncModelsIconButton className="nodrag" />
</Flex>
);
};

View File

@ -1,7 +1,6 @@
import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { SyncModelsIconButton } from 'features/modelManagerV2/components/SyncModels/SyncModelsIconButton';
import { fieldMainModelValueChanged } from 'features/nodes/store/nodesSlice';
import type { SDXLMainModelFieldInputInstance, SDXLMainModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
@ -49,7 +48,6 @@ const SDXLMainModelFieldInputComponent = (props: Props) => {
noOptionsMessage={noOptionsMessage}
/>
</FormControl>
<SyncModelsIconButton className="nodrag" />
</Flex>
);
};

View File

@ -1,7 +1,6 @@
import { Combobox, Flex, FormControl } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGroupedModelCombobox } from 'common/hooks/useGroupedModelCombobox';
import { SyncModelsIconButton } from 'features/modelManagerV2/components/SyncModels/SyncModelsIconButton';
import { fieldVaeModelValueChanged } from 'features/nodes/store/nodesSlice';
import type { VAEModelFieldInputInstance, VAEModelFieldInputTemplate } from 'features/nodes/types/field';
import { memo, useCallback } from 'react';
@ -49,7 +48,6 @@ const VAEModelFieldInputComponent = (props: Props) => {
noOptionsMessage={noOptionsMessage}
/>
</FormControl>
<SyncModelsIconButton className="nodrag" />
</Flex>
);
};

View File

@ -1,6 +1,7 @@
import { Button, Flex, Image, Text } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { workflowModeChanged } from 'features/nodes/store/workflowSlice';
/** @knipignore */
import InvokeLogoSVG from 'public/assets/images/invoke-symbol-wht-lrg.svg';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';

View File

@ -324,6 +324,7 @@ export const isSDXLMainModelFieldInputTemplate = (val: unknown): val is SDXLMain
const zSDXLRefinerModelFieldType = zFieldTypeBase.extend({
name: z.literal('SDXLRefinerModelField'),
});
/** @alias */ // tells knip to ignore this duplicate export
export const zSDXLRefinerModelFieldValue = zMainModelFieldValue; // TODO: Narrow to SDXL Refiner models only.
const zSDXLRefinerModelFieldInputInstance = zFieldInputInstanceBase.extend({
value: zSDXLRefinerModelFieldValue,

View File

@ -1,11 +1,10 @@
import { Flex, forwardRef } from '@invoke-ai/ui-library';
import { memo } from 'react';
import { Flex, forwardRef, typedMemo } from '@invoke-ai/ui-library';
import type { Components } from 'react-virtuoso';
import type { SessionQueueItemDTO } from 'services/api/types';
import type { ListContext } from './types';
const QueueListComponent: Components<SessionQueueItemDTO, ListContext>['List'] = memo(
const QueueListComponent: Components<SessionQueueItemDTO, ListContext>['List'] = typedMemo(
forwardRef((props, ref) => {
return (
<Flex {...props} ref={ref} flexDirection="column" gap={0.5}>
@ -15,4 +14,6 @@ const QueueListComponent: Components<SessionQueueItemDTO, ListContext>['List'] =
})
);
export default memo(QueueListComponent);
QueueListComponent.displayName = 'QueueListComponent';
export default QueueListComponent;

View File

@ -6,7 +6,6 @@ import { useAppSelector } from 'app/store/storeHooks';
import { LoRAList } from 'features/lora/components/LoRAList';
import LoRASelect from 'features/lora/components/LoRASelect';
import { selectLoraSlice } from 'features/lora/store/loraSlice';
import { SyncModelsIconButton } from 'features/modelManagerV2/components/SyncModels/SyncModelsIconButton';
import ParamCFGScale from 'features/parameters/components/Core/ParamCFGScale';
import ParamScheduler from 'features/parameters/components/Core/ParamScheduler';
import ParamSteps from 'features/parameters/components/Core/ParamSteps';
@ -60,7 +59,6 @@ export const GenerationSettingsAccordion = memo(() => {
<ParamMainModelSelect />
<Flex>
<UseDefaultSettingsButton />
<SyncModelsIconButton />
<NavigateToModelManagerButton />
</Flex>
</Flex>

View File

@ -21,6 +21,7 @@ import {
import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent';
import { discordLink, githubLink, websiteLink } from 'features/system/store/constants';
import { map } from 'lodash-es';
/** @knipignore */
import InvokeLogoYellow from 'public/assets/images/invoke-tag-lrg.svg';
import type { ReactElement } from 'react';
import { cloneElement, memo, useCallback } from 'react';

View File

@ -2,6 +2,7 @@
import { Image, Text, Tooltip } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $logo } from 'app/store/nanostores/logo';
/** @knipignore */
import InvokeLogoYellow from 'public/assets/images/invoke-symbol-ylw-lrg.svg';
import { memo, useMemo, useRef } from 'react';
import { useGetAppVersionQuery } from 'services/api/endpoints/appInfo';

View File

@ -73,7 +73,7 @@ const WorkflowLibraryList = () => {
}, [projectId, ORDER_BY_OPTIONS]);
const [order_by, setOrderBy] = useState<WorkflowRecordOrderBy>(orderByOptions[0]?.value as WorkflowRecordOrderBy);
const [direction, setDirection] = useState<SQLiteDirection>('ASC');
const [direction, setDirection] = useState<SQLiteDirection>('DESC');
const [debouncedQuery] = useDebounce(query, 500);
const queryArg = useMemo<Parameters<typeof useListWorkflowsQuery>[0]>(() => {

View File

@ -1,10 +0,0 @@
import type { BaseModelType } from './types';
export const ALL_BASE_MODELS: BaseModelType[] = ['sd-1', 'sd-2', 'sdxl', 'sdxl-refiner'];
export const NON_REFINER_BASE_MODELS: BaseModelType[] = ['sd-1', 'sd-2', 'sdxl'];
export const SDXL_MAIN_MODELS: BaseModelType[] = ['sdxl'];
export const NON_SDXL_MAIN_MODELS: BaseModelType[] = ['sd-1', 'sd-2'];
export const REFINER_BASE_MODELS: BaseModelType[] = ['sdxl-refiner'];

View File

@ -188,15 +188,6 @@ export const modelsApi = api.injectEndpoints({
},
serializeQueryArgs: ({ queryArgs }) => `${queryArgs.name}.${queryArgs.base}.${queryArgs.type}`,
}),
syncModels: build.mutation<void, void>({
query: () => {
return {
url: buildModelsUrl('sync'),
method: 'PATCH',
};
},
invalidatesTags: [{ type: 'ModelConfig', id: LIST_TAG }],
}),
scanFolder: build.query<ScanFolderResponse, ScanFolderArg>({
query: (arg) => {
const folderQueryStr = arg ? queryString.stringify(arg, {}) : '';
@ -278,7 +269,6 @@ export const {
useUpdateModelImageMutation,
useInstallModelMutation,
useConvertModelMutation,
useSyncModelsMutation,
useLazyScanFolderQuery,
useLazyGetHuggingFaceModelsQuery,
useListModelInstallsQuery,

View File

@ -6,7 +6,7 @@ import { $authToken } from 'app/store/nanostores/authToken';
import { $baseUrl } from 'app/store/nanostores/baseUrl';
import { $projectId } from 'app/store/nanostores/projectId';
export const tagTypes = [
const tagTypes = [
'AppVersion',
'AppConfig',
'Board',

View File

@ -118,10 +118,6 @@ export const isTIModelConfig = (config: AnyModelConfig): config is MainModelConf
export type ModelInstallJob = S['ModelInstallJob'];
export type ModelInstallStatus = S['InstallStatus'];
export type HFModelSource = S['HFModelSource'];
export type LocalModelSource = S['LocalModelSource'];
export type URLModelSource = S['URLModelSource'];
// Graphs
export type Graph = S['Graph'];
export type NonNullableGraph = O.Required<Graph, 'nodes' | 'edges'>;

View File

@ -1 +1 @@
__version__ = "4.0.0rc5"
__version__ = "4.0.0rc6"

View File

@ -0,0 +1,122 @@
import re
from argparse import ArgumentParser, RawTextHelpFormatter
from typing import Any
import requests
from attr import dataclass
from tqdm import tqdm
def get_author(commit: dict[str, Any]) -> str:
"""Gets the author of a commit.
If the author is not present, the committer is used instead and an asterisk appended to the name."""
return commit["author"]["login"] if commit["author"] else f"{commit['commit']['author']['name']}*"
@dataclass
class CommitInfo:
sha: str
url: str
author: str
is_username: bool
message: str
data: dict[str, Any]
def __str__(self) -> str:
return f"{self.sha}: {self.author}{'*' if not self.is_username else ''} - {self.message} ({self.url})"
@classmethod
def from_data(cls, commit: dict[str, Any]) -> "CommitInfo":
return CommitInfo(
sha=commit["sha"],
url=commit["url"],
author=commit["author"]["login"] if commit["author"] else commit["commit"]["author"]["name"],
is_username=bool(commit["author"]),
message=commit["commit"]["message"].split("\n")[0],
data=commit,
)
def fetch_commits_between_tags(
org_name: str, repo_name: str, from_ref: str, to_ref: str, token: str
) -> list[CommitInfo]:
"""Fetches all commits between two tags in a GitHub repository."""
commit_info: list[CommitInfo] = []
headers = {"Authorization": f"token {token}"} if token else None
# Get the total number of pages w/ an intial request - a bit hacky but it works...
response = requests.get(
f"https://api.github.com/repos/{org_name}/{repo_name}/compare/{from_ref}...{to_ref}?page=1&per_page=100",
headers=headers,
)
last_page_match = re.search(r'page=(\d+)&per_page=\d+>; rel="last"', response.headers["Link"])
last_page = int(last_page_match.group(1)) if last_page_match else 1
pbar = tqdm(range(1, last_page + 1), desc="Fetching commits", unit="page", leave=False)
for page in pbar:
compare_url = f"https://api.github.com/repos/{org_name}/{repo_name}/compare/{from_ref}...{to_ref}?page={page}&per_page=100"
response = requests.get(compare_url, headers=headers)
commits = response.json()["commits"]
commit_info.extend([CommitInfo.from_data(c) for c in commits])
return commit_info
def main():
description = """Fetch external contributions between two tags in the InvokeAI GitHub repository. Useful for generating a list of contributors to include in release notes.
When the GitHub username for a commit is not available, the committer name is used instead and an asterisk appended to the name.
Example output (note the second commit has an asterisk appended to the name):
171f2aa20ddfefa23c5edbeb2849c4bd601fe104: rohinish404 - fix(ui): image not getting selected (https://api.github.com/repos/invoke-ai/InvokeAI/commits/171f2aa20ddfefa23c5edbeb2849c4bd601fe104)
0bb0e226dcec8a17e843444ad27c29b4821dad7c: Mark E. Shoulson* - Flip default ordering of workflow library; #5477 (https://api.github.com/repos/invoke-ai/InvokeAI/commits/0bb0e226dcec8a17e843444ad27c29b4821dad7c)
"""
parser = ArgumentParser(description=description, formatter_class=RawTextHelpFormatter)
parser.add_argument("--token", dest="token", type=str, default=None, help="The GitHub token to use")
parser.add_argument("--from", dest="from_ref", type=str, help="The start reference (commit, tag, etc)")
parser.add_argument("--to", dest="to_ref", type=str, help="The end reference (commit, tag, etc)")
args = parser.parse_args()
org_name = "invoke-ai"
repo_name = "InvokeAI"
# List of members of the organization, including usernames and known display names,
# any of which may be used in the commit data. Used to filter out commits.
org_members = [
"blessedcoolant",
"brandonrising",
"chainchompa",
"ebr",
"Eugene Brodsky",
"hipsterusername",
"Kent Keirsey",
"lstein",
"Lincoln Stein",
"maryhipp",
"Mary Hipp Rogers",
"Mary Hipp",
"psychedelicious",
"RyanJDick",
"Ryan Dick",
]
all_commits = fetch_commits_between_tags(
org_name=org_name,
repo_name=repo_name,
from_ref=args.from_ref,
to_ref=args.to_ref,
token=args.token,
)
filtered_commits = filter(lambda x: x.author not in org_members, all_commits)
for commit in filtered_commits:
print(commit)
if __name__ == "__main__":
main()