Merge branch 'main' into chainchompa/preselect-workflows

This commit is contained in:
chainchompa 2024-08-14 09:02:12 -04:00 committed by GitHub
commit 8840df2b00
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
96 changed files with 3012 additions and 1456 deletions

View File

@ -32,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
@ -80,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)
@ -115,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,
@ -140,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,227 @@
import io
import json
import traceback
from typing import Optional
import pydantic
from fastapi import APIRouter, File, Form, HTTPException, Path, 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 (
PresetData,
PresetType,
StylePresetChanges,
StylePresetNotFoundError,
StylePresetRecordWithImage,
StylePresetWithoutId,
)
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)

View File

@ -30,6 +30,7 @@ from invokeai.app.api.routers import (
images,
model_manager,
session_queue,
style_presets,
utilities,
workflows,
)
@ -108,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)

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

@ -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,36 @@
from abc import ABC, abstractmethod
from invokeai.app.services.style_preset_records.style_preset_records_common import (
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 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) -> list[StylePresetRecordDTO]:
"""Gets many workflows."""
pass

View File

@ -0,0 +1,51 @@
from enum import Enum
from typing import Any, Optional
from pydantic import BaseModel, 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")

View File

@ -0,0 +1,175 @@
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 (
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 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,
) -> list[StylePresetRecordDTO]:
try:
self._lock.acquire()
main_query = """
SELECT
*
FROM style_presets
ORDER BY LOWER(name) ASC
"""
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

@ -1689,6 +1689,42 @@
"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.",
"editTemplate": "Edit Template",
"flatten": "Flatten selected template into current prompt",
"insertPlaceholder": "Insert placeholder",
"myTemplates": "My Templates",
"name": "Name",
"negativePrompt": "Negative Prompt",
"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",
"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

@ -13,6 +13,7 @@ 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';
@ -120,6 +121,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflow, destina
<DeleteImageModal />
<ChangeBoardModal />
<DynamicPromptsModal />
<StylePresetModal />
<PreselectedImage selectedImage={selectedImage} />
</ErrorBoundary>
);

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

@ -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,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,63 @@
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);
}
}, [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,31 @@
import { Button, Collapse, Flex, Icon, Text, useDisclosure } from '@invoke-ai/ui-library';
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 { onToggle, isOpen } = useDisclosure({ defaultIsOpen: true });
if (!data.length) {
return <></>;
}
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.map((preset) => (
<StylePresetListItem preset={preset} key={preset.id} />
))}
</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,90 @@
import { Flex, IconButton, Text } from '@invoke-ai/ui-library';
import { EMPTY_ARRAY } from 'app/store/constants';
import { useAppSelector } from 'app/store/storeHooks';
import { $stylePresetModalState } from 'features/stylePresets/store/stylePresetModal';
import { useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets';
import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets';
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();
const handleClickAddNew = useCallback(() => {
$stylePresetModalState.set({
prefilledFormData: null,
updatingStylePresetId: null,
isModalOpen: true,
});
}, []);
return (
<Flex flexDir="column" gap={2} padding={3} layerStyle="second" borderRadius="base">
<Flex alignItems="center" gap={2} w="full" justifyContent="space-between">
<StylePresetSearch />
<IconButton
icon={<PiPlusBold />}
tooltip={t('stylePresets.createPromptTemplate')}
aria-label={t('stylePresets.createPromptTemplate')}
onClick={handleClickAddNew}
size="md"
variant="link"
w={8}
h={8}
/>
</Flex>
{data.presets.length === 0 && data.defaultPresets.length === 0 && (
<Text p={10} textAlign="center">
{t('stylePresets.noMatchingTemplates')}
</Text>
)}
<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,35 @@
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';
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"
>
<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">
<StylePresetMenu />
</Flex>
</OverlayScrollbarsComponent>
)}
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
{isSDXL ? <SDXLPrompts /> : <Prompts />}
<Prompts />
<ImageSettingsAccordion />
<GenerationSettingsAccordion />
<ControlSettingsAccordion />

View File

@ -1,5 +1,6 @@
import type { ChakraProps } from '@invoke-ai/ui-library';
import { Box, Flex, Tab, TabList, TabPanel, TabPanels, Tabs } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { ControlLayersPanelContent } from 'features/controlLayers/components/ControlLayersPanelContent';
@ -7,17 +8,19 @@ import { $isPreviewVisible } from 'features/controlLayers/store/controlLayersSli
import { isImageViewerOpenChanged } from 'features/gallery/store/gallerySlice';
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 { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { OverlayScrollbarsComponent } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo } from 'react';
import { memo, useCallback, useMemo, useRef } from 'react';
import { useTranslation } from 'react-i18next';
const overlayScrollbarsStyles: CSSProperties = {
@ -59,14 +62,25 @@ const ParametersPanelTextToImage = () => {
[dispatch]
);
const ref = useRef<HTMLDivElement>(null);
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}>
<Box position="absolute" top={0} left={0} right={0} bottom={0} ref={ref}>
{isMenuOpen && (
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
<StylePresetMenu />
</Flex>
</OverlayScrollbarsComponent>
)}
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
{isSDXL ? <SDXLPrompts /> : <Prompts />}
<Prompts />
<Tabs
defaultIndex={0}
variant="enclosed"

View File

@ -1,12 +1,14 @@
import { Box, Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useStore } from '@nanostores/react';
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 { GenerationSettingsAccordion } from 'features/settingsAccordions/components/GenerationSettingsAccordion/GenerationSettingsAccordion';
import { UpscaleSettingsAccordion } from 'features/settingsAccordions/components/UpscaleSettingsAccordion/UpscaleSettingsAccordion';
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';
@ -17,16 +19,23 @@ const overlayScrollbarsStyles: CSSProperties = {
};
const ParametersPanelUpscale = () => {
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">
<StylePresetMenu />
</Flex>
</OverlayScrollbarsComponent>
)}
<OverlayScrollbarsComponent defer style={overlayScrollbarsStyles} options={overlayScrollbarsParams.options}>
<Flex gap={2} flexDirection="column" h="full" w="full">
{isSDXL ? <SDXLPrompts /> : <Prompts />}
<Prompts />
<UpscaleSettingsAccordion />
<GenerationSettingsAccordion />
<AdvancedSettingsAccordion />

View File

@ -0,0 +1,103 @@
import type { paths } from 'services/api/schema';
import type { S } from 'services/api/types';
import { api, buildV1Url, LIST_TAG } from '..';
export type StylePresetRecordWithImage =
paths['/api/v1/style_presets/i/{style_preset_id}']['get']['responses']['200']['content']['application/json'];
export type PresetType = S['PresetType'];
/**
* Builds an endpoint URL for the style_presets router
* @example
* buildStylePresetsUrl('some-path')
* // '/api/v1/style_presets/some-path'
*/
const buildStylePresetsUrl = (path: string = '') => buildV1Url(`style_presets/${path}`);
export const stylePresetsApi = api.injectEndpoints({
endpoints: (build) => ({
getStylePreset: build.query<
paths['/api/v1/style_presets/i/{style_preset_id}']['get']['responses']['200']['content']['application/json'],
string
>({
query: (style_preset_id) => buildStylePresetsUrl(`i/${style_preset_id}`),
providesTags: (result, error, style_preset_id) => [
{ type: 'StylePreset', id: style_preset_id },
'FetchOnReconnect',
],
}),
deleteStylePreset: build.mutation<void, string>({
query: (style_preset_id) => ({
url: buildStylePresetsUrl(`i/${style_preset_id}`),
method: 'DELETE',
}),
invalidatesTags: (result, error, style_preset_id) => [
{ type: 'StylePreset', id: LIST_TAG },
{ type: 'StylePreset', id: style_preset_id },
],
}),
createStylePreset: build.mutation<
paths['/api/v1/style_presets/']['post']['responses']['200']['content']['application/json'],
{ data: { name: string; positive_prompt: string; negative_prompt: string; type: PresetType }; image: Blob | null }
>({
query: ({ data, image }) => {
const formData = new FormData();
if (image) {
formData.append('image', image);
}
formData.append('data', JSON.stringify(data));
return {
url: buildStylePresetsUrl(),
method: 'POST',
body: formData,
};
},
invalidatesTags: [
{ type: 'StylePreset', id: LIST_TAG },
{ type: 'StylePreset', id: LIST_TAG },
],
}),
updateStylePreset: build.mutation<
paths['/api/v1/style_presets/i/{style_preset_id}']['patch']['responses']['200']['content']['application/json'],
{ data: { name: string; positive_prompt: string; negative_prompt: string }; image: Blob | null; id: string }
>({
query: ({ id, data, image }) => {
const formData = new FormData();
if (image) {
formData.append('image', image);
}
formData.append('data', JSON.stringify(data));
return {
url: buildStylePresetsUrl(`i/${id}`),
method: 'PATCH',
body: formData,
};
},
invalidatesTags: (response, error, { id }) => [
{ type: 'StylePreset', id: LIST_TAG },
{ type: 'StylePreset', id: id },
],
}),
listStylePresets: build.query<
paths['/api/v1/style_presets/']['get']['responses']['200']['content']['application/json'],
void
>({
query: () => ({
url: buildStylePresetsUrl(),
}),
providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }],
}),
}),
});
export const {
useCreateStylePresetMutation,
useDeleteStylePresetMutation,
useUpdateStylePresetMutation,
useListStylePresetsQuery,
} = stylePresetsApi;

View File

@ -40,6 +40,7 @@ const tagTypes = [
'SDXLRefinerModel',
'Workflow',
'WorkflowsRecent',
'StylePreset',
'Schema',
// This is invalidated on reconnect. It should be used for queries that have changing data,
// especially related to the queue and generation.

View File

@ -1272,6 +1272,78 @@ export type paths = {
patch?: never;
trace?: never;
};
"/api/v1/style_presets/i/{style_preset_id}": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Style Preset
* @description Gets a style preset
*/
get: operations["get_style_preset"];
put?: never;
post?: never;
/**
* Delete Style Preset
* @description Deletes a style preset
*/
delete: operations["delete_style_preset"];
options?: never;
head?: never;
/**
* Update Style Preset
* @description Updates a style preset
*/
patch: operations["update_style_preset"];
trace?: never;
};
"/api/v1/style_presets/": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* List Style Presets
* @description Gets a page of style presets
*/
get: operations["list_style_presets"];
put?: never;
/**
* Create Style Preset
* @description Creates a style preset
*/
post: operations["create_style_preset"];
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
"/api/v1/style_presets/i/{style_preset_id}/image": {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
/**
* Get Style Preset Image
* @description Gets an image file that previews the model
*/
get: operations["get_style_preset_image"];
put?: never;
post?: never;
delete?: never;
options?: never;
head?: never;
patch?: never;
trace?: never;
};
};
export type webhooks = Record<string, never>;
export type components = {
@ -1851,6 +1923,19 @@ export type components = {
*/
batch_ids: string[];
};
/** Body_create_style_preset */
Body_create_style_preset: {
/**
* Image
* @description The image file to upload
*/
image?: Blob | null;
/**
* Data
* @description The data of the style preset to create
*/
data: string;
};
/** Body_create_workflow */
Body_create_workflow: {
/** @description The workflow to create */
@ -1973,6 +2058,19 @@ export type components = {
*/
image: Blob;
};
/** Body_update_style_preset */
Body_update_style_preset: {
/**
* Image
* @description The image file to upload
*/
image?: Blob | null;
/**
* Data
* @description The data of the style preset to update
*/
data: string;
};
/** Body_update_workflow */
Body_update_workflow: {
/** @description The updated workflow */
@ -11206,6 +11304,24 @@ export type components = {
*/
type: "pidi_image_processor";
};
/** PresetData */
PresetData: {
/**
* Positive Prompt
* @description Positive prompt
*/
positive_prompt: string;
/**
* Negative Prompt
* @description Negative prompt
*/
negative_prompt: string;
};
/**
* PresetType
* @enum {string}
*/
PresetType: "user" | "default" | "project";
/**
* ProgressImage
* @description The progress image sent intermittently during processing
@ -13605,6 +13721,28 @@ export type components = {
*/
type: "string_split_neg";
};
/** StylePresetRecordWithImage */
StylePresetRecordWithImage: {
/**
* Name
* @description The name of the style preset.
*/
name: string;
/** @description The preset data */
preset_data: components["schemas"]["PresetData"];
/** @description The type of style preset */
type: components["schemas"]["PresetType"];
/**
* Id
* @description The style preset ID.
*/
id: string;
/**
* Image
* @description The path for image
*/
image: string | null;
};
/**
* SubModelType
* @description Submodel type.
@ -17746,4 +17884,203 @@ export interface operations {
};
};
};
get_style_preset: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The style preset to get */
style_preset_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StylePresetRecordWithImage"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
delete_style_preset: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The style preset to delete */
style_preset_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
update_style_preset: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id of the style preset to update */
style_preset_id: string;
};
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_update_style_preset"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StylePresetRecordWithImage"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
list_style_presets: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody?: never;
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StylePresetRecordWithImage"][];
};
};
};
};
create_style_preset: {
parameters: {
query?: never;
header?: never;
path?: never;
cookie?: never;
};
requestBody: {
content: {
"multipart/form-data": components["schemas"]["Body_create_style_preset"];
};
};
responses: {
/** @description Successful Response */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["StylePresetRecordWithImage"];
};
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
get_style_preset_image: {
parameters: {
query?: never;
header?: never;
path: {
/** @description The id of the style preset image to get */
style_preset_id: string;
};
cookie?: never;
};
requestBody?: never;
responses: {
/** @description The style preset image was fetched successfully */
200: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": unknown;
};
};
/** @description Bad request */
400: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description The style preset image could not be found */
404: {
headers: {
[name: string]: unknown;
};
content?: never;
};
/** @description Validation Error */
422: {
headers: {
[name: string]: unknown;
};
content: {
"application/json": components["schemas"]["HTTPValidationError"];
};
};
};
};
}

View File

@ -56,6 +56,8 @@ def mock_services() -> InvocationServices:
workflow_records=None, # type: ignore
tensors=None, # type: ignore
conditioning=None, # type: ignore
style_preset_records=None, # type: ignore
style_preset_image_files=None, # type: ignore
)

View File

@ -3,7 +3,7 @@ from scripts.update_config_docstring import generate_config_docstrings
def test_app_config_docstrings_are_current():
# If this test fails, run `python scripts/generate_config_docstring.py`. See the comments in that script for
# If this test fails, run `python scripts/update_config_docstring.py`. See the comments in that script for
# an explanation of why this is necessary.
#
# A make target is provided to run the script: `make update-config-docstring`.