Compare commits

..

92 Commits

Author SHA1 Message Date
9b482e2a4f chore: bump version to v4.2.8rc1 2024-08-16 10:53:19 +10:00
Max
df4dbe2d57 Fix invoke.sh not detecting symlinks
When invoke.sh is executed using a symlink with a working directory outside of InvokeAI's root directory, it will fail.

invoke.sh attempts to cd into the correct directory at the start of the script, but will cd into the directory of the symlink instead. This commit fixes that.
2024-08-16 10:40:59 +10:00
713bd11177 feat(ui, api): prompt template export (#6745)
## Summary

Adds option to download all prompt templates to a CSV

## Related Issues / Discussions

<!--WHEN APPLICABLE: List any related issues or discussions on github or
discord. If this PR closes an issue, please use the "Closes #1234"
format, so that the issue will be automatically closed when the PR
merges.-->

## QA Instructions

<!--WHEN APPLICABLE: Describe how you have tested the changes in this
PR. Provide enough detail that a reviewer can reproduce your tests.-->

## Merge Plan

<!--WHEN APPLICABLE: Large PRs, or PRs that touch sensitive things like
DB schemas, may need some care when merging. For example, a careful
rebase by the change author, timing to not interfere with a pending
release, or a message to contributors on discord after merging.-->

## Checklist

- [ ] _The PR has a short but descriptive title, suitable for a
changelog_
- [ ] _Tests added / updated (if applicable)_
- [ ] _Documentation added / updated (if applicable)_
2024-08-16 10:38:50 +10:00
182571df4b Merge branch 'main' into maryhipp/export-presets 2024-08-16 10:17:07 +10:00
29bfe492b6 ui: translations update from weblate (#6746)
Translations update from [Hosted Weblate](https://hosted.weblate.org)
for [InvokeAI/Web
UI](https://hosted.weblate.org/projects/invokeai/web-ui/).



Current translation status:

![Weblate translation
status](https://hosted.weblate.org/widget/invokeai/web-ui/horizontal-auto.svg)
2024-08-16 10:16:51 +10:00
3fb4e3050c feat(ui): focus in textarea after inserting placeholder 2024-08-16 10:14:25 +10:00
39c7ec3cd9 feat(ui): per type fallbacks for templates 2024-08-16 10:11:43 +10:00
26bfbdec7f feat(ui): use buttons instead of menu for preset import/export 2024-08-16 09:58:19 +10:00
7a3eaa8da9 feat(api): save file as prompt_templates.csv 2024-08-16 09:51:46 +10:00
599db7296f export only user style presets 2024-08-15 16:07:32 -04:00
042aab4295 translationBot(ui): update translation (Italian)
Currently translated at 98.6% (1340 of 1359 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2024-08-15 20:44:02 +02:00
24f298283f clean up, add context menu to import/download templates 2024-08-15 12:39:55 -04:00
68dac6349d Merge remote-tracking branch 'origin/main' into maryhipp/export-presets 2024-08-15 11:21:56 -04:00
b675fc19e8 feat: add base prop for selectedWorkflow to allow loading a workflow on launch (#6742)
## Summary
added a base prop for selectedWorkflow to allow loading a workflow on
launch

<!--A description of the changes in this PR. Include the kind of change
(fix, feature, docs, etc), the "why" and the "how". Screenshots or
videos are useful for frontend changes.-->

## Related Issues / Discussions

<!--WHEN APPLICABLE: List any related issues or discussions on github or
discord. If this PR closes an issue, please use the "Closes #1234"
format, so that the issue will be automatically closed when the PR
merges.-->

## QA Instructions
can test by loading InvokeAIUI with a selectedWorkflow prop of the
workflow ID
<!--WHEN APPLICABLE: Describe how you have tested the changes in this
PR. Provide enough detail that a reviewer can reproduce your tests.-->

## Merge Plan

<!--WHEN APPLICABLE: Large PRs, or PRs that touch sensitive things like
DB schemas, may need some care when merging. For example, a careful
rebase by the change author, timing to not interfere with a pending
release, or a message to contributors on discord after merging.-->

## Checklist

- [ ] _The PR has a short but descriptive title, suitable for a
changelog_
- [ ] _Tests added / updated (if applicable)_
- [ ] _Documentation added / updated (if applicable)_
2024-08-15 10:52:23 -04:00
659019cfd6 Merge branch 'main' into chainchompa/preselect-workflows 2024-08-15 10:40:44 -04:00
dcd61e1f82 pin ruff version in python check gha 2024-08-15 09:47:49 -04:00
f5c99b1488 exclude jupyter notebooks from ruff 2024-08-15 09:47:49 -04:00
810be3e1d4 update import directions to include JSON 2024-08-15 09:47:49 -04:00
60d754d1df feat(api): tidy style presets import logic
- Extract parsing into utility function
- Log import errors
- Forbid extra properties on the imported data
2024-08-15 09:47:49 -04:00
bd07c86db9 feat(ui): make style preset menu trigger look like button 2024-08-15 09:47:49 -04:00
bcbf8b6bd8 feat(ui): revert to using {prompt} for prompt template placeholder 2024-08-15 09:47:49 -04:00
356661459b feat(api): support JSON for preset imports
This allows us to support Fooocus format presets.
2024-08-15 09:47:49 -04:00
deb917825e feat(api): use pydantic validation during style preset import
- Enforce name is present and not an empty string
- Provide empty string as default for positive and negative prompt
- Add `positive_prompt` as validation alias for `prompt` field
- Strip whitespace automatically
- Create `TypeAdapter` to validate the whole list in one go
2024-08-15 09:47:49 -04:00
15415c6d85 feat(ui): use dropzone for style preset upload
Easier to accept multiple file types and supper drag and drop in the future.
2024-08-15 09:47:49 -04:00
76b0380b5f feat(ui): create component to upload CSV of style presets to import 2024-08-15 09:47:49 -04:00
2d58754789 feat(api): add endpoint to take a CSV, parse it, validate it, and create many style preset entries 2024-08-15 09:47:49 -04:00
9cdf1f599c Merge branch 'main' into chainchompa/preselect-workflows 2024-08-15 09:25:19 -04:00
268be97ba0 remove ref, make options optional for useGetLoadWorkflow 2024-08-15 09:18:41 -04:00
a9014673a0 wip export 2024-08-15 09:00:11 -04:00
d36c43a10f ui: translations update from weblate (#6727)
Translations update from [Hosted Weblate](https://hosted.weblate.org)
for [InvokeAI/Web
UI](https://hosted.weblate.org/projects/invokeai/web-ui/).



Current translation status:

![Weblate translation
status](https://hosted.weblate.org/widget/invokeai/web-ui/horizontal-auto.svg)
2024-08-15 08:48:03 +10:00
54a5c4e482 translationBot(ui): update translation (Chinese (Simplified))
Currently translated at 98.1% (1296 of 1320 strings)

Co-authored-by: Phrixus2023 <920414016@qq.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/zh_Hans/
Translation: InvokeAI/Web UI
2024-08-15 00:46:01 +02:00
5e09a244e3 translationBot(ui): update translation (Italian)
Currently translated at 98.5% (1336 of 1355 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.5% (1302 of 1321 strings)

translationBot(ui): update translation (Italian)

Currently translated at 98.6% (1302 of 1320 strings)

Co-authored-by: Riccardo Giovanetti <riccardo.giovanetti@gmail.com>
Translate-URL: https://hosted.weblate.org/projects/invokeai/web-ui/it/
Translation: InvokeAI/Web UI
2024-08-15 00:46:01 +02:00
88648dca1a change selectedWorkflow to selectedWorkflowId 2024-08-14 11:22:37 -04:00
8840df2b00 Merge branch 'main' into chainchompa/preselect-workflows 2024-08-14 09:02:12 -04:00
af159acbdf cleanup 2024-08-14 08:58:38 -04:00
471719bbbe add base prop for selectedWorkflow to allow loading a workflow on launch 2024-08-14 08:47:02 -04:00
b126f2ffd5 feat(ui, api): prompt templates (#6729)
## Summary

Adds prompt templates to the UI. Demo video is attached.
* added default prompt templates to seed database on startup (these
cannot be edited or deleted by users via the UI)
* can create fresh prompt template, create from an image in gallery that
has prompt metadata, or copy an existing prompt template and modify
* if a template is active, can view what your prompt will be invoked as
by switching to "view mode"



https://github.com/user-attachments/assets/32d84e0c-b04c-48da-bae5-aa6eb685d209



## Related Issues / Discussions

<!--WHEN APPLICABLE: List any related issues or discussions on github or
discord. If this PR closes an issue, please use the "Closes #1234"
format, so that the issue will be automatically closed when the PR
merges.-->

## QA Instructions

<!--WHEN APPLICABLE: Describe how you have tested the changes in this
PR. Provide enough detail that a reviewer can reproduce your tests.-->

## Merge Plan

<!--WHEN APPLICABLE: Large PRs, or PRs that touch sensitive things like
DB schemas, may need some care when merging. For example, a careful
rebase by the change author, timing to not interfere with a pending
release, or a message to contributors on discord after merging.-->

## Checklist

- [ ] _The PR has a short but descriptive title, suitable for a
changelog_
- [ ] _Tests added / updated (if applicable)_
- [ ] _Documentation added / updated (if applicable)_
2024-08-14 12:49:31 +10:00
9938f12ef0 Merge branch 'main' into maryhipp/style-presets 2024-08-14 12:33:30 +10:00
982c266073 tidy: remove extra characters in prompt templates 2024-08-14 12:31:57 +10:00
5c37391883 fix(ui): do not show [prompt] in preset preview 2024-08-14 12:29:05 +10:00
ddeafc6833 fix(ui): minimize layout shift when overlaying preset prompt preview 2024-08-14 12:24:57 +10:00
41b2d5d013 fix(ui): prompt preview not working preset starts with [prompt] 2024-08-14 12:21:38 +10:00
29d6f48901 fix(ui): prompt shows thru prompt label text 2024-08-14 12:01:49 +10:00
d5c9f4e47f chore(ui): revert framer-motion upgrade
`framer-motion` 11 breaks a lot of stuff in profoundly unintuitive ways, holy crap. UI lib rolled back its dep, pulling in latest version of that
2024-08-14 06:12:00 +10:00
24d73387d8 build(ui): fix chakra deps
We had multiple versions of @emotion/react, stemming from an extraneous dependency on @chakra-ui/react. Removed the extraneosu dep
2024-08-14 06:12:00 +10:00
e0d3927265 feat: add flag for allowPrivateStylePresets that shows a type field when creating a style preset 2024-08-13 14:08:54 -04:00
e5f7c2a9b7 add type safety / validation to form data payloads and allow type to be passed through api 2024-08-13 13:00:31 -04:00
b0760710d5 add the rest of default style presets, update image service to return default images correctly by name, add tooltip popover to images in UI 2024-08-13 11:33:15 -04:00
764accc921 update config docstring 2024-08-12 15:17:40 -04:00
6a01fce9c1 fix payloads for stringified data 2024-08-12 15:16:22 -04:00
9c732ac3b1 Merge remote-tracking branch 'origin/main' into maryhipp/style-presets 2024-08-12 14:53:45 -04:00
b70891c661 update descriptoin of placeholder in modal 2024-08-12 13:37:04 -04:00
4dbf851741 ui: add labels to prompt boxes 2024-08-12 13:33:39 -04:00
6c927a9fd4 move mdoal state into nanostore 2024-08-12 12:46:02 -04:00
096f001634 ui: add ability to copy template 2024-08-12 12:32:31 -04:00
4837e578b2 api: update dir path for style preset images, update payload for create/update formdata 2024-08-12 12:00:14 -04:00
1e547ef912 UI more pr feedback 2024-08-12 11:59:25 -04:00
f6b8970bd1 fix(app): create reference to events task to prevent accidental GC
This wasn't a problem, but it's advised in the official docs so I've done it.
2024-08-12 07:49:58 +10:00
29325a7214 fix(app): use asyncio queue and existing event loop for events
Around the time we (I) implemented pydantic events, I noticed a short pause between progress images every 4 or 5 steps when generating with SDXL. It didn't happen with SD1.5, but I did notice that with SD1.5, we'd get 4 or 5 progress events simultaneously. I'd expect one event every ~25ms, matching my it/s with SD1.5. Mysterious!

Digging in, I found an issue is related to our use of a synchronous queue for events. When the event queue is empty, we must call `asyncio.sleep` before checking again. We were sleeping for 100ms.

Said another way, every time we clear the event queue, we have to wait 100ms before another event can be dispatched, even if it is put on the queue immediately after we start waiting. In practice, this means our events get buffered into batches, dispatched once every 100ms.

This explains why I was getting batches of 4 or 5 SD1.5 progress events at once, but not the intermittent SDXL delay.

But this 100ms wait has another effect when the events are put on the queue in intervals that don't perfectly line up with the 100ms wait. This is most noticeable when the time between events is >100ms, and can add up to 100ms delay before the event is dispatched.

For example, say the queue is empty and we start a 100ms wait. Then, immediately after - like 0.01ms later - we push an event on to the queue. We still need to wait another 99.9ms before that event will be dispatched. That's the SDXL delay.

The easy fix is to reduce the sleep to something like 0.01 seconds, but this feels kinda dirty. Can't we just wait on the queue and dispatch every event immediately? Not with the normal synchronous queue - but we can with `asyncio.Queue`.

I switched the events queue to use `asyncio.Queue` (as seen in this commit), which lets us asynchronous wait on the queue in a loop.

Unfortunately, I ran into another issue - events now felt like their timing was inconsistent, but in a different way than with the 100ms sleep. The time between pushing events on the queue and dispatching them was not consistently ~0ms as I'd expect - it was highly variable from ~0ms up to ~100ms.

This is resolved by passing the asyncio loop directly into the events service and using its methods to create the task and interact with the queue. I don't fully understand why this resolved the issue, because either way we are interacting with the same event loop (as shown by `asyncio.get_running_loop()`). I suppose there's some scheduling magic happening.
2024-08-12 07:49:58 +10:00
41c3e73a3c fix tests 2024-08-09 16:31:42 -04:00
97553a7de2 API/DB updates per PR feedback 2024-08-09 16:27:37 -04:00
12ba15bfa9 UI updates per PR feedback 2024-08-09 16:00:13 -04:00
8eb5d08499 missed translation 2024-08-08 16:01:16 -04:00
9be6acde7d require name to submit style preset 2024-08-08 15:53:21 -04:00
5f83bb0069 update config docstring 2024-08-08 15:20:43 -04:00
b138882abc fix tests? 2024-08-08 15:18:32 -04:00
0cd7cdb52e remove send2trash 2024-08-08 15:13:36 -04:00
1d8b7e2bcf ruff 2024-08-08 15:08:45 -04:00
6461f4758d lint fix 2024-08-08 15:07:58 -04:00
3189ab6863 get dynamic prompts working 2024-08-08 15:07:23 -04:00
3f9a674d4b seed default presets and handle them in UI 2024-08-08 15:02:41 -04:00
587f59b25b focus on prompt textarea when exiting view mode by clicking 2024-08-08 14:38:50 -04:00
4952eada87 ruff format 2024-08-08 14:22:40 -04:00
581029ebaa ruff 2024-08-08 14:21:37 -04:00
42d68780de lint 2024-08-08 14:19:33 -04:00
28032a2f80 more cleanup 2024-08-08 14:18:05 -04:00
e381e021e9 knip lint 2024-08-08 14:00:17 -04:00
641af64f93 regnerate schema 2024-08-08 13:58:25 -04:00
a7b83c8b5b Merge remote-tracking branch 'origin/main' into maryhipp/style-presets 2024-08-08 13:56:59 -04:00
4cc41e0188 translations and lint fix 2024-08-08 13:56:37 -04:00
442fc02429 resize images to 100x100 for style preset images 2024-08-08 12:56:55 -04:00
9a4d075074 fix path for style_preset_images, fix png type when converting blobs to files, built view mode components 2024-08-08 12:31:20 -04:00
0b0abfbe8f clean up image implementation 2024-08-07 10:36:38 -04:00
cc96dcf0ed style preset images 2024-08-07 09:58:27 -04:00
2604fd9fde a whole bunch of stuff 2024-08-06 15:31:13 -04:00
857d74bbfe wip apply and calculate prompt with interpolation 2024-08-05 19:11:48 -04:00
fd7a635777 (ui) the most basic crud ui: view list of presets, create a new preset, edit/delete existing presets 2024-08-05 15:48:23 -04:00
af9110e964 fix prompt concat logic 2024-08-05 13:42:28 -04:00
a61209206b remove custom SDXL prompts component 2024-08-05 13:40:46 -04:00
e05cc62e5f add style presets API layer to UI 2024-08-05 13:37:07 -04:00
217fe40d99 feat(api): add style_presets router, make sure all CRUD is working, add is_default 2024-08-02 12:29:54 -04:00
b76bf50b93 feat(db,api): create new table for style presets, build out record storage service for style presets 2024-08-01 22:20:11 -04:00
110 changed files with 3612 additions and 1514 deletions

View File

@ -62,7 +62,7 @@ jobs:
- name: install ruff
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
run: pip install ruff
run: pip install ruff==0.6.0
shell: bash
- name: ruff check

View File

@ -197,22 +197,6 @@ tips to reduce the problem:
This should be sufficient to generate larger images up to about 1280x1280.
## Checkpoint Models Load Slowly or Use Too Much RAM
The difference between diffusers models (a folder containing multiple
subfolders) and checkpoint models (a file ending with .safetensors or
.ckpt) is that InvokeAI is able to load diffusers models into memory
incrementally, while checkpoint models must be loaded all at
once. With very large models, or systems with limited RAM, you may
experience slowdowns and other memory-related issues when loading
checkpoint models.
To solve this, go to the Model Manager tab (the cube), select the
checkpoint model that's giving you trouble, and press the "Convert"
button in the upper right of your browser window. This will conver the
checkpoint into a diffusers model, after which loading should be
faster and less memory-intensive.
## Memory Leak (Linux)
If you notice a memory leak, it could be caused to memory fragmentation as models are loaded and/or moved from CPU to GPU.

View File

@ -17,7 +17,7 @@
set -eu
# Ensure we're in the correct folder in case user's CWD is somewhere else
scriptdir=$(dirname "$0")
scriptdir=$(dirname $(readlink -f "$0"))
cd "$scriptdir"
. .venv/bin/activate

View File

@ -1,5 +1,6 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
import asyncio
from logging import Logger
import torch
@ -31,6 +32,8 @@ from invokeai.app.services.session_processor.session_processor_default import (
)
from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue
from invokeai.app.services.shared.sqlite.sqlite_util import init_db
from invokeai.app.services.style_preset_images.style_preset_images_disk import StylePresetImageFileStorageDisk
from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage
from invokeai.app.services.urls.urls_default import LocalUrlService
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
@ -63,7 +66,12 @@ class ApiDependencies:
invoker: Invoker
@staticmethod
def initialize(config: InvokeAIAppConfig, event_handler_id: int, logger: Logger = logger) -> None:
def initialize(
config: InvokeAIAppConfig,
event_handler_id: int,
loop: asyncio.AbstractEventLoop,
logger: Logger = logger,
) -> None:
logger.info(f"InvokeAI version {__version__}")
logger.info(f"Root directory = {str(config.root_path)}")
@ -74,6 +82,7 @@ class ApiDependencies:
image_files = DiskImageFileStorage(f"{output_folder}/images")
model_images_folder = config.models_path
style_presets_folder = config.style_presets_path
db = init_db(config=config, logger=logger, image_files=image_files)
@ -84,7 +93,7 @@ class ApiDependencies:
board_images = BoardImagesService()
board_records = SqliteBoardRecordStorage(db=db)
boards = BoardService()
events = FastAPIEventService(event_handler_id)
events = FastAPIEventService(event_handler_id, loop=loop)
bulk_download = BulkDownloadService()
image_records = SqliteImageRecordStorage(db=db)
images = ImageService()
@ -109,6 +118,8 @@ class ApiDependencies:
session_queue = SqliteSessionQueue(db=db)
urls = LocalUrlService()
workflow_records = SqliteWorkflowRecordsStorage(db=db)
style_preset_records = SqliteStylePresetRecordsStorage(db=db)
style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images")
services = InvocationServices(
board_image_records=board_image_records,
@ -134,6 +145,8 @@ class ApiDependencies:
workflow_records=workflow_records,
tensors=tensors,
conditioning=conditioning,
style_preset_records=style_preset_records,
style_preset_image_files=style_preset_image_files,
)
ApiDependencies.invoker = Invoker(services)

View File

@ -0,0 +1,276 @@
import csv
import io
import json
import traceback
from typing import Optional
import pydantic
from fastapi import APIRouter, File, Form, HTTPException, Path, Response, UploadFile
from fastapi.responses import FileResponse
from PIL import Image
from pydantic import BaseModel, Field
from invokeai.app.api.dependencies import ApiDependencies
from invokeai.app.api.routers.model_manager import IMAGE_MAX_AGE
from invokeai.app.services.style_preset_images.style_preset_images_common import StylePresetImageFileNotFoundException
from invokeai.app.services.style_preset_records.style_preset_records_common import (
InvalidPresetImportDataError,
PresetData,
PresetType,
StylePresetChanges,
StylePresetNotFoundError,
StylePresetRecordWithImage,
StylePresetWithoutId,
UnsupportedFileTypeError,
parse_presets_from_file,
)
class StylePresetUpdateFormData(BaseModel):
name: str = Field(description="Preset name")
positive_prompt: str = Field(description="Positive prompt")
negative_prompt: str = Field(description="Negative prompt")
class StylePresetCreateFormData(StylePresetUpdateFormData):
type: PresetType = Field(description="Preset type")
style_presets_router = APIRouter(prefix="/v1/style_presets", tags=["style_presets"])
@style_presets_router.get(
"/i/{style_preset_id}",
operation_id="get_style_preset",
responses={
200: {"model": StylePresetRecordWithImage},
},
)
async def get_style_preset(
style_preset_id: str = Path(description="The style preset to get"),
) -> StylePresetRecordWithImage:
"""Gets a style preset"""
try:
image = ApiDependencies.invoker.services.style_preset_image_files.get_url(style_preset_id)
style_preset = ApiDependencies.invoker.services.style_preset_records.get(style_preset_id)
return StylePresetRecordWithImage(image=image, **style_preset.model_dump())
except StylePresetNotFoundError:
raise HTTPException(status_code=404, detail="Style preset not found")
@style_presets_router.patch(
"/i/{style_preset_id}",
operation_id="update_style_preset",
responses={
200: {"model": StylePresetRecordWithImage},
},
)
async def update_style_preset(
image: Optional[UploadFile] = File(description="The image file to upload", default=None),
style_preset_id: str = Path(description="The id of the style preset to update"),
data: str = Form(description="The data of the style preset to update"),
) -> StylePresetRecordWithImage:
"""Updates a style preset"""
if image is not None:
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
contents = await image.read()
try:
pil_image = Image.open(io.BytesIO(contents))
except Exception:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=415, detail="Failed to read image")
try:
ApiDependencies.invoker.services.style_preset_image_files.save(style_preset_id, pil_image)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
else:
try:
ApiDependencies.invoker.services.style_preset_image_files.delete(style_preset_id)
except StylePresetImageFileNotFoundException:
pass
try:
parsed_data = json.loads(data)
validated_data = StylePresetUpdateFormData(**parsed_data)
name = validated_data.name
positive_prompt = validated_data.positive_prompt
negative_prompt = validated_data.negative_prompt
except pydantic.ValidationError:
raise HTTPException(status_code=400, detail="Invalid preset data")
preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt)
changes = StylePresetChanges(name=name, preset_data=preset_data)
style_preset_image = ApiDependencies.invoker.services.style_preset_image_files.get_url(style_preset_id)
style_preset = ApiDependencies.invoker.services.style_preset_records.update(
style_preset_id=style_preset_id, changes=changes
)
return StylePresetRecordWithImage(image=style_preset_image, **style_preset.model_dump())
@style_presets_router.delete(
"/i/{style_preset_id}",
operation_id="delete_style_preset",
)
async def delete_style_preset(
style_preset_id: str = Path(description="The style preset to delete"),
) -> None:
"""Deletes a style preset"""
try:
ApiDependencies.invoker.services.style_preset_image_files.delete(style_preset_id)
except StylePresetImageFileNotFoundException:
pass
ApiDependencies.invoker.services.style_preset_records.delete(style_preset_id)
@style_presets_router.post(
"/",
operation_id="create_style_preset",
responses={
200: {"model": StylePresetRecordWithImage},
},
)
async def create_style_preset(
image: Optional[UploadFile] = File(description="The image file to upload", default=None),
data: str = Form(description="The data of the style preset to create"),
) -> StylePresetRecordWithImage:
"""Creates a style preset"""
try:
parsed_data = json.loads(data)
validated_data = StylePresetCreateFormData(**parsed_data)
name = validated_data.name
type = validated_data.type
positive_prompt = validated_data.positive_prompt
negative_prompt = validated_data.negative_prompt
except pydantic.ValidationError:
raise HTTPException(status_code=400, detail="Invalid preset data")
preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt)
style_preset = StylePresetWithoutId(name=name, preset_data=preset_data, type=type)
new_style_preset = ApiDependencies.invoker.services.style_preset_records.create(style_preset=style_preset)
if image is not None:
if not image.content_type or not image.content_type.startswith("image"):
raise HTTPException(status_code=415, detail="Not an image")
contents = await image.read()
try:
pil_image = Image.open(io.BytesIO(contents))
except Exception:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=415, detail="Failed to read image")
try:
ApiDependencies.invoker.services.style_preset_image_files.save(new_style_preset.id, pil_image)
except ValueError as e:
raise HTTPException(status_code=409, detail=str(e))
preset_image = ApiDependencies.invoker.services.style_preset_image_files.get_url(new_style_preset.id)
return StylePresetRecordWithImage(image=preset_image, **new_style_preset.model_dump())
@style_presets_router.get(
"/",
operation_id="list_style_presets",
responses={
200: {"model": list[StylePresetRecordWithImage]},
},
)
async def list_style_presets() -> list[StylePresetRecordWithImage]:
"""Gets a page of style presets"""
style_presets_with_image: list[StylePresetRecordWithImage] = []
style_presets = ApiDependencies.invoker.services.style_preset_records.get_many()
for preset in style_presets:
image = ApiDependencies.invoker.services.style_preset_image_files.get_url(preset.id)
style_preset_with_image = StylePresetRecordWithImage(image=image, **preset.model_dump())
style_presets_with_image.append(style_preset_with_image)
return style_presets_with_image
@style_presets_router.get(
"/i/{style_preset_id}/image",
operation_id="get_style_preset_image",
responses={
200: {
"description": "The style preset image was fetched successfully",
},
400: {"description": "Bad request"},
404: {"description": "The style preset image could not be found"},
},
status_code=200,
)
async def get_style_preset_image(
style_preset_id: str = Path(description="The id of the style preset image to get"),
) -> FileResponse:
"""Gets an image file that previews the model"""
try:
path = ApiDependencies.invoker.services.style_preset_image_files.get_path(style_preset_id)
response = FileResponse(
path,
media_type="image/png",
filename=style_preset_id + ".png",
content_disposition_type="inline",
)
response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}"
return response
except Exception:
raise HTTPException(status_code=404)
@style_presets_router.get(
"/export",
operation_id="export_style_presets",
responses={200: {"content": {"text/csv": {}}, "description": "A CSV file with the requested data."}},
status_code=200,
)
async def export_style_presets():
# Create an in-memory stream to store the CSV data
output = io.StringIO()
writer = csv.writer(output)
# Write the header
writer.writerow(["name", "prompt", "negative_prompt"])
style_presets = ApiDependencies.invoker.services.style_preset_records.get_many(type=PresetType.User)
for preset in style_presets:
writer.writerow([preset.name, preset.preset_data.positive_prompt, preset.preset_data.negative_prompt])
csv_data = output.getvalue()
output.close()
return Response(
content=csv_data,
media_type="text/csv",
headers={"Content-Disposition": "attachment; filename=prompt_templates.csv"},
)
@style_presets_router.post(
"/import",
operation_id="import_style_presets",
)
async def import_style_presets(file: UploadFile = File(description="The file to import")):
try:
style_presets = await parse_presets_from_file(file)
ApiDependencies.invoker.services.style_preset_records.create_many(style_presets)
except InvalidPresetImportDataError as e:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=400, detail=str(e))
except UnsupportedFileTypeError as e:
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
raise HTTPException(status_code=415, detail=str(e))

View File

@ -30,6 +30,7 @@ from invokeai.app.api.routers import (
images,
model_manager,
session_queue,
style_presets,
utilities,
workflows,
)
@ -55,11 +56,13 @@ mimetypes.add_type("text/css", ".css")
torch_device_name = TorchDevice.get_torch_device_name()
logger.info(f"Using torch device: {torch_device_name}")
loop = asyncio.new_event_loop()
@asynccontextmanager
async def lifespan(app: FastAPI):
# Add startup event to load dependencies
ApiDependencies.initialize(config=app_config, event_handler_id=event_handler_id, logger=logger)
ApiDependencies.initialize(config=app_config, event_handler_id=event_handler_id, loop=loop, logger=logger)
yield
# Shut down threads
ApiDependencies.shutdown()
@ -106,6 +109,7 @@ app.include_router(board_images.board_images_router, prefix="/api")
app.include_router(app_info.app_router, prefix="/api")
app.include_router(session_queue.session_queue_router, prefix="/api")
app.include_router(workflows.workflows_router, prefix="/api")
app.include_router(style_presets.style_presets_router, prefix="/api")
app.openapi = get_openapi_func(app)
@ -184,8 +188,6 @@ def invoke_api() -> None:
check_cudnn(logger)
# Start our own event loop for eventing usage
loop = asyncio.new_event_loop()
config = uvicorn.Config(
app=app,
host=app_config.host,

View File

@ -91,6 +91,7 @@ class InvokeAIAppConfig(BaseSettings):
db_dir: Path to InvokeAI databases directory.
outputs_dir: Path to directory for outputs.
custom_nodes_dir: Path to directory for custom nodes.
style_presets_dir: Path to directory for style presets.
log_handlers: Log handler. Valid options are "console", "file=<path>", "syslog=path|address:host:port", "http=<url>".
log_format: Log format. Use "plain" for text-only, "color" for colorized output, "legacy" for 2.3-style logging and "syslog" for syslog-style.<br>Valid values: `plain`, `color`, `syslog`, `legacy`
log_level: Emit logging messages at this level or higher.<br>Valid values: `debug`, `info`, `warning`, `error`, `critical`
@ -153,6 +154,7 @@ class InvokeAIAppConfig(BaseSettings):
db_dir: Path = Field(default=Path("databases"), description="Path to InvokeAI databases directory.")
outputs_dir: Path = Field(default=Path("outputs"), description="Path to directory for outputs.")
custom_nodes_dir: Path = Field(default=Path("nodes"), description="Path to directory for custom nodes.")
style_presets_dir: Path = Field(default=Path("style_presets"), description="Path to directory for style presets.")
# LOGGING
log_handlers: list[str] = Field(default=["console"], description='Log handler. Valid options are "console", "file=<path>", "syslog=path|address:host:port", "http=<url>".')
@ -300,6 +302,11 @@ class InvokeAIAppConfig(BaseSettings):
"""Path to the models directory, resolved to an absolute path.."""
return self._resolve(self.models_dir)
@property
def style_presets_path(self) -> Path:
"""Path to the style presets directory, resolved to an absolute path.."""
return self._resolve(self.style_presets_dir)
@property
def convert_cache_path(self) -> Path:
"""Path to the converted cache models directory, resolved to an absolute path.."""

View File

@ -1,46 +1,44 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)
import asyncio
import threading
from queue import Empty, Queue
from fastapi_events.dispatcher import dispatch
from invokeai.app.services.events.events_base import EventServiceBase
from invokeai.app.services.events.events_common import (
EventBase,
)
from invokeai.app.services.events.events_common import EventBase
class FastAPIEventService(EventServiceBase):
def __init__(self, event_handler_id: int) -> None:
def __init__(self, event_handler_id: int, loop: asyncio.AbstractEventLoop) -> None:
self.event_handler_id = event_handler_id
self._queue = Queue[EventBase | None]()
self._queue = asyncio.Queue[EventBase | None]()
self._stop_event = threading.Event()
asyncio.create_task(self._dispatch_from_queue(stop_event=self._stop_event))
self._loop = loop
# We need to store a reference to the task so it doesn't get GC'd
# See: https://docs.python.org/3/library/asyncio-task.html#creating-tasks
self._background_tasks: set[asyncio.Task[None]] = set()
task = self._loop.create_task(self._dispatch_from_queue(stop_event=self._stop_event))
self._background_tasks.add(task)
task.add_done_callback(self._background_tasks.remove)
super().__init__()
def stop(self, *args, **kwargs):
self._stop_event.set()
self._queue.put(None)
self._loop.call_soon_threadsafe(self._queue.put_nowait, None)
def dispatch(self, event: EventBase) -> None:
self._queue.put(event)
self._loop.call_soon_threadsafe(self._queue.put_nowait, event)
async def _dispatch_from_queue(self, stop_event: threading.Event):
"""Get events on from the queue and dispatch them, from the correct thread"""
while not stop_event.is_set():
try:
event = self._queue.get(block=False)
event = await self._queue.get()
if not event: # Probably stopping
continue
# Leave the payloads as live pydantic models
dispatch(event, middleware_id=self.event_handler_id, payload_schema_dump=False)
except Empty:
await asyncio.sleep(0.1)
pass
except asyncio.CancelledError as e:
raise e # Raise a proper error

View File

@ -4,6 +4,8 @@ from __future__ import annotations
from typing import TYPE_CHECKING
from invokeai.app.services.object_serializer.object_serializer_base import ObjectSerializerBase
from invokeai.app.services.style_preset_images.style_preset_images_base import StylePresetImageFileStorageBase
from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase
if TYPE_CHECKING:
from logging import Logger
@ -61,6 +63,8 @@ class InvocationServices:
workflow_records: "WorkflowRecordsStorageBase",
tensors: "ObjectSerializerBase[torch.Tensor]",
conditioning: "ObjectSerializerBase[ConditioningFieldData]",
style_preset_records: "StylePresetRecordsStorageBase",
style_preset_image_files: "StylePresetImageFileStorageBase",
):
self.board_images = board_images
self.board_image_records = board_image_records
@ -85,3 +89,5 @@ class InvocationServices:
self.workflow_records = workflow_records
self.tensors = tensors
self.conditioning = conditioning
self.style_preset_records = style_preset_records
self.style_preset_image_files = style_preset_image_files

View File

@ -16,6 +16,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_10 import
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_11 import build_migration_11
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_12 import build_migration_12
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_13 import build_migration_13
from invokeai.app.services.shared.sqlite_migrator.migrations.migration_14 import build_migration_14
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator
@ -49,6 +50,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto
migrator.register_migration(build_migration_11(app_config=config, logger=logger))
migrator.register_migration(build_migration_12(app_config=config))
migrator.register_migration(build_migration_13())
migrator.register_migration(build_migration_14())
migrator.run_migrations()
return db

View File

@ -0,0 +1,61 @@
import sqlite3
from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration
class Migration14Callback:
def __call__(self, cursor: sqlite3.Cursor) -> None:
self._create_style_presets(cursor)
def _create_style_presets(self, cursor: sqlite3.Cursor) -> None:
"""Create the table used to store style presets."""
tables = [
"""--sql
CREATE TABLE IF NOT EXISTS style_presets (
id TEXT NOT NULL PRIMARY KEY,
name TEXT NOT NULL,
preset_data TEXT NOT NULL,
type TEXT NOT NULL DEFAULT "user",
created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')),
-- Updated via trigger
updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW'))
);
"""
]
# Add trigger for `updated_at`.
triggers = [
"""--sql
CREATE TRIGGER IF NOT EXISTS style_presets
AFTER UPDATE
ON style_presets FOR EACH ROW
BEGIN
UPDATE style_presets SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')
WHERE id = old.id;
END;
"""
]
# Add indexes for searchable fields
indices = [
"CREATE INDEX IF NOT EXISTS idx_style_presets_name ON style_presets(name);",
]
for stmt in tables + indices + triggers:
cursor.execute(stmt)
def build_migration_14() -> Migration:
"""
Build the migration from database version 13 to 14..
This migration does the following:
- Create the table used to store style presets.
"""
migration_14 = Migration(
from_version=13,
to_version=14,
callback=Migration14Callback(),
)
return migration_14

Binary file not shown.

After

Width:  |  Height:  |  Size: 98 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 138 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 122 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 123 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 160 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 146 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 117 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 110 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 46 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 79 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 156 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 141 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 96 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 91 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 88 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 107 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 132 KiB

View File

@ -0,0 +1,33 @@
from abc import ABC, abstractmethod
from pathlib import Path
from PIL.Image import Image as PILImageType
class StylePresetImageFileStorageBase(ABC):
"""Low-level service responsible for storing and retrieving image files."""
@abstractmethod
def get(self, style_preset_id: str) -> PILImageType:
"""Retrieves a style preset image as PIL Image."""
pass
@abstractmethod
def get_path(self, style_preset_id: str) -> Path:
"""Gets the internal path to a style preset image."""
pass
@abstractmethod
def get_url(self, style_preset_id: str) -> str | None:
"""Gets the URL to fetch a style preset image."""
pass
@abstractmethod
def save(self, style_preset_id: str, image: PILImageType) -> None:
"""Saves a style preset image."""
pass
@abstractmethod
def delete(self, style_preset_id: str) -> None:
"""Deletes a style preset image."""
pass

View File

@ -0,0 +1,19 @@
class StylePresetImageFileNotFoundException(Exception):
"""Raised when an image file is not found in storage."""
def __init__(self, message: str = "Style preset image file not found"):
super().__init__(message)
class StylePresetImageFileSaveException(Exception):
"""Raised when an image cannot be saved."""
def __init__(self, message: str = "Style preset image file not saved"):
super().__init__(message)
class StylePresetImageFileDeleteException(Exception):
"""Raised when an image cannot be deleted."""
def __init__(self, message: str = "Style preset image file not deleted"):
super().__init__(message)

View File

@ -0,0 +1,88 @@
from pathlib import Path
from PIL import Image
from PIL.Image import Image as PILImageType
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.style_preset_images.style_preset_images_base import StylePresetImageFileStorageBase
from invokeai.app.services.style_preset_images.style_preset_images_common import (
StylePresetImageFileDeleteException,
StylePresetImageFileNotFoundException,
StylePresetImageFileSaveException,
)
from invokeai.app.services.style_preset_records.style_preset_records_common import PresetType
from invokeai.app.util.misc import uuid_string
from invokeai.app.util.thumbnails import make_thumbnail
class StylePresetImageFileStorageDisk(StylePresetImageFileStorageBase):
"""Stores images on disk"""
def __init__(self, style_preset_images_folder: Path):
self._style_preset_images_folder = style_preset_images_folder
self._validate_storage_folders()
def start(self, invoker: Invoker) -> None:
self._invoker = invoker
def get(self, style_preset_id: str) -> PILImageType:
try:
path = self.get_path(style_preset_id)
return Image.open(path)
except FileNotFoundError as e:
raise StylePresetImageFileNotFoundException from e
def save(self, style_preset_id: str, image: PILImageType) -> None:
try:
self._validate_storage_folders()
image_path = self._style_preset_images_folder / (style_preset_id + ".webp")
thumbnail = make_thumbnail(image, 256)
thumbnail.save(image_path, format="webp")
except Exception as e:
raise StylePresetImageFileSaveException from e
def get_path(self, style_preset_id: str) -> Path:
style_preset = self._invoker.services.style_preset_records.get(style_preset_id)
if style_preset.type is PresetType.Default:
default_images_dir = Path(__file__).parent / Path("default_style_preset_images")
path = default_images_dir / (style_preset.name + ".png")
else:
path = self._style_preset_images_folder / (style_preset_id + ".webp")
return path
def get_url(self, style_preset_id: str) -> str | None:
path = self.get_path(style_preset_id)
if not self._validate_path(path):
return
url = self._invoker.services.urls.get_style_preset_image_url(style_preset_id)
# The image URL never changes, so we must add random query string to it to prevent caching
url += f"?{uuid_string()}"
return url
def delete(self, style_preset_id: str) -> None:
try:
path = self.get_path(style_preset_id)
if not self._validate_path(path):
raise StylePresetImageFileNotFoundException
path.unlink()
except StylePresetImageFileNotFoundException as e:
raise StylePresetImageFileNotFoundException from e
except Exception as e:
raise StylePresetImageFileDeleteException from e
def _validate_path(self, path: Path) -> bool:
"""Validates the path given for an image."""
return path.exists()
def _validate_storage_folders(self) -> None:
"""Checks if the required folders exist and create them if they don't"""
self._style_preset_images_folder.mkdir(parents=True, exist_ok=True)

View File

@ -0,0 +1,146 @@
[
{
"name": "Photography (General)",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt}. photography. f/2.8 macro photo, bokeh, photorealism",
"negative_prompt": "painting, digital art. sketch, blurry"
}
},
{
"name": "Photography (Studio Lighting)",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt}, photography. f/8 photo. centered subject, studio lighting.",
"negative_prompt": "painting, digital art. sketch, blurry"
}
},
{
"name": "Photography (Landscape)",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt}, landscape photograph, f/12, lifelike, highly detailed.",
"negative_prompt": "painting, digital art. sketch, blurry"
}
},
{
"name": "Photography (Portrait)",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt}. photography. portraiture. catch light in eyes. one flash. rembrandt lighting. Soft box. dark shadows. High contrast. 80mm lens. F2.8.",
"negative_prompt": "painting, digital art. sketch, blurry"
}
},
{
"name": "Photography (Black and White)",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} photography. natural light. 80mm lens. F1.4. strong contrast, hard light. dark contrast. blurred background. black and white",
"negative_prompt": "painting, digital art. sketch, colour+"
}
},
{
"name": "Architectural Visualization",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt}. architectural photography, f/12, luxury, aesthetically pleasing form and function.",
"negative_prompt": "painting, digital art. sketch, blurry"
}
},
{
"name": "Concept Art (Fantasy)",
"type": "default",
"preset_data": {
"positive_prompt": "concept artwork of a {prompt}. (digital painterly art style)++, mythological, (textured 2d dry media brushpack)++, glazed brushstrokes, otherworldly. painting+, illustration+",
"negative_prompt": "photo. distorted, blurry, out of focus. sketch. (cgi, 3d.)++"
}
},
{
"name": "Concept Art (Sci-Fi)",
"type": "default",
"preset_data": {
"positive_prompt": "(concept art)++, {prompt}, (sleek futurism)++, (textured 2d dry media)++, metallic highlights, digital painting style",
"negative_prompt": "photo. distorted, blurry, out of focus. sketch. (cgi, 3d.)++"
}
},
{
"name": "Concept Art (Character)",
"type": "default",
"preset_data": {
"positive_prompt": "(character concept art)++, stylized painterly digital painting of {prompt}, (painterly, impasto. Dry brush.)++",
"negative_prompt": "photo. distorted, blurry, out of focus. sketch. (cgi, 3d.)++"
}
},
{
"name": "Concept Art (Painterly)",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} oil painting. high contrast. impasto. sfumato. chiaroscuro. Palette knife.",
"negative_prompt": "photo. smooth. border. frame"
}
},
{
"name": "Environment Art",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} environment artwork, hyper-realistic digital painting style with cinematic composition, atmospheric, depth and detail, voluminous. textured dry brush 2d media",
"negative_prompt": "photo, distorted, blurry, out of focus. sketch."
}
},
{
"name": "Interior Design (Visualization)",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} interior design photo, gentle shadows, light mid-tones, dimension, mix of smooth and textured surfaces, focus on negative space and clean lines, focus",
"negative_prompt": "photo, distorted. sketch."
}
},
{
"name": "Product Rendering",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} high quality product photography, 3d rendering with key lighting, shallow depth of field, simple plain background, studio lighting.",
"negative_prompt": "blurry, sketch, messy, dirty. unfinished."
}
},
{
"name": "Sketch",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} black and white pencil drawing, off-center composition, cross-hatching for shadows, bold strokes, textured paper. sketch+++",
"negative_prompt": "blurry, photo, painting, color. messy, dirty. unfinished. frame, borders."
}
},
{
"name": "Line Art",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} Line art. bold outline. simplistic. white background. 2d",
"negative_prompt": "photo. digital art. greyscale. solid black. painting"
}
},
{
"name": "Anime",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} anime++, bold outline, cel-shaded coloring, shounen, seinen",
"negative_prompt": "(photo)+++. greyscale. solid black. painting"
}
},
{
"name": "Illustration",
"type": "default",
"preset_data": {
"positive_prompt": "{prompt} illustration, bold linework, illustrative details, vector art style, flat coloring",
"negative_prompt": "(photo)+++. greyscale. painting, black and white."
}
},
{
"name": "Vehicles",
"type": "default",
"preset_data": {
"positive_prompt": "A weird futuristic normal auto, {prompt} elegant design, nice color, nice wheels",
"negative_prompt": "sketch. digital art. greyscale. painting"
}
}
]

View File

@ -0,0 +1,42 @@
from abc import ABC, abstractmethod
from invokeai.app.services.style_preset_records.style_preset_records_common import (
PresetType,
StylePresetChanges,
StylePresetRecordDTO,
StylePresetWithoutId,
)
class StylePresetRecordsStorageBase(ABC):
"""Base class for style preset storage services."""
@abstractmethod
def get(self, style_preset_id: str) -> StylePresetRecordDTO:
"""Get style preset by id."""
pass
@abstractmethod
def create(self, style_preset: StylePresetWithoutId) -> StylePresetRecordDTO:
"""Creates a style preset."""
pass
@abstractmethod
def create_many(self, style_presets: list[StylePresetWithoutId]) -> None:
"""Creates many style presets."""
pass
@abstractmethod
def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO:
"""Updates a style preset."""
pass
@abstractmethod
def delete(self, style_preset_id: str) -> None:
"""Deletes a style preset."""
pass
@abstractmethod
def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]:
"""Gets many workflows."""
pass

View File

@ -0,0 +1,138 @@
import codecs
import csv
import json
from enum import Enum
from typing import Any, Optional
import pydantic
from fastapi import UploadFile
from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter
from invokeai.app.util.metaenum import MetaEnum
class StylePresetNotFoundError(Exception):
"""Raised when a style preset is not found"""
class PresetData(BaseModel, extra="forbid"):
positive_prompt: str = Field(description="Positive prompt")
negative_prompt: str = Field(description="Negative prompt")
PresetDataValidator = TypeAdapter(PresetData)
class PresetType(str, Enum, metaclass=MetaEnum):
User = "user"
Default = "default"
Project = "project"
class StylePresetChanges(BaseModel, extra="forbid"):
name: Optional[str] = Field(default=None, description="The style preset's new name.")
preset_data: Optional[PresetData] = Field(default=None, description="The updated data for style preset.")
class StylePresetWithoutId(BaseModel):
name: str = Field(description="The name of the style preset.")
preset_data: PresetData = Field(description="The preset data")
type: PresetType = Field(description="The type of style preset")
class StylePresetRecordDTO(StylePresetWithoutId):
id: str = Field(description="The style preset ID.")
@classmethod
def from_dict(cls, data: dict[str, Any]) -> "StylePresetRecordDTO":
data["preset_data"] = PresetDataValidator.validate_json(data.get("preset_data", ""))
return StylePresetRecordDTOValidator.validate_python(data)
StylePresetRecordDTOValidator = TypeAdapter(StylePresetRecordDTO)
class StylePresetRecordWithImage(StylePresetRecordDTO):
image: Optional[str] = Field(description="The path for image")
class StylePresetImportRow(BaseModel):
name: str = Field(min_length=1, description="The name of the preset.")
positive_prompt: str = Field(
default="",
description="The positive prompt for the preset.",
validation_alias=AliasChoices("positive_prompt", "prompt"),
)
negative_prompt: str = Field(default="", description="The negative prompt for the preset.")
model_config = ConfigDict(str_strip_whitespace=True, extra="forbid")
StylePresetImportList = list[StylePresetImportRow]
StylePresetImportListTypeAdapter = TypeAdapter(StylePresetImportList)
class UnsupportedFileTypeError(ValueError):
"""Raised when an unsupported file type is encountered"""
pass
class InvalidPresetImportDataError(ValueError):
"""Raised when invalid preset import data is encountered"""
pass
async def parse_presets_from_file(file: UploadFile) -> list[StylePresetWithoutId]:
"""Parses style presets from a file. The file must be a CSV or JSON file.
If CSV, the file must have the following columns:
- name
- prompt (or positive_prompt)
- negative_prompt
If JSON, the file must be a list of objects with the following keys:
- name
- prompt (or positive_prompt)
- negative_prompt
Args:
file (UploadFile): The file to parse.
Returns:
list[StylePresetWithoutId]: The parsed style presets.
Raises:
UnsupportedFileTypeError: If the file type is not supported.
InvalidPresetImportDataError: If the data in the file is invalid.
"""
if file.content_type not in ["text/csv", "application/json"]:
raise UnsupportedFileTypeError()
if file.content_type == "text/csv":
csv_reader = csv.DictReader(codecs.iterdecode(file.file, "utf-8"))
data = list(csv_reader)
else: # file.content_type == "application/json":
json_data = await file.read()
data = json.loads(json_data)
try:
imported_presets = StylePresetImportListTypeAdapter.validate_python(data)
style_presets: list[StylePresetWithoutId] = []
for imported in imported_presets:
preset_data = PresetData(positive_prompt=imported.positive_prompt, negative_prompt=imported.negative_prompt)
style_preset = StylePresetWithoutId(name=imported.name, preset_data=preset_data, type=PresetType.User)
style_presets.append(style_preset)
except pydantic.ValidationError as e:
if file.content_type == "text/csv":
msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt' and name cannot be blank"
else: # file.content_type == "application/json":
msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt' and name cannot be blank"
raise InvalidPresetImportDataError(msg) from e
finally:
file.file.close()
return style_presets

View File

@ -0,0 +1,215 @@
import json
from pathlib import Path
from invokeai.app.services.invoker import Invoker
from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase
from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase
from invokeai.app.services.style_preset_records.style_preset_records_common import (
PresetType,
StylePresetChanges,
StylePresetNotFoundError,
StylePresetRecordDTO,
StylePresetWithoutId,
)
from invokeai.app.util.misc import uuid_string
class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase):
def __init__(self, db: SqliteDatabase) -> None:
super().__init__()
self._lock = db.lock
self._conn = db.conn
self._cursor = self._conn.cursor()
def start(self, invoker: Invoker) -> None:
self._invoker = invoker
self._sync_default_style_presets()
def get(self, style_preset_id: str) -> StylePresetRecordDTO:
"""Gets a style preset by ID."""
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
SELECT *
FROM style_presets
WHERE id = ?;
""",
(style_preset_id,),
)
row = self._cursor.fetchone()
if row is None:
raise StylePresetNotFoundError(f"Style preset with id {style_preset_id} not found")
return StylePresetRecordDTO.from_dict(dict(row))
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
def create(self, style_preset: StylePresetWithoutId) -> StylePresetRecordDTO:
style_preset_id = uuid_string()
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
INSERT OR IGNORE INTO style_presets (
id,
name,
preset_data,
type
)
VALUES (?, ?, ?, ?);
""",
(
style_preset_id,
style_preset.name,
style_preset.preset_data.model_dump_json(),
style_preset.type,
),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
return self.get(style_preset_id)
def create_many(self, style_presets: list[StylePresetWithoutId]) -> None:
style_preset_ids = []
try:
self._lock.acquire()
for style_preset in style_presets:
style_preset_id = uuid_string()
style_preset_ids.append(style_preset_id)
self._cursor.execute(
"""--sql
INSERT OR IGNORE INTO style_presets (
id,
name,
preset_data,
type
)
VALUES (?, ?, ?, ?);
""",
(
style_preset_id,
style_preset.name,
style_preset.preset_data.model_dump_json(),
style_preset.type,
),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
return None
def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO:
try:
self._lock.acquire()
# Change the name of a style preset
if changes.name is not None:
self._cursor.execute(
"""--sql
UPDATE style_presets
SET name = ?
WHERE id = ?;
""",
(changes.name, style_preset_id),
)
# Change the preset data for a style preset
if changes.preset_data is not None:
self._cursor.execute(
"""--sql
UPDATE style_presets
SET preset_data = ?
WHERE id = ?;
""",
(changes.preset_data.model_dump_json(), style_preset_id),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
return self.get(style_preset_id)
def delete(self, style_preset_id: str) -> None:
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
DELETE from style_presets
WHERE id = ?;
""",
(style_preset_id,),
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
return None
def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]:
try:
self._lock.acquire()
main_query = """
SELECT
*
FROM style_presets
"""
if type is not None:
main_query += "WHERE type = ? "
main_query += "ORDER BY LOWER(name) ASC"
if type is not None:
self._cursor.execute(main_query, (type,))
else:
self._cursor.execute(main_query)
rows = self._cursor.fetchall()
style_presets = [StylePresetRecordDTO.from_dict(dict(row)) for row in rows]
return style_presets
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
def _sync_default_style_presets(self) -> None:
"""Syncs default style presets to the database. Internal use only."""
# First delete all existing default style presets
try:
self._lock.acquire()
self._cursor.execute(
"""--sql
DELETE FROM style_presets
WHERE type = "default";
"""
)
self._conn.commit()
except Exception:
self._conn.rollback()
raise
finally:
self._lock.release()
# Next, parse and create the default style presets
with self._lock, open(Path(__file__).parent / Path("default_style_presets.json"), "r") as file:
presets = json.load(file)
for preset in presets:
style_preset = StylePresetWithoutId.model_validate(preset)
self.create(style_preset)

View File

@ -13,3 +13,8 @@ class UrlServiceBase(ABC):
def get_model_image_url(self, model_key: str) -> str:
"""Gets the URL for a model image"""
pass
@abstractmethod
def get_style_preset_image_url(self, style_preset_id: str) -> str:
"""Gets the URL for a style preset image"""
pass

View File

@ -19,3 +19,6 @@ class LocalUrlService(UrlServiceBase):
def get_model_image_url(self, model_key: str) -> str:
return f"{self._base_url_v2}/models/i/{model_key}/image"
def get_style_preset_image_url(self, style_preset_id: str) -> str:
return f"{self._base_url}/style_presets/i/{style_preset_id}/image"

View File

@ -59,7 +59,7 @@
"@dnd-kit/sortable": "^8.0.0",
"@dnd-kit/utilities": "^3.2.2",
"@fontsource-variable/inter": "^5.0.20",
"@invoke-ai/ui-library": "^0.0.25",
"@invoke-ai/ui-library": "^0.0.29",
"@nanostores/react": "^0.7.3",
"@reduxjs/toolkit": "2.2.3",
"@roarr/browser-log-writer": "^1.3.0",
@ -110,7 +110,6 @@
"zod-validation-error": "^3.3.1"
},
"peerDependencies": {
"@chakra-ui/react": "^2.8.2",
"react": "^18.2.0",
"react-dom": "^18.2.0",
"ts-toolbelt": "^9.6.0"

File diff suppressed because it is too large Load Diff

View File

@ -1141,6 +1141,8 @@
"imageSavingFailed": "Image Saving Failed",
"imageUploaded": "Image Uploaded",
"imageUploadFailed": "Image Upload Failed",
"importFailed": "Import Failed",
"importSuccessful": "Import Successful",
"invalidUpload": "Invalid Upload",
"loadedWithWarnings": "Workflow Loaded with Warnings",
"maskSavedAssets": "Mask Saved to Assets",
@ -1689,6 +1691,52 @@
"missingUpscaleModel": "Missing upscale model",
"missingTileControlNetModel": "No valid tile ControlNet models installed"
},
"stylePresets": {
"active": "Active",
"choosePromptTemplate": "Choose Prompt Template",
"clearTemplateSelection": "Clear Template Selection",
"copyTemplate": "Copy Template",
"createPromptTemplate": "Create Prompt Template",
"defaultTemplates": "Default Templates",
"deleteImage": "Delete Image",
"deleteTemplate": "Delete Template",
"deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.",
"exportPromptTemplates": "Export My Prompt Templates (CSV)",
"editTemplate": "Edit Template",
"exportDownloaded": "Export Downloaded",
"exportFailed": "Unable to generate and download CSV",
"flatten": "Flatten selected template into current prompt",
"importTemplates": "Import Prompt Templates (CSV/JSON)",
"acceptedColumnsKeys": "Accepted columns/keys:",
"nameColumn": "'name'",
"positivePromptColumn": "'prompt' or 'positive_prompt'",
"negativePromptColumn": "'negative_prompt'",
"insertPlaceholder": "Insert placeholder",
"myTemplates": "My Templates",
"name": "Name",
"negativePrompt": "Negative Prompt",
"noTemplates": "No templates",
"noMatchingTemplates": "No matching templates",
"promptTemplatesDesc1": "Prompt templates add text to the prompts you write in the prompt box.",
"promptTemplatesDesc2": "Use the placeholder string <Pre>{{placeholder}}</Pre> to specify where your prompt should be included in the template.",
"promptTemplatesDesc3": "If you omit the placeholder, the template will be appended to the end of your prompt.",
"positivePrompt": "Positive Prompt",
"preview": "Preview",
"private": "Private",
"searchByName": "Search by name",
"shared": "Shared",
"sharedTemplates": "Shared Templates",
"templateActions": "Template Actions",
"templateDeleted": "Prompt template deleted",
"toggleViewMode": "Toggle View Mode",
"type": "Type",
"unableToDeleteTemplate": "Unable to delete prompt template",
"updatePromptTemplate": "Update Prompt Template",
"uploadImage": "Upload Image",
"useForTemplate": "Use For Prompt Template",
"viewList": "View Template List",
"viewModeTooltip": "This is how your prompt will look with your currently selected template. To edit your prompt, click anywhere in the text box."
},
"upsell": {
"inviteTeammates": "Invite Teammates",
"professional": "Professional",

View File

@ -90,7 +90,7 @@
"disabled": "Disabilitato",
"comparingDesc": "Confronta due immagini",
"comparing": "Confronta",
"dontShowMeThese": "Non mostrarmi questi"
"dontShowMeThese": "Non mostrare più"
},
"gallery": {
"galleryImageSize": "Dimensione dell'immagine",
@ -701,7 +701,9 @@
"baseModelChanged": "Modello base modificato",
"sessionRef": "Sessione: {{sessionId}}",
"somethingWentWrong": "Qualcosa è andato storto",
"outOfMemoryErrorDesc": "Le impostazioni della generazione attuale superano la capacità del sistema. Modifica le impostazioni e riprova."
"outOfMemoryErrorDesc": "Le impostazioni della generazione attuale superano la capacità del sistema. Modifica le impostazioni e riprova.",
"importFailed": "Importazione non riuscita",
"importSuccessful": "Importazione riuscita"
},
"tooltip": {
"feature": {
@ -1526,7 +1528,7 @@
},
"upscaleModel": {
"paragraphs": [
"Il modello di ampliamento ridimensiona l'immagine alle dimensioni di uscita prima che vengano aggiunti i dettagli. È possibile utilizzare qualsiasi modello di ampliamento supportato, ma alcuni sono specializzati per diversi tipi di immagini, come foto o disegni al tratto."
"Il modello di ampliamento (Upscale), scala l'immagine alle dimensioni di uscita prima di aggiungere i dettagli. È possibile utilizzare qualsiasi modello di ampliamento supportato, ma alcuni sono specializzati per diversi tipi di immagini, come foto o disegni al tratto."
],
"heading": "Modello di ampliamento"
},
@ -1735,12 +1737,52 @@
"missingUpscaleModel": "Modello per lampliamento mancante",
"missingTileControlNetModel": "Nessun modello ControlNet Tile valido installato",
"postProcessingModel": "Modello di post-elaborazione",
"postProcessingMissingModelWarning": "Visita <LinkComponent>Gestione modelli</LinkComponent> per installare un modello di post-elaborazione (da immagine a immagine)."
"postProcessingMissingModelWarning": "Visita <LinkComponent>Gestione modelli</LinkComponent> per installare un modello di post-elaborazione (da immagine a immagine).",
"exceedsMaxSize": "Le impostazioni di ampliamento superano il limite massimo delle dimensioni",
"exceedsMaxSizeDetails": "Il limite massimo di ampliamento è {{maxUpscaleDimension}}x{{maxUpscaleDimension}} pixel. Prova un'immagine più piccola o diminuisci la scala selezionata."
},
"upsell": {
"inviteTeammates": "Invita collaboratori",
"shareAccess": "Condividi l'accesso",
"professional": "Professionale",
"professionalUpsell": "Disponibile nell'edizione Professional di Invoke. Fai clic qui o visita invoke.com/pricing per ulteriori dettagli."
},
"stylePresets": {
"active": "Attivo",
"choosePromptTemplate": "Scegli un modello di prompt",
"clearTemplateSelection": "Cancella selezione modello",
"copyTemplate": "Copia modello",
"createPromptTemplate": "Crea modello di prompt",
"defaultTemplates": "Modelli predefiniti",
"deleteImage": "Elimina immagine",
"deleteTemplate": "Elimina modello",
"editTemplate": "Modifica modello",
"flatten": "Unisci il modello selezionato al prompt corrente",
"insertPlaceholder": "Inserisci segnaposto",
"myTemplates": "I miei modelli",
"name": "Nome",
"negativePrompt": "Prompt Negativo",
"noMatchingTemplates": "Nessun modello corrispondente",
"promptTemplatesDesc1": "I modelli di prompt aggiungono testo ai prompt che scrivi nelle caselle dei prompt.",
"promptTemplatesDesc3": "Se si omette il segnaposto, il modello verrà aggiunto alla fine del prompt.",
"positivePrompt": "Prompt Positivo",
"preview": "Anteprima",
"private": "Privato",
"searchByName": "Cerca per nome",
"shared": "Condiviso",
"sharedTemplates": "Modelli condivisi",
"templateDeleted": "Modello di prompt eliminato",
"toggleViewMode": "Attiva/disattiva visualizzazione",
"uploadImage": "Carica immagine",
"useForTemplate": "Usa per modello di prompt",
"viewList": "Visualizza l'elenco dei modelli",
"viewModeTooltip": "Ecco come apparirà il tuo prompt con il modello attualmente selezionato. Per modificare il tuo prompt, clicca in un punto qualsiasi della casella di testo.",
"deleteTemplate2": "Vuoi davvero eliminare questo modello? Questa operazione non può essere annullata.",
"unableToDeleteTemplate": "Impossibile eliminare il modello di prompt",
"updatePromptTemplate": "Aggiorna il modello di prompt",
"type": "Tipo",
"promptTemplatesDesc2": "Utilizza la stringa segnaposto <Pre>{{placeholder}}</Pre> per specificare dove inserire il tuo prompt nel modello.",
"importTemplates": "Importa modelli di prompt",
"importTemplatesDesc": "Il formato deve essere un CSV con colonne 'name' e 'prompt' o 'positive_prompt' e 'negative_prompt' incluse, oppure un file JSON con chiavi 'name' e 'prompt' o 'positive_prompt' e 'negative_prompt"
}
}

View File

@ -493,7 +493,8 @@
"defaultSettingsSaved": "默认设置已保存",
"huggingFacePlaceholder": "所有者或模型名称",
"huggingFaceRepoID": "HuggingFace仓库ID",
"loraTriggerPhrases": "LoRA 触发词"
"loraTriggerPhrases": "LoRA 触发词",
"ipAdapters": "IP适配器"
},
"parameters": {
"images": "图像",
@ -1702,7 +1703,9 @@
"upscaleModelDesc": "图像放大(图像到图像转换)模型",
"postProcessingMissingModelWarning": "请访问 <LinkComponent>模型管理器</LinkComponent>来安装一个后处理(图像到图像转换)模型.",
"missingModelsWarning": "请访问<LinkComponent>模型管理器</LinkComponent> 安装所需的模型:",
"mainModelDesc": "主模型SD1.5或SDXL架构"
"mainModelDesc": "主模型SD1.5或SDXL架构",
"exceedsMaxSize": "放大设置超出了最大尺寸限制",
"exceedsMaxSizeDetails": "最大放大限制是 {{maxUpscaleDimension}}x{{maxUpscaleDimension}} 像素.请尝试一个较小的图像或减少您的缩放选择."
},
"upsell": {
"inviteTeammates": "邀请团队成员",

View File

@ -13,11 +13,13 @@ import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardMo
import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal';
import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal';
import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast';
import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal';
import { configChanged } from 'features/system/store/configSlice';
import { languageSelector } from 'features/system/store/systemSelectors';
import InvokeTabs from 'features/ui/components/InvokeTabs';
import type { InvokeTabName } from 'features/ui/store/tabMap';
import { setActiveTab } from 'features/ui/store/uiSlice';
import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow';
import { AnimatePresence } from 'framer-motion';
import i18n from 'i18n';
import { size } from 'lodash-es';
@ -36,10 +38,11 @@ interface Props {
imageName: string;
action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters';
};
selectedWorkflowId?: string;
destination?: InvokeTabName | undefined;
}
const App = ({ config = DEFAULT_CONFIG, selectedImage, destination }: Props) => {
const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, destination }: Props) => {
const language = useAppSelector(languageSelector);
const logger = useLogger('system');
const dispatch = useAppDispatch();
@ -70,6 +73,14 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, destination }: Props) =>
}
}, [dispatch, config, logger]);
const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow();
useEffect(() => {
if (selectedWorkflowId) {
getAndLoadWorkflow(selectedWorkflowId);
}
}, [selectedWorkflowId, getAndLoadWorkflow]);
useEffect(() => {
if (destination) {
dispatch(setActiveTab(destination));
@ -104,6 +115,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, destination }: Props) =>
<DeleteImageModal />
<ChangeBoardModal />
<DynamicPromptsModal />
<StylePresetModal />
<PreselectedImage selectedImage={selectedImage} />
</ErrorBoundary>
);

View File

@ -44,6 +44,7 @@ interface Props extends PropsWithChildren {
imageName: string;
action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters';
};
selectedWorkflowId?: string;
destination?: InvokeTabName;
customStarUi?: CustomStarUi;
socketOptions?: Partial<ManagerOptions & SocketOptions>;
@ -64,6 +65,7 @@ const InvokeAIUI = ({
projectUrl,
queueId,
selectedImage,
selectedWorkflowId,
destination,
customStarUi,
socketOptions,
@ -221,7 +223,12 @@ const InvokeAIUI = ({
<React.Suspense fallback={<Loading />}>
<ThemeLocaleProvider>
<AppDndContext>
<App config={config} selectedImage={selectedImage} destination={destination} />
<App
config={config}
selectedImage={selectedImage}
selectedWorkflowId={selectedWorkflowId}
destination={destination}
/>
</AppDndContext>
</ThemeLocaleProvider>
</React.Suspense>

View File

@ -11,6 +11,8 @@ import {
promptsChanged,
} from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { getPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import { activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { utilitiesApi } from 'services/api/endpoints/utilities';
import { socketConnected } from 'services/events/actions';
@ -19,7 +21,8 @@ const matcher = isAnyOf(
combinatorialToggled,
maxPromptsChanged,
maxPromptsReset,
socketConnected
socketConnected,
activeStylePresetIdChanged
);
export const addDynamicPromptsListener = (startAppListening: AppStartListening) => {
@ -28,7 +31,7 @@ export const addDynamicPromptsListener = (startAppListening: AppStartListening)
effect: async (action, { dispatch, getState, cancelActiveListeners, delay }) => {
cancelActiveListeners();
const state = getState();
const { positivePrompt } = state.controlLayers.present;
const { positivePrompt } = getPresetModifiedPrompts(state);
const { maxPrompts } = state.dynamicPrompts;
if (state.config.disabledFeatures.includes('dynamicPrompting')) {

View File

@ -28,6 +28,7 @@ import { generationPersistConfig, generationSlice } from 'features/parameters/st
import { upscalePersistConfig, upscaleSlice } from 'features/parameters/store/upscaleSlice';
import { queueSlice } from 'features/queue/store/queueSlice';
import { sdxlPersistConfig, sdxlSlice } from 'features/sdxl/store/sdxlSlice';
import { stylePresetPersistConfig, stylePresetSlice } from 'features/stylePresets/store/stylePresetSlice';
import { configSlice } from 'features/system/store/configSlice';
import { systemPersistConfig, systemSlice } from 'features/system/store/systemSlice';
import { uiPersistConfig, uiSlice } from 'features/ui/store/uiSlice';
@ -69,6 +70,7 @@ const allReducers = {
[workflowSettingsSlice.name]: workflowSettingsSlice.reducer,
[api.reducerPath]: api.reducer,
[upscaleSlice.name]: upscaleSlice.reducer,
[stylePresetSlice.name]: stylePresetSlice.reducer,
};
const rootReducer = combineReducers(allReducers);
@ -114,6 +116,7 @@ const persistConfigs: { [key in keyof typeof allReducers]?: PersistConfig } = {
[controlLayersPersistConfig.name]: controlLayersPersistConfig,
[workflowSettingsPersistConfig.name]: workflowSettingsPersistConfig,
[upscalePersistConfig.name]: upscalePersistConfig,
[stylePresetPersistConfig.name]: stylePresetPersistConfig,
};
const unserialize: UnserializeFunction = (data, key) => {
@ -164,8 +167,8 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
reducer: rememberedRootReducer,
middleware: (getDefaultMiddleware) =>
getDefaultMiddleware({
serializableCheck: false,
immutableCheck: false,
serializableCheck: import.meta.env.MODE === 'development',
immutableCheck: import.meta.env.MODE === 'development',
})
.concat(api.middleware)
.concat(dynamicMiddlewares)

View File

@ -71,6 +71,7 @@ export type AppConfig = {
*/
maxUpscaleDimension?: number;
allowPrivateBoards: boolean;
allowPrivateStylePresets: boolean;
disabledTabs: InvokeTabName[];
disabledFeatures: AppFeature[];
disabledSDFeatures: SDFeature[];

View File

@ -47,6 +47,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => {
userSelect: 'none',
opacity: 0.7,
color: 'base.500',
fontSize: 'md',
...sx,
}),
[sx]
@ -55,11 +56,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => {
return (
<Flex sx={styles} {...rest}>
{icon && <Icon as={icon} boxSize={boxSize} opacity={0.7} />}
{props.label && (
<Text textAlign="center" fontSize="md">
{props.label}
</Text>
)}
{props.label && <Text textAlign="center">{props.label}</Text>}
</Flex>
);
});

View File

@ -1,4 +1,4 @@
import { useImageUrlToBlob } from 'common/hooks/useImageUrlToBlob';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import { copyBlobToClipboard } from 'features/system/util/copyBlobToClipboard';
import { toast } from 'features/toast/toast';
import { useCallback, useMemo } from 'react';
@ -6,7 +6,6 @@ import { useTranslation } from 'react-i18next';
export const useCopyImageToClipboard = () => {
const { t } = useTranslation();
const imageUrlToBlob = useImageUrlToBlob();
const isClipboardAPIAvailable = useMemo(() => {
return Boolean(navigator.clipboard) && Boolean(window.ClipboardItem);
@ -23,7 +22,7 @@ export const useCopyImageToClipboard = () => {
});
}
try {
const blob = await imageUrlToBlob(image_url);
const blob = await convertImageUrlToBlob(image_url);
if (!blob) {
throw new Error('Unable to create Blob');
@ -45,7 +44,7 @@ export const useCopyImageToClipboard = () => {
});
}
},
[imageUrlToBlob, isClipboardAPIAvailable, t]
[isClipboardAPIAvailable, t]
);
return { isClipboardAPIAvailable, copyImageToClipboard };

View File

@ -1,40 +0,0 @@
import { $authToken } from 'app/store/nanostores/authToken';
import { useCallback } from 'react';
/**
* Converts an image URL to a Blob by creating an <img /> element, drawing it to canvas
* and then converting the canvas to a Blob.
*
* @returns A function that takes a URL and returns a Promise that resolves with a Blob
*/
export const useImageUrlToBlob = () => {
const imageUrlToBlob = useCallback(
async (url: string) =>
new Promise<Blob | null>((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
context.drawImage(img, 0, 0);
resolve(
new Promise<Blob | null>((resolve) => {
canvas.toBlob(function (blob) {
resolve(blob);
}, 'image/png');
})
);
};
img.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous';
img.src = url;
}),
[]
);
return imageUrlToBlob;
};

View File

@ -0,0 +1,33 @@
import { $authToken } from 'app/store/nanostores/authToken';
/**
* Converts an image URL to a Blob by creating an <img /> element, drawing it to canvas
* and then converting the canvas to a Blob.
*
* @returns A function that takes a URL and returns a Promise that resolves with a Blob
*/
export const convertImageUrlToBlob = async (url: string) =>
new Promise<Blob | null>((resolve) => {
const img = new Image();
img.onload = () => {
const canvas = document.createElement('canvas');
canvas.width = img.width;
canvas.height = img.height;
const context = canvas.getContext('2d');
if (!context) {
return;
}
context.drawImage(img, 0, 0);
resolve(
new Promise<Blob | null>((resolve) => {
canvas.toBlob(function (blob) {
resolve(blob);
}, 'image/png');
})
);
};
img.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous';
img.src = url;
});

View File

@ -30,6 +30,7 @@ import {
PiFlowArrowBold,
PiFoldersBold,
PiImagesBold,
PiPaintBrushBold,
PiPlantBold,
PiQuotesBold,
PiShareFatBold,
@ -55,8 +56,17 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
const { downloadImage } = useDownloadImage();
const templates = useStore($templates);
const { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, isLoadingMetadata } =
useImageActions(imageDTO?.image_name);
const {
recallAll,
remix,
recallSeed,
recallPrompts,
hasMetadata,
hasSeed,
hasPrompts,
isLoadingMetadata,
createAsPreset,
} = useImageActions(imageDTO?.image_name);
const { getAndLoadEmbeddedWorkflow, getAndLoadEmbeddedWorkflowResult } = useGetAndLoadEmbeddedWorkflow({});
@ -182,6 +192,13 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
>
{t('parameters.useAll')}
</MenuItem>
<MenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiPaintBrushBold />}
onClickCapture={createAsPreset}
isDisabled={isLoadingMetadata || !hasPrompts}
>
{t('stylePresets.useForTemplate')}
</MenuItem>
<MenuDivider />
<MenuItem icon={<PiShareFatBold />} onClickCapture={handleSendToImageToImage} id="send-to-img2img">
{t('parameters.sendToImg2Img')}

View File

@ -1,7 +1,10 @@
import { skipToken } from '@reduxjs/toolkit/query';
import { useAppSelector } from 'app/store/storeHooks';
import { handlers, parseAndRecallAllMetadata, parseAndRecallPrompts } from 'features/metadata/util/handlers';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { useCallback, useEffect, useState } from 'react';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
export const useImageActions = (image_name?: string) => {
@ -10,6 +13,7 @@ export const useImageActions = (image_name?: string) => {
const [hasMetadata, setHasMetadata] = useState(false);
const [hasSeed, setHasSeed] = useState(false);
const [hasPrompts, setHasPrompts] = useState(false);
const { data: imageDTO } = useGetImageDTOQuery(image_name ?? skipToken);
useEffect(() => {
const parseMetadata = async () => {
@ -61,5 +65,34 @@ export const useImageActions = (image_name?: string) => {
parseAndRecallPrompts(metadata);
}, [metadata]);
return { recallAll, remix, recallSeed, recallPrompts, hasMetadata, hasSeed, hasPrompts, isLoadingMetadata };
const createAsPreset = useCallback(async () => {
if (image_name && metadata && imageDTO) {
const positivePrompt = await handlers.positivePrompt.parse(metadata);
const negativePrompt = await handlers.negativePrompt.parse(metadata);
$stylePresetModalState.set({
prefilledFormData: {
name: '',
positivePrompt,
negativePrompt,
imageUrl: imageDTO.image_url,
type: 'user',
},
updatingStylePresetId: null,
isModalOpen: true,
});
}
}, [image_name, metadata, imageDTO]);
return {
recallAll,
remix,
recallSeed,
recallPrompts,
hasMetadata,
hasSeed,
hasPrompts,
isLoadingMetadata,
createAsPreset,
};
};

View File

@ -22,11 +22,10 @@ import {
} from './constants';
import { addLoRAs } from './generation/addLoRAs';
import { addSDXLLoRas } from './generation/addSDXLLoRAs';
import { getBoardField, getSDXLStylePrompts } from './graphBuilderUtils';
import { getBoardField, getPresetModifiedPrompts } from './graphBuilderUtils';
export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise<GraphType> => {
const { model, cfgScale: cfg_scale, scheduler, steps, vaePrecision, seed, vae } = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { upscaleModel, upscaleInitialImage, structure, creativity, tileControlnetModel, scale } = state.upscale;
assert(model, 'No model found in state');
@ -99,7 +98,8 @@ export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise
let modelNode;
if (model.base === 'sdxl') {
const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
const { positivePrompt, negativePrompt, positiveStylePrompt, negativeStylePrompt } =
getPresetModifiedPrompts(state);
posCondNode = g.addNode({
type: 'sdxl_compel_prompt',
@ -132,6 +132,8 @@ export const buildMultidiffusionUpscaleGraph = async (state: RootState): Promise
negative_style_prompt: negativeStylePrompt,
});
} else {
const { positivePrompt, negativePrompt } = getPresetModifiedPrompts(state);
posCondNode = g.addNode({
type: 'compel',
id: POSITIVE_CONDITIONING,

View File

@ -16,7 +16,7 @@ import {
SDXL_REFINER_POSITIVE_CONDITIONING,
SDXL_REFINER_SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getSDXLStylePrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import { getPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import type { NonNullableGraph } from 'services/api/types';
import { isRefinerMainModelModelConfig } from 'services/api/types';
@ -59,7 +59,7 @@ export const addSDXLRefinerToGraph = async (
const modelLoaderId = modelLoaderNodeId ? modelLoaderNodeId : SDXL_MODEL_LOADER;
// Construct Style Prompt
const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
const { positiveStylePrompt, negativeStylePrompt } = getPresetModifiedPrompts(state);
// Unplug SDXL Latents Generation To Latents To Image
graph.edges = graph.edges.filter((e) => !(e.source.node_id === baseNodeId && ['latents'].includes(e.source.field)));

View File

@ -16,7 +16,11 @@ import {
POSITIVE_CONDITIONING,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import type { ImageDTO, Invocation, NonNullableGraph } from 'services/api/types';
import { isNonRefinerMainModelConfig } from 'services/api/types';
@ -51,7 +55,6 @@ export const buildCanvasImageToImageGraph = async (
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
// The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions;
@ -71,6 +74,8 @@ export const buildCanvasImageToImageGraph = async (
const use_cpu = shouldUseCpuNoise;
const { positivePrompt, negativePrompt } = getPresetModifiedPrompts(state);
/**
* The easiest way to build linear graphs is to do it in the node editor, then copy and paste the
* full graph here as a template. Then use the parameters from app state and set friendlier node

View File

@ -19,7 +19,11 @@ import {
POSITIVE_CONDITIONING,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import type { ImageDTO, Invocation, NonNullableGraph } from 'services/api/types';
import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
@ -58,7 +62,6 @@ export const buildCanvasInpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
if (!model) {
log.error('No model found in state');
@ -79,6 +82,8 @@ export const buildCanvasInpaintGraph = async (
const use_cpu = shouldUseCpuNoise;
const { positivePrompt, negativePrompt } = getPresetModifiedPrompts(state);
const graph: NonNullableGraph = {
id: CANVAS_INPAINT_GRAPH,
nodes: {

View File

@ -23,7 +23,11 @@ import {
POSITIVE_CONDITIONING,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import type { ImageDTO, Invocation, NonNullableGraph } from 'services/api/types';
import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
@ -70,7 +74,6 @@ export const buildCanvasOutpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
if (!model) {
log.error('No model found in state');
@ -91,6 +94,8 @@ export const buildCanvasOutpaintGraph = async (
const use_cpu = shouldUseCpuNoise;
const { positivePrompt, negativePrompt } = getPresetModifiedPrompts(state);
const graph: NonNullableGraph = {
id: CANVAS_OUTPAINT_GRAPH,
nodes: {

View File

@ -16,7 +16,11 @@ import {
SDXL_REFINER_SEAMLESS,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate, getSDXLStylePrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import type { ImageDTO, Invocation, NonNullableGraph } from 'services/api/types';
import { isNonRefinerMainModelConfig } from 'services/api/types';
@ -51,7 +55,6 @@ export const buildCanvasSDXLImageToImageGraph = async (
seamlessYAxis,
img2imgStrength: strength,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { refinerModel, refinerStart } = state.sdxl;
@ -75,7 +78,7 @@ export const buildCanvasSDXLImageToImageGraph = async (
const use_cpu = shouldUseCpuNoise;
// Construct Style Prompt
const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
const { positivePrompt, negativePrompt, positiveStylePrompt, negativeStylePrompt } = getPresetModifiedPrompts(state);
/**
* The easiest way to build linear graphs is to do it in the node editor, then copy and paste the

View File

@ -19,7 +19,11 @@ import {
SDXL_REFINER_SEAMLESS,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate, getSDXLStylePrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import type { ImageDTO, Invocation, NonNullableGraph } from 'services/api/types';
import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
@ -58,7 +62,6 @@ export const buildCanvasSDXLInpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { refinerModel, refinerStart } = state.sdxl;
@ -83,7 +86,7 @@ export const buildCanvasSDXLInpaintGraph = async (
const use_cpu = shouldUseCpuNoise;
// Construct Style Prompt
const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
const { positivePrompt, negativePrompt, positiveStylePrompt, negativeStylePrompt } = getPresetModifiedPrompts(state);
const graph: NonNullableGraph = {
id: SDXL_CANVAS_INPAINT_GRAPH,

View File

@ -23,7 +23,11 @@ import {
SDXL_REFINER_SEAMLESS,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate, getSDXLStylePrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import type { ImageDTO, Invocation, NonNullableGraph } from 'services/api/types';
import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
@ -70,7 +74,6 @@ export const buildCanvasSDXLOutpaintGraph = async (
canvasCoherenceEdgeSize,
maskBlur,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { refinerModel, refinerStart } = state.sdxl;
@ -94,7 +97,7 @@ export const buildCanvasSDXLOutpaintGraph = async (
const use_cpu = shouldUseCpuNoise;
// Construct Style Prompt
const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
const { positivePrompt, negativePrompt, positiveStylePrompt, negativeStylePrompt } = getPresetModifiedPrompts(state);
const graph: NonNullableGraph = {
id: SDXL_CANVAS_OUTPAINT_GRAPH,

View File

@ -14,7 +14,11 @@ import {
SDXL_REFINER_SEAMLESS,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate, getSDXLStylePrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import { isNonRefinerMainModelConfig, type NonNullableGraph } from 'services/api/types';
import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
@ -44,7 +48,6 @@ export const buildCanvasSDXLTextToImageGraph = async (state: RootState): Promise
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
// The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions;
@ -67,7 +70,7 @@ export const buildCanvasSDXLTextToImageGraph = async (state: RootState): Promise
let modelLoaderNodeId = SDXL_MODEL_LOADER;
// Construct Style Prompt
const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
const { positivePrompt, negativePrompt, positiveStylePrompt, negativeStylePrompt } = getPresetModifiedPrompts(state);
/**
* The easiest way to build linear graphs is to do it in the node editor, then copy and paste the

View File

@ -14,7 +14,11 @@ import {
POSITIVE_CONDITIONING,
SEAMLESS,
} from 'features/nodes/util/graph/constants';
import { getBoardField, getIsIntermediate } from 'features/nodes/util/graph/graphBuilderUtils';
import {
getBoardField,
getIsIntermediate,
getPresetModifiedPrompts,
} from 'features/nodes/util/graph/graphBuilderUtils';
import { isNonRefinerMainModelConfig, type NonNullableGraph } from 'services/api/types';
import { addControlNetToLinearGraph } from './addControlNetToLinearGraph';
@ -44,7 +48,6 @@ export const buildCanvasTextToImageGraph = async (state: RootState): Promise<Non
seamlessXAxis,
seamlessYAxis,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
// The bounding box determines width and height, not the width and height params
const { width, height } = state.canvas.boundingBoxDimensions;
@ -64,6 +67,8 @@ export const buildCanvasTextToImageGraph = async (state: RootState): Promise<Non
let modelLoaderNodeId = MAIN_MODEL_LOADER;
const { positivePrompt, negativePrompt } = getPresetModifiedPrompts(state);
/**
* The easiest way to build linear graphs is to do it in the node editor, then copy and paste the
* full graph here as a template. Then use the parameters from app state and set friendlier node

View File

@ -22,7 +22,7 @@ import { addSeamless } from 'features/nodes/util/graph/generation/addSeamless';
import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker';
import type { GraphType } from 'features/nodes/util/graph/generation/Graph';
import { Graph } from 'features/nodes/util/graph/generation/Graph';
import { getBoardField } from 'features/nodes/util/graph/graphBuilderUtils';
import { getBoardField, getPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import type { Invocation } from 'services/api/types';
import { isNonRefinerMainModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
@ -40,11 +40,12 @@ export const buildGenerationTabGraph = async (state: RootState): Promise<GraphTy
seed,
vae,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { width, height } = state.controlLayers.present.size;
assert(model, 'No model found in state');
const { positivePrompt, negativePrompt } = getPresetModifiedPrompts(state);
const g = new Graph(CONTROL_LAYERS_GRAPH);
const modelLoader = g.addNode({
type: 'main_model_loader',

View File

@ -19,7 +19,7 @@ import { addSDXLRefiner } from 'features/nodes/util/graph/generation/addSDXLRefi
import { addSeamless } from 'features/nodes/util/graph/generation/addSeamless';
import { addWatermarker } from 'features/nodes/util/graph/generation/addWatermarker';
import { Graph } from 'features/nodes/util/graph/generation/Graph';
import { getBoardField, getSDXLStylePrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import { getBoardField, getPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils';
import type { Invocation, NonNullableGraph } from 'services/api/types';
import { isNonRefinerMainModelConfig } from 'services/api/types';
import { assert } from 'tsafe';
@ -36,14 +36,13 @@ export const buildGenerationTabSDXLGraph = async (state: RootState): Promise<Non
vaePrecision,
vae,
} = state.generation;
const { positivePrompt, negativePrompt } = state.controlLayers.present;
const { width, height } = state.controlLayers.present.size;
const { refinerModel, refinerStart } = state.sdxl;
assert(model, 'No model found in state');
const { positiveStylePrompt, negativeStylePrompt } = getSDXLStylePrompts(state);
const { positivePrompt, negativePrompt, positiveStylePrompt, negativeStylePrompt } = getPresetModifiedPrompts(state);
const g = new Graph(SDXL_CONTROL_LAYERS_GRAPH);
const modelLoader = g.addNode({

View File

@ -1,6 +1,8 @@
import type { RootState } from 'app/store/store';
import type { BoardField } from 'features/nodes/types/common';
import { buildPresetModifiedPrompt } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { stylePresetsApi } from 'services/api/endpoints/stylePresets';
/**
* Gets the board field, based on the autoAddBoardId setting.
@ -14,13 +16,43 @@ export const getBoardField = (state: RootState): BoardField | undefined => {
};
/**
* Gets the SDXL style prompts, based on the concat setting.
* Gets the prompts, modified for the active style preset.
*/
export const getSDXLStylePrompts = (state: RootState): { positiveStylePrompt: string; negativeStylePrompt: string } => {
export const getPresetModifiedPrompts = (
state: RootState
): { positivePrompt: string; negativePrompt: string; positiveStylePrompt?: string; negativeStylePrompt?: string } => {
const { positivePrompt, negativePrompt, positivePrompt2, negativePrompt2, shouldConcatPrompts } =
state.controlLayers.present;
const { activeStylePresetId } = state.stylePreset;
if (activeStylePresetId) {
const { data } = stylePresetsApi.endpoints.listStylePresets.select()(state);
const activeStylePreset = data?.find((item) => item.id === activeStylePresetId);
if (activeStylePreset) {
const presetModifiedPositivePrompt = buildPresetModifiedPrompt(
activeStylePreset.preset_data.positive_prompt,
positivePrompt
);
const presetModifiedNegativePrompt = buildPresetModifiedPrompt(
activeStylePreset.preset_data.negative_prompt,
negativePrompt
);
return {
positivePrompt: presetModifiedPositivePrompt,
negativePrompt: presetModifiedNegativePrompt,
positiveStylePrompt: shouldConcatPrompts ? presetModifiedPositivePrompt : positivePrompt2,
negativeStylePrompt: shouldConcatPrompts ? presetModifiedNegativePrompt : negativePrompt2,
};
}
}
return {
positivePrompt,
negativePrompt,
positiveStylePrompt: shouldConcatPrompts ? positivePrompt : positivePrompt2,
negativeStylePrompt: shouldConcatPrompts ? negativePrompt : negativePrompt2,
};

View File

@ -1,16 +1,32 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { negativePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { ViewModePrompt } from 'features/parameters/components/Prompts/ViewModePrompt';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
export const ParamNegativePrompt = memo(() => {
const dispatch = useAppDispatch();
const prompt = useAppSelector((s) => s.controlLayers.present.negativePrompt);
const viewMode = useAppSelector((s) => s.stylePreset.viewMode);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
const _onChange = useCallback(
@ -27,22 +43,34 @@ export const ParamNegativePrompt = memo(() => {
return (
<PromptPopover isOpen={isOpen} onClose={onClose} onSelect={onSelect} width={textareaRef.current?.clientWidth}>
<Box pos="relative">
<Box pos="relative" w="full">
<Textarea
id="negativePrompt"
name="negativePrompt"
ref={textareaRef}
value={prompt}
placeholder={t('parameters.globalNegativePromptPlaceholder')}
onChange={onChange}
onKeyDown={onKeyDown}
fontSize="sm"
variant="darkFilled"
paddingRight={30}
minH={28}
borderTopWidth={24} // This prevents the prompt from being hidden behind the header
paddingInlineEnd={10}
paddingInlineStart={3}
paddingTop={0}
paddingBottom={3}
/>
<PromptOverlayButtonWrapper>
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
<PromptLabel label={t('parameters.negativePromptPlaceholder')} />
{viewMode && (
<ViewModePrompt
prompt={prompt}
presetPrompt={activeStylePreset?.preset_data.negative_prompt || ''}
label={`${t('parameters.negativePromptPlaceholder')} (${t('stylePresets.preview')})`}
/>
)}
</Box>
</PromptPopover>
);

View File

@ -2,7 +2,9 @@ import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { positivePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import { ShowDynamicPromptsPreviewButton } from 'features/dynamicPrompts/components/ShowDynamicPromptsPreviewButton';
import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { ViewModePrompt } from 'features/parameters/components/Prompts/ViewModePrompt';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
import { usePrompt } from 'features/prompt/usePrompt';
@ -11,11 +13,24 @@ import { memo, useCallback, useRef } from 'react';
import type { HotkeyCallback } from 'react-hotkeys-hook';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
export const ParamPositivePrompt = memo(() => {
const dispatch = useAppDispatch();
const prompt = useAppSelector((s) => s.controlLayers.present.positivePrompt);
const baseModel = useAppSelector((s) => s.generation.model)?.base;
const viewMode = useAppSelector((s) => s.stylePreset.viewMode);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
@ -49,18 +64,29 @@ export const ParamPositivePrompt = memo(() => {
name="prompt"
ref={textareaRef}
value={prompt}
placeholder={t('parameters.globalPositivePromptPlaceholder')}
onChange={onChange}
minH={28}
minH={40}
onKeyDown={onKeyDown}
variant="darkFilled"
paddingRight={30}
borderTopWidth={24} // This prevents the prompt from being hidden behind the header
paddingInlineEnd={10}
paddingInlineStart={3}
paddingTop={0}
paddingBottom={3}
/>
<PromptOverlayButtonWrapper>
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
{baseModel === 'sdxl' && <SDXLConcatButton />}
<ShowDynamicPromptsPreviewButton />
</PromptOverlayButtonWrapper>
<PromptLabel label={t('parameters.positivePromptPlaceholder')} />
{viewMode && (
<ViewModePrompt
prompt={prompt}
presetPrompt={activeStylePreset?.preset_data.positive_prompt || ''}
label={`${t('parameters.positivePromptPlaceholder')} (${t('stylePresets.preview')})`}
/>
)}
</Box>
</PromptPopover>
);

View File

@ -0,0 +1,9 @@
import { Text } from '@invoke-ai/ui-library';
export const PromptLabel = ({ label }: { label: string }) => {
return (
<Text variant="subtext" fontWeight="semibold" pos="absolute" top={1} left={2}>
{label}
</Text>
);
};

View File

@ -1,13 +1,29 @@
import { Flex } from '@invoke-ai/ui-library';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectControlLayersSlice } from 'features/controlLayers/store/controlLayersSlice';
import { ParamNegativePrompt } from 'features/parameters/components/Core/ParamNegativePrompt';
import { ParamPositivePrompt } from 'features/parameters/components/Core/ParamPositivePrompt';
import { selectGenerationSlice } from 'features/parameters/store/generationSlice';
import { ParamSDXLNegativeStylePrompt } from 'features/sdxl/components/SDXLPrompts/ParamSDXLNegativeStylePrompt';
import { ParamSDXLPositiveStylePrompt } from 'features/sdxl/components/SDXLPrompts/ParamSDXLPositiveStylePrompt';
import { memo } from 'react';
const concatPromptsSelector = createSelector(
[selectGenerationSlice, selectControlLayersSlice],
(generation, controlLayers) => {
return generation.model?.base !== 'sdxl' || controlLayers.present.shouldConcatPrompts;
}
);
export const Prompts = memo(() => {
const shouldConcatPrompts = useAppSelector(concatPromptsSelector);
return (
<Flex flexDir="column" gap={2}>
<ParamPositivePrompt />
{!shouldConcatPrompts && <ParamSDXLPositiveStylePrompt />}
<ParamNegativePrompt />
{!shouldConcatPrompts && <ParamSDXLNegativeStylePrompt />}
</Flex>
);
});

View File

@ -0,0 +1,81 @@
import { Box, Flex, Icon, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { viewModeChanged } from 'features/stylePresets/store/stylePresetSlice';
import { getViewModeChunks } from 'features/stylePresets/util/getViewModeChunks';
import { useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold } from 'react-icons/pi';
import { PromptLabel } from './PromptLabel';
export const ViewModePrompt = ({
presetPrompt,
prompt,
label,
}: {
presetPrompt: string;
prompt: string;
label: string;
}) => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const presetChunks = useMemo(() => {
return getViewModeChunks(prompt, presetPrompt);
}, [presetPrompt, prompt]);
const handleExitViewMode = useCallback(() => {
dispatch(viewModeChanged(false));
}, [dispatch]);
return (
<Box position="absolute" top={0} bottom={0} left={0} right={0} layerStyle="second" borderRadius="base">
<Flex
flexDir="column"
onClick={handleExitViewMode}
justifyContent="space-between"
h="full"
borderWidth={1}
borderTopWidth={24} // This prevents the prompt from being hidden behind the header
borderColor="transparent"
paddingInlineEnd={10}
paddingInlineStart={3}
paddingTop={0}
paddingBottom={3}
>
<PromptLabel label={label} />
<Flex overflow="scroll">
<Text w="full" lineHeight="short">
{presetChunks.map((chunk, index) => (
<Text
as="span"
color={index === 1 ? 'white' : 'base.200'}
fontWeight={index === 1 ? 'semibold' : 'normal'}
key={index}
>
{chunk}
</Text>
))}
</Text>
</Flex>
<Tooltip label={t('stylePresets.viewModeTooltip')}>
<Flex
position="absolute"
insetInlineEnd={0}
insetBlockStart={0}
alignItems="center"
justifyContent="center"
p={2}
bg="base.900"
opacity={0.8}
backgroundClip="border-box"
borderBottomStartRadius="base"
>
<Icon as={PiEyeBold} color="base.500" boxSize={4} />
</Flex>
</Tooltip>
</Flex>
</Box>
);
};

View File

@ -1,6 +1,7 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { negativePrompt2Changed } from 'features/controlLayers/store/controlLayersSlice';
import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
@ -36,16 +37,21 @@ export const ParamSDXLNegativeStylePrompt = memo(() => {
name="prompt"
ref={textareaRef}
value={prompt}
placeholder={t('sdxl.negStylePrompt')}
onChange={onChange}
onKeyDown={onKeyDown}
fontSize="sm"
variant="darkFilled"
paddingRight={30}
minH={24}
borderTopWidth={24} // This prevents the prompt from being hidden behind the header
paddingInlineEnd={10}
paddingInlineStart={3}
paddingTop={0}
paddingBottom={3}
/>
<PromptOverlayButtonWrapper>
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
<PromptLabel label={t('sdxl.negStylePrompt')} />
</Box>
</PromptPopover>
);

View File

@ -1,6 +1,7 @@
import { Box, Textarea } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { positivePrompt2Changed } from 'features/controlLayers/store/controlLayersSlice';
import { PromptLabel } from 'features/parameters/components/Prompts/PromptLabel';
import { PromptOverlayButtonWrapper } from 'features/parameters/components/Prompts/PromptOverlayButtonWrapper';
import { AddPromptTriggerButton } from 'features/prompt/AddPromptTriggerButton';
import { PromptPopover } from 'features/prompt/PromptPopover';
@ -33,16 +34,21 @@ export const ParamSDXLPositiveStylePrompt = memo(() => {
name="prompt"
ref={textareaRef}
value={prompt}
placeholder={t('sdxl.posStylePrompt')}
onChange={onChange}
onKeyDown={onKeyDown}
fontSize="sm"
variant="darkFilled"
paddingRight={30}
minH={24}
borderTopWidth={24} // This prevents the prompt from being hidden behind the header
paddingInlineEnd={10}
paddingInlineStart={3}
paddingTop={0}
paddingBottom={3}
/>
<PromptOverlayButtonWrapper>
<AddPromptTriggerButton isOpen={isOpen} onOpen={onOpen} />
</PromptOverlayButtonWrapper>
<PromptLabel label={t('sdxl.posStylePrompt')} />
</Box>
</PromptPopover>
);

View File

@ -1,20 +0,0 @@
import type { Meta, StoryObj } from '@storybook/react';
import { SDXLPrompts } from './SDXLPrompts';
const meta: Meta<typeof SDXLPrompts> = {
title: 'Feature/Prompt/SDXLPrompts',
tags: ['autodocs'],
component: SDXLPrompts,
};
export default meta;
type Story = StoryObj<typeof SDXLPrompts>;
const Component = () => {
return <SDXLPrompts />;
};
export const Default: Story = {
render: Component,
};

View File

@ -1,22 +0,0 @@
import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { ParamNegativePrompt } from 'features/parameters/components/Core/ParamNegativePrompt';
import { ParamPositivePrompt } from 'features/parameters/components/Core/ParamPositivePrompt';
import { memo } from 'react';
import { ParamSDXLNegativeStylePrompt } from './ParamSDXLNegativeStylePrompt';
import { ParamSDXLPositiveStylePrompt } from './ParamSDXLPositiveStylePrompt';
export const SDXLPrompts = memo(() => {
const shouldConcatPrompts = useAppSelector((s) => s.controlLayers.present.shouldConcatPrompts);
return (
<Flex flexDir="column" gap={2} pos="relative">
<ParamPositivePrompt />
{!shouldConcatPrompts && <ParamSDXLPositiveStylePrompt />}
<ParamNegativePrompt />
{!shouldConcatPrompts && <ParamSDXLNegativeStylePrompt />}
</Flex>
);
});
SDXLPrompts.displayName = 'SDXLPrompts';

View File

@ -0,0 +1,113 @@
import { Badge, Flex, IconButton, Text, Tooltip } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { negativePromptChanged, positivePromptChanged } from 'features/controlLayers/store/controlLayersSlice';
import { usePresetModifiedPrompts } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
import { activeStylePresetIdChanged, viewModeChanged } from 'features/stylePresets/store/stylePresetSlice';
import type { MouseEventHandler } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiEyeBold, PiStackSimpleBold, PiXBold } from 'react-icons/pi';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
import StylePresetImage from './StylePresetImage';
export const ActiveStylePreset = () => {
const viewMode = useAppSelector((s) => s.stylePreset.viewMode);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { presetModifiedPositivePrompt, presetModifiedNegativePrompt } = usePresetModifiedPrompts();
const handleClearActiveStylePreset = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(viewModeChanged(false));
dispatch(activeStylePresetIdChanged(null));
},
[dispatch]
);
const handleFlattenPrompts = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(positivePromptChanged(presetModifiedPositivePrompt));
dispatch(negativePromptChanged(presetModifiedNegativePrompt));
dispatch(viewModeChanged(false));
dispatch(activeStylePresetIdChanged(null));
},
[dispatch, presetModifiedPositivePrompt, presetModifiedNegativePrompt]
);
const handleToggleViewMode = useCallback<MouseEventHandler<HTMLButtonElement>>(
(e) => {
e.stopPropagation();
dispatch(viewModeChanged(!viewMode));
},
[dispatch, viewMode]
);
if (!activeStylePreset) {
return (
<Flex h={25} alignItems="center">
<Text fontSize="sm" fontWeight="semibold" color="base.300">
{t('stylePresets.choosePromptTemplate')}
</Text>
</Flex>
);
}
return (
<Flex justifyContent="space-between" w="full" alignItems="center">
<Flex gap={2} alignItems="center">
<StylePresetImage imageWidth={25} presetImageUrl={activeStylePreset.image} />
<Flex flexDir="column">
<Badge colorScheme="invokeBlue" variant="subtle">
{activeStylePreset.name}
</Badge>
</Flex>
</Flex>
<Flex gap={1}>
<Tooltip label={t('stylePresets.toggleViewMode')}>
<IconButton
onClick={handleToggleViewMode}
variant="outline"
size="sm"
aria-label={t('stylePresets.toggleViewMode')}
colorScheme={viewMode ? 'invokeBlue' : 'base'}
icon={<PiEyeBold />}
/>
</Tooltip>
<Tooltip label={t('stylePresets.flatten')}>
<IconButton
onClick={handleFlattenPrompts}
variant="outline"
size="sm"
aria-label={t('stylePresets.flatten')}
icon={<PiStackSimpleBold />}
/>
</Tooltip>
<Tooltip label={t('stylePresets.clearTemplateSelection')}>
<IconButton
onClick={handleClearActiveStylePreset}
variant="outline"
size="sm"
aria-label={t('stylePresets.clearTemplateSelection')}
icon={<PiXBold />}
/>
</Tooltip>
</Flex>
</Flex>
);
};

View File

@ -0,0 +1,30 @@
import { IconButton } from '@invoke-ai/ui-library';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
export const StylePresetCreateButton = () => {
const handleClickAddNew = useCallback(() => {
$stylePresetModalState.set({
prefilledFormData: null,
updatingStylePresetId: null,
isModalOpen: true,
});
}, []);
const { t } = useTranslation();
return (
<IconButton
icon={<PiPlusBold />}
tooltip={t('stylePresets.createPromptTemplate')}
aria-label={t('stylePresets.createPromptTemplate')}
onClick={handleClickAddNew}
size="md"
variant="ghost"
w={8}
h={8}
/>
);
};

View File

@ -0,0 +1,68 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { IconButton, spinAnimation } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiDownloadSimpleBold, PiSpinner } from 'react-icons/pi';
import { useLazyExportStylePresetsQuery, useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
const loadingStyles: SystemStyleObject = {
svg: { animation: spinAnimation },
};
export const StylePresetExportButton = () => {
const [exportStylePresets, { isLoading }] = useLazyExportStylePresetsQuery();
const { t } = useTranslation();
const { presetCount } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
const userPresets = data?.filter((preset) => preset.type === 'user') ?? EMPTY_ARRAY;
return {
presetCount: userPresets.length,
};
},
});
const handleClickDownloadCsv = useCallback(async () => {
let blob;
try {
const response = await exportStylePresets().unwrap();
blob = new Blob([response], { type: 'text/csv' });
} catch (error) {
toast({
status: 'error',
title: t('stylePresets.exportFailed'),
});
return;
}
if (blob) {
const url = window.URL.createObjectURL(blob);
const a = document.createElement('a');
a.href = url;
a.download = 'data.csv';
document.body.appendChild(a);
a.click();
document.body.removeChild(a);
window.URL.revokeObjectURL(url);
toast({
status: 'success',
title: t('stylePresets.exportDownloaded'),
});
}
}, [exportStylePresets, t]);
return (
<IconButton
onClick={handleClickDownloadCsv}
icon={!isLoading ? <PiDownloadSimpleBold /> : <PiSpinner />}
tooltip={t('stylePresets.exportPromptTemplates')}
aria-label={t('stylePresets.exportPromptTemplates')}
size="md"
variant="link"
w={8}
h={8}
sx={isLoading ? loadingStyles : undefined}
isDisabled={isLoading || presetCount === 0}
/>
);
};

View File

@ -0,0 +1,124 @@
import { Box, Button, Flex, FormControl, FormLabel, Input, Spacer, Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { PRESET_PLACEHOLDER } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { toast } from 'features/toast/toast';
import type { PropsWithChildren } from 'react';
import { useCallback } from 'react';
import type { SubmitHandler } from 'react-hook-form';
import { useForm } from 'react-hook-form';
import { Trans, useTranslation } from 'react-i18next';
import type { PresetType } from 'services/api/endpoints/stylePresets';
import { useCreateStylePresetMutation, useUpdateStylePresetMutation } from 'services/api/endpoints/stylePresets';
import { StylePresetImageField } from './StylePresetImageField';
import { StylePresetPromptField } from './StylePresetPromptField';
import { StylePresetTypeField } from './StylePresetTypeField';
export type StylePresetFormData = {
name: string;
positivePrompt: string;
negativePrompt: string;
image: File | null;
type: PresetType;
};
export const StylePresetForm = ({
updatingStylePresetId,
formData,
}: {
updatingStylePresetId: string | null;
formData: StylePresetFormData | null;
}) => {
const [createStylePreset] = useCreateStylePresetMutation();
const [updateStylePreset] = useUpdateStylePresetMutation();
const { t } = useTranslation();
const allowPrivateStylePresets = useAppSelector((s) => s.config.allowPrivateStylePresets);
const { handleSubmit, control, register, formState } = useForm<StylePresetFormData>({
defaultValues: formData || {
name: '',
positivePrompt: '',
negativePrompt: '',
image: null,
type: 'user',
},
mode: 'onChange',
});
const handleClickSave = useCallback<SubmitHandler<StylePresetFormData>>(
async (data) => {
const payload = {
data: {
name: data.name,
positive_prompt: data.positivePrompt,
negative_prompt: data.negativePrompt,
type: data.type,
},
image: data.image,
};
try {
if (updatingStylePresetId) {
await updateStylePreset({
id: updatingStylePresetId,
...payload,
}).unwrap();
} else {
await createStylePreset(payload).unwrap();
}
} catch (error) {
toast({
status: 'error',
title: 'Failed to save style preset',
});
}
$stylePresetModalState.set({
prefilledFormData: null,
updatingStylePresetId: null,
isModalOpen: false,
});
},
[updatingStylePresetId, updateStylePreset, createStylePreset]
);
return (
<Flex flexDir="column" gap={4}>
<Flex alignItems="center" gap={4}>
<StylePresetImageField control={control} name="image" />
<FormControl orientation="vertical">
<FormLabel>{t('stylePresets.name')}</FormLabel>
<Input size="md" {...register('name', { required: true, minLength: 1 })} />
</FormControl>
</Flex>
<StylePresetPromptField label="Positive Prompt" control={control} name="positivePrompt" />
<StylePresetPromptField label="Negative Prompt" control={control} name="negativePrompt" />
<Box>
<Text variant="subtext">{t('stylePresets.promptTemplatesDesc1')}</Text>
<Text variant="subtext">
<Trans
i18nKey="stylePresets.promptTemplatesDesc2"
components={{ Pre: <Pre /> }}
values={{ placeholder: PRESET_PLACEHOLDER }}
/>
</Text>
<Text variant="subtext">{t('stylePresets.promptTemplatesDesc3')}</Text>
</Box>
<Flex justifyContent="space-between" alignItems="flex-end" gap={10}>
{allowPrivateStylePresets ? <StylePresetTypeField control={control} name="type" /> : <Spacer />}
<Button onClick={handleSubmit(handleClickSave)} isDisabled={!formState.isValid}>
{t('common.save')}
</Button>
</Flex>
</Flex>
);
};
const Pre = (props: PropsWithChildren) => (
<Text as="span" fontFamily="monospace" fontWeight="semibold">
{props.children}
</Text>
);

View File

@ -0,0 +1,82 @@
import { Box, Button, Flex, Icon, IconButton, Image, Tooltip } from '@invoke-ai/ui-library';
import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import type { UseControllerProps } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold, PiUploadSimpleBold } from 'react-icons/pi';
import type { StylePresetFormData } from './StylePresetForm';
export const StylePresetImageField = (props: UseControllerProps<StylePresetFormData, 'image'>) => {
const { field } = useController(props);
const { t } = useTranslation();
const onDropAccepted = useCallback(
(files: File[]) => {
const file = files[0];
if (file) {
field.onChange(file);
}
},
[field]
);
const handleResetImage = useCallback(() => {
field.onChange(null);
}, [field]);
const { getInputProps, getRootProps } = useDropzone({
accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] },
onDropAccepted,
noDrag: true,
multiple: false,
});
if (field.value) {
return (
<Box position="relative" flexShrink={0}>
<Image
src={URL.createObjectURL(field.value)}
objectFit="cover"
objectPosition="50% 50%"
w={65}
h={65}
minWidth={65}
borderRadius="base"
/>
<IconButton
position="absolute"
insetInlineEnd={0}
insetBlockStart={0}
onClick={handleResetImage}
aria-label={t('stylePresets.deleteImage')}
tooltip={t('stylePresets.deleteImage')}
icon={<PiArrowCounterClockwiseBold />}
size="md"
variant="ghost"
/>
</Box>
);
}
return (
<>
<Tooltip label={t('stylePresets.uploadImage')}>
<Flex
as={Button}
w={65}
h={65}
opacity={0.3}
borderRadius="base"
alignItems="center"
justifyContent="center"
flexShrink={0}
{...getRootProps()}
>
<Icon as={PiUploadSimpleBold} w={8} h={8} />
</Flex>
</Tooltip>
<input {...getInputProps()} />
</>
);
};

View File

@ -0,0 +1,82 @@
import {
Modal,
ModalBody,
ModalCloseButton,
ModalContent,
ModalFooter,
ModalHeader,
ModalOverlay,
Spinner,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob';
import type { PrefilledFormData } from 'features/stylePresets/store/stylePresetModal';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { useCallback, useEffect, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import type { StylePresetFormData } from './StylePresetForm';
import { StylePresetForm } from './StylePresetForm';
export const StylePresetModal = () => {
const [formData, setFormData] = useState<StylePresetFormData | null>(null);
const { t } = useTranslation();
const stylePresetModalState = useStore($stylePresetModalState);
const modalTitle = useMemo(() => {
return stylePresetModalState.updatingStylePresetId
? t('stylePresets.updatePromptTemplate')
: t('stylePresets.createPromptTemplate');
}, [stylePresetModalState.updatingStylePresetId, t]);
const handleCloseModal = useCallback(() => {
$stylePresetModalState.set({
prefilledFormData: null,
updatingStylePresetId: null,
isModalOpen: false,
});
}, []);
useEffect(() => {
setFormData(null);
}, []);
useEffect(() => {
const convertImageToBlob = async (data: PrefilledFormData | null) => {
if (!data) {
setFormData(null);
} else {
let file = null;
if (data.imageUrl) {
const blob = await convertImageUrlToBlob(data.imageUrl);
if (blob) {
file = new File([blob], 'style_preset.png', { type: 'image/png' });
}
}
setFormData({
...data,
image: file,
});
}
};
convertImageToBlob(stylePresetModalState.prefilledFormData);
}, [stylePresetModalState.prefilledFormData]);
return (
<Modal isOpen={stylePresetModalState.isModalOpen} onClose={handleCloseModal} isCentered size="2xl">
<ModalOverlay />
<ModalContent>
<ModalHeader>{modalTitle}</ModalHeader>
<ModalCloseButton />
<ModalBody display="flex" flexDir="column" gap={4}>
{!stylePresetModalState.prefilledFormData || formData ? (
<StylePresetForm updatingStylePresetId={stylePresetModalState.updatingStylePresetId} formData={formData} />
) : (
<Spinner />
)}
</ModalBody>
<ModalFooter p={2} />
</ModalContent>
</Modal>
);
};

View File

@ -0,0 +1,65 @@
import { Button, Flex, FormControl, FormLabel, Textarea } from '@invoke-ai/ui-library';
import { PRESET_PLACEHOLDER } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
import type { ChangeEventHandler } from 'react';
import { useCallback, useMemo, useRef } from 'react';
import type { UseControllerProps } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { StylePresetFormData } from './StylePresetForm';
interface Props extends UseControllerProps<StylePresetFormData, 'negativePrompt' | 'positivePrompt'> {
label: string;
}
export const StylePresetPromptField = (props: Props) => {
const { field } = useController(props);
const textareaRef = useRef<HTMLTextAreaElement>(null);
const { t } = useTranslation();
const onChange = useCallback<ChangeEventHandler<HTMLTextAreaElement>>(
(v) => {
field.onChange(v.target.value);
},
[field]
);
const value = useMemo(() => {
return field.value;
}, [field.value]);
const insertPromptPlaceholder = useCallback(() => {
if (textareaRef.current) {
const cursorPos = textareaRef.current.selectionStart;
const textBeforeCursor = value.slice(0, cursorPos);
const textAfterCursor = value.slice(cursorPos);
const newValue = textBeforeCursor + PRESET_PLACEHOLDER + textAfterCursor;
field.onChange(newValue);
} else {
field.onChange(value + PRESET_PLACEHOLDER);
}
textareaRef.current?.focus();
}, [value, field, textareaRef]);
const isPromptPresent = useMemo(() => value?.includes(PRESET_PLACEHOLDER), [value]);
return (
<FormControl orientation="vertical" gap={3}>
<Flex alignItems="center" gap={2}>
<FormLabel>{props.label}</FormLabel>
<Button
onClick={insertPromptPlaceholder}
size="xs"
aria-label={t('stylePresets.insertPlaceholder')}
isDisabled={isPromptPresent}
>
{t('stylePresets.insertPlaceholder')}
</Button>
</Flex>
<Textarea size="sm" ref={textareaRef} value={value} onChange={onChange} />
</FormControl>
);
};

View File

@ -0,0 +1,46 @@
import type { ComboboxOnChange } from '@invoke-ai/ui-library';
import { Combobox, FormControl, FormLabel } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { t } from 'i18next';
import { useCallback, useMemo } from 'react';
import type { UseControllerProps } from 'react-hook-form';
import { useController } from 'react-hook-form';
import { useTranslation } from 'react-i18next';
import type { StylePresetFormData } from './StylePresetForm';
const OPTIONS = [
{ label: t('stylePresets.private'), value: 'user' },
{ label: t('stylePresets.shared'), value: 'project' },
];
export const StylePresetTypeField = (props: UseControllerProps<StylePresetFormData, 'type'>) => {
const { field } = useController(props);
const stylePresetModalState = useStore($stylePresetModalState);
const { t } = useTranslation();
const onChange = useCallback<ComboboxOnChange>(
(v) => {
if (v) {
field.onChange(v.value);
}
},
[field]
);
const value = useMemo(() => {
return OPTIONS.find((opt) => opt.value === field.value);
}, [field.value]);
return (
<FormControl
orientation="vertical"
maxW={48}
isDisabled={stylePresetModalState.prefilledFormData?.type === 'project'}
>
<FormLabel>{t('stylePresets.type')}</FormLabel>
<Combobox value={value} options={OPTIONS} onChange={onChange} />
</FormControl>
);
};

View File

@ -0,0 +1,52 @@
import { Flex, Icon, Image, Tooltip } from '@invoke-ai/ui-library';
import { typedMemo } from 'common/util/typedMemo';
import { PiImage } from 'react-icons/pi';
const IMAGE_THUMBNAIL_SIZE = '40px';
const FALLBACK_ICON_SIZE = '24px';
const StylePresetImage = ({ presetImageUrl, imageWidth }: { presetImageUrl: string | null; imageWidth?: number }) => {
return (
<Tooltip
label={
presetImageUrl && (
<Image
src={presetImageUrl}
draggable={false}
objectFit="cover"
maxW={150}
aspectRatio="1/1"
borderRadius="base"
borderBottomRadius="lg"
/>
)
}
>
<Image
src={presetImageUrl || ''}
fallbackStrategy="beforeLoadOrError"
fallback={
<Flex
height={imageWidth || IMAGE_THUMBNAIL_SIZE}
minWidth={imageWidth || IMAGE_THUMBNAIL_SIZE}
bg="base.650"
borderRadius="base"
alignItems="center"
justifyContent="center"
>
<Icon color="base.500" as={PiImage} boxSize={imageWidth ? imageWidth / 2 : FALLBACK_ICON_SIZE} />
</Flex>
}
objectFit="cover"
objectPosition="50% 50%"
height={imageWidth || IMAGE_THUMBNAIL_SIZE}
width={imageWidth || IMAGE_THUMBNAIL_SIZE}
minHeight={imageWidth || IMAGE_THUMBNAIL_SIZE}
minWidth={imageWidth || IMAGE_THUMBNAIL_SIZE}
borderRadius="base"
/>
</Tooltip>
);
};
export default typedMemo(StylePresetImage);

View File

@ -0,0 +1,84 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, IconButton, ListItem, spinAnimation, Text, UnorderedList } from '@invoke-ai/ui-library';
import { toast } from 'features/toast/toast';
import { useCallback } from 'react';
import { useDropzone } from 'react-dropzone';
import { useTranslation } from 'react-i18next';
import { PiSpinner, PiUploadSimpleBold } from 'react-icons/pi';
import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets';
const loadingStyles: SystemStyleObject = {
svg: { animation: spinAnimation },
};
export const StylePresetImportButton = () => {
const [importStylePresets, { isLoading }] = useImportStylePresetsMutation();
const { t } = useTranslation();
const onDropAccepted = useCallback(
(files: File[]) => {
const file = files[0];
if (!file) {
return;
}
importStylePresets(file)
.unwrap()
.then(() => {
toast({
status: 'success',
title: t('toast.importSuccessful'),
});
})
.catch((error) => {
toast({
status: 'error',
title: t('toast.importFailed'),
description: error ? `${error.data.detail}` : undefined,
});
});
},
[importStylePresets, t]
);
const { getInputProps, getRootProps } = useDropzone({
accept: { 'text/csv': ['.csv'], 'application/json': ['.json'] },
onDropAccepted,
noDrag: true,
multiple: false,
});
return (
<>
<IconButton
icon={!isLoading ? <PiUploadSimpleBold /> : <PiSpinner />}
tooltip={<TooltipContent />}
aria-label={t('stylePresets.importTemplates')}
size="md"
variant="link"
w={8}
h={8}
sx={isLoading ? loadingStyles : undefined}
isDisabled={isLoading}
{...getRootProps()}
/>
<input {...getInputProps()} />
</>
);
};
const TooltipContent = () => {
const { t } = useTranslation();
return (
<Flex flexDir="column">
<Text pb={1} fontWeight="semibold">
{t('stylePresets.importTemplates')}
</Text>
<Text>{t('stylePresets.acceptedColumnsKeys')}</Text>
<UnorderedList>
<ListItem>{t('stylePresets.nameColumn')}</ListItem>
<ListItem>{t('stylePresets.positivePromptColumn')}</ListItem>
<ListItem>{t('stylePresets.negativePromptColumn')}</ListItem>
</UnorderedList>
</Flex>
);
};

View File

@ -0,0 +1,39 @@
import { Button, Collapse, Flex, Icon, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets';
import { StylePresetListItem } from './StylePresetListItem';
export const StylePresetList = ({ title, data }: { title: string; data: StylePresetRecordWithImage[] }) => {
const { t } = useTranslation();
const { onToggle, isOpen } = useDisclosure({ defaultIsOpen: true });
const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm);
return (
<Flex flexDir="column">
<Button variant="unstyled" onClick={onToggle}>
<Flex gap={2} alignItems="center">
<Icon boxSize={4} as={PiCaretDownBold} transform={isOpen ? undefined : 'rotate(-90deg)'} fill="base.500" />
<Text fontSize="sm" fontWeight="semibold" userSelect="none" color="base.500">
{title}
</Text>
</Flex>
</Button>
<Collapse in={isOpen}>
{data.length ? (
data.map((preset) => <StylePresetListItem preset={preset} key={preset.id} />)
) : (
<IAINoContentFallback
fontSize="sm"
py={4}
label={searchTerm ? t('stylePresets.noMatchingTemplates') : t('stylePresets.noTemplates')}
icon={null}
/>
)}
</Collapse>
</Flex>
);
};

View File

@ -0,0 +1,183 @@
import { Badge, ConfirmationAlertDialog, Flex, IconButton, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $isMenuOpen } from 'features/stylePresets/store/isMenuOpen';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { activeStylePresetIdChanged } from 'features/stylePresets/store/stylePresetSlice';
import { toast } from 'features/toast/toast';
import type { MouseEvent } from 'react';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold, PiPencilBold, PiTrashBold } from 'react-icons/pi';
import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets';
import { useDeleteStylePresetMutation } from 'services/api/endpoints/stylePresets';
import StylePresetImage from './StylePresetImage';
export const StylePresetListItem = ({ preset }: { preset: StylePresetRecordWithImage }) => {
const dispatch = useAppDispatch();
const [deleteStylePreset] = useDeleteStylePresetMutation();
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { isOpen, onOpen, onClose } = useDisclosure();
const { t } = useTranslation();
const handleClickEdit = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const { name, preset_data } = preset;
const { positive_prompt, negative_prompt } = preset_data;
$stylePresetModalState.set({
prefilledFormData: {
name,
positivePrompt: positive_prompt || '',
negativePrompt: negative_prompt || '',
imageUrl: preset.image,
type: preset.type,
},
updatingStylePresetId: preset.id,
isModalOpen: true,
});
},
[preset]
);
const handleClickApply = useCallback(async () => {
dispatch(activeStylePresetIdChanged(preset.id));
$isMenuOpen.set(false);
}, [dispatch, preset.id]);
const handleClickDelete = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
onOpen();
},
[onOpen]
);
const handleClickCopy = useCallback(
(e: MouseEvent<HTMLButtonElement>) => {
e.stopPropagation();
const { name, preset_data } = preset;
const { positive_prompt, negative_prompt } = preset_data;
$stylePresetModalState.set({
prefilledFormData: {
name: `${name} (${t('common.copy')})`,
positivePrompt: positive_prompt || '',
negativePrompt: negative_prompt || '',
imageUrl: preset.image,
type: 'user',
},
updatingStylePresetId: null,
isModalOpen: true,
});
},
[preset, t]
);
const handleDeletePreset = useCallback(async () => {
try {
await deleteStylePreset(preset.id);
toast({
status: 'success',
title: t('stylePresets.templateDeleted'),
});
} catch (error) {
toast({
status: 'error',
title: t('stylePresets.unableToDeleteTemplate'),
});
}
}, [preset, t, deleteStylePreset]);
return (
<>
<Flex
gap={4}
onClick={handleClickApply}
cursor="pointer"
_hover={{ backgroundColor: 'base.750' }}
py={3}
px={2}
borderRadius="base"
alignItems="flex-start"
w="full"
>
<StylePresetImage presetImageUrl={preset.image} />
<Flex flexDir="column" w="full">
<Flex w="full" justifyContent="space-between" alignItems="flex-start">
<Flex alignItems="center" gap={2}>
<Text fontSize="md" noOfLines={2}>
{preset.name}
</Text>
{activeStylePresetId === preset.id && (
<Badge
color="invokeBlue.400"
borderColor="invokeBlue.700"
borderWidth={1}
bg="transparent"
flexShrink={0}
>
{t('stylePresets.active')}
</Badge>
)}
</Flex>
<Flex alignItems="center" gap={1}>
<IconButton
size="sm"
variant="ghost"
aria-label={t('stylePresets.copyTemplate')}
onClick={handleClickCopy}
icon={<PiCopyBold />}
/>
{preset.type !== 'default' && (
<>
<IconButton
size="sm"
variant="ghost"
aria-label={t('stylePresets.editTemplate')}
onClick={handleClickEdit}
icon={<PiPencilBold />}
/>
<IconButton
size="sm"
variant="ghost"
aria-label={t('stylePresets.deleteTemplate')}
onClick={handleClickDelete}
colorScheme="error"
icon={<PiTrashBold />}
/>
</>
)}
</Flex>
</Flex>
<Flex flexDir="column" gap={1}>
<Text fontSize="xs">
<Text as="span" fontWeight="semibold">
{t('stylePresets.positivePrompt')}:
</Text>{' '}
{preset.preset_data.positive_prompt}
</Text>
<Text fontSize="xs">
<Text as="span" fontWeight="semibold">
{t('stylePresets.negativePrompt')}:
</Text>{' '}
{preset.preset_data.negative_prompt}
</Text>
</Flex>
</Flex>
</Flex>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('stylePresets.deleteTemplate')}
acceptCallback={handleDeletePreset}
acceptButtonText="Delete"
>
<p>{t('stylePresets.deleteTemplate2')}</p>
</ConfirmationAlertDialog>
</>
);
};

View File

@ -0,0 +1,67 @@
import { Flex } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { StylePresetExportButton } from 'features/stylePresets/components/StylePresetExportButton';
import { StylePresetImportButton } from 'features/stylePresets/components/StylePresetImportButton';
import { useTranslation } from 'react-i18next';
import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
import { StylePresetCreateButton } from './StylePresetCreateButton';
import { StylePresetList } from './StylePresetList';
import StylePresetSearch from './StylePresetSearch';
export const StylePresetMenu = () => {
const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm);
const allowPrivateStylePresets = useAppSelector((s) => s.config.allowPrivateStylePresets);
const { data } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
const filteredData =
data?.filter((preset) => preset.name.toLowerCase().includes(searchTerm.toLowerCase())) || EMPTY_ARRAY;
const groupedData = filteredData.reduce(
(
acc: {
defaultPresets: StylePresetRecordWithImage[];
sharedPresets: StylePresetRecordWithImage[];
presets: StylePresetRecordWithImage[];
},
preset
) => {
if (preset.type === 'default') {
acc.defaultPresets.push(preset);
} else if (preset.type === 'project') {
acc.sharedPresets.push(preset);
} else {
acc.presets.push(preset);
}
return acc;
},
{ defaultPresets: [], sharedPresets: [], presets: [] }
);
return {
data: groupedData,
};
},
});
const { t } = useTranslation();
return (
<Flex flexDir="column" gap={2} padding={3} layerStyle="second" borderRadius="base">
<Flex alignItems="center" gap={2} w="full" justifyContent="space-between">
<StylePresetSearch />
<StylePresetCreateButton />
<StylePresetImportButton />
<StylePresetExportButton />
</Flex>
<StylePresetList title={t('stylePresets.myTemplates')} data={data.presets} />
{allowPrivateStylePresets && (
<StylePresetList title={t('stylePresets.sharedTemplates')} data={data.sharedPresets} />
)}
<StylePresetList title={t('stylePresets.defaultTemplates')} data={data.defaultPresets} />
</Flex>
);
};

View File

@ -0,0 +1,43 @@
import type { SystemStyleObject } from '@invoke-ai/ui-library';
import { Flex, IconButton } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $isMenuOpen } from 'features/stylePresets/store/isMenuOpen';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCaretDownBold } from 'react-icons/pi';
import { ActiveStylePreset } from './ActiveStylePreset';
const _hover: SystemStyleObject = {
bg: 'base.750',
};
export const StylePresetMenuTrigger = () => {
const isMenuOpen = useStore($isMenuOpen);
const { t } = useTranslation();
const handleToggle = useCallback(() => {
$isMenuOpen.set(!isMenuOpen);
}, [isMenuOpen]);
return (
<Flex
onClick={handleToggle}
backgroundColor="base.800"
justifyContent="space-between"
alignItems="center"
py={2}
px={3}
borderRadius="base"
gap={1}
role="button"
_hover={_hover}
transitionProperty="background-color"
transitionDuration="normal"
>
<ActiveStylePreset />
<IconButton aria-label={t('stylePresets.viewList')} variant="ghost" icon={<PiCaretDownBold />} size="sm" />
</Flex>
);
};

View File

@ -0,0 +1,65 @@
import { IconButton, Input, InputGroup, InputRightElement } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { searchTermChanged } from 'features/stylePresets/store/stylePresetSlice';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
const StylePresetSearch = () => {
const dispatch = useAppDispatch();
const searchTerm = useAppSelector((s) => s.stylePreset.searchTerm);
const { t } = useTranslation();
const handlePresetSearch = useCallback(
(newSearchTerm: string) => {
dispatch(searchTermChanged(newSearchTerm));
},
[dispatch]
);
const clearPresetSearch = useCallback(() => {
dispatch(searchTermChanged(''));
}, [dispatch]);
const handleKeydown = useCallback(
(e: KeyboardEvent<HTMLInputElement>) => {
// exit search mode on escape
if (e.key === 'Escape') {
clearPresetSearch();
}
},
[clearPresetSearch]
);
const handleChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
handlePresetSearch(e.target.value);
},
[handlePresetSearch]
);
return (
<InputGroup>
<Input
placeholder={t('stylePresets.searchByName')}
value={searchTerm}
onKeyDown={handleKeydown}
onChange={handleChange}
/>
{searchTerm && searchTerm.length && (
<InputRightElement h="full" pe={2}>
<IconButton
onClick={clearPresetSearch}
size="sm"
variant="link"
aria-label={t('boards.clearSearch')}
icon={<PiXBold />}
/>
</InputRightElement>
)}
</InputGroup>
);
};
export default memo(StylePresetSearch);

View File

@ -0,0 +1,39 @@
import { useAppSelector } from 'app/store/storeHooks';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
export const PRESET_PLACEHOLDER = '{prompt}';
export const buildPresetModifiedPrompt = (presetPrompt: string, currentPrompt: string) => {
return presetPrompt.includes(PRESET_PLACEHOLDER)
? presetPrompt.replace(PRESET_PLACEHOLDER, currentPrompt)
: `${currentPrompt} ${presetPrompt}`;
};
export const usePresetModifiedPrompts = () => {
const { positivePrompt, negativePrompt } = useAppSelector((s) => s.controlLayers.present);
const activeStylePresetId = useAppSelector((s) => s.stylePreset.activeStylePresetId);
const { activeStylePreset } = useListStylePresetsQuery(undefined, {
selectFromResult: ({ data }) => {
let activeStylePreset = null;
if (data) {
activeStylePreset = data.find((sp) => sp.id === activeStylePresetId);
}
return { activeStylePreset };
},
});
if (!activeStylePreset) {
return { presetModifiedPositivePrompt: positivePrompt, presetModifiedNegativePrompt: negativePrompt };
}
const { positive_prompt: presetPositivePrompt, negative_prompt: presetNegativePrompt } =
activeStylePreset.preset_data;
const presetModifiedPositivePrompt = buildPresetModifiedPrompt(presetPositivePrompt, positivePrompt);
const presetModifiedNegativePrompt = buildPresetModifiedPrompt(presetNegativePrompt, negativePrompt);
return { presetModifiedPositivePrompt, presetModifiedNegativePrompt };
};

View File

@ -0,0 +1,6 @@
import { atom } from 'nanostores';
/**
* Tracks whether or not the style preset menu is open.
*/
export const $isMenuOpen = atom<boolean>(false);

View File

@ -0,0 +1,27 @@
import { atom } from 'nanostores';
import type { PresetType } from 'services/api/endpoints/stylePresets';
const initialState: StylePresetModalState = {
isModalOpen: false,
updatingStylePresetId: null,
prefilledFormData: null,
};
/**
* Tracks the state for the style preset modal.
*/
export const $stylePresetModalState = atom<StylePresetModalState>(initialState);
type StylePresetModalState = {
isModalOpen: boolean;
updatingStylePresetId: string | null;
prefilledFormData: PrefilledFormData | null;
};
export type PrefilledFormData = {
name: string;
positivePrompt: string;
negativePrompt: string;
imageUrl: string | null;
type: PresetType;
};

View File

@ -0,0 +1,44 @@
import type { PayloadAction } from '@reduxjs/toolkit';
import { createSlice } from '@reduxjs/toolkit';
import type { PersistConfig } from 'app/store/store';
import type { StylePresetState } from './types';
const initialState: StylePresetState = {
activeStylePresetId: null,
searchTerm: '',
viewMode: false,
};
export const stylePresetSlice = createSlice({
name: 'stylePreset',
initialState: initialState,
reducers: {
activeStylePresetIdChanged: (state, action: PayloadAction<string | null>) => {
state.activeStylePresetId = action.payload;
},
searchTermChanged: (state, action: PayloadAction<string>) => {
state.searchTerm = action.payload;
},
viewModeChanged: (state, action: PayloadAction<boolean>) => {
state.viewMode = action.payload;
},
},
});
export const { activeStylePresetIdChanged, searchTermChanged, viewModeChanged } = stylePresetSlice.actions;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
const migrateStylePresetState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};
export const stylePresetPersistConfig: PersistConfig<StylePresetState> = {
name: stylePresetSlice.name,
initialState,
migrate: migrateStylePresetState,
persistDenylist: [],
};

View File

@ -0,0 +1,5 @@
export type StylePresetState = {
activeStylePresetId: string | null;
searchTerm: string;
viewMode: boolean;
};

View File

@ -0,0 +1,15 @@
import { PRESET_PLACEHOLDER } from 'features/stylePresets/hooks/usePresetModifiedPrompts';
export const getViewModeChunks = (currentPrompt: string, presetPrompt?: string): [string, string, string] => {
if (!presetPrompt || !presetPrompt.length) {
return ['', currentPrompt, ''];
}
const [before, after] = presetPrompt.split(PRESET_PLACEHOLDER, 2);
if (!before || !after) {
return ['', `${currentPrompt} `, before || after || ''];
}
return [before ?? '', currentPrompt, after ?? ''];
};

View File

@ -19,6 +19,7 @@ const initialConfigState: AppConfig = {
shouldUpdateImagesOnConnect: false,
shouldFetchMetadataFromApi: false,
allowPrivateBoards: false,
allowPrivateStylePresets: false,
disabledTabs: [],
disabledFeatures: ['lightbox', 'faceRestore', 'batches'],
disabledSDFeatures: ['variation', 'symmetry', 'hires', 'perlinNoise', 'noiseThreshold'],

View File

@ -1,15 +1,18 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { Prompts } from 'features/parameters/components/Prompts/Prompts';
import QueueControls from 'features/queue/components/QueueControls';
import { SDXLPrompts } from 'features/sdxl/components/SDXLPrompts/SDXLPrompts';
import { AdvancedSettingsAccordion } from 'features/settingsAccordions/components/AdvancedSettingsAccordion/AdvancedSettingsAccordion';
import { CompositingSettingsAccordion } from 'features/settingsAccordions/components/CompositingSettingsAccordion/CompositingSettingsAccordion';
import { ControlSettingsAccordion } from 'features/settingsAccordions/components/ControlSettingsAccordion/ControlSettingsAccordion';
import { GenerationSettingsAccordion } from 'features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion';
import { ImageSettingsAccordion } from 'features/settingsAccordions/components/ImageSettingsAccordion/ImageSettingsAccordion';
import { RefinerSettingsAccordion } from 'features/settingsAccordions/components/RefinerSettingsAccordion/RefinerSettingsAccordion';
import { StylePresetMenu } from 'features/stylePresets/components/StylePresetMenu';
import { StylePresetMenuTrigger } from 'features/stylePresets/components/StylePresetMenuTrigger';
import { $isMenuOpen } from 'features/stylePresets/store/isMenuOpen';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo } from 'react';
@ -21,15 +24,24 @@ const overlayScrollbarsStyles: CSSProperties = {
const ParametersPanelCanvas = () => {
const isSDXL = useAppSelector((s) => s.generation.model?.base === 'sdxl');
const isMenuOpen = useStore($isMenuOpen);
return (
<Flex w="full" h="full" flexDir="column" gap={2}>
<QueueControls />
<StylePresetMenuTrigger />
<Flex w="full" h="full" position="relative">
<Box position="absolute" top={0} left={0} right={0} bottom={0}>
{isMenuOpen && (
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
{isSDXL ? <SDXLPrompts /> : <Prompts />}
<StylePresetMenu />
</Flex>
</OverlayScrollbarsComponent>
)}
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
<Prompts />
<ImageSettingsAccordion />
<GenerationSettingsAccordion />
<ControlSettingsAccordion />

Some files were not shown because too many files have changed in this diff Show More