Merge branch 'main' into chainchompa/preselect-workflows
@ -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)
|
||||
|
227
invokeai/app/api/routers/style_presets.py
Normal 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)
|
@ -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)
|
||||
|
||||
|
@ -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.."""
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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
|
After Width: | Height: | Size: 98 KiB |
After Width: | Height: | Size: 138 KiB |
After Width: | Height: | Size: 122 KiB |
After Width: | Height: | Size: 123 KiB |
After Width: | Height: | Size: 160 KiB |
After Width: | Height: | Size: 146 KiB |
After Width: | Height: | Size: 119 KiB |
After Width: | Height: | Size: 117 KiB |
After Width: | Height: | Size: 110 KiB |
After Width: | Height: | Size: 46 KiB |
After Width: | Height: | Size: 79 KiB |
After Width: | Height: | Size: 156 KiB |
After Width: | Height: | Size: 141 KiB |
After Width: | Height: | Size: 96 KiB |
After Width: | Height: | Size: 91 KiB |
After Width: | Height: | Size: 88 KiB |
After Width: | Height: | Size: 107 KiB |
After Width: | Height: | Size: 132 KiB |
@ -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
|
@ -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)
|
@ -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)
|
@ -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"
|
||||
}
|
||||
}
|
||||
]
|
@ -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
|
@ -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")
|
@ -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)
|
@ -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
|
||||
|
@ -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"
|
||||
|
@ -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"
|
||||
|
@ -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",
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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')) {
|
||||
|
@ -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)
|
||||
|
@ -71,6 +71,7 @@ export type AppConfig = {
|
||||
*/
|
||||
maxUpscaleDimension?: number;
|
||||
allowPrivateBoards: boolean;
|
||||
allowPrivateStylePresets: boolean;
|
||||
disabledTabs: InvokeTabName[];
|
||||
disabledFeatures: AppFeature[];
|
||||
disabledSDFeatures: SDFeature[];
|
||||
|
@ -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 };
|
||||
|
@ -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;
|
||||
};
|
@ -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;
|
||||
});
|
@ -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')}
|
||||
|
@ -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,
|
||||
};
|
||||
};
|
||||
|
@ -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,
|
||||
|
@ -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)));
|
||||
|
@ -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
|
||||
|
@ -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: {
|
||||
|
@ -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: {
|
||||
|
@ -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
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
@ -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
|
||||
|
@ -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
|
||||
|
@ -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',
|
||||
|
@ -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({
|
||||
|
@ -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,
|
||||
};
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
});
|
||||
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
|
@ -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>
|
||||
);
|
||||
|
@ -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,
|
||||
};
|
@ -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';
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
@ -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()} />
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
</>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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>
|
||||
);
|
||||
};
|
@ -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);
|
@ -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 };
|
||||
};
|
@ -0,0 +1,6 @@
|
||||
import { atom } from 'nanostores';
|
||||
|
||||
/**
|
||||
* Tracks whether or not the style preset menu is open.
|
||||
*/
|
||||
export const $isMenuOpen = atom<boolean>(false);
|
@ -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;
|
||||
};
|
@ -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: [],
|
||||
};
|
@ -0,0 +1,5 @@
|
||||
export type StylePresetState = {
|
||||
activeStylePresetId: string | null;
|
||||
searchTerm: string;
|
||||
viewMode: boolean;
|
||||
};
|
@ -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 ?? ''];
|
||||
};
|
@ -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'],
|
||||
|
@ -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 />
|
||||
|
@ -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"
|
||||
|
@ -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 />
|
||||
|
103
invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts
Normal 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;
|
@ -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.
|
||||
|
@ -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"];
|
||||
};
|
||||
};
|
||||
};
|
||||
};
|
||||
}
|
||||
|
@ -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
|
||||
)
|
||||
|
||||
|
||||
|
@ -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`.
|
||||
|