mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
Merge branch 'main' into checkpoint-ip-adapter
This commit is contained in:
commit
cd52e99bb9
2
.github/workflows/build-installer.yml
vendored
2
.github/workflows/build-installer.yml
vendored
@ -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 }}
|
||||
|
@ -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/
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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:
|
||||
"""
|
||||
|
@ -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()
|
||||
|
@ -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(
|
||||
|
@ -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.."""
|
||||
|
@ -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:
|
||||
"""
|
||||
|
@ -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
|
||||
|
@ -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":
|
||||
|
@ -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};")
|
||||
|
@ -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__(
|
||||
|
@ -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:
|
||||
|
@ -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)
|
||||
|
@ -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;
|
||||
|
@ -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
@ -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';
|
||||
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
@ -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));
|
||||
}
|
||||
|
@ -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';
|
||||
|
@ -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';
|
@ -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';
|
@ -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 };
|
||||
};
|
@ -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>
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
@ -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';
|
||||
|
@ -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,
|
||||
|
@ -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;
|
||||
|
@ -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>
|
||||
|
@ -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';
|
||||
|
@ -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';
|
||||
|
@ -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]>(() => {
|
||||
|
@ -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'];
|
@ -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,
|
||||
|
@ -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',
|
||||
|
@ -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'>;
|
||||
|
@ -1 +1 @@
|
||||
__version__ = "4.0.0rc5"
|
||||
__version__ = "4.0.0rc6"
|
||||
|
122
scripts/get_external_contributions.py
Normal file
122
scripts/get_external_contributions.py
Normal 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()
|
Loading…
Reference in New Issue
Block a user