diff --git a/docs/contributing/CONTRIBUTING.md b/docs/contributing/CONTRIBUTING.md index 3360faed70..415b214f48 100644 --- a/docs/contributing/CONTRIBUTING.md +++ b/docs/contributing/CONTRIBUTING.md @@ -1,42 +1,38 @@ +# How to Contribute + ## Welcome to Invoke AI - -We're thrilled to have you here and we're excited for you to contribute. - Invoke AI originated as a project built by the community, and that vision carries forward today as we aim to build the best pro-grade tools available. We work together to incorporate the latest in AI/ML research, making these tools available in over 20 languages to artists and creatives around the world as part of our fully permissive OSS project designed for individual users to self-host and use. -Here are some guidelines to help you get started: -### Technical Prerequisites +## Contributing to Invoke AI +Anyone who wishes to contribute to InvokeAI, whether features, bug fixes, code cleanup, testing, code reviews, documentation or translation is very much encouraged to do so. -Front-end: You'll need a working knowledge of React and TypeScript. +To join, just raise your hand on the InvokeAI Discord server (#dev-chat) or the GitHub discussion board. -Back-end: Depending on the scope of your contribution, you may need to know SQLite, FastAPI, Python, and Socketio. Also, a good majority of the backend logic involved in processing images is built in a modular way using a concept called "Nodes", which are isolated functions that carry out individual, discrete operations. This design allows for easy contributions of novel pipelines and capabilities. +### Areas of contribution: -### How to Submit Contributions +#### Development +If you’d like to help with development, please see our [development guide](contribution_guides/development.md). If you’re unfamiliar with contributing to open source projects, there is a tutorial contained within the development guide. -To start contributing, please follow these steps: +#### Documentation +If you’d like to help with documentation, please see our [documentation guide](contribution_guides/documenation.md). -1. Familiarize yourself with our roadmap and open projects to see where your skills and interests align. These documents can serve as a source of inspiration. -2. Open a Pull Request (PR) with a clear description of the feature you're adding or the problem you're solving. Make sure your contribution aligns with the project's vision. -3. Adhere to general best practices. This includes assuming interoperability with other nodes, keeping the scope of your functions as small as possible, and organizing your code according to our architecture documents. +#### Translation +If you'd like to help with translation, please see our [translation guide](docs/contributing/.contribution_guides/translation.md). -### Types of Contributions We're Looking For +#### Tutorials +Please reach out to @imic or @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) to help create tutorials for InvokeAI. -We welcome all contributions that improve the project. Right now, we're especially looking for: +We hope you enjoy using our software as much as we enjoy creating it, and we hope that some of those of you who are reading this will elect to become part of our contributor community. -1. Quality of life (QOL) enhancements on the front-end. -2. New backend capabilities added through nodes. -3. Incorporating additional optimizations from the broader open-source software community. -### Communication and Decision-making Process +### Contributors -Project maintainers and code owners review PRs to ensure they align with the project's goals. They may provide design or architectural guidance, suggestions on user experience, or provide more significant feedback on the contribution itself. Expect to receive feedback on your submissions, and don't hesitate to ask questions or propose changes. +This project is a combined effort of dedicated people from across the world. [Check out the list of all these amazing people](https://invoke-ai.github.io/InvokeAI/other/CONTRIBUTORS/). We thank them for their time, hard work and effort. -For more robust discussions, or if you're planning to add capabilities not currently listed on our roadmap, please reach out to us on our Discord server. That way, we can ensure your proposed contribution aligns with the project's direction before you start writing code. +### Code of Conduct -### Code of Conduct and Contribution Expectations - -We want everyone in our community to have a positive experience. To facilitate this, we've established a code of conduct and a statement of values that we expect all contributors to adhere to. Please take a moment to review these documents—they're essential to maintaining a respectful and inclusive environment. +The InvokeAI community is a welcoming place, and we want your help in maintaining that. Please review our [Code of Conduct](https://github.com/invoke-ai/InvokeAI/blob/main/CODE_OF_CONDUCT.md) to learn more - it's essential to maintaining a respectful and inclusive environment. By making a contribution to this project, you certify that: @@ -49,6 +45,12 @@ This disclaimer is not a license and does not grant any rights or permissions. Y This disclaimer is provided "as is" without warranty of any kind, whether expressed or implied, including but not limited to the warranties of merchantability, fitness for a particular purpose, or non-infringement. In no event shall the authors or copyright holders be liable for any claim, damages, or other liability, whether in an action of contract, tort, or otherwise, arising from, out of, or in connection with the contribution or the use or other dealings in the contribution. +### Support + +For support, please use this repository's [GitHub Issues](https://github.com/invoke-ai/InvokeAI/issues), or join the [Discord](https://discord.gg/ZmtBAhwWhy). + +Original portions of the software are Copyright (c) 2023 by respective contributors. + --- Remember, your contributions help make this project great. We're excited to see what you'll bring to our community! diff --git a/docs/contributing/contribution_guides/development.md b/docs/contributing/contribution_guides/development.md new file mode 100644 index 0000000000..59c2b05c0e --- /dev/null +++ b/docs/contributing/contribution_guides/development.md @@ -0,0 +1,91 @@ +# Development + +## **What do I need to know to help?** + +If you are looking to help to with a code contribution, InvokeAI uses several different technologies under the hood: Python (Pydantic, FastAPI, diffusers) and Typescript (React, Redux Toolkit, ChakraUI, Mantine, Konva). Familiarity with StableDiffusion and image generation concepts is helpful, but not essential. + +For more information, please review our area specific documentation: + +* #### [InvokeAI Architecure](../ARCHITECTURE.md) +* #### [Frontend Documentation](development_guides/contributingToFrontend.md) +* #### [Node Documentation](../INVOCATIONS.md) +* #### [Local Development](../LOCAL_DEVELOPMENT.md) + +If you don't feel ready to make a code contribution yet, no problem! You can also help out in other ways, such as [documentation](documentation.md) or [translation](translation.md). + +There are two paths to making a development contribution: + +1. Choosing an open issue to address. Open issues can be found in the [Issues](https://github.com/invoke-ai/InvokeAI/issues?q=is%3Aissue+is%3Aopen) section of the InvokeAI repository. These are tagged by the issue type (bug, enhancement, etc.) along with the “good first issues” tag denoting if they are suitable for first time contributors. + 1. Additional items can be found on our roadmap <******************************link to roadmap>******************************. The roadmap is organized in terms of priority, and contains features of varying size and complexity. If there is an inflight item you’d like to help with, reach out to the contributor assigned to the item to see how you can help. +2. Opening a new issue or feature to add. **Please make sure you have searched through existing issues before creating new ones.** + +*Regardless of what you choose, please post in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord before you start development in order to confirm that the issue or feature is aligned with the current direction of the project. We value our contributors time and effort and want to ensure that no one’s time is being misspent.* + +## Best Practices: +* Keep your pull requests small. Smaller pull requests are more likely to be accepted and merged +* Comments! Commenting your code helps reviwers easily understand your contribution +* Use Python and Typescript’s typing systems, and consider using an editor with [LSP](https://microsoft.github.io/language-server-protocol/) support to streamline development +* Make all communications public. This ensure knowledge is shared with the whole community + +## **How do I make a contribution?** + +Never made an open source contribution before? Wondering how contributions work in our project? Here's a quick rundown! + +Before starting these steps, ensure you have your local environment [configured for development](../LOCAL_DEVELOPMENT.md). + +1. Find a [good first issue](https://github.com/invoke-ai/InvokeAI/contribute) that you are interested in addressing or a feature that you would like to add. Then, reach out to our team in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord to ensure you are setup for success. +2. Fork the [InvokeAI](https://github.com/invoke-ai/InvokeAI) repository to your GitHub profile. This means that you will have a copy of the repository under **your-GitHub-username/InvokeAI**. +3. Clone the repository to your local machine using: + +```bash +git clone https://github.com/your-GitHub-username/InvokeAI.git +``` + +If you're unfamiliar with using Git through the commandline, [GitHub Desktop](https://desktop.github.com) is a easy-to-use alternative with a UI. You can do all the same steps listed here, but through the interface. + +4. Create a new branch for your fix using: + +```bash +git checkout -b branch-name-here +``` + +5. Make the appropriate changes for the issue you are trying to address or the feature that you want to add. +6. Add the file contents of the changed files to the "snapshot" git uses to manage the state of the project, also known as the index: + +```bash +git add insert-paths-of-changed-files-here +``` + +7. Store the contents of the index with a descriptive message. + +```bash +git commit -m "Insert a short message of the changes made here" +``` + +8. Push the changes to the remote repository using + +```markdown +git push origin branch-name-here +``` + +9. Submit a pull request to the **main** branch of the InvokeAI repository. +10. Title the pull request with a short description of the changes made and the issue or bug number associated with your change. For example, you can title an issue like so "Added more log outputting to resolve #1234". +11. In the description of the pull request, explain the changes that you made, any issues you think exist with the pull request you made, and any questions you have for the maintainer. It's OK if your pull request is not perfect (no pull request is), the reviewer will be able to help you fix any problems and improve it! +12. Wait for the pull request to be reviewed by other collaborators. +13. Make changes to the pull request if the reviewer(s) recommend them. +14. Celebrate your success after your pull request is merged! + +If you’d like to learn more about contributing to Open Source projects, here is a [Getting Started Guide](https://opensource.com/article/19/7/create-pull-request-github). + +## **Where can I go for help?** + +If you need help, you can ask questions in the [#dev-chat](https://discord.com/channels/1020123559063990373/1049495067846524939) channel of the Discord. + +For frontend related work, **@pyschedelicious** is the best person to reach out to. + +For backend related work, please reach out to **@blessedcoolant**, **@lstein**, **@StAlKeR7779** or **@pyschedelicious**. + +## **What does the Code of Conduct mean for me?** + +Our [Code of Conduct](CODE_OF_CONDUCT.md) means that you are responsible for treating everyone on the project with respect and courtesy regardless of their identity. If you are the victim of any inappropriate behavior or comments as described in our Code of Conduct, we are here for you and will do the best to ensure that the abuser is reprimanded appropriately, per our code. + diff --git a/docs/contributing/contribution_guides/development_guides/contributingToFrontend.md b/docs/contributing/contribution_guides/development_guides/contributingToFrontend.md new file mode 100644 index 0000000000..d1f0fb7d38 --- /dev/null +++ b/docs/contributing/contribution_guides/development_guides/contributingToFrontend.md @@ -0,0 +1,75 @@ +# Contributing to the Frontend + +# InvokeAI Web UI + +- [InvokeAI Web UI](https://github.com/invoke-ai/InvokeAI/tree/main/invokeai/frontend/web/docs#invokeai-web-ui) + - [Stack](https://github.com/invoke-ai/InvokeAI/tree/main/invokeai/frontend/web/docs#stack) + - [Contributing](https://github.com/invoke-ai/InvokeAI/tree/main/invokeai/frontend/web/docs#contributing) + - [Dev Environment](https://github.com/invoke-ai/InvokeAI/tree/main/invokeai/frontend/web/docs#dev-environment) + - [Production builds](https://github.com/invoke-ai/InvokeAI/tree/main/invokeai/frontend/web/docs#production-builds) + +The UI is a fairly straightforward Typescript React app, with the Unified Canvas being more complex. + +Code is located in `invokeai/frontend/web/` for review. + +## Stack + +State management is Redux via [Redux Toolkit](https://github.com/reduxjs/redux-toolkit). We lean heavily on RTK: + +- `createAsyncThunk` for HTTP requests +- `createEntityAdapter` for fetching images and models +- `createListenerMiddleware` for workflows + +The API client and associated types are generated from the OpenAPI schema. See API_CLIENT.md. + +Communication with server is a mix of HTTP and [socket.io](https://github.com/socketio/socket.io-client) (with a simple socket.io redux middleware to help). + +[Chakra-UI](https://github.com/chakra-ui/chakra-ui) & [Mantine](https://github.com/mantinedev/mantine) for components and styling. + +[Konva](https://github.com/konvajs/react-konva) for the canvas, but we are pushing the limits of what is feasible with it (and HTML canvas in general). We plan to rebuild it with [PixiJS](https://github.com/pixijs/pixijs) to take advantage of WebGL's improved raster handling. + +[Vite](https://vitejs.dev/) for bundling. + +Localisation is via [i18next](https://github.com/i18next/react-i18next), but translation happens on our [Weblate](https://hosted.weblate.org/engage/invokeai/) project. Only the English source strings should be changed on this repo. + +## Contributing + +Thanks for your interest in contributing to the InvokeAI Web UI! + +We encourage you to ping @psychedelicious and @blessedcoolant on [Discord](https://discord.gg/ZmtBAhwWhy) if you want to contribute, just to touch base and ensure your work doesn't conflict with anything else going on. The project is very active. + +### Dev Environment + +**Setup** + +1. Install [node](https://nodejs.org/en/download/). You can confirm node is installed with: +```bash +node --version +``` +2. Install [yarn classic](https://classic.yarnpkg.com/lang/en/) and confirm it is installed by running this: +```bash +npm install --global yarn +yarn --version +``` + +From `invokeai/frontend/web/` run `yarn install` to get everything set up. + +Start everything in dev mode: +1. Ensure your virtual environment is running +2. Start the dev server: `yarn dev` +3. Start the InvokeAI Nodes backend: `python scripts/invokeai-web.py # run from the repo root` +4. Point your browser to the dev server address e.g. [http://localhost:5173/](http://localhost:5173/) + +### VSCode Remote Dev + +We've noticed an intermittent issue with the VSCode Remote Dev port forwarding. If you use this feature of VSCode, you may intermittently click the Invoke button and then get nothing until the request times out. Suggest disabling the IDE's port forwarding feature and doing it manually via SSH: + +`ssh -L 9090:localhost:9090 -L 5173:localhost:5173 user@host` + +### Production builds + +For a number of technical and logistical reasons, we need to commit UI build artefacts to the repo. + +If you submit a PR, there is a good chance we will ask you to include a separate commit with a build of the app. + +To build for production, run `yarn build`. \ No newline at end of file diff --git a/docs/contributing/contribution_guides/documentation.md b/docs/contributing/contribution_guides/documentation.md new file mode 100644 index 0000000000..1a0ec03cf7 --- /dev/null +++ b/docs/contributing/contribution_guides/documentation.md @@ -0,0 +1,13 @@ +# Documentation + +Documentation is an important part of any open source project. It provides a clear and concise way to communicate how the software works, how to use it, and how to troubleshoot issues. Without proper documentation, it can be difficult for users to understand the purpose and functionality of the project. + +## Contributing + +All documentation is maintained in the InvokeAI GitHub repository. If you come across documentation that is out of date or incorrect, please submit a pull request with the necessary changes. + +When updating or creating documentation, please keep in mind InvokeAI is a tool for everyone, not just those who have familiarity with generative art. + +## Help & Questions + +Please ping @imic1 or @hipsterusername in the [Discord](https://discord.com/channels/1020123559063990373/1049495067846524939) if you have any questions. \ No newline at end of file diff --git a/docs/contributing/contribution_guides/translation.md b/docs/contributing/contribution_guides/translation.md new file mode 100644 index 0000000000..669e403346 --- /dev/null +++ b/docs/contributing/contribution_guides/translation.md @@ -0,0 +1,19 @@ +# Translation + +InvokeAI uses [Weblate](https://weblate.org/) for translation. Weblate is a FOSS project providing a scalable translation service. Weblate automates the tedious parts of managing translation of a growing project, and the service is generously provided at no cost to FOSS projects like InvokeAI. + +## Contributing + +If you'd like to contribute by adding or updating a translation, please visit our [Weblate project](https://hosted.weblate.org/engage/invokeai/). You'll need to sign in with your GitHub account (a number of other accounts are supported, including Google). + +Once signed in, select a language and then the Web UI component. From here you can Browse and Translate strings from English to your chosen language. Zen mode offers a simpler translation experience. + +Your changes will be attributed to you in the automated PR process; you don't need to do anything else. + +## Help & Questions + +Please check Weblate's [documentation](https://docs.weblate.org/en/latest/index.html) or ping @Harvestor on [Discord](https://discord.com/channels/1020123559063990373/1049495067846524939) if you have any questions. + +## Thanks + +Thanks to the InvokeAI community for their efforts to translate the project! \ No newline at end of file diff --git a/docs/contributing/contribution_guides/tutorials.md b/docs/contributing/contribution_guides/tutorials.md new file mode 100644 index 0000000000..0d550e7023 --- /dev/null +++ b/docs/contributing/contribution_guides/tutorials.md @@ -0,0 +1,11 @@ +# Tutorials + +Tutorials help new & existing users expand their abilty to use InvokeAI to the full extent of our features and services. + +Currently, we have a set of tutorials available on our [YouTube channel](https://www.youtube.com/@invokeai), but as InvokeAI continues to evolve with new updates, we want to ensure that we are giving our users the resources they need to succeed. + +Tutorials can be in the form of videos or article walkthroughs on a subject of your choice. We recommend focusing tutorials on the key image generation methods, or on a specific component within one of the image generation methods. + +## Contributing + +Please reach out to @imic or @hipsterusername on [Discord](https://discord.gg/ZmtBAhwWhy) to help create tutorials for InvokeAI. \ No newline at end of file diff --git a/docs/index.md b/docs/index.md index 8bfaa02287..4f8fa1fd20 100644 --- a/docs/index.md +++ b/docs/index.md @@ -222,14 +222,10 @@ get solutions for common installation problems and other issues. Anyone who wishes to contribute to this project, whether documentation, features, bug fixes, code cleanup, testing, or code reviews, is very much -encouraged to do so. If you are unfamiliar with how to contribute to GitHub -projects, here is a -[Getting Started Guide](https://opensource.com/article/19/7/create-pull-request-github). +encouraged to do so. -A full set of contribution guidelines, along with templates, are in progress, -but for now the most important thing is to **make your pull request against the -"development" branch**, and not against "main". This will help keep public -breakage to a minimum and will allow you to propose more radical changes. +[Please take a look at our Contribution documentation to learn more about contributing to InvokeAI. +](contributing/CONTRIBUTING.md) ## :octicons-person-24: Contributors diff --git a/docs/nodes/communityNodes.md b/docs/nodes/communityNodes.md new file mode 100644 index 0000000000..8bd9a613a0 --- /dev/null +++ b/docs/nodes/communityNodes.md @@ -0,0 +1,28 @@ +# Community Nodes + +These are nodes that have been developed by the community for the community. If you're not sure what a node is, you can learn more about nodes [here](overview.md). + +If you'd like to submit a node for the community, please refer to the [node creation overview](overview.md). + +To download a node, simply download the `.py` node file from the link and add it to the `invokeai/app/invocations/` folder in your Invoke AI install location. Along with the node, an example node graph should be provided to help you get started with the node. + +To use a community node graph, download the the `.json` node graph file and load it into Invoke AI via the **Load Nodes** button on the Node Editor. + +## List of Nodes + +-------------------------------- +### Super Cool Node Template + +**Description:** This node allows you to do super cool things with InvokeAI. + +**Node Link:** https://github.com/invoke-ai/InvokeAI/fake_node.py + +**Example Node Graph:** https://github.com/invoke-ai/InvokeAI/fake_node_graph.json + +**Output Examples** + +![Invoke AI](https://invoke-ai.github.io/InvokeAI/assets/invoke_ai_banner.png) + + +## Help +If you run into any issues with a node, please post in the [InvokeAI Discord](https://discord.gg/ZmtBAhwWhy). \ No newline at end of file diff --git a/docs/nodes/overview.md b/docs/nodes/overview.md new file mode 100644 index 0000000000..f7dc2384dd --- /dev/null +++ b/docs/nodes/overview.md @@ -0,0 +1,41 @@ +# Nodes +## What are Nodes? +An Node is simply a single operation that takes in some inputs and gives +out some outputs. We can then chain multiple nodes together to create more +complex functionality. All InvokeAI features are added through nodes. + +This means nodes can be used to easily extend the image generation capabilities of InvokeAI, and allow you build workflows to suit your needs. + +You can read more about nodes and the node editor [here](../features/NODES.md). + + +## Downloading Nodes +To download a new node, visit our list of [Community Nodes](communityNodes.md). These are codes that have been created by the community, for the community. + + +## Contributing Nodes + +To learn about creating a new node, please visit our [Node creation documenation](../contributing/INVOCATIONS.md). + +Once you’ve created a node and confirmed that it behaves as expected locally, follow these steps: +- Make sure the node is contained in a new Python (.py) file +- Submit a pull request with a link to your node in GitHub against the `nodes` branch to add the node to the [Community Nodes](Community Nodes) list + - Make sure you are following the template below and have provided all relevant details about the node and what it does. +- A maintainer will review the pull request and node. If the node is aligned with the direction of the project, you might be asked for permission to include it in the core project. + +### Community Node Template + +```markdown +-------------------------------- +### Super Cool Node Template + +**Description:** This node allows you to do super cool things with InvokeAI. + +**Node Link:** https://github.com/invoke-ai/InvokeAI/fake_node.py + +**Example Node Graph:** https://github.com/invoke-ai/InvokeAI/fake_node_graph.json + +**Output Examples** + +![InvokeAI](https://invoke-ai.github.io/InvokeAI/assets/invoke_ai_banner.png) +``` diff --git a/invokeai/app/api/routers/app_info.py b/invokeai/app/api/routers/app_info.py index 8e2955c9aa..e1bfeda4a1 100644 --- a/invokeai/app/api/routers/app_info.py +++ b/invokeai/app/api/routers/app_info.py @@ -1,9 +1,22 @@ +from enum import Enum +from fastapi import Body from fastapi.routing import APIRouter from pydantic import BaseModel, Field from invokeai.backend.image_util.patchmatch import PatchMatch from invokeai.version import __version__ +from ..dependencies import ApiDependencies +from invokeai.backend.util.logging import logging + +class LogLevel(int, Enum): + NotSet = logging.NOTSET + Debug = logging.DEBUG + Info = logging.INFO + Warning = logging.WARNING + Error = logging.ERROR + Critical = logging.CRITICAL + app_router = APIRouter(prefix="/v1/app", tags=["app"]) @@ -34,3 +47,27 @@ async def get_config() -> AppConfig: if PatchMatch.patchmatch_available(): infill_methods.append('patchmatch') return AppConfig(infill_methods=infill_methods) + +@app_router.get( + "/logging", + operation_id="get_log_level", + responses={200: {"description" : "The operation was successful"}}, + response_model = LogLevel, +) +async def get_log_level( +) -> LogLevel: + """Returns the log level""" + return LogLevel(ApiDependencies.invoker.services.logger.level) + +@app_router.post( + "/logging", + operation_id="set_log_level", + responses={200: {"description" : "The operation was successful"}}, + response_model = LogLevel, +) +async def set_log_level( + level: LogLevel = Body(description="New log verbosity level"), +) -> LogLevel: + """Sets the log verbosity level""" + ApiDependencies.invoker.services.logger.setLevel(level) + return LogLevel(ApiDependencies.invoker.services.logger.level) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 559afa3b37..01e7dd2c26 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -1,8 +1,7 @@ import io from typing import Optional -from fastapi import (Body, HTTPException, Path, Query, Request, Response, - UploadFile) +from fastapi import Body, HTTPException, Path, Query, Request, Response, UploadFile from fastapi.responses import FileResponse from fastapi.routing import APIRouter from PIL import Image @@ -11,9 +10,11 @@ from invokeai.app.invocations.metadata import ImageMetadata from invokeai.app.models.image import ImageCategory, ResourceOrigin from invokeai.app.services.image_record_storage import OffsetPaginatedResults from invokeai.app.services.item_storage import PaginatedResults -from invokeai.app.services.models.image_record import (ImageDTO, - ImageRecordChanges, - ImageUrlsDTO) +from invokeai.app.services.models.image_record import ( + ImageDTO, + ImageRecordChanges, + ImageUrlsDTO, +) from ..dependencies import ApiDependencies @@ -84,15 +85,16 @@ async def delete_image( # TODO: Does this need any exception handling at all? pass + @images_router.post("/clear-intermediates", operation_id="clear_intermediates") async def clear_intermediates() -> int: - """Clears first 100 intermediates""" + """Clears all intermediates""" try: - count_deleted = ApiDependencies.invoker.services.images.delete_many(is_intermediate=True) + count_deleted = ApiDependencies.invoker.services.images.delete_intermediates() return count_deleted except Exception as e: - # TODO: Does this need any exception handling at all? + raise HTTPException(status_code=500, detail="Failed to clear intermediates") pass @@ -130,6 +132,7 @@ async def get_image_dto( except Exception as e: raise HTTPException(status_code=404) + @images_router.get( "/{image_name}/metadata", operation_id="get_image_metadata", @@ -254,7 +257,8 @@ async def list_image_dtos( default=None, description="Whether to list intermediate images." ), board_id: Optional[str] = Query( - default=None, description="The board id to filter by. Use 'none' to find images without a board." + default=None, + description="The board id to filter by. Use 'none' to find images without a board.", ), offset: int = Query(default=0, description="The page offset"), limit: int = Query(default=10, description="The number of images per page"), diff --git a/invokeai/app/api/routers/models.py b/invokeai/app/api/routers/models.py index cc6d09f761..870ca33534 100644 --- a/invokeai/app/api/routers/models.py +++ b/invokeai/app/api/routers/models.py @@ -315,20 +315,21 @@ async def list_ckpt_configs( return ApiDependencies.invoker.services.model_manager.list_checkpoint_configs() -@models_router.get( +@models_router.post( "/sync", operation_id="sync_to_config", responses={ 201: { "description": "synchronization successful" }, }, status_code = 201, - response_model = None + response_model = bool ) async def sync_to_config( -)->None: +)->bool: """Call after making changes to models.yaml, autoimport directories or models directory to synchronize in-memory data structures with disk data structures.""" - return ApiDependencies.invoker.services.model_manager.sync_to_config() + ApiDependencies.invoker.services.model_manager.sync_to_config() + return True @models_router.put( "/merge/{base_model}", @@ -373,50 +374,3 @@ async def merge_models( except ValueError as e: raise HTTPException(status_code=400, detail=str(e)) return response - -# The rename operation is now supported by update_model and no longer needs to be -# a standalone route. -# @models_router.post( -# "/rename/{base_model}/{model_type}/{model_name}", -# operation_id="rename_model", -# responses= { -# 201: {"description" : "The model was renamed successfully"}, -# 404: {"description" : "The model could not be found"}, -# 409: {"description" : "There is already a model corresponding to the new name"}, -# }, -# status_code=201, -# response_model=ImportModelResponse -# ) -# async def rename_model( -# base_model: BaseModelType = Path(description="Base model"), -# model_type: ModelType = Path(description="The type of model"), -# model_name: str = Path(description="current model name"), -# new_name: Optional[str] = Query(description="new model name", default=None), -# new_base: Optional[BaseModelType] = Query(description="new model base", default=None), -# ) -> ImportModelResponse: -# """ Rename a model""" - -# logger = ApiDependencies.invoker.services.logger - -# try: -# result = ApiDependencies.invoker.services.model_manager.rename_model( -# base_model = base_model, -# model_type = model_type, -# model_name = model_name, -# new_name = new_name, -# new_base = new_base, -# ) -# logger.debug(result) -# logger.info(f'Successfully renamed {model_name}=>{new_name}') -# model_raw = ApiDependencies.invoker.services.model_manager.list_model( -# model_name=new_name or model_name, -# base_model=new_base or base_model, -# model_type=model_type -# ) -# return parse_obj_as(ImportModelResponse, model_raw) -# except ModelNotFoundException as e: -# logger.error(str(e)) -# raise HTTPException(status_code=404, detail=str(e)) -# except ValueError as e: -# logger.error(str(e)) -# raise HTTPException(status_code=409, detail=str(e)) diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 4afa3aa161..824b697d54 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -4,6 +4,7 @@ import sys from inspect import signature import uvicorn +import socket from fastapi import FastAPI from fastapi.middleware.cors import CORSMiddleware @@ -193,9 +194,22 @@ app.mount("/", ) def invoke_api(): + def find_port(port: int): + """Find a port not in use starting at given port""" + # Taken from https://waylonwalker.com/python-find-available-port/, thanks Waylon! + # https://github.com/WaylonWalker + with socket.socket(socket.AF_INET, socket.SOCK_STREAM) as s: + if s.connect_ex(("localhost", port)) == 0: + return find_port(port=port + 1) + else: + return port + + port = find_port(app_config.port) + if port != app_config.port: + logger.warn(f"Port {app_config.port} in use, using port {port}") # Start our own event loop for eventing usage loop = asyncio.new_event_loop() - config = uvicorn.Config(app=app, host=app_config.host, port=app_config.port, loop=loop) + config = uvicorn.Config(app=app, host=app_config.host, port=port, loop=loop) # Use access_log to turn off logging server = uvicorn.Server(config) loop.run_until_complete(server.serve()) diff --git a/invokeai/app/invocations/controlnet_image_processors.py b/invokeai/app/invocations/controlnet_image_processors.py index 43cad3dcaf..911fede8fb 100644 --- a/invokeai/app/invocations/controlnet_image_processors.py +++ b/invokeai/app/invocations/controlnet_image_processors.py @@ -85,8 +85,8 @@ CONTROLNET_DEFAULT_MODELS = [ CONTROLNET_NAME_VALUES = Literal[tuple(CONTROLNET_DEFAULT_MODELS)] CONTROLNET_MODE_VALUES = Literal[tuple( ["balanced", "more_prompt", "more_control", "unbalanced"])] -# crop and fill options not ready yet -# CONTROLNET_RESIZE_VALUES = Literal[tuple(["just_resize", "crop_resize", "fill_resize"])] +CONTROLNET_RESIZE_VALUES = Literal[tuple( + ["just_resize", "crop_resize", "fill_resize", "just_resize_simple",])] class ControlNetModelField(BaseModel): @@ -111,7 +111,8 @@ class ControlField(BaseModel): description="When the ControlNet is last applied (% of total steps)") control_mode: CONTROLNET_MODE_VALUES = Field( default="balanced", description="The control mode to use") - # resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode to use") + resize_mode: CONTROLNET_RESIZE_VALUES = Field( + default="just_resize", description="The resize mode to use") @validator("control_weight") def validate_control_weight(cls, v): @@ -161,6 +162,7 @@ class ControlNetInvocation(BaseInvocation): end_step_percent: float = Field(default=1, ge=0, le=1, description="When the ControlNet is last applied (% of total steps)") control_mode: CONTROLNET_MODE_VALUES = Field(default="balanced", description="The control mode used") + resize_mode: CONTROLNET_RESIZE_VALUES = Field(default="just_resize", description="The resize mode used") # fmt: on class Config(InvocationConfig): @@ -187,6 +189,7 @@ class ControlNetInvocation(BaseInvocation): begin_step_percent=self.begin_step_percent, end_step_percent=self.end_step_percent, control_mode=self.control_mode, + resize_mode=self.resize_mode, ), ) diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index cd15fe156b..b4c3454c88 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -30,6 +30,7 @@ from .compel import ConditioningField from .controlnet_image_processors import ControlField from .image import ImageOutput from .model import ModelInfo, UNetField, VaeField +from invokeai.app.util.controlnet_utils import prepare_control_image from diffusers.models.attention_processor import ( AttnProcessor2_0, @@ -288,7 +289,7 @@ class TextToLatentsInvocation(BaseInvocation): # and add in batch_size, num_images_per_prompt? # and do real check for classifier_free_guidance? # prepare_control_image should return torch.Tensor of shape(batch_size, 3, height, width) - control_image = model.prepare_control_image( + control_image = prepare_control_image( image=input_image, do_classifier_free_guidance=do_classifier_free_guidance, width=control_width_resize, @@ -298,13 +299,18 @@ class TextToLatentsInvocation(BaseInvocation): device=control_model.device, dtype=control_model.dtype, control_mode=control_info.control_mode, + resize_mode=control_info.resize_mode, ) control_item = ControlNetData( - model=control_model, image_tensor=control_image, + model=control_model, + image_tensor=control_image, weight=control_info.control_weight, begin_step_percent=control_info.begin_step_percent, end_step_percent=control_info.end_step_percent, control_mode=control_info.control_mode, + # any resizing needed should currently be happening in prepare_control_image(), + # but adding resize_mode to ControlNetData in case needed in the future + resize_mode=control_info.resize_mode, ) control_data.append(control_item) # MultiControlNetModel has been refactored out, just need list[ControlNetData] @@ -601,7 +607,7 @@ class ResizeLatentsInvocation(BaseInvocation): antialias: bool = Field( default=False, description="Whether or not to antialias (applied in bilinear and bicubic modes only)") - + class Config(InvocationConfig): schema_extra = { "ui": { @@ -647,7 +653,7 @@ class ScaleLatentsInvocation(BaseInvocation): antialias: bool = Field( default=False, description="Whether or not to antialias (applied in bilinear and bicubic modes only)") - + class Config(InvocationConfig): schema_extra = { "ui": { diff --git a/invokeai/app/services/image_record_storage.py b/invokeai/app/services/image_record_storage.py index 09c3bdcc3e..eb69679a35 100644 --- a/invokeai/app/services/image_record_storage.py +++ b/invokeai/app/services/image_record_storage.py @@ -122,6 +122,11 @@ class ImageRecordStorageBase(ABC): """Deletes many image records.""" pass + @abstractmethod + def delete_intermediates(self) -> list[str]: + """Deletes all intermediate image records, returning a list of deleted image names.""" + pass + @abstractmethod def save( self, @@ -461,6 +466,32 @@ class SqliteImageRecordStorage(ImageRecordStorageBase): finally: self._lock.release() + + def delete_intermediates(self) -> list[str]: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + SELECT image_name FROM images + WHERE is_intermediate = TRUE; + """ + ) + result = cast(list[sqlite3.Row], self._cursor.fetchall()) + image_names = list(map(lambda r: r[0], result)) + self._cursor.execute( + """--sql + DELETE FROM images + WHERE is_intermediate = TRUE; + """ + ) + self._conn.commit() + return image_names + except sqlite3.Error as e: + self._conn.rollback() + raise ImageRecordDeleteException from e + finally: + self._lock.release() + def save( self, image_name: str, diff --git a/invokeai/app/services/images.py b/invokeai/app/services/images.py index 13c6c04719..6fdb6237f8 100644 --- a/invokeai/app/services/images.py +++ b/invokeai/app/services/images.py @@ -6,21 +6,33 @@ from typing import TYPE_CHECKING, Optional from PIL.Image import Image as PILImageType from invokeai.app.invocations.metadata import ImageMetadata -from invokeai.app.models.image import (ImageCategory, - InvalidImageCategoryException, - InvalidOriginException, ResourceOrigin) -from invokeai.app.services.board_image_record_storage import \ - BoardImageRecordStorageBase +from invokeai.app.models.image import ( + ImageCategory, + InvalidImageCategoryException, + InvalidOriginException, + ResourceOrigin, +) +from invokeai.app.services.board_image_record_storage import BoardImageRecordStorageBase from invokeai.app.services.image_file_storage import ( - ImageFileDeleteException, ImageFileNotFoundException, - ImageFileSaveException, ImageFileStorageBase) + ImageFileDeleteException, + ImageFileNotFoundException, + ImageFileSaveException, + ImageFileStorageBase, +) from invokeai.app.services.image_record_storage import ( - ImageRecordDeleteException, ImageRecordNotFoundException, - ImageRecordSaveException, ImageRecordStorageBase, OffsetPaginatedResults) + ImageRecordDeleteException, + ImageRecordNotFoundException, + ImageRecordSaveException, + ImageRecordStorageBase, + OffsetPaginatedResults, +) from invokeai.app.services.item_storage import ItemStorageABC -from invokeai.app.services.models.image_record import (ImageDTO, ImageRecord, - ImageRecordChanges, - image_record_to_dto) +from invokeai.app.services.models.image_record import ( + ImageDTO, + ImageRecord, + ImageRecordChanges, + image_record_to_dto, +) from invokeai.app.services.resource_name import NameServiceBase from invokeai.app.services.urls import UrlServiceBase from invokeai.app.util.metadata import get_metadata_graph_from_raw_session @@ -109,12 +121,10 @@ class ImageServiceABC(ABC): pass @abstractmethod - def delete_many(self, is_intermediate: bool) -> int: - """Deletes many images.""" + def delete_intermediates(self) -> int: + """Deletes all intermediate images.""" pass - - @abstractmethod def delete_images_on_board(self, board_id: str): """Deletes all images on a board.""" @@ -401,21 +411,13 @@ class ImageService(ImageServiceABC): except Exception as e: self._services.logger.error("Problem deleting image records and files") raise e - - def delete_many(self, is_intermediate: bool): + + def delete_intermediates(self) -> int: try: - # only clears 100 at a time - images = self._services.image_records.get_many(offset=0, limit=100, is_intermediate=is_intermediate,) - count = len(images.items) - image_name_list = list( - map( - lambda r: r.image_name, - images.items, - ) - ) - for image_name in image_name_list: + image_names = self._services.image_records.delete_intermediates() + count = len(image_names) + for image_name in image_names: self._services.image_files.delete(image_name) - self._services.image_records.delete_many(image_name_list) return count except ImageRecordDeleteException: self._services.logger.error(f"Failed to delete image records") diff --git a/invokeai/app/util/controlnet_utils.py b/invokeai/app/util/controlnet_utils.py new file mode 100644 index 0000000000..342fa147c5 --- /dev/null +++ b/invokeai/app/util/controlnet_utils.py @@ -0,0 +1,342 @@ +import torch +import numpy as np +import cv2 +from PIL import Image +from diffusers.utils import PIL_INTERPOLATION + +from einops import rearrange +from controlnet_aux.util import HWC3, resize_image + +################################################################### +# Copy of scripts/lvminthin.py from Mikubill/sd-webui-controlnet +################################################################### +# High Quality Edge Thinning using Pure Python +# Written by Lvmin Zhangu +# 2023 April +# Stanford University +# If you use this, please Cite "High Quality Edge Thinning using Pure Python", Lvmin Zhang, In Mikubill/sd-webui-controlnet. + +lvmin_kernels_raw = [ + np.array([ + [-1, -1, -1], + [0, 1, 0], + [1, 1, 1] + ], dtype=np.int32), + np.array([ + [0, -1, -1], + [1, 1, -1], + [0, 1, 0] + ], dtype=np.int32) +] + +lvmin_kernels = [] +lvmin_kernels += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_kernels_raw] +lvmin_kernels += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_kernels_raw] + +lvmin_prunings_raw = [ + np.array([ + [-1, -1, -1], + [-1, 1, -1], + [0, 0, -1] + ], dtype=np.int32), + np.array([ + [-1, -1, -1], + [-1, 1, -1], + [-1, 0, 0] + ], dtype=np.int32) +] + +lvmin_prunings = [] +lvmin_prunings += [np.rot90(x, k=0, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=1, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=2, axes=(0, 1)) for x in lvmin_prunings_raw] +lvmin_prunings += [np.rot90(x, k=3, axes=(0, 1)) for x in lvmin_prunings_raw] + + +def remove_pattern(x, kernel): + objects = cv2.morphologyEx(x, cv2.MORPH_HITMISS, kernel) + objects = np.where(objects > 127) + x[objects] = 0 + return x, objects[0].shape[0] > 0 + + +def thin_one_time(x, kernels): + y = x + is_done = True + for k in kernels: + y, has_update = remove_pattern(y, k) + if has_update: + is_done = False + return y, is_done + + +def lvmin_thin(x, prunings=True): + y = x + for i in range(32): + y, is_done = thin_one_time(y, lvmin_kernels) + if is_done: + break + if prunings: + y, _ = thin_one_time(y, lvmin_prunings) + return y + + +def nake_nms(x): + f1 = np.array([[0, 0, 0], [1, 1, 1], [0, 0, 0]], dtype=np.uint8) + f2 = np.array([[0, 1, 0], [0, 1, 0], [0, 1, 0]], dtype=np.uint8) + f3 = np.array([[1, 0, 0], [0, 1, 0], [0, 0, 1]], dtype=np.uint8) + f4 = np.array([[0, 0, 1], [0, 1, 0], [1, 0, 0]], dtype=np.uint8) + y = np.zeros_like(x) + for f in [f1, f2, f3, f4]: + np.putmask(y, cv2.dilate(x, kernel=f) == x, x) + return y + + +################################################################################ +# copied from Mikubill/sd-webui-controlnet external_code.py and modified for InvokeAI +################################################################################ +# FIXME: not using yet, if used in the future will most likely require modification of preprocessors +def pixel_perfect_resolution( + image: np.ndarray, + target_H: int, + target_W: int, + resize_mode: str, +) -> int: + """ + Calculate the estimated resolution for resizing an image while preserving aspect ratio. + + The function first calculates scaling factors for height and width of the image based on the target + height and width. Then, based on the chosen resize mode, it either takes the smaller or the larger + scaling factor to estimate the new resolution. + + If the resize mode is OUTER_FIT, the function uses the smaller scaling factor, ensuring the whole image + fits within the target dimensions, potentially leaving some empty space. + + If the resize mode is not OUTER_FIT, the function uses the larger scaling factor, ensuring the target + dimensions are fully filled, potentially cropping the image. + + After calculating the estimated resolution, the function prints some debugging information. + + Args: + image (np.ndarray): A 3D numpy array representing an image. The dimensions represent [height, width, channels]. + target_H (int): The target height for the image. + target_W (int): The target width for the image. + resize_mode (ResizeMode): The mode for resizing. + + Returns: + int: The estimated resolution after resizing. + """ + raw_H, raw_W, _ = image.shape + + k0 = float(target_H) / float(raw_H) + k1 = float(target_W) / float(raw_W) + + if resize_mode == "fill_resize": + estimation = min(k0, k1) * float(min(raw_H, raw_W)) + else: # "crop_resize" or "just_resize" (or possibly "just_resize_simple"?) + estimation = max(k0, k1) * float(min(raw_H, raw_W)) + + # print(f"Pixel Perfect Computation:") + # print(f"resize_mode = {resize_mode}") + # print(f"raw_H = {raw_H}") + # print(f"raw_W = {raw_W}") + # print(f"target_H = {target_H}") + # print(f"target_W = {target_W}") + # print(f"estimation = {estimation}") + + return int(np.round(estimation)) + + +########################################################################### +# Copied from detectmap_proc method in scripts/detectmap_proc.py in Mikubill/sd-webui-controlnet +# modified for InvokeAI +########################################################################### +# def detectmap_proc(detected_map, module, resize_mode, h, w): +def np_img_resize( + np_img: np.ndarray, + resize_mode: str, + h: int, + w: int, + device: torch.device = torch.device('cpu') +): + # if 'inpaint' in module: + # np_img = np_img.astype(np.float32) + # else: + # np_img = HWC3(np_img) + np_img = HWC3(np_img) + + def safe_numpy(x): + # A very safe method to make sure that Apple/Mac works + y = x + + # below is very boring but do not change these. If you change these Apple or Mac may fail. + y = y.copy() + y = np.ascontiguousarray(y) + y = y.copy() + return y + + def get_pytorch_control(x): + # A very safe method to make sure that Apple/Mac works + y = x + + # below is very boring but do not change these. If you change these Apple or Mac may fail. + y = torch.from_numpy(y) + y = y.float() / 255.0 + y = rearrange(y, 'h w c -> 1 c h w') + y = y.clone() + # y = y.to(devices.get_device_for("controlnet")) + y = y.to(device) + y = y.clone() + return y + + def high_quality_resize(x: np.ndarray, + size): + # Written by lvmin + # Super high-quality control map up-scaling, considering binary, seg, and one-pixel edges + inpaint_mask = None + if x.ndim == 3 and x.shape[2] == 4: + inpaint_mask = x[:, :, 3] + x = x[:, :, 0:3] + + new_size_is_smaller = (size[0] * size[1]) < (x.shape[0] * x.shape[1]) + new_size_is_bigger = (size[0] * size[1]) > (x.shape[0] * x.shape[1]) + unique_color_count = np.unique(x.reshape(-1, x.shape[2]), axis=0).shape[0] + is_one_pixel_edge = False + is_binary = False + if unique_color_count == 2: + is_binary = np.min(x) < 16 and np.max(x) > 240 + if is_binary: + xc = x + xc = cv2.erode(xc, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + xc = cv2.dilate(xc, np.ones(shape=(3, 3), dtype=np.uint8), iterations=1) + one_pixel_edge_count = np.where(xc < x)[0].shape[0] + all_edge_count = np.where(x > 127)[0].shape[0] + is_one_pixel_edge = one_pixel_edge_count * 2 > all_edge_count + + if 2 < unique_color_count < 200: + interpolation = cv2.INTER_NEAREST + elif new_size_is_smaller: + interpolation = cv2.INTER_AREA + else: + interpolation = cv2.INTER_CUBIC # Must be CUBIC because we now use nms. NEVER CHANGE THIS + + y = cv2.resize(x, size, interpolation=interpolation) + if inpaint_mask is not None: + inpaint_mask = cv2.resize(inpaint_mask, size, interpolation=interpolation) + + if is_binary: + y = np.mean(y.astype(np.float32), axis=2).clip(0, 255).astype(np.uint8) + if is_one_pixel_edge: + y = nake_nms(y) + _, y = cv2.threshold(y, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + y = lvmin_thin(y, prunings=new_size_is_bigger) + else: + _, y = cv2.threshold(y, 0, 255, cv2.THRESH_BINARY + cv2.THRESH_OTSU) + y = np.stack([y] * 3, axis=2) + + if inpaint_mask is not None: + inpaint_mask = (inpaint_mask > 127).astype(np.float32) * 255.0 + inpaint_mask = inpaint_mask[:, :, None].clip(0, 255).astype(np.uint8) + y = np.concatenate([y, inpaint_mask], axis=2) + + return y + + # if resize_mode == external_code.ResizeMode.RESIZE: + if resize_mode == "just_resize": # RESIZE + np_img = high_quality_resize(np_img, (w, h)) + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img + + old_h, old_w, _ = np_img.shape + old_w = float(old_w) + old_h = float(old_h) + k0 = float(h) / old_h + k1 = float(w) / old_w + + safeint = lambda x: int(np.round(x)) + + # if resize_mode == external_code.ResizeMode.OUTER_FIT: + if resize_mode == "fill_resize": # OUTER_FIT + k = min(k0, k1) + borders = np.concatenate([np_img[0, :, :], np_img[-1, :, :], np_img[:, 0, :], np_img[:, -1, :]], axis=0) + high_quality_border_color = np.median(borders, axis=0).astype(np_img.dtype) + if len(high_quality_border_color) == 4: + # Inpaint hijack + high_quality_border_color[3] = 255 + high_quality_background = np.tile(high_quality_border_color[None, None], [h, w, 1]) + np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (h - new_h) // 2) + pad_w = max(0, (w - new_w) // 2) + high_quality_background[pad_h:pad_h + new_h, pad_w:pad_w + new_w] = np_img + np_img = high_quality_background + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img + else: # resize_mode == "crop_resize" (INNER_FIT) + k = max(k0, k1) + np_img = high_quality_resize(np_img, (safeint(old_w * k), safeint(old_h * k))) + new_h, new_w, _ = np_img.shape + pad_h = max(0, (new_h - h) // 2) + pad_w = max(0, (new_w - w) // 2) + np_img = np_img[pad_h:pad_h + h, pad_w:pad_w + w] + np_img = safe_numpy(np_img) + return get_pytorch_control(np_img), np_img + +def prepare_control_image( + # image used to be Union[PIL.Image.Image, List[PIL.Image.Image], torch.Tensor, List[torch.Tensor]] + # but now should be able to assume that image is a single PIL.Image, which simplifies things + image: Image, + # FIXME: need to fix hardwiring of width and height, change to basing on latents dimensions? + # latents_to_match_resolution, # TorchTensor of shape (batch_size, 3, height, width) + width=512, # should be 8 * latent.shape[3] + height=512, # should be 8 * latent height[2] + # batch_size=1, # currently no batching + # num_images_per_prompt=1, # currently only single image + device="cuda", + dtype=torch.float16, + do_classifier_free_guidance=True, + control_mode="balanced", + resize_mode="just_resize_simple", +): + # FIXME: implement "crop_resize_simple" and "fill_resize_simple", or pull them out + if (resize_mode == "just_resize_simple" or + resize_mode == "crop_resize_simple" or + resize_mode == "fill_resize_simple"): + image = image.convert("RGB") + if (resize_mode == "just_resize_simple"): + image = image.resize((width, height), resample=PIL_INTERPOLATION["lanczos"]) + elif (resize_mode == "crop_resize_simple"): # not yet implemented + pass + elif (resize_mode == "fill_resize_simple"): # not yet implemented + pass + nimage = np.array(image) + nimage = nimage[None, :] + nimage = np.concatenate([nimage], axis=0) + # normalizing RGB values to [0,1] range (in PIL.Image they are [0-255]) + nimage = np.array(nimage).astype(np.float32) / 255.0 + nimage = nimage.transpose(0, 3, 1, 2) + timage = torch.from_numpy(nimage) + + # use fancy lvmin controlnet resizing + elif (resize_mode == "just_resize" or resize_mode == "crop_resize" or resize_mode == "fill_resize"): + nimage = np.array(image) + timage, nimage = np_img_resize( + np_img=nimage, + resize_mode=resize_mode, + h=height, + w=width, + # device=torch.device('cpu') + device=device, + ) + else: + pass + print("ERROR: invalid resize_mode ==> ", resize_mode) + exit(1) + + timage = timage.to(device=device, dtype=dtype) + cfg_injection = (control_mode == "more_control" or control_mode == "unbalanced") + if do_classifier_free_guidance and not cfg_injection: + timage = torch.cat([timage] * 2) + return timage diff --git a/invokeai/backend/stable_diffusion/diffusers_pipeline.py b/invokeai/backend/stable_diffusion/diffusers_pipeline.py index 228fbd0585..8acfb100a6 100644 --- a/invokeai/backend/stable_diffusion/diffusers_pipeline.py +++ b/invokeai/backend/stable_diffusion/diffusers_pipeline.py @@ -219,6 +219,7 @@ class ControlNetData: begin_step_percent: float = Field(default=0.0) end_step_percent: float = Field(default=1.0) control_mode: str = Field(default="balanced") + resize_mode: str = Field(default="just_resize") @dataclass @@ -653,7 +654,7 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): if cfg_injection: # Inferred ControlNet only for the conditional batch. # To apply the output of ControlNet to both the unconditional and conditional batches, - # add 0 to the unconditional batch to keep it unchanged. + # prepend zeros for unconditional batch down_samples = [torch.cat([torch.zeros_like(d), d]) for d in down_samples] mid_sample = torch.cat([torch.zeros_like(mid_sample), mid_sample]) @@ -954,53 +955,3 @@ class StableDiffusionGeneratorPipeline(StableDiffusionPipeline): debug_image( img, f"latents {msg} {i+1}/{len(decoded)}", debug_status=True ) - - # Copied from diffusers pipeline_stable_diffusion_controlnet.py - # Returns torch.Tensor of shape (batch_size, 3, height, width) - @staticmethod - def prepare_control_image( - image, - # FIXME: need to fix hardwiring of width and height, change to basing on latents dimensions? - # latents, - width=512, # should be 8 * latent.shape[3] - height=512, # should be 8 * latent height[2] - batch_size=1, - num_images_per_prompt=1, - device="cuda", - dtype=torch.float16, - do_classifier_free_guidance=True, - control_mode="balanced" - ): - - if not isinstance(image, torch.Tensor): - if isinstance(image, PIL.Image.Image): - image = [image] - - if isinstance(image[0], PIL.Image.Image): - images = [] - for image_ in image: - image_ = image_.convert("RGB") - image_ = image_.resize((width, height), resample=PIL_INTERPOLATION["lanczos"]) - image_ = np.array(image_) - image_ = image_[None, :] - images.append(image_) - image = images - image = np.concatenate(image, axis=0) - image = np.array(image).astype(np.float32) / 255.0 - image = image.transpose(0, 3, 1, 2) - image = torch.from_numpy(image) - elif isinstance(image[0], torch.Tensor): - image = torch.cat(image, dim=0) - - image_batch_size = image.shape[0] - if image_batch_size == 1: - repeat_by = batch_size - else: - # image batch size is the same as prompt batch size - repeat_by = num_images_per_prompt - image = image.repeat_interleave(repeat_by, dim=0) - image = image.to(device=device, dtype=dtype) - cfg_injection = (control_mode == "more_control" or control_mode == "unbalanced") - if do_classifier_free_guidance and not cfg_injection: - image = torch.cat([image] * 2) - return image diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 32ff574925..59cf87fbda 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -455,7 +455,12 @@ "addDifference": "Add Difference", "pickModelType": "Pick Model Type", "selectModel": "Select Model", - "importModels": "Import Models" + "importModels": "Import Models", + "settings": "Settings", + "syncModels": "Sync Models", + "syncModelsDesc": "If your models are out of sync with the backend, you can refresh them up using this option. This is generally handy in cases where you manually update your models.yaml file or add models to the InvokeAI root folder after the application has booted.", + "modelsSynced": "Models Synced", + "modelSyncFailed": "Model Sync Failed" }, "parameters": { "general": "General", @@ -547,7 +552,8 @@ "saveSteps": "Save images every n steps", "confirmOnDelete": "Confirm On Delete", "displayHelpIcons": "Display Help Icons", - "useCanvasBeta": "Use Canvas Beta Layout", + "alternateCanvasLayout": "Alternate Canvas Layout", + "enableNodesEditor": "Enable Nodes Editor", "enableImageDebugging": "Enable Image Debugging", "useSlidersForAll": "Use Sliders For All Options", "showProgressInViewer": "Show Progress Images in Viewer", @@ -564,7 +570,9 @@ "ui": "User Interface", "favoriteSchedulers": "Favorite Schedulers", "favoriteSchedulersPlaceholder": "No schedulers favorited", - "showAdvancedOptions": "Show Advanced Options" + "showAdvancedOptions": "Show Advanced Options", + "experimental": "Experimental", + "beta": "Beta" }, "toast": { "serverError": "Server Error", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts index c2c57e0913..97cccfa05c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/socketio/socketInvocationComplete.ts @@ -6,11 +6,7 @@ import { imageSelected, } from 'features/gallery/store/gallerySlice'; import { progressImageSet } from 'features/system/store/systemSlice'; -import { - SYSTEM_BOARDS, - imagesAdapter, - imagesApi, -} from 'services/api/endpoints/images'; +import { imagesAdapter, imagesApi } from 'services/api/endpoints/images'; import { isImageOutput } from 'services/api/guards'; import { sessionCanceled } from 'services/api/thunks/session'; import { @@ -32,8 +28,7 @@ export const addInvocationCompleteEventListener = () => { ); const session_id = action.payload.data.graph_execution_state_id; - const { cancelType, isCancelScheduled, boardIdToAddTo } = - getState().system; + const { cancelType, isCancelScheduled } = getState().system; // Handle scheduled cancelation if (cancelType === 'scheduled' && isCancelScheduled) { @@ -88,26 +83,28 @@ export const addInvocationCompleteEventListener = () => { ) ); - // add image to the board if we had one selected - if (boardIdToAddTo && !SYSTEM_BOARDS.includes(boardIdToAddTo)) { + const { autoAddBoardId } = gallery; + + // add image to the board if auto-add is enabled + if (autoAddBoardId) { dispatch( imagesApi.endpoints.addImageToBoard.initiate({ - board_id: boardIdToAddTo, + board_id: autoAddBoardId, imageDTO, }) ); } - const { selectedBoardId } = gallery; - - if (boardIdToAddTo && boardIdToAddTo !== selectedBoardId) { - dispatch(boardIdSelected(boardIdToAddTo)); - } else if (!boardIdToAddTo) { - dispatch(boardIdSelected('all')); - } + const { selectedBoardId, shouldAutoSwitch } = gallery; // If auto-switch is enabled, select the new image - if (getState().gallery.shouldAutoSwitch) { + if (shouldAutoSwitch) { + // if auto-add is enabled, switch the board as the image comes in + if (autoAddBoardId && autoAddBoardId !== selectedBoardId) { + dispatch(boardIdSelected(autoAddBoardId)); + } else if (!autoAddBoardId) { + dispatch(boardIdSelected('images')); + } dispatch(imageSelected(imageDTO.image_name)); } } diff --git a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx index 6082843c55..57d54e155e 100644 --- a/invokeai/frontend/web/src/common/components/IAIDndImage.tsx +++ b/invokeai/frontend/web/src/common/components/IAIDndImage.tsx @@ -17,13 +17,13 @@ import { } from 'common/components/IAIImageFallback'; import ImageMetadataOverlay from 'common/components/ImageMetadataOverlay'; import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; import { MouseEvent, ReactElement, SyntheticEvent, memo } from 'react'; import { FaImage, FaUndo, FaUpload } from 'react-icons/fa'; import { ImageDTO, PostUploadAction } from 'services/api/types'; import { mode } from 'theme/util/mode'; import IAIDraggable from './IAIDraggable'; import IAIDroppable from './IAIDroppable'; -import ImageContextMenu from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; type IAIDndImageProps = { imageDTO: ImageDTO | undefined; @@ -148,7 +148,9 @@ const IAIDndImage = (props: IAIDndImageProps) => { maxH: 'full', borderRadius: 'base', shadow: isSelected ? 'selected.light' : undefined, - _dark: { shadow: isSelected ? 'selected.dark' : undefined }, + _dark: { + shadow: isSelected ? 'selected.dark' : undefined, + }, ...imageSx, }} /> @@ -183,13 +185,6 @@ const IAIDndImage = (props: IAIDndImageProps) => { )} {!imageDTO && isUploadDisabled && noContentFallback} - {!isDropDisabled && ( - - )} {imageDTO && !isDragDisabled && ( { onClick={onClick} /> )} + {!isDropDisabled && ( + + )} {onClickReset && withResetIcon && imageDTO && ( ; }; const IAIDroppable = (props: IAIDroppableProps) => { - const { dropLabel, data, disabled } = props; + const { dropLabel, data, disabled, hoverRef } = props; const dndId = useRef(uuidv4()); const { isOver, setNodeRef, active } = useDroppable({ diff --git a/invokeai/frontend/web/src/common/components/IAISwitch.tsx b/invokeai/frontend/web/src/common/components/IAISwitch.tsx index d25ab0d87e..9803626397 100644 --- a/invokeai/frontend/web/src/common/components/IAISwitch.tsx +++ b/invokeai/frontend/web/src/common/components/IAISwitch.tsx @@ -9,7 +9,7 @@ import { } from '@chakra-ui/react'; import { memo } from 'react'; -interface Props extends SwitchProps { +export interface IAISwitchProps extends SwitchProps { label?: string; width?: string | number; formControlProps?: FormControlProps; @@ -20,7 +20,7 @@ interface Props extends SwitchProps { /** * Customized Chakra FormControl + Switch multi-part component. */ -const IAISwitch = (props: Props) => { +const IAISwitch = (props: IAISwitchProps) => { const { label, isDisabled = false, diff --git a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx index 368b9f727c..d858e46fdb 100644 --- a/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx +++ b/invokeai/frontend/web/src/features/controlNet/components/ControlNet.tsx @@ -24,6 +24,7 @@ import ParamControlNetShouldAutoConfig from './ParamControlNetShouldAutoConfig'; import ParamControlNetBeginEnd from './parameters/ParamControlNetBeginEnd'; import ParamControlNetControlMode from './parameters/ParamControlNetControlMode'; import ParamControlNetProcessorSelect from './parameters/ParamControlNetProcessorSelect'; +import ParamControlNetResizeMode from './parameters/ParamControlNetResizeMode'; type ControlNetProps = { controlNetId: string; @@ -68,7 +69,7 @@ const ControlNet = (props: ControlNetProps) => { { tooltip={isExpanded ? 'Hide Advanced' : 'Show Advanced'} aria-label={isExpanded ? 'Hide Advanced' : 'Show Advanced'} onClick={toggleIsExpanded} - variant="link" + variant="ghost" + sx={{ + _hover: { + bg: 'none', + }, + }} icon={ { /> )} - + { h: 28, w: 28, aspectRatio: '1/1', - mt: 3, }} > )} - + - + + diff --git a/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx new file mode 100644 index 0000000000..4b31ebfc64 --- /dev/null +++ b/invokeai/frontend/web/src/features/controlNet/components/parameters/ParamControlNetResizeMode.tsx @@ -0,0 +1,62 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIMantineSelect from 'common/components/IAIMantineSelect'; +import { + ResizeModes, + controlNetResizeModeChanged, +} from 'features/controlNet/store/controlNetSlice'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; + +type ParamControlNetResizeModeProps = { + controlNetId: string; +}; + +const RESIZE_MODE_DATA = [ + { label: 'Resize', value: 'just_resize' }, + { label: 'Crop', value: 'crop_resize' }, + { label: 'Fill', value: 'fill_resize' }, +]; + +export default function ParamControlNetResizeMode( + props: ParamControlNetResizeModeProps +) { + const { controlNetId } = props; + const dispatch = useAppDispatch(); + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ controlNet }) => { + const { resizeMode, isEnabled } = + controlNet.controlNets[controlNetId]; + return { resizeMode, isEnabled }; + }, + defaultSelectorOptions + ), + [controlNetId] + ); + + const { resizeMode, isEnabled } = useAppSelector(selector); + + const { t } = useTranslation(); + + const handleResizeModeChange = useCallback( + (resizeMode: ResizeModes) => { + dispatch(controlNetResizeModeChanged({ controlNetId, resizeMode })); + }, + [controlNetId, dispatch] + ); + + return ( + + ); +} diff --git a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts index 663edfd65f..2f8668115a 100644 --- a/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts +++ b/invokeai/frontend/web/src/features/controlNet/store/controlNetSlice.ts @@ -3,6 +3,7 @@ import { RootState } from 'app/store/store'; import { ControlNetModelParam } from 'features/parameters/types/parameterSchemas'; import { cloneDeep, forEach } from 'lodash-es'; import { imagesApi } from 'services/api/endpoints/images'; +import { components } from 'services/api/schema'; import { isAnySessionRejected } from 'services/api/thunks/session'; import { appSocketInvocationError } from 'services/events/actions'; import { controlNetImageProcessed } from './actions'; @@ -16,11 +17,13 @@ import { RequiredControlNetProcessorNode, } from './types'; -export type ControlModes = - | 'balanced' - | 'more_prompt' - | 'more_control' - | 'unbalanced'; +export type ControlModes = NonNullable< + components['schemas']['ControlNetInvocation']['control_mode'] +>; + +export type ResizeModes = NonNullable< + components['schemas']['ControlNetInvocation']['resize_mode'] +>; export const initialControlNet: Omit = { isEnabled: true, @@ -29,6 +32,7 @@ export const initialControlNet: Omit = { beginStepPct: 0, endStepPct: 1, controlMode: 'balanced', + resizeMode: 'just_resize', controlImage: null, processedControlImage: null, processorType: 'canny_image_processor', @@ -45,6 +49,7 @@ export type ControlNetConfig = { beginStepPct: number; endStepPct: number; controlMode: ControlModes; + resizeMode: ResizeModes; controlImage: string | null; processedControlImage: string | null; processorType: ControlNetProcessorType; @@ -215,6 +220,16 @@ export const controlNetSlice = createSlice({ const { controlNetId, controlMode } = action.payload; state.controlNets[controlNetId].controlMode = controlMode; }, + controlNetResizeModeChanged: ( + state, + action: PayloadAction<{ + controlNetId: string; + resizeMode: ResizeModes; + }> + ) => { + const { controlNetId, resizeMode } = action.payload; + state.controlNets[controlNetId].resizeMode = resizeMode; + }, controlNetProcessorParamsChanged: ( state, action: PayloadAction<{ @@ -342,6 +357,7 @@ export const { controlNetBeginStepPctChanged, controlNetEndStepPctChanged, controlNetControlModeChanged, + controlNetResizeModeChanged, controlNetProcessorParamsChanged, controlNetProcessorTypeChanged, controlNetReset, diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx new file mode 100644 index 0000000000..827d49c88e --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardAutoAddSelect.tsx @@ -0,0 +1,80 @@ +import { SelectItem } from '@mantine/core'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; +import IAIMantineSelectItemWithTooltip from 'common/components/IAIMantineSelectItemWithTooltip'; +import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; +import { useCallback, useRef } from 'react'; +import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; + +const selector = createSelector( + [stateSelector], + ({ gallery }) => { + const { autoAddBoardId } = gallery; + + return { + autoAddBoardId, + }; + }, + defaultSelectorOptions +); + +const BoardAutoAddSelect = () => { + const dispatch = useAppDispatch(); + const { autoAddBoardId } = useAppSelector(selector); + const inputRef = useRef(null); + const { boards, hasBoards } = useListAllBoardsQuery(undefined, { + selectFromResult: ({ data }) => { + const boards: SelectItem[] = [ + { + label: 'None', + value: 'none', + }, + ]; + data?.forEach(({ board_id, board_name }) => { + boards.push({ + label: board_name, + value: board_id, + }); + }); + return { + boards, + hasBoards: boards.length > 1, + }; + }, + }); + + const handleChange = useCallback( + (v: string | null) => { + if (!v) { + return; + } + + dispatch(autoAddBoardIdChanged(v === 'none' ? null : v)); + }, + [dispatch] + ); + + return ( + + item.label?.toLowerCase().includes(value.toLowerCase().trim()) || + item.value.toLowerCase().includes(value.toLowerCase().trim()) + } + onChange={handleChange} + /> + ); +}; + +export default BoardAutoAddSelect; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx new file mode 100644 index 0000000000..3b3303f0c8 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardContextMenu.tsx @@ -0,0 +1,58 @@ +import { Box, MenuItem, MenuList } from '@chakra-ui/react'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { ContextMenu, ContextMenuProps } from 'chakra-ui-contextmenu'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback } from 'react'; +import { FaFolder } from 'react-icons/fa'; +import { BoardDTO } from 'services/api/types'; +import { menuListMotionProps } from 'theme/components/menu'; +import GalleryBoardContextMenuItems from './GalleryBoardContextMenuItems'; +import SystemBoardContextMenuItems from './SystemBoardContextMenuItems'; + +type Props = { + board?: BoardDTO; + board_id: string; + children: ContextMenuProps['children']; + setBoardToDelete?: (board?: BoardDTO) => void; +}; + +const BoardContextMenu = memo( + ({ board, board_id, setBoardToDelete, children }: Props) => { + const dispatch = useAppDispatch(); + const handleSelectBoard = useCallback(() => { + dispatch(boardIdSelected(board?.board_id ?? board_id)); + }, [board?.board_id, board_id, dispatch]); + return ( + + menuProps={{ size: 'sm', isLazy: true }} + menuButtonProps={{ + bg: 'transparent', + _hover: { bg: 'transparent' }, + }} + renderMenu={() => ( + + } onClickCapture={handleSelectBoard}> + Select Board + + {!board && } + {board && ( + + )} + + )} + > + {children} + + ); + } +); + +BoardContextMenu.displayName = 'HoverableBoard'; + +export default BoardContextMenu; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx index a08fdec07f..7a07680878 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AddBoardButton.tsx @@ -1,5 +1,6 @@ -import IAIButton from 'common/components/IAIButton'; +import IAIIconButton from 'common/components/IAIIconButton'; import { useCallback } from 'react'; +import { FaPlus } from 'react-icons/fa'; import { useCreateBoardMutation } from 'services/api/endpoints/boards'; const DEFAULT_BOARD_NAME = 'My Board'; @@ -12,15 +13,14 @@ const AddBoardButton = () => { }, [createBoard]); return ( - } isLoading={isLoading} + tooltip="Add Board" aria-label="Add Board" onClick={handleCreateBoard} size="sm" - sx={{ px: 4 }} - > - Add Board - + /> ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx index 5f4f1cbeb0..76f6238cd0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/AllAssetsBoard.tsx @@ -38,6 +38,7 @@ const AllAssetsBoard = ({ isSelected }: { isSelected: boolean }) => { return ( { return ( { return ( { ) : boards; const [boardToDelete, setBoardToDelete] = useState(); - const [searchMode, setSearchMode] = useState(false); + const [isSearching, setIsSearching] = useState(false); + const handleClickSearchIcon = useCallback(() => { + setIsSearching((v) => !v); + }, []); return ( <> @@ -64,7 +61,54 @@ const BoardsList = (props: Props) => { }} > - + + {isSearching ? ( + + + + ) : ( + + + + + + + + )} + + } + /> { - {!searchMode && ( - <> - - - - - - - - - - {isBatchEnabled && ( - - - - )} - - )} {filteredBoards && filteredBoards.map((board) => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx index fffe50f6a7..f556b83d24 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/BoardsSearch.tsx @@ -10,7 +10,14 @@ import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { setBoardSearchText } from 'features/gallery/store/boardSlice'; -import { memo } from 'react'; +import { + ChangeEvent, + KeyboardEvent, + memo, + useCallback, + useEffect, + useRef, +} from 'react'; const selector = createSelector( [stateSelector], @@ -22,31 +29,60 @@ const selector = createSelector( ); type Props = { - setSearchMode: (searchMode: boolean) => void; + setIsSearching: (isSearching: boolean) => void; }; const BoardsSearch = (props: Props) => { - const { setSearchMode } = props; + const { setIsSearching } = props; const dispatch = useAppDispatch(); const { searchText } = useAppSelector(selector); + const inputRef = useRef(null); - const handleBoardSearch = (searchTerm: string) => { - setSearchMode(searchTerm.length > 0); - dispatch(setBoardSearchText(searchTerm)); - }; - const clearBoardSearch = () => { - setSearchMode(false); + const handleBoardSearch = useCallback( + (searchTerm: string) => { + dispatch(setBoardSearchText(searchTerm)); + }, + [dispatch] + ); + + const clearBoardSearch = useCallback(() => { dispatch(setBoardSearchText('')); - }; + setIsSearching(false); + }, [dispatch, setIsSearching]); + + const handleKeydown = useCallback( + (e: KeyboardEvent) => { + // exit search mode on escape + if (e.key === 'Escape') { + clearBoardSearch(); + } + }, + [clearBoardSearch] + ); + + const handleChange = useCallback( + (e: ChangeEvent) => { + handleBoardSearch(e.target.value); + }, + [handleBoardSearch] + ); + + useEffect(() => { + // focus the search box on mount + if (!inputRef.current) { + return; + } + inputRef.current.focus(); + }, []); return ( { - handleBoardSearch(e.target.value); - }} + onKeyDown={handleKeydown} + onChange={handleChange} /> {searchText && searchText.length && ( @@ -55,7 +91,8 @@ const BoardsSearch = (props: Props) => { size="xs" variant="ghost" aria-label="Clear Search" - icon={} + opacity={0.5} + icon={} /> )} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx index 1b542eee4d..5d76ad743c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GalleryBoard.tsx @@ -1,31 +1,39 @@ import { Badge, Box, + ChakraProps, Editable, EditableInput, EditablePreview, Flex, + Icon, Image, - MenuItem, - MenuList, Text, - useColorMode, } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; import { skipToken } from '@reduxjs/toolkit/dist/query'; import { MoveBoardDropData } from 'app/components/ImageDnd/typesafeDnd'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { ContextMenu } from 'chakra-ui-contextmenu'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIDroppable from 'common/components/IAIDroppable'; -import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { FaTrash, FaUser } from 'react-icons/fa'; +import { memo, useCallback, useMemo, useState } from 'react'; +import { FaFolder } from 'react-icons/fa'; import { useUpdateBoardMutation } from 'services/api/endpoints/boards'; import { useGetImageDTOQuery } from 'services/api/endpoints/images'; import { BoardDTO } from 'services/api/types'; -import { menuListMotionProps } from 'theme/components/menu'; -import { mode } from 'theme/util/mode'; +import BoardContextMenu from '../BoardContextMenu'; +const AUTO_ADD_BADGE_STYLES: ChakraProps['sx'] = { + bg: 'accent.200', + color: 'blackAlpha.900', +}; + +const BASE_BADGE_STYLES: ChakraProps['sx'] = { + bg: 'base.500', + color: 'whiteAlpha.900', +}; interface GalleryBoardProps { board: BoardDTO; isSelected: boolean; @@ -35,13 +43,30 @@ interface GalleryBoardProps { const GalleryBoard = memo( ({ board, isSelected, setBoardToDelete }: GalleryBoardProps) => { const dispatch = useAppDispatch(); + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ gallery }) => { + const isSelectedForAutoAdd = + board.board_id === gallery.autoAddBoardId; + + return { isSelectedForAutoAdd }; + }, + defaultSelectorOptions + ), + [board.board_id] + ); + + const { isSelectedForAutoAdd } = useAppSelector(selector); const { currentData: coverImage } = useGetImageDTOQuery( board.cover_image_name ?? skipToken ); - const { colorMode } = useColorMode(); const { board_name, board_id } = board; + const [localBoardName, setLocalBoardName] = useState(board_name); + const handleSelectBoard = useCallback(() => { dispatch(boardIdSelected(board_id)); }, [board_id, dispatch]); @@ -49,14 +74,6 @@ const GalleryBoard = memo( const [updateBoard, { isLoading: isUpdateBoardLoading }] = useUpdateBoardMutation(); - const handleUpdateBoardName = (newBoardName: string) => { - updateBoard({ board_id, changes: { board_name: newBoardName } }); - }; - - const handleDeleteBoard = useCallback(() => { - setBoardToDelete(board); - }, [board, setBoardToDelete]); - const droppableData: MoveBoardDropData = useMemo( () => ({ id: board_id, @@ -66,86 +83,116 @@ const GalleryBoard = memo( [board_id] ); + const handleSubmit = useCallback( + (newBoardName: string) => { + if (!newBoardName) { + // empty strings are not allowed + setLocalBoardName(board_name); + return; + } + if (newBoardName === board_name) { + // don't updated the board name if it hasn't changed + return; + } + updateBoard({ board_id, changes: { board_name: newBoardName } }) + .unwrap() + .then((response) => { + // update local state + setLocalBoardName(response.board_name); + }) + .catch(() => { + // revert on error + setLocalBoardName(board_name); + }); + }, + [board_id, board_name, updateBoard] + ); + + const handleChange = useCallback((newBoardName: string) => { + setLocalBoardName(newBoardName); + }, []); + return ( - - - menuProps={{ size: 'sm', isLazy: true }} - menuButtonProps={{ - bg: 'transparent', - _hover: { bg: 'transparent' }, + + ( - - {board.image_count > 0 && ( - <> - {/* } - onClickCapture={handleAddBoardToBatch} - > - Add Board to Batch - */} - - )} - } - onClickCapture={handleDeleteBoard} - > - Delete Board - - - )} > - {(ref) => ( - + + {(ref) => ( - {board.cover_image_name && coverImage?.thumbnail_url && ( - - )} - {!(board.cover_image_name && coverImage?.thumbnail_url) && ( - - )} + + {coverImage?.thumbnail_url ? ( + + ) : ( + + + + )} + - {board.image_count} + + {board.image_count} + + + + + + + + + Move} /> - - - { - handleUpdateBoardName(nextValue); - }} - sx={{ maxW: 'full' }} - > - - - - - - )} - + )} + + ); } diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx index 5067dac33a..fa7f944a24 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/GenericBoard.tsx @@ -2,9 +2,12 @@ import { As, Badge, Flex } from '@chakra-ui/react'; import { TypesafeDroppableData } from 'app/components/ImageDnd/typesafeDnd'; import IAIDroppable from 'common/components/IAIDroppable'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; +import { BoardId } from 'features/gallery/store/gallerySlice'; import { ReactNode } from 'react'; +import BoardContextMenu from '../BoardContextMenu'; type GenericBoardProps = { + board_id: BoardId; droppableData?: TypesafeDroppableData; onClick: () => void; isSelected: boolean; @@ -14,7 +17,7 @@ type GenericBoardProps = { badgeCount?: number; }; -const formatBadgeCount = (count: number) => +export const formatBadgeCount = (count: number) => Intl.NumberFormat('en-US', { notation: 'compact', maximumFractionDigits: 1, @@ -22,6 +25,7 @@ const formatBadgeCount = (count: number) => const GenericBoard = (props: GenericBoardProps) => { const { + board_id, droppableData, onClick, isSelected, @@ -32,67 +36,72 @@ const GenericBoard = (props: GenericBoardProps) => { } = props; return ( - - - + + {(ref) => ( - {badgeCount !== undefined && ( - {formatBadgeCount(badgeCount)} - )} + + + + {badgeCount !== undefined && ( + {formatBadgeCount(badgeCount)} + )} + + + + + {label} + - - - - {label} - - + )} + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx index a47d4cd49f..772f5af97d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/NoBoardBoard.tsx @@ -39,6 +39,7 @@ const NoBoardBoard = ({ isSelected }: { isSelected: boolean }) => { return ( Move} onClick={handleClick} diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/SystemBoardButton.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/SystemBoardButton.tsx new file mode 100644 index 0000000000..b538eee9d1 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/BoardsList/SystemBoardButton.tsx @@ -0,0 +1,53 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import IAIButton from 'common/components/IAIButton'; +import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useBoardName } from 'services/api/hooks/useBoardName'; + +type Props = { + board_id: 'images' | 'assets' | 'no_board'; +}; + +const SystemBoardButton = ({ board_id }: Props) => { + const dispatch = useAppDispatch(); + + const selector = useMemo( + () => + createSelector( + [stateSelector], + ({ gallery }) => { + const { selectedBoardId } = gallery; + return { isSelected: selectedBoardId === board_id }; + }, + defaultSelectorOptions + ), + [board_id] + ); + + const { isSelected } = useAppSelector(selector); + + const boardName = useBoardName(board_id); + + const handleClick = useCallback(() => { + dispatch(boardIdSelected(board_id)); + }, [board_id, dispatch]); + + return ( + + {boardName} + + ); +}; + +export default memo(SystemBoardButton); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx new file mode 100644 index 0000000000..5d39eaaf28 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/GalleryBoardContextMenuItems.tsx @@ -0,0 +1,79 @@ +import { MenuItem } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { autoAddBoardIdChanged } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback, useMemo } from 'react'; +import { FaMinus, FaPlus, FaTrash } from 'react-icons/fa'; +import { BoardDTO } from 'services/api/types'; + +type Props = { + board: BoardDTO; + setBoardToDelete?: (board?: BoardDTO) => void; +}; + +const GalleryBoardContextMenuItems = ({ board, setBoardToDelete }: Props) => { + const dispatch = useAppDispatch(); + + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ gallery }) => { + const isSelectedForAutoAdd = + board.board_id === gallery.autoAddBoardId; + + return { isSelectedForAutoAdd }; + }, + defaultSelectorOptions + ), + [board.board_id] + ); + + const { isSelectedForAutoAdd } = useAppSelector(selector); + + const handleDelete = useCallback(() => { + if (!setBoardToDelete) { + return; + } + setBoardToDelete(board); + }, [board, setBoardToDelete]); + + const handleToggleAutoAdd = useCallback(() => { + dispatch( + autoAddBoardIdChanged(isSelectedForAutoAdd ? null : board.board_id) + ); + }, [board.board_id, dispatch, isSelectedForAutoAdd]); + + return ( + <> + {board.image_count > 0 && ( + <> + {/* } + onClickCapture={handleAddBoardToBatch} + > + Add Board to Batch + */} + + )} + : } + onClickCapture={handleToggleAutoAdd} + > + {isSelectedForAutoAdd ? 'Disable Auto-Add' : 'Auto-Add to this Board'} + + } + onClickCapture={handleDelete} + > + Delete Board + + + ); +}; + +export default memo(GalleryBoardContextMenuItems); diff --git a/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx new file mode 100644 index 0000000000..58eb6d2c0c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/Boards/SystemBoardContextMenuItems.tsx @@ -0,0 +1,12 @@ +import { BoardId } from 'features/gallery/store/gallerySlice'; +import { memo } from 'react'; + +type Props = { + board_id: BoardId; +}; + +const SystemBoardContextMenuItems = ({ board_id }: Props) => { + return <>; +}; + +export default memo(SystemBoardContextMenuItems); diff --git a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx index 4aa65b234e..27565a52aa 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GalleryBoardName.tsx @@ -1,20 +1,19 @@ import { ChevronUpIcon } from '@chakra-ui/icons'; -import { Button, Flex, Text } from '@chakra-ui/react'; +import { Box, Button, Flex, Spacer, Text } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { memo } from 'react'; -import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; +import { memo, useMemo } from 'react'; +import { useBoardName } from 'services/api/hooks/useBoardName'; +import { useBoardTotal } from 'services/api/hooks/useBoardTotal'; const selector = createSelector( [stateSelector], (state) => { const { selectedBoardId } = state.gallery; - return { - selectedBoardId, - }; + return { selectedBoardId }; }, defaultSelectorOptions ); @@ -27,25 +26,18 @@ type Props = { const GalleryBoardName = (props: Props) => { const { isOpen, onToggle } = props; const { selectedBoardId } = useAppSelector(selector); - const { selectedBoardName } = useListAllBoardsQuery(undefined, { - selectFromResult: ({ data }) => { - let selectedBoardName = ''; - if (selectedBoardId === 'images') { - selectedBoardName = 'All Images'; - } else if (selectedBoardId === 'assets') { - selectedBoardName = 'All Assets'; - } else if (selectedBoardId === 'no_board') { - selectedBoardName = 'No Board'; - } else if (selectedBoardId === 'batch') { - selectedBoardName = 'Batch'; - } else { - const selectedBoard = data?.find((b) => b.board_id === selectedBoardId); - selectedBoardName = selectedBoard?.board_name || 'Unknown Board'; - } + const boardName = useBoardName(selectedBoardId); + const numOfBoardImages = useBoardTotal(selectedBoardId); - return { selectedBoardName }; - }, - }); + const formattedBoardName = useMemo(() => { + if (!boardName || !numOfBoardImages) { + return ''; + } + if (boardName.length > 20) { + return `${boardName.substring(0, 20)}... (${numOfBoardImages})`; + } + return `${boardName} (${numOfBoardImages})`; + }, [boardName, numOfBoardImages]); return ( { size="sm" variant="ghost" sx={{ + position: 'relative', + gap: 2, w: 'full', justifyContent: 'center', alignItems: 'center', @@ -64,19 +58,22 @@ const GalleryBoardName = (props: Props) => { }, }} > - - {selectedBoardName} - + + + + {formattedBoardName} + + + { isResizable={true} isOpen={shouldShowGallery} onClose={handleCloseGallery} - minWidth={337} + minWidth={400} > diff --git a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx index 0677364400..21a580d9a9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/GallerySettingsPopover.tsx @@ -1,19 +1,20 @@ import { Flex } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIIconButton from 'common/components/IAIIconButton'; import IAIPopover from 'common/components/IAIPopover'; import IAISimpleCheckbox from 'common/components/IAISimpleCheckbox'; import IAISlider from 'common/components/IAISlider'; -import { setGalleryImageMinimumWidth } from 'features/gallery/store/gallerySlice'; - +import { + setGalleryImageMinimumWidth, + shouldAutoSwitchChanged, +} from 'features/gallery/store/gallerySlice'; import { ChangeEvent } from 'react'; import { useTranslation } from 'react-i18next'; import { FaWrench } from 'react-icons/fa'; - -import { createSelector } from '@reduxjs/toolkit'; -import { stateSelector } from 'app/store/store'; -import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; -import { shouldAutoSwitchChanged } from 'features/gallery/store/gallerySlice'; +import BoardAutoAddSelect from './Boards/BoardAutoAddSelect'; const selector = createSelector( [stateSelector], @@ -50,7 +51,7 @@ const GallerySettingsPopover = () => { /> } > - + { dispatch(shouldAutoSwitchChanged(e.target.checked)) } /> + ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index dcce3a1b18..bf627b9591 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,4 +1,4 @@ -import { Box } from '@chakra-ui/react'; +import { Box, Flex } from '@chakra-ui/react'; import { createSelector } from '@reduxjs/toolkit'; import { TypesafeDraggableData } from 'app/components/ImageDnd/typesafeDnd'; import { stateSelector } from 'app/store/store'; @@ -86,38 +86,31 @@ const GalleryImage = (props: HoverableImageProps) => { return ( - - {(ref) => ( - - } - // resetTooltip="Delete image" - // withResetIcon // removed bc it's too easy to accidentally delete images - /> - - )} - + + } + // resetTooltip="Delete image" + // withResetIcon // removed bc it's too easy to accidentally delete images + /> + ); }; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 340559561f..314f933e9b 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -25,6 +25,7 @@ export type BoardId = type GalleryState = { selection: string[]; shouldAutoSwitch: boolean; + autoAddBoardId: string | null; galleryImageMinimumWidth: number; selectedBoardId: BoardId; batchImageNames: string[]; @@ -34,6 +35,7 @@ type GalleryState = { export const initialGalleryState: GalleryState = { selection: [], shouldAutoSwitch: true, + autoAddBoardId: null, galleryImageMinimumWidth: 96, selectedBoardId: 'images', batchImageNames: [], @@ -123,14 +125,34 @@ export const gallerySlice = createSlice({ state.batchImageNames = []; state.selection = []; }, + autoAddBoardIdChanged: (state, action: PayloadAction) => { + state.autoAddBoardId = action.payload; + }, }, extraReducers: (builder) => { builder.addMatcher( boardsApi.endpoints.deleteBoard.matchFulfilled, (state, action) => { - if (action.meta.arg.originalArgs === state.selectedBoardId) { + const deletedBoardId = action.meta.arg.originalArgs; + if (deletedBoardId === state.selectedBoardId) { state.selectedBoardId = 'images'; } + if (deletedBoardId === state.autoAddBoardId) { + state.autoAddBoardId = null; + } + } + ); + builder.addMatcher( + boardsApi.endpoints.listAllBoards.matchFulfilled, + (state, action) => { + const boards = action.payload; + if (!state.autoAddBoardId) { + return; + } + + if (!boards.map((b) => b.board_id).includes(state.autoAddBoardId)) { + state.autoAddBoardId = null; + } } ); }, @@ -147,6 +169,7 @@ export const { isBatchEnabledChanged, imagesAddedToBatch, imagesRemovedFromBatch, + autoAddBoardIdChanged, } = gallerySlice.actions; export default gallerySlice.reducer; diff --git a/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx index 67878ed82c..b578298149 100644 --- a/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/fields/ModelInputFieldComponent.tsx @@ -5,10 +5,12 @@ import { ModelInputFieldTemplate, } from 'features/nodes/types/types'; +import { Box, Flex } from '@chakra-ui/react'; import { SelectItem } from '@mantine/core'; import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; import { MODEL_TYPE_MAP } from 'features/parameters/types/constants'; import { modelIdToMainModelParam } from 'features/parameters/util/modelIdToMainModelParam'; +import SyncModelsButton from 'features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModelsButton'; import { forEach } from 'lodash-es'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -88,18 +90,23 @@ const ModelInputFieldComponent = ( data={[]} /> ) : ( - 0 ? 'Select a model' : 'No models available'} - data={data} - error={data.length === 0} - disabled={data.length === 0} - onChange={handleChangeModel} - /> + + 0 ? 'Select a model' : 'No models available'} + data={data} + error={data.length === 0} + disabled={data.length === 0} + onChange={handleChangeModel} + /> + + + + ); }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts index 0f882f248d..578c4371f2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graphBuilders/addControlNetToLinearGraph.ts @@ -48,6 +48,7 @@ export const addControlNetToLinearGraph = ( beginStepPct, endStepPct, controlMode, + resizeMode, model, processorType, weight, @@ -60,6 +61,7 @@ export const addControlNetToLinearGraph = ( begin_step_percent: beginStepPct, end_step_percent: endStepPct, control_mode: controlMode, + resize_mode: resizeMode, control_model: model as ControlNetInvocation['control_model'], control_weight: weight, }; diff --git a/invokeai/frontend/web/src/features/parameters/components/Parameters/MainModel/ParamMainModelSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Parameters/MainModel/ParamMainModelSelect.tsx index eee2a36d1b..75f1bc8bd9 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Parameters/MainModel/ParamMainModelSelect.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Parameters/MainModel/ParamMainModelSelect.tsx @@ -4,6 +4,7 @@ import { useTranslation } from 'react-i18next'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIMantineSearchableSelect from 'common/components/IAIMantineSearchableSelect'; +import { Box, Flex } from '@chakra-ui/react'; import { SelectItem } from '@mantine/core'; import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; @@ -11,6 +12,7 @@ import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import { modelSelected } from 'features/parameters/store/actions'; import { MODEL_TYPE_MAP } from 'features/parameters/types/constants'; import { modelIdToMainModelParam } from 'features/parameters/util/modelIdToMainModelParam'; +import SyncModelsButton from 'features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModelsButton'; import { forEach } from 'lodash-es'; import { useGetMainModelsQuery } from 'services/api/endpoints/models'; @@ -84,16 +86,22 @@ const ParamMainModelSelect = () => { data={[]} /> ) : ( - 0 ? 'Select a model' : 'No models available'} - data={data} - error={data.length === 0} - disabled={data.length === 0} - onChange={handleChangeModel} - /> + + 0 ? 'Select a model' : 'No models available'} + data={data} + error={data.length === 0} + disabled={data.length === 0} + onChange={handleChangeModel} + w="100%" + /> + + + + ); }; diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx index ab4949392d..3880f717b9 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/InvokeButton.tsx @@ -1,6 +1,9 @@ -import { Box, ChakraProps } from '@chakra-ui/react'; +import { Box, ChakraProps, Tooltip } from '@chakra-ui/react'; +import { createSelector } from '@reduxjs/toolkit'; import { userInvoked } from 'app/store/actions'; +import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; import IAIButton, { IAIButtonProps } from 'common/components/IAIButton'; import IAIIconButton, { IAIIconButtonProps, @@ -8,11 +11,13 @@ import IAIIconButton, { import { useIsReadyToInvoke } from 'common/hooks/useIsReadyToInvoke'; import { clampSymmetrySteps } from 'features/parameters/store/generationSlice'; import ProgressBar from 'features/system/components/ProgressBar'; +import { selectIsBusy } from 'features/system/store/systemSelectors'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; import { useTranslation } from 'react-i18next'; import { FaPlay } from 'react-icons/fa'; +import { useBoardName } from 'services/api/hooks/useBoardName'; const IN_PROGRESS_STYLES: ChakraProps['sx'] = { _disabled: { @@ -26,6 +31,20 @@ const IN_PROGRESS_STYLES: ChakraProps['sx'] = { }, }; +const selector = createSelector( + [stateSelector, activeTabNameSelector, selectIsBusy], + ({ gallery }, activeTabName, isBusy) => { + const { autoAddBoardId } = gallery; + + return { + isBusy, + autoAddBoardId, + activeTabName, + }; + }, + defaultSelectorOptions +); + interface InvokeButton extends Omit { iconButton?: boolean; @@ -35,8 +54,8 @@ export default function InvokeButton(props: InvokeButton) { const { iconButton = false, ...rest } = props; const dispatch = useAppDispatch(); const isReady = useIsReadyToInvoke(); - const activeTabName = useAppSelector(activeTabNameSelector); - const isProcessing = useAppSelector((state) => state.system.isProcessing); + const { isBusy, autoAddBoardId, activeTabName } = useAppSelector(selector); + const autoAddBoardName = useBoardName(autoAddBoardId); const handleInvoke = useCallback(() => { dispatch(clampSymmetrySteps()); @@ -75,43 +94,52 @@ export default function InvokeButton(props: InvokeButton) { )} - {iconButton ? ( - } - isDisabled={!isReady || isProcessing} - onClick={handleInvoke} - tooltip={t('parameters.invoke')} - tooltipProps={{ placement: 'top' }} - colorScheme="accent" - id="invoke-button" - {...rest} - sx={{ - w: 'full', - flexGrow: 1, - ...(isProcessing ? IN_PROGRESS_STYLES : {}), - }} - /> - ) : ( - - Invoke - - )} + + {iconButton ? ( + } + isDisabled={!isReady || isBusy} + onClick={handleInvoke} + tooltip={t('parameters.invoke')} + tooltipProps={{ placement: 'top' }} + colorScheme="accent" + id="invoke-button" + {...rest} + sx={{ + w: 'full', + flexGrow: 1, + ...(isBusy ? IN_PROGRESS_STYLES : {}), + }} + /> + ) : ( + + Invoke + + )} + ); diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/Loopback.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/Loopback.tsx deleted file mode 100644 index 3bd405d1ce..0000000000 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/Loopback.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import IAIIconButton from 'common/components/IAIIconButton'; -import { postprocessingSelector } from 'features/parameters/store/postprocessingSelectors'; -import { setShouldLoopback } from 'features/parameters/store/postprocessingSlice'; -import { useTranslation } from 'react-i18next'; -import { FaRecycle } from 'react-icons/fa'; - -const loopbackSelector = createSelector( - postprocessingSelector, - ({ shouldLoopback }) => shouldLoopback -); - -const LoopbackButton = () => { - const dispatch = useAppDispatch(); - const shouldLoopback = useAppSelector(loopbackSelector); - - const { t } = useTranslation(); - - return ( - } - onClick={() => { - dispatch(setShouldLoopback(!shouldLoopback)); - }} - /> - ); -}; - -export default LoopbackButton; diff --git a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx index 4449866ef2..f132092012 100644 --- a/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/ProcessButtons/ProcessButtons.tsx @@ -9,7 +9,6 @@ const ProcessButtons = () => { return ( - {/* {activeTabName === 'img2img' && } */} ); diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingSwitch.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingSwitch.tsx new file mode 100644 index 0000000000..e035b90d3a --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingSwitch.tsx @@ -0,0 +1,57 @@ +import { Badge, BadgeProps, Flex, Text, TextProps } from '@chakra-ui/react'; +import IAISwitch, { IAISwitchProps } from 'common/components/IAISwitch'; +import { useTranslation } from 'react-i18next'; + +type SettingSwitchProps = IAISwitchProps & { + label: string; + useBadge?: boolean; + badgeLabel?: string; + textProps?: TextProps; + badgeProps?: BadgeProps; +}; + +export default function SettingSwitch(props: SettingSwitchProps) { + const { t } = useTranslation(); + + const { + label, + textProps, + useBadge = false, + badgeLabel = t('settings.experimental'), + badgeProps, + ...rest + } = props; + + return ( + + + + {label} + + {useBadge && ( + + {badgeLabel} + + )} + + + + ); +} diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx index d75eb4d4c2..9d095f3511 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsClearIntermediates.tsx @@ -1,60 +1,71 @@ -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useCallback, useEffect, useState } from 'react'; -import { StyledFlex } from './SettingsModal'; import { Heading, Text } from '@chakra-ui/react'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useCallback, useEffect } from 'react'; import IAIButton from '../../../../common/components/IAIButton'; -import { useClearIntermediatesMutation } from '../../../../services/api/endpoints/images'; -import { addToast } from '../../store/systemSlice'; +import { + useClearIntermediatesMutation, + useGetIntermediatesCountQuery, +} from '../../../../services/api/endpoints/images'; import { resetCanvas } from '../../../canvas/store/canvasSlice'; +import { addToast } from '../../store/systemSlice'; +import { StyledFlex } from './SettingsModal'; +import { controlNetReset } from 'features/controlNet/store/controlNetSlice'; export default function SettingsClearIntermediates() { const dispatch = useAppDispatch(); - const [isDisabled, setIsDisabled] = useState(false); + + const { data: intermediatesCount, refetch: updateIntermediatesCount } = + useGetIntermediatesCountQuery(); const [clearIntermediates, { isLoading: isLoadingClearIntermediates }] = useClearIntermediatesMutation(); const handleClickClearIntermediates = useCallback(() => { - clearIntermediates({}) + clearIntermediates() .unwrap() .then((response) => { + dispatch(controlNetReset()); dispatch(resetCanvas()); dispatch( addToast({ - title: - response === 0 - ? `No intermediates to clear` - : `Successfully cleared ${response} intermediates`, + title: `Cleared ${response} intermediates`, status: 'info', }) ); - if (response < 100) { - setIsDisabled(true); - } }); }, [clearIntermediates, dispatch]); + useEffect(() => { + // update the count on mount + updateIntermediatesCount(); + }, [updateIntermediatesCount]); + + const buttonText = intermediatesCount + ? `Clear ${intermediatesCount} Intermediate${ + intermediatesCount > 1 ? 's' : '' + }` + : 'No Intermediates to Clear'; + return ( Clear Intermediates - {isDisabled ? 'Intermediates Cleared' : 'Clear 100 Intermediates'} + {buttonText} - - Will permanently delete first 100 intermediates found on disk and in - database + + Clearing intermediates will reset your Canvas and ControlNet state. - This will also clear your canvas state. - + Intermediate images are byproducts of generation, different from the - result images in the gallery. Purging intermediates will free disk - space. Your gallery images will not be deleted. + result images in the gallery. Clearing intermediates will free disk + space. + Your gallery images will not be deleted. ); } diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index ccc4a9aa24..31dd6162ec 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -11,13 +11,12 @@ import { Text, useDisclosure, } from '@chakra-ui/react'; -import { createSelector, current } from '@reduxjs/toolkit'; +import { createSelector } from '@reduxjs/toolkit'; import { VALID_LOG_LEVELS } from 'app/logging/useLogger'; import { LOCALSTORAGE_KEYS, LOCALSTORAGE_PREFIX } from 'app/store/constants'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIButton from 'common/components/IAIButton'; import IAIMantineSelect from 'common/components/IAIMantineSelect'; -import IAISwitch from 'common/components/IAISwitch'; import { systemSelector } from 'features/system/store/systemSelectors'; import { SystemState, @@ -25,7 +24,6 @@ import { setEnableImageDebugging, setIsNodesEnabled, setShouldConfirmOnDelete, - setShouldDisplayGuides, shouldAntialiasProgressImageChanged, shouldLogToConsoleChanged, } from 'features/system/store/systemSlice'; @@ -48,15 +46,15 @@ import { } from 'react'; import { useTranslation } from 'react-i18next'; import { LogLevelName } from 'roarr'; -import SettingsSchedulers from './SettingsSchedulers'; +import SettingSwitch from './SettingSwitch'; import SettingsClearIntermediates from './SettingsClearIntermediates'; +import SettingsSchedulers from './SettingsSchedulers'; const selector = createSelector( [systemSelector, uiSelector], (system: SystemState, ui: UIState) => { const { shouldConfirmOnDelete, - shouldDisplayGuides, enableImageDebugging, consoleLogLevel, shouldLogToConsole, @@ -73,7 +71,6 @@ const selector = createSelector( return { shouldConfirmOnDelete, - shouldDisplayGuides, enableImageDebugging, shouldUseCanvasBetaLayout, shouldUseSliders, @@ -139,7 +136,6 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { const { shouldConfirmOnDelete, - shouldDisplayGuides, enableImageDebugging, shouldUseCanvasBetaLayout, shouldUseSliders, @@ -195,7 +191,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { @@ -206,7 +202,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { {t('settings.general')} - ) => @@ -214,7 +210,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { } /> {shouldShowAdvancedOptionsSettings && ( - ) => @@ -231,37 +227,21 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { {t('settings.ui')} - ) => - dispatch(setShouldDisplayGuides(e.target.checked)) - } - /> - {shouldShowBetaLayout && ( - ) => - dispatch(setShouldUseCanvasBetaLayout(e.target.checked)) - } - /> - )} - ) => dispatch(setShouldUseSliders(e.target.checked)) } /> - ) => dispatch(setShouldShowProgressInViewer(e.target.checked)) } /> - ) => @@ -270,9 +250,21 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { ) } /> + {shouldShowBetaLayout && ( + ) => + dispatch(setShouldUseCanvasBetaLayout(e.target.checked)) + } + /> + )} {shouldShowNodesToggle && ( - @@ -282,7 +274,7 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { {shouldShowDeveloperSettings && ( {t('settings.developer')} - { value={consoleLogLevel} data={VALID_LOG_LEVELS.concat()} /> - ) => @@ -313,8 +305,12 @@ const SettingsModal = ({ children, config }: SettingsModalProps) => { {shouldShowResetWebUiText && ( <> - {t('settings.resetWebUIDesc1')} - {t('settings.resetWebUIDesc2')} + + {t('settings.resetWebUIDesc1')} + + + {t('settings.resetWebUIDesc2')} + )} diff --git a/invokeai/frontend/web/src/features/system/store/systemSlice.ts b/invokeai/frontend/web/src/features/system/store/systemSlice.ts index 5bbaa2265f..1f2b452151 100644 --- a/invokeai/frontend/web/src/features/system/store/systemSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/systemSlice.ts @@ -38,7 +38,6 @@ export interface SystemState { currentIteration: number; totalIterations: number; currentStatusHasSteps: boolean; - shouldDisplayGuides: boolean; isCancelable: boolean; enableImageDebugging: boolean; toastQueue: UseToastOptions[]; @@ -84,14 +83,12 @@ export interface SystemState { shouldAntialiasProgressImage: boolean; language: keyof typeof LANGUAGES; isUploading: boolean; - boardIdToAddTo?: string; isNodesEnabled: boolean; } export const initialSystemState: SystemState = { isConnected: false, isProcessing: false, - shouldDisplayGuides: true, isGFPGANAvailable: true, isESRGANAvailable: true, shouldConfirmOnDelete: true, @@ -134,9 +131,6 @@ export const systemSlice = createSlice({ setShouldConfirmOnDelete: (state, action: PayloadAction) => { state.shouldConfirmOnDelete = action.payload; }, - setShouldDisplayGuides: (state, action: PayloadAction) => { - state.shouldDisplayGuides = action.payload; - }, setIsCancelable: (state, action: PayloadAction) => { state.isCancelable = action.payload; }, @@ -204,7 +198,6 @@ export const systemSlice = createSlice({ */ builder.addCase(appSocketSubscribed, (state, action) => { state.sessionId = action.payload.sessionId; - state.boardIdToAddTo = action.payload.boardId; state.canceledSession = ''; }); @@ -213,7 +206,6 @@ export const systemSlice = createSlice({ */ builder.addCase(appSocketUnsubscribed, (state) => { state.sessionId = null; - state.boardIdToAddTo = undefined; }); /** @@ -390,7 +382,6 @@ export const { setIsProcessing, setShouldConfirmOnDelete, setCurrentStatus, - setShouldDisplayGuides, setIsCancelable, setEnableImageDebugging, addToast, diff --git a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx index 94195a27c1..6c683470e7 100644 --- a/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx +++ b/invokeai/frontend/web/src/features/ui/components/InvokeTabs.tsx @@ -105,7 +105,7 @@ const enabledTabsSelector = createSelector( } ); -const MIN_GALLERY_WIDTH = 300; +const MIN_GALLERY_WIDTH = 350; const DEFAULT_GALLERY_PCT = 20; export const NO_GALLERY_TABS: InvokeTabName[] = ['modelManager']; diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/ModelManagerTab.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/ModelManagerTab.tsx index d397058795..1c8ea3a735 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/ModelManagerTab.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/ModelManagerTab.tsx @@ -4,8 +4,13 @@ import { ReactNode, memo } from 'react'; import ImportModelsPanel from './subpanels/ImportModelsPanel'; import MergeModelsPanel from './subpanels/MergeModelsPanel'; import ModelManagerPanel from './subpanels/ModelManagerPanel'; +import ModelManagerSettingsPanel from './subpanels/ModelManagerSettingsPanel'; -type ModelManagerTabName = 'modelManager' | 'importModels' | 'mergeModels'; +type ModelManagerTabName = + | 'modelManager' + | 'importModels' + | 'mergeModels' + | 'settings'; type ModelManagerTabInfo = { id: ModelManagerTabName; @@ -29,6 +34,11 @@ const tabs: ModelManagerTabInfo[] = [ label: i18n.t('modelManager.mergeModels'), content: , }, + { + id: 'settings', + label: i18n.t('modelManager.settings'), + content: , + }, ]; const ModelManagerTab = () => { diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx index c9f8384b9a..722bd83b6e 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerPanel/ModelList.tsx @@ -75,42 +75,49 @@ const ModelList = (props: ModelListProps) => { labelPos="side" /> - {['images', 'diffusers'].includes(modelFormatFilter) && - filteredDiffusersModels.length > 0 && ( - - - - Diffusers - - {filteredDiffusersModels.map((model) => ( - - ))} - - - )} - {['images', 'checkpoint'].includes(modelFormatFilter) && - filteredCheckpointModels.length > 0 && ( - - - - Checkpoint - - {filteredCheckpointModels.map((model) => ( - - ))} - - - )} + + {['images', 'diffusers'].includes(modelFormatFilter) && + filteredDiffusersModels.length > 0 && ( + + + + Diffusers + + {filteredDiffusersModels.map((model) => ( + + ))} + + + )} + {['images', 'checkpoint'].includes(modelFormatFilter) && + filteredCheckpointModels.length > 0 && ( + + + + Checkpoints + + {filteredCheckpointModels.map((model) => ( + + ))} + + + )} + ); @@ -146,8 +153,6 @@ const StyledModelContainer = (props: PropsWithChildren) => { return ( - + { modelBaseTypeMap[ model.base_model as keyof typeof modelBaseTypeMap diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel.tsx new file mode 100644 index 0000000000..eebd46de6d --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel.tsx @@ -0,0 +1,10 @@ +import { Flex } from '@chakra-ui/react'; +import SyncModels from './ModelManagerSettingsPanel/SyncModels'; + +export default function ModelManagerSettingsPanel() { + return ( + + + + ); +} diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModels.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModels.tsx new file mode 100644 index 0000000000..e92b118566 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModels.tsx @@ -0,0 +1,35 @@ +import { Flex, Text } from '@chakra-ui/react'; +import { useTranslation } from 'react-i18next'; +import SyncModelsButton from './SyncModelsButton'; + +export default function SyncModels() { + const { t } = useTranslation(); + + return ( + + + {t('modelManager.syncModels')} + + {t('modelManager.syncModelsDesc')} + + + + + ); +} diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModelsButton.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModelsButton.tsx new file mode 100644 index 0000000000..e42794c0b4 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ModelManager/subpanels/ModelManagerSettingsPanel/SyncModelsButton.tsx @@ -0,0 +1,66 @@ +import { makeToast } from 'app/components/Toaster'; +import { useAppDispatch } from 'app/store/storeHooks'; +import IAIButton from 'common/components/IAIButton'; +import IAIIconButton from 'common/components/IAIIconButton'; +import { addToast } from 'features/system/store/systemSlice'; +import { useTranslation } from 'react-i18next'; +import { FaSync } from 'react-icons/fa'; +import { useSyncModelsMutation } from 'services/api/endpoints/models'; + +type SyncModelsButtonProps = { + iconMode?: boolean; +}; + +export default function SyncModelsButton(props: SyncModelsButtonProps) { + const { iconMode = false } = props; + const dispatch = useAppDispatch(); + const { t } = useTranslation(); + + const [syncModels, { isLoading }] = useSyncModelsMutation(); + + const syncModelsHandler = () => { + 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', + }) + ) + ); + } + }); + }; + + return !iconMode ? ( + + Sync Models + + ) : ( + } + tooltip={t('modelManager.syncModels')} + aria-label={t('modelManager.syncModels')} + isLoading={isLoading} + onClick={syncModelsHandler} + size="sm" + /> + ); +} diff --git a/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx b/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx index 7ef0b48784..57f2e89ef0 100644 --- a/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx +++ b/invokeai/frontend/web/src/features/ui/components/tabs/ResizeHandle.tsx @@ -3,7 +3,7 @@ import { memo } from 'react'; import { PanelResizeHandle } from 'react-resizable-panels'; import { mode } from 'theme/util/mode'; -type ResizeHandleProps = FlexProps & { +type ResizeHandleProps = Omit & { direction?: 'horizontal' | 'vertical'; }; diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index a37edd48aa..5eeb86d9c5 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -127,6 +127,13 @@ export const imagesApi = api.injectEndpoints({ // 24 hours - reducing this to a few minutes would reduce memory usage. keepUnusedDataFor: 86400, }), + getIntermediatesCount: build.query({ + query: () => ({ url: getListImagesUrl({ is_intermediate: true }) }), + providesTags: ['IntermediatesCount'], + transformResponse: (response: OffsetPaginatedResults_ImageDTO_) => { + return response.total; + }, + }), getImageDTO: build.query({ query: (image_name) => ({ url: `images/${image_name}` }), providesTags: (result, error, arg) => { @@ -148,8 +155,9 @@ export const imagesApi = api.injectEndpoints({ }, keepUnusedDataFor: 86400, // 24 hours }), - clearIntermediates: build.mutation({ + clearIntermediates: build.mutation({ query: () => ({ url: `images/clear-intermediates`, method: 'POST' }), + invalidatesTags: ['IntermediatesCount'], }), deleteImage: build.mutation({ query: ({ image_name }) => ({ @@ -161,10 +169,11 @@ export const imagesApi = api.injectEndpoints({ ], async onQueryStarted(imageDTO, { dispatch, queryFulfilled }) { /** - * Cache changes for deleteImage: - * - Remove from "All Images" - * - Remove from image's `board_id` if it has one, or "No Board" if not - * - Remove from "Batch" + * Cache changes for `deleteImage`: + * - *remove* from "All Images" / "All Assets" + * - IF it has a board: + * - THEN *remove* from it's own board + * - ELSE *remove* from "No Board" */ const { image_name, board_id, image_category } = imageDTO; @@ -173,22 +182,23 @@ export const imagesApi = api.injectEndpoints({ // That means constructing the possible query args that are serialized into the cache key... const removeFromCacheKeys: ListImagesArgs[] = []; + + // determine `categories`, i.e. do we update "All Images" or "All Assets" const categories = IMAGE_CATEGORIES.includes(image_category) ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; - // All Images board (e.g. no board) + // remove from "All Images" removeFromCacheKeys.push({ categories }); - // Board specific if (board_id) { + // remove from it's own board removeFromCacheKeys.push({ board_id }); } else { - // TODO: No Board + // remove from "No Board" + removeFromCacheKeys.push({ board_id: 'none' }); } - // TODO: Batch - const patches: PatchCollection[] = []; removeFromCacheKeys.forEach((cacheKey) => { patches.push( @@ -232,32 +242,37 @@ export const imagesApi = api.injectEndpoints({ { imageDTO: oldImageDTO, changes: _changes }, { dispatch, queryFulfilled, getState } ) { - // TODO: Should we handle changes to boards via this mutation? Seems reasonable... - // let's be extra-sure we do not accidentally change categories const changes = omit(_changes, 'image_category'); /** - * Cache changes for `updateImage`: - * - Update the ImageDTO - * - Update the image in "All Images" board: - * - IF it is in the date range represented by the cache: - * - add the image IF it is not already in the cache & update the total - * - ELSE update the image IF it is already in the cache + * Cache changes for "updateImage": + * - *update* "getImageDTO" cache + * - for "All Images" || "All Assets": + * - IF it is not already in the cache + * - THEN *add* it to "All Images" / "All Assets" and update the total + * - ELSE *update* it * - IF the image has a board: - * - Update the image in it's own board - * - ELSE Update the image in the "No Board" board (TODO) + * - THEN *update* it's own board + * - ELSE *update* the "No Board" board */ const patches: PatchCollection[] = []; - const { image_name, board_id, image_category } = oldImageDTO; + const { image_name, board_id, image_category, is_intermediate } = + oldImageDTO; + + const isChangingFromIntermediate = changes.is_intermediate === false; + // do not add intermediates to gallery cache + if (is_intermediate && !isChangingFromIntermediate) { + return; + } + + // determine `categories`, i.e. do we update "All Images" or "All Assets" const categories = IMAGE_CATEGORIES.includes(image_category) ? IMAGE_CATEGORIES : ASSETS_CATEGORIES; - // TODO: No Board - - // Update `getImageDTO` cache + // update `getImageDTO` cache patches.push( dispatch( imagesApi.util.updateQueryData( @@ -273,9 +288,13 @@ export const imagesApi = api.injectEndpoints({ // Update the "All Image" or "All Assets" board const queryArgsToUpdate: ListImagesArgs[] = [{ categories }]; + // IF the image has a board: if (board_id) { - // We also need to update the user board + // THEN update it's own board queryArgsToUpdate.push({ board_id }); + } else { + // ELSE update the "No Board" board + queryArgsToUpdate.push({ board_id: 'none' }); } queryArgsToUpdate.forEach((queryArg) => { @@ -363,12 +382,12 @@ export const imagesApi = api.injectEndpoints({ return; } - // Add the image to the "All Images" / "All Assets" board - const queryArg = { - categories: IMAGE_CATEGORIES.includes(image_category) - ? IMAGE_CATEGORIES - : ASSETS_CATEGORIES, - }; + // determine `categories`, i.e. do we update "All Images" or "All Assets" + const categories = IMAGE_CATEGORIES.includes(image_category) + ? IMAGE_CATEGORIES + : ASSETS_CATEGORIES; + + const queryArg = { categories }; dispatch( imagesApi.util.updateQueryData('listImages', queryArg, (draft) => { @@ -402,16 +421,14 @@ export const imagesApi = api.injectEndpoints({ { dispatch, queryFulfilled, getState } ) { /** - * Cache changes for addImageToBoard: - * - Remove from "No Board" - * - Remove from `old_board_id` if it has one - * - Add to new `board_id` - * - IF the image's `created_at` is within the range of the board's cached images + * Cache changes for `addImageToBoard`: + * - *update* the `getImageDTO` cache + * - *remove* from "No Board" + * - IF the image has an old `board_id`: + * - THEN *remove* from it's old `board_id` + * - IF the image's `created_at` is within the range of the board's cached images * - OR the board cache has length of 0 or 1 - * - Update the `total` for each board whose cache is updated - * - Update the ImageDTO - * - * TODO: maybe total should just be updated in the boards endpoints? + * - THEN *add* it to new `board_id` */ const { image_name, board_id: old_board_id } = oldImageDTO; @@ -419,13 +436,10 @@ export const imagesApi = api.injectEndpoints({ // Figure out the `listImages` caches that we need to update const removeFromQueryArgs: ListImagesArgs[] = []; - // TODO: No Board - // TODO: Batch - - // Remove from No Board + // remove from "No Board" removeFromQueryArgs.push({ board_id: 'none' }); - // Remove from old board + // remove from old board if (old_board_id) { removeFromQueryArgs.push({ board_id: old_board_id }); } @@ -526,17 +540,15 @@ export const imagesApi = api.injectEndpoints({ { dispatch, queryFulfilled, getState } ) { /** - * Cache changes for removeImageFromBoard: - * - Add to "No Board" - * - IF the image's `created_at` is within the range of the board's cached images - * - Remove from `old_board_id` - * - Update the ImageDTO + * Cache changes for `removeImageFromBoard`: + * - *update* `getImageDTO` + * - IF the image's `created_at` is within the range of the board's cached images + * - THEN *add* to "No Board" + * - *remove* from `old_board_id` */ const { image_name, board_id: old_board_id } = imageDTO; - // TODO: Batch - const patches: PatchCollection[] = []; // Updated imageDTO with new board_id @@ -617,6 +629,7 @@ export const imagesApi = api.injectEndpoints({ }); export const { + useGetIntermediatesCountQuery, useListImagesQuery, useLazyListImagesQuery, useGetImageDTOQuery, diff --git a/invokeai/frontend/web/src/services/api/endpoints/models.ts b/invokeai/frontend/web/src/services/api/endpoints/models.ts index 27e9aefcdb..ff82bc2802 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/models.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/models.ts @@ -93,6 +93,9 @@ type AddMainModelArg = { type AddMainModelResponse = paths['/api/v1/models/add']['post']['responses']['201']['content']['application/json']; +type SyncModelsResponse = + paths['/api/v1/models/sync']['post']['responses']['201']['content']['application/json']; + export type SearchFolderResponse = paths['/api/v1/models/search']['get']['responses']['200']['content']['application/json']; @@ -244,6 +247,15 @@ export const modelsApi = api.injectEndpoints({ }, invalidatesTags: [{ type: 'MainModel', id: LIST_TAG }], }), + syncModels: build.mutation({ + query: () => { + return { + url: `models/sync`, + method: 'POST', + }; + }, + invalidatesTags: [{ type: 'MainModel', id: LIST_TAG }], + }), getLoRAModels: build.query, void>({ query: () => ({ url: 'models/', params: { model_type: 'lora' } }), providesTags: (result, error, arg) => { @@ -423,6 +435,7 @@ export const { useAddMainModelsMutation, useConvertMainModelsMutation, useMergeMainModelsMutation, + useSyncModelsMutation, useGetModelsInFolderQuery, useGetCheckpointConfigsQuery, } = modelsApi; diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts new file mode 100644 index 0000000000..cbe0ec1808 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardName.ts @@ -0,0 +1,26 @@ +import { BoardId } from 'features/gallery/store/gallerySlice'; +import { useListAllBoardsQuery } from '../endpoints/boards'; + +export const useBoardName = (board_id: BoardId | null | undefined) => { + const { boardName } = useListAllBoardsQuery(undefined, { + selectFromResult: ({ data }) => { + let boardName = ''; + if (board_id === 'images') { + boardName = 'Images'; + } else if (board_id === 'assets') { + boardName = 'Assets'; + } else if (board_id === 'no_board') { + boardName = 'No Board'; + } else if (board_id === 'batch') { + boardName = 'Batch'; + } else { + const selectedBoard = data?.find((b) => b.board_id === board_id); + boardName = selectedBoard?.board_name || 'Unknown Board'; + } + + return { boardName }; + }, + }); + + return boardName; +}; diff --git a/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts new file mode 100644 index 0000000000..8deccd8947 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/hooks/useBoardTotal.ts @@ -0,0 +1,53 @@ +import { skipToken } from '@reduxjs/toolkit/dist/query'; +import { + ASSETS_CATEGORIES, + BoardId, + IMAGE_CATEGORIES, + INITIAL_IMAGE_LIMIT, +} from 'features/gallery/store/gallerySlice'; +import { useMemo } from 'react'; +import { ListImagesArgs, useListImagesQuery } from '../endpoints/images'; + +const baseQueryArg: ListImagesArgs = { + offset: 0, + limit: INITIAL_IMAGE_LIMIT, + is_intermediate: false, +}; + +const imagesQueryArg: ListImagesArgs = { + categories: IMAGE_CATEGORIES, + ...baseQueryArg, +}; + +const assetsQueryArg: ListImagesArgs = { + categories: ASSETS_CATEGORIES, + ...baseQueryArg, +}; + +const noBoardQueryArg: ListImagesArgs = { + board_id: 'none', + ...baseQueryArg, +}; + +export const useBoardTotal = (board_id: BoardId | null | undefined) => { + const queryArg = useMemo(() => { + if (!board_id) { + return; + } + if (board_id === 'images') { + return imagesQueryArg; + } else if (board_id === 'assets') { + return assetsQueryArg; + } else if (board_id === 'no_board') { + return noBoardQueryArg; + } else { + return { board_id, ...baseQueryArg }; + } + }, [board_id]); + + const { total } = useListImagesQuery(queryArg ?? skipToken, { + selectFromResult: ({ currentData }) => ({ total: currentData?.total }), + }); + + return total; +}; diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts index b332d2b035..100eda6432 100644 --- a/invokeai/frontend/web/src/services/api/schema.d.ts +++ b/invokeai/frontend/web/src/services/api/schema.d.ts @@ -126,7 +126,7 @@ export type paths = { * @description Call after making changes to models.yaml, autoimport directories or models directory to synchronize * in-memory data structures with disk data structures. */ - get: operations["sync_to_config"]; + post: operations["sync_to_config"]; }; "/api/v1/models/merge/{base_model}": { /** @@ -167,7 +167,7 @@ export type paths = { "/api/v1/images/clear-intermediates": { /** * Clear Intermediates - * @description Clears first 100 intermediates + * @description Clears all intermediates */ post: operations["clear_intermediates"]; }; @@ -228,6 +228,13 @@ export type paths = { */ patch: operations["update_board"]; }; + "/api/v1/boards/{board_id}/image_names": { + /** + * List All Board Image Names + * @description Gets a list of images for a board + */ + get: operations["list_all_board_image_names"]; + }; "/api/v1/board_images/": { /** * Create Board Image @@ -240,13 +247,6 @@ export type paths = { */ delete: operations["remove_board_image"]; }; - "/api/v1/board_images/{board_id}": { - /** - * List Board Images - * @description Gets a list of images for a board - */ - get: operations["list_board_images"]; - }; "/api/v1/app/version": { /** Get Version */ get: operations["app_version"]; @@ -255,6 +255,18 @@ export type paths = { /** Get Config */ get: operations["get_config"]; }; + "/api/v1/app/logging": { + /** + * Get Log Level + * @description Returns the log level + */ + get: operations["get_log_level"]; + /** + * Set Log Level + * @description Sets the log verbosity level + */ + post: operations["set_log_level"]; + }; }; export type webhooks = Record; @@ -800,6 +812,13 @@ export type components = { * @enum {string} */ control_mode?: "balanced" | "more_prompt" | "more_control" | "unbalanced"; + /** + * Resize Mode + * @description The resize mode to use + * @default just_resize + * @enum {string} + */ + resize_mode?: "just_resize" | "crop_resize" | "fill_resize" | "just_resize_simple"; }; /** * ControlNetInvocation @@ -859,6 +878,13 @@ export type components = { * @enum {string} */ control_mode?: "balanced" | "more_prompt" | "more_control" | "unbalanced"; + /** + * Resize Mode + * @description The resize mode used + * @default just_resize + * @enum {string} + */ + resize_mode?: "just_resize" | "crop_resize" | "fill_resize" | "just_resize_simple"; }; /** ControlNetModelConfig */ ControlNetModelConfig: { @@ -1037,6 +1063,24 @@ export type components = { */ mask?: components["schemas"]["ImageField"]; }; + /** DeleteBoardResult */ + DeleteBoardResult: { + /** + * Board Id + * @description The id of the board that was deleted. + */ + board_id: string; + /** + * Deleted Board Images + * @description The image names of the board-images relationships that were deleted. + */ + deleted_board_images: (string)[]; + /** + * Deleted Images + * @description The names of the images that were deleted. + */ + deleted_images: (string)[]; + }; /** * DivideInvocation * @description Divides two numbers @@ -2878,6 +2922,12 @@ export type components = { */ image?: components["schemas"]["ImageField"]; }; + /** + * LogLevel + * @description An enumeration. + * @enum {integer} + */ + LogLevel: 0 | 10 | 20 | 30 | 40 | 50; /** LoraInfo */ LoraInfo: { /** @@ -5305,18 +5355,18 @@ export type components = { */ image?: components["schemas"]["ImageField"]; }; - /** - * StableDiffusion1ModelFormat - * @description An enumeration. - * @enum {string} - */ - StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusionXLModelFormat * @description An enumeration. * @enum {string} */ StableDiffusionXLModelFormat: "checkpoint" | "diffusers"; + /** + * StableDiffusion1ModelFormat + * @description An enumeration. + * @enum {string} + */ + StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusion2ModelFormat * @description An enumeration. @@ -5909,7 +5959,7 @@ export type operations = { /** @description synchronization successful */ 201: { content: { - "application/json": unknown; + "application/json": boolean; }; }; }; @@ -5956,13 +6006,13 @@ export type operations = { list_image_dtos: { parameters: { query?: { - /** @description The origin of images to list */ + /** @description The origin of images to list. */ image_origin?: components["schemas"]["ResourceOrigin"]; - /** @description The categories of image to include */ + /** @description The categories of image to include. */ categories?: (components["schemas"]["ImageCategory"])[]; - /** @description Whether to list intermediate images */ + /** @description Whether to list intermediate images. */ is_intermediate?: boolean; - /** @description The board id to filter by */ + /** @description The board id to filter by. Use 'none' to find images without a board. */ board_id?: string; /** @description The page offset */ offset?: number; @@ -6107,7 +6157,7 @@ export type operations = { }; /** * Clear Intermediates - * @description Clears first 100 intermediates + * @description Clears all intermediates */ clear_intermediates: { responses: { @@ -6328,7 +6378,7 @@ export type operations = { /** @description Successful Response */ 200: { content: { - "application/json": unknown; + "application/json": components["schemas"]["DeleteBoardResult"]; }; }; /** @description Validation Error */ @@ -6370,6 +6420,32 @@ export type operations = { }; }; }; + /** + * List All Board Image Names + * @description Gets a list of images for a board + */ + list_all_board_image_names: { + parameters: { + path: { + /** @description The id of the board */ + board_id: string; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + content: { + "application/json": (string)[]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; /** * Create Board Image * @description Creates a board_image @@ -6420,38 +6496,6 @@ export type operations = { }; }; }; - /** - * List Board Images - * @description Gets a list of images for a board - */ - list_board_images: { - parameters: { - query?: { - /** @description The page offset */ - offset?: number; - /** @description The number of boards per page */ - limit?: number; - }; - path: { - /** @description The id of the board */ - board_id: string; - }; - }; - responses: { - /** @description Successful Response */ - 200: { - content: { - "application/json": components["schemas"]["OffsetPaginatedResults_ImageDTO_"]; - }; - }; - /** @description Validation Error */ - 422: { - content: { - "application/json": components["schemas"]["HTTPValidationError"]; - }; - }; - }; - }; /** Get Version */ app_version: { responses: { @@ -6474,4 +6518,43 @@ export type operations = { }; }; }; + /** + * Get Log Level + * @description Returns the log level + */ + get_log_level: { + responses: { + /** @description The operation was successful */ + 200: { + content: { + "application/json": components["schemas"]["LogLevel"]; + }; + }; + }; + }; + /** + * Set Log Level + * @description Sets the log verbosity level + */ + set_log_level: { + requestBody: { + content: { + "application/json": components["schemas"]["LogLevel"]; + }; + }; + responses: { + /** @description The operation was successful */ + 200: { + content: { + "application/json": components["schemas"]["LogLevel"]; + }; + }; + /** @description Validation Error */ + 422: { + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; }; diff --git a/invokeai/frontend/web/src/theme/colors/colors.ts b/invokeai/frontend/web/src/theme/colors/colors.ts index bcb2e43c0b..99260ee071 100644 --- a/invokeai/frontend/web/src/theme/colors/colors.ts +++ b/invokeai/frontend/web/src/theme/colors/colors.ts @@ -2,11 +2,16 @@ import { InvokeAIThemeColors } from 'theme/themeTypes'; import { generateColorPalette } from 'theme/util/generateColorPalette'; const BASE = { H: 220, S: 16 }; -const ACCENT = { H: 250, S: 52 }; -const WORKING = { H: 47, S: 50 }; -const WARNING = { H: 28, S: 50 }; -const OK = { H: 113, S: 50 }; -const ERROR = { H: 0, S: 50 }; +const ACCENT = { H: 250, S: 42 }; +// const ACCENT = { H: 250, S: 52 }; +const WORKING = { H: 47, S: 42 }; +// const WORKING = { H: 47, S: 50 }; +const WARNING = { H: 28, S: 42 }; +// const WARNING = { H: 28, S: 50 }; +const OK = { H: 113, S: 42 }; +// const OK = { H: 113, S: 50 }; +const ERROR = { H: 0, S: 42 }; +// const ERROR = { H: 0, S: 50 }; export const InvokeAIColors: InvokeAIThemeColors = { base: generateColorPalette(BASE.H, BASE.S), diff --git a/invokeai/frontend/web/src/theme/components/editable.ts b/invokeai/frontend/web/src/theme/components/editable.ts new file mode 100644 index 0000000000..19321e5968 --- /dev/null +++ b/invokeai/frontend/web/src/theme/components/editable.ts @@ -0,0 +1,56 @@ +import { editableAnatomy as parts } from '@chakra-ui/anatomy'; +import { + createMultiStyleConfigHelpers, + defineStyle, +} from '@chakra-ui/styled-system'; +import { mode } from '@chakra-ui/theme-tools'; + +const { definePartsStyle, defineMultiStyleConfig } = + createMultiStyleConfigHelpers(parts.keys); + +const baseStylePreview = defineStyle({ + borderRadius: 'md', + py: '1', + transitionProperty: 'common', + transitionDuration: 'normal', +}); + +const baseStyleInput = defineStyle((props) => ({ + borderRadius: 'md', + py: '1', + transitionProperty: 'common', + transitionDuration: 'normal', + width: 'full', + _focusVisible: { boxShadow: 'outline' }, + _placeholder: { opacity: 0.6 }, + '::selection': { + color: mode('accent.900', 'accent.50')(props), + bg: mode('accent.200', 'accent.400')(props), + }, +})); + +const baseStyleTextarea = defineStyle({ + borderRadius: 'md', + py: '1', + transitionProperty: 'common', + transitionDuration: 'normal', + width: 'full', + _focusVisible: { boxShadow: 'outline' }, + _placeholder: { opacity: 0.6 }, +}); + +const invokeAI = definePartsStyle((props) => ({ + preview: baseStylePreview, + input: baseStyleInput(props), + textarea: baseStyleTextarea, +})); + +export const editableTheme = defineMultiStyleConfig({ + variants: { + invokeAI, + }, + defaultProps: { + size: 'sm', + variant: 'invokeAI', + }, +}); diff --git a/invokeai/frontend/web/src/theme/theme.ts b/invokeai/frontend/web/src/theme/theme.ts index 42a5a12c3f..6f7a719e85 100644 --- a/invokeai/frontend/web/src/theme/theme.ts +++ b/invokeai/frontend/web/src/theme/theme.ts @@ -4,6 +4,7 @@ import { InvokeAIColors } from './colors/colors'; import { accordionTheme } from './components/accordion'; import { buttonTheme } from './components/button'; import { checkboxTheme } from './components/checkbox'; +import { editableTheme } from './components/editable'; import { formLabelTheme } from './components/formLabel'; import { inputTheme } from './components/input'; import { menuTheme } from './components/menu'; @@ -72,7 +73,17 @@ export const theme: ThemeOverride = { selected: { light: '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', - dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-400)', + dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-500)', + }, + hoverSelected: { + light: + '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-500)', + dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-300)', + }, + hoverUnselected: { + light: + '0px 0px 0px 1px var(--invokeai-colors-base-150), 0px 0px 0px 4px var(--invokeai-colors-accent-200)', + dark: '0px 0px 0px 1px var(--invokeai-colors-base-900), 0px 0px 0px 4px var(--invokeai-colors-accent-600)', }, nodeSelectedOutline: `0 0 0 2px var(--invokeai-colors-accent-450)`, }, @@ -80,6 +91,7 @@ export const theme: ThemeOverride = { components: { Button: buttonTheme, // Button and IconButton Input: inputTheme, + Editable: editableTheme, Textarea: textareaTheme, Tabs: tabsTheme, Progress: progressTheme, diff --git a/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts b/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts index 8cf64cbd94..ba5fc9e4c1 100644 --- a/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts +++ b/invokeai/frontend/web/src/theme/util/getInputOutlineStyles.ts @@ -37,4 +37,7 @@ export const getInputOutlineStyles = (props: StyleFunctionProps) => ({ _placeholder: { color: mode('base.700', 'base.400')(props), }, + '::selection': { + bg: mode('accent.200', 'accent.400')(props), + }, }); diff --git a/mkdocs.yml b/mkdocs.yml index ebd9ec0acf..7d3e0e0b85 100644 --- a/mkdocs.yml +++ b/mkdocs.yml @@ -12,7 +12,7 @@ repo_url: 'https://github.com/invoke-ai/InvokeAI' edit_uri: edit/main/docs/ # Copyright -copyright: Copyright © 2022 InvokeAI Team +copyright: Copyright © 2023 InvokeAI Team # Configuration theme: @@ -35,8 +35,11 @@ theme: features: - navigation.instant - navigation.tabs + - navigation.tabs.sticky - navigation.top - navigation.tracking + - navigation.indexes + - navigation.path - search.highlight - search.suggest - toc.integrate @@ -95,3 +98,68 @@ plugins: 'installation/INSTALL_DOCKER.md': 'installation/040_INSTALL_DOCKER.md' 'installation/INSTALLING_MODELS.md': 'installation/050_INSTALLING_MODELS.md' 'installation/INSTALL_PATCHMATCH.md': 'installation/060_INSTALL_PATCHMATCH.md' + +nav: + - Home: 'index.md' + - Installation: + - Overview: 'installation/index.md' + - Installing with the Automated Installer: 'installation/010_INSTALL_AUTOMATED.md' + - Installing manually: 'installation/020_INSTALL_MANUAL.md' + - NVIDIA Cuda / AMD ROCm: 'installation/030_INSTALL_CUDA_AND_ROCM.md' + - Installing with Docker: 'installation/040_INSTALL_DOCKER.md' + - Installing Models: 'installation/050_INSTALLING_MODELS.md' + - Installing PyPatchMatch: 'installation/060_INSTALL_PATCHMATCH.md' + - Installing xFormers: 'installation/070_INSTALL_XFORMERS.md' + - Developers Documentation: 'installation/Developers_documentation/BUILDING_BINARY_INSTALLERS.md' + - Deprecated Documentation: + - Binary Installer: 'installation/deprecated_documentation/INSTALL_BINARY.md' + - Runninng InvokeAI on Google Colab: 'installation/deprecated_documentation/INSTALL_JUPYTER.md' + - Manual Installation on Linux: 'installation/deprecated_documentation/INSTALL_LINUX.md' + - Manual Installation on macOS: 'installation/deprecated_documentation/INSTALL_MAC.md' + - Manual Installation on Windows: 'installation/deprecated_documentation/INSTALL_WINDOWS.md' + - Installing Invoke with pip: 'installation/deprecated_documentation/INSTALL_PCP.md' + - Source Installer: 'installation/deprecated_documentation/INSTALL_SOURCE.md' + - Community Nodes: + - Community Nodes: 'nodes/communityNodes.md' + - Overview: 'nodes/overview.md' + - Features: + - Overview: 'features/index.md' + - Concepts: 'features/CONCEPTS.md' + - Configuration: 'features/CONFIGURATION.md' + - ControlNet: 'features/CONTROLNET.md' + - Image-to-Image: 'features/IMG2IMG.md' + - Controlling Logging: 'features/LOGGING.md' + - Model Mergeing: 'features/MODEL_MERGING.md' + - Nodes Editor (Experimental): 'features/NODES.md' + - NSFW Checker: 'features/NSFW.md' + - Postprocessing: 'features/POSTPROCESS.md' + - Prompting Features: 'features/PROMPTS.md' + - Training: 'features/TRAINING.md' + - Unified Canvas: 'features/UNIFIED_CANVAS.md' + - Variations: 'features/VARIATIONS.md' + - InvokeAI Web Server: 'features/WEB.md' + - WebUI Hotkeys: "features/WEBUIHOTKEYS.md" + - Other: 'features/OTHER.md' + - Contributing: + - How to Contribute: 'contributing/CONTRIBUTING.md' + - Development: + - Overview: 'contributing/contribution_guides/development.md' + - InvokeAI Architecture: 'contributing/ARCHITECTURE.md' + - Frontend Documentation: 'contributing/contribution_guides/development_guides/contributingToFrontend.md' + - Local Development: 'contributing/LOCAL_DEVELOPMENT.md' + - Documentation: 'contributing/contribution_guides/documentation.md' + - Translation: 'contributing/contribution_guides/translation.md' + - Tutorials: 'contributing/contribution_guides/tutorials.md' + - Changelog: 'CHANGELOG.md' + - Deprecated: + - Command Line Interface: 'deprecated/CLI.md' + - Embiggen: 'deprecated/EMBIGGEN.md' + - Inpainting: 'deprecated/INPAINTING.md' + - Outpainting: 'deprecated/OUTPAINTING.md' + - Help: + - Sampler Convergence: 'help/SAMPLER_CONVERGENCE.md' + - Other: + - Contributors: 'other/CONTRIBUTORS.md' + - CompViz-README: 'other/README-CompViz.md' + + diff --git a/pull_request_template.md b/pull_request_template.md index e365120f24..7a0203ef92 100644 --- a/pull_request_template.md +++ b/pull_request_template.md @@ -5,6 +5,7 @@ - [ ] Bug Fix - [ ] Optimization - [ ] Documentation Update +- [ ] Community Node Submission ## Have you discussed this change with the InvokeAI team? @@ -12,6 +13,11 @@ - [ ] No, because: +## Have you updated relevant documentation? +- [ ] Yes +- [ ] No + + ## Description