diff --git a/.github/workflows/python-checks.yml b/.github/workflows/python-checks.yml index 94232e1576..40d028826b 100644 --- a/.github/workflows/python-checks.yml +++ b/.github/workflows/python-checks.yml @@ -62,7 +62,7 @@ jobs: - name: install ruff if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }} - run: pip install ruff + run: pip install ruff==0.6.0 shell: bash - name: ruff check diff --git a/installer/templates/invoke.sh.in b/installer/templates/invoke.sh.in index b8d5a7af23..0bc6bbb471 100644 --- a/installer/templates/invoke.sh.in +++ b/installer/templates/invoke.sh.in @@ -17,7 +17,7 @@ set -eu # Ensure we're in the correct folder in case user's CWD is somewhere else -scriptdir=$(dirname "$0") +scriptdir=$(dirname $(readlink -f "$0")) cd "$scriptdir" . .venv/bin/activate diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index 1cd2093ec9..d2be674e53 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -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) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py new file mode 100644 index 0000000000..0f4967cc63 --- /dev/null +++ b/invokeai/app/api/routers/style_presets.py @@ -0,0 +1,276 @@ +import csv +import io +import json +import traceback +from typing import Optional + +import pydantic +from fastapi import APIRouter, File, Form, HTTPException, Path, Response, UploadFile +from fastapi.responses import FileResponse +from PIL import Image +from pydantic import BaseModel, Field + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.model_manager import IMAGE_MAX_AGE +from invokeai.app.services.style_preset_images.style_preset_images_common import StylePresetImageFileNotFoundException +from invokeai.app.services.style_preset_records.style_preset_records_common import ( + InvalidPresetImportDataError, + PresetData, + PresetType, + StylePresetChanges, + StylePresetNotFoundError, + StylePresetRecordWithImage, + StylePresetWithoutId, + UnsupportedFileTypeError, + parse_presets_from_file, +) + + +class StylePresetUpdateFormData(BaseModel): + name: str = Field(description="Preset name") + positive_prompt: str = Field(description="Positive prompt") + negative_prompt: str = Field(description="Negative prompt") + + +class StylePresetCreateFormData(StylePresetUpdateFormData): + type: PresetType = Field(description="Preset type") + + +style_presets_router = APIRouter(prefix="/v1/style_presets", tags=["style_presets"]) + + +@style_presets_router.get( + "/i/{style_preset_id}", + operation_id="get_style_preset", + responses={ + 200: {"model": StylePresetRecordWithImage}, + }, +) +async def get_style_preset( + style_preset_id: str = Path(description="The style preset to get"), +) -> StylePresetRecordWithImage: + """Gets a style preset""" + try: + image = ApiDependencies.invoker.services.style_preset_image_files.get_url(style_preset_id) + style_preset = ApiDependencies.invoker.services.style_preset_records.get(style_preset_id) + return StylePresetRecordWithImage(image=image, **style_preset.model_dump()) + except StylePresetNotFoundError: + raise HTTPException(status_code=404, detail="Style preset not found") + + +@style_presets_router.patch( + "/i/{style_preset_id}", + operation_id="update_style_preset", + responses={ + 200: {"model": StylePresetRecordWithImage}, + }, +) +async def update_style_preset( + image: Optional[UploadFile] = File(description="The image file to upload", default=None), + style_preset_id: str = Path(description="The id of the style preset to update"), + data: str = Form(description="The data of the style preset to update"), +) -> StylePresetRecordWithImage: + """Updates a style preset""" + if image is not None: + if not image.content_type or not image.content_type.startswith("image"): + raise HTTPException(status_code=415, detail="Not an image") + + contents = await image.read() + try: + pil_image = Image.open(io.BytesIO(contents)) + + except Exception: + ApiDependencies.invoker.services.logger.error(traceback.format_exc()) + raise HTTPException(status_code=415, detail="Failed to read image") + + try: + ApiDependencies.invoker.services.style_preset_image_files.save(style_preset_id, pil_image) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + else: + try: + ApiDependencies.invoker.services.style_preset_image_files.delete(style_preset_id) + except StylePresetImageFileNotFoundException: + pass + + try: + parsed_data = json.loads(data) + validated_data = StylePresetUpdateFormData(**parsed_data) + + name = validated_data.name + positive_prompt = validated_data.positive_prompt + negative_prompt = validated_data.negative_prompt + + except pydantic.ValidationError: + raise HTTPException(status_code=400, detail="Invalid preset data") + + preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt) + changes = StylePresetChanges(name=name, preset_data=preset_data) + + style_preset_image = ApiDependencies.invoker.services.style_preset_image_files.get_url(style_preset_id) + style_preset = ApiDependencies.invoker.services.style_preset_records.update( + style_preset_id=style_preset_id, changes=changes + ) + return StylePresetRecordWithImage(image=style_preset_image, **style_preset.model_dump()) + + +@style_presets_router.delete( + "/i/{style_preset_id}", + operation_id="delete_style_preset", +) +async def delete_style_preset( + style_preset_id: str = Path(description="The style preset to delete"), +) -> None: + """Deletes a style preset""" + try: + ApiDependencies.invoker.services.style_preset_image_files.delete(style_preset_id) + except StylePresetImageFileNotFoundException: + pass + + ApiDependencies.invoker.services.style_preset_records.delete(style_preset_id) + + +@style_presets_router.post( + "/", + operation_id="create_style_preset", + responses={ + 200: {"model": StylePresetRecordWithImage}, + }, +) +async def create_style_preset( + image: Optional[UploadFile] = File(description="The image file to upload", default=None), + data: str = Form(description="The data of the style preset to create"), +) -> StylePresetRecordWithImage: + """Creates a style preset""" + + try: + parsed_data = json.loads(data) + validated_data = StylePresetCreateFormData(**parsed_data) + + name = validated_data.name + type = validated_data.type + positive_prompt = validated_data.positive_prompt + negative_prompt = validated_data.negative_prompt + + except pydantic.ValidationError: + raise HTTPException(status_code=400, detail="Invalid preset data") + + preset_data = PresetData(positive_prompt=positive_prompt, negative_prompt=negative_prompt) + style_preset = StylePresetWithoutId(name=name, preset_data=preset_data, type=type) + new_style_preset = ApiDependencies.invoker.services.style_preset_records.create(style_preset=style_preset) + + if image is not None: + if not image.content_type or not image.content_type.startswith("image"): + raise HTTPException(status_code=415, detail="Not an image") + + contents = await image.read() + try: + pil_image = Image.open(io.BytesIO(contents)) + + except Exception: + ApiDependencies.invoker.services.logger.error(traceback.format_exc()) + raise HTTPException(status_code=415, detail="Failed to read image") + + try: + ApiDependencies.invoker.services.style_preset_image_files.save(new_style_preset.id, pil_image) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + + preset_image = ApiDependencies.invoker.services.style_preset_image_files.get_url(new_style_preset.id) + return StylePresetRecordWithImage(image=preset_image, **new_style_preset.model_dump()) + + +@style_presets_router.get( + "/", + operation_id="list_style_presets", + responses={ + 200: {"model": list[StylePresetRecordWithImage]}, + }, +) +async def list_style_presets() -> list[StylePresetRecordWithImage]: + """Gets a page of style presets""" + style_presets_with_image: list[StylePresetRecordWithImage] = [] + style_presets = ApiDependencies.invoker.services.style_preset_records.get_many() + for preset in style_presets: + image = ApiDependencies.invoker.services.style_preset_image_files.get_url(preset.id) + style_preset_with_image = StylePresetRecordWithImage(image=image, **preset.model_dump()) + style_presets_with_image.append(style_preset_with_image) + + return style_presets_with_image + + +@style_presets_router.get( + "/i/{style_preset_id}/image", + operation_id="get_style_preset_image", + responses={ + 200: { + "description": "The style preset image was fetched successfully", + }, + 400: {"description": "Bad request"}, + 404: {"description": "The style preset image could not be found"}, + }, + status_code=200, +) +async def get_style_preset_image( + style_preset_id: str = Path(description="The id of the style preset image to get"), +) -> FileResponse: + """Gets an image file that previews the model""" + + try: + path = ApiDependencies.invoker.services.style_preset_image_files.get_path(style_preset_id) + + response = FileResponse( + path, + media_type="image/png", + filename=style_preset_id + ".png", + content_disposition_type="inline", + ) + response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}" + return response + except Exception: + raise HTTPException(status_code=404) + + +@style_presets_router.get( + "/export", + operation_id="export_style_presets", + responses={200: {"content": {"text/csv": {}}, "description": "A CSV file with the requested data."}}, + status_code=200, +) +async def export_style_presets(): + # Create an in-memory stream to store the CSV data + output = io.StringIO() + writer = csv.writer(output) + + # Write the header + writer.writerow(["name", "prompt", "negative_prompt"]) + + style_presets = ApiDependencies.invoker.services.style_preset_records.get_many(type=PresetType.User) + + for preset in style_presets: + writer.writerow([preset.name, preset.preset_data.positive_prompt, preset.preset_data.negative_prompt]) + + csv_data = output.getvalue() + output.close() + + return Response( + content=csv_data, + media_type="text/csv", + headers={"Content-Disposition": "attachment; filename=prompt_templates.csv"}, + ) + + +@style_presets_router.post( + "/import", + operation_id="import_style_presets", +) +async def import_style_presets(file: UploadFile = File(description="The file to import")): + try: + style_presets = await parse_presets_from_file(file) + ApiDependencies.invoker.services.style_preset_records.create_many(style_presets) + except InvalidPresetImportDataError as e: + ApiDependencies.invoker.services.logger.error(traceback.format_exc()) + raise HTTPException(status_code=400, detail=str(e)) + except UnsupportedFileTypeError as e: + ApiDependencies.invoker.services.logger.error(traceback.format_exc()) + raise HTTPException(status_code=415, detail=str(e)) diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index cabde15d52..ce61964246 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -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) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 6c39760bdc..55f7a095ca 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -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=", "syslog=path|address:host:port", "http=". log_format: Log format. Use "plain" for text-only, "color" for colorized output, "legacy" for 2.3-style logging and "syslog" for syslog-style.
Valid values: `plain`, `color`, `syslog`, `legacy` log_level: Emit logging messages at this level or higher.
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=", "syslog=path|address:host:port", "http=".') @@ -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..""" diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 90ca613074..db693dc837 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -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 diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 49fd337da2..e35c351ff0 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -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 diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_14.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_14.py new file mode 100644 index 0000000000..399f5a71d2 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_14.py @@ -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 diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Anime.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Anime.png new file mode 100644 index 0000000000..def6dce259 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Anime.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Architectural Visualization.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Architectural Visualization.png new file mode 100644 index 0000000000..97a2e74772 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Architectural Visualization.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Character).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Character).png new file mode 100644 index 0000000000..5db78ce086 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Character).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Fantasy).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Fantasy).png new file mode 100644 index 0000000000..93c3c5c301 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Fantasy).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Painterly).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Painterly).png new file mode 100644 index 0000000000..5d3d0c4af6 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Painterly).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Sci-Fi).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Sci-Fi).png new file mode 100644 index 0000000000..3f287fc335 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Concept Art (Sci-Fi).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Environment Art.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Environment Art.png new file mode 100644 index 0000000000..a0e1cbfb42 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Environment Art.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Illustration.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Illustration.png new file mode 100644 index 0000000000..5b5976c4f9 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Illustration.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Interior Design (Visualization).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Interior Design (Visualization).png new file mode 100644 index 0000000000..5c78410377 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Interior Design (Visualization).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Line Art.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Line Art.png new file mode 100644 index 0000000000..b8cdfea030 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Line Art.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Black and White).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Black and White).png new file mode 100644 index 0000000000..b47da9fb94 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Black and White).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (General).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (General).png new file mode 100644 index 0000000000..a034cd197b Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (General).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Landscape).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Landscape).png new file mode 100644 index 0000000000..5985fb6c4b Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Landscape).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Portrait).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Portrait).png new file mode 100644 index 0000000000..7718735b23 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Portrait).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Studio Lighting).png b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Studio Lighting).png new file mode 100644 index 0000000000..60bd40b1fa Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Photography (Studio Lighting).png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Product Rendering.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Product Rendering.png new file mode 100644 index 0000000000..4a426f4769 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Product Rendering.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Sketch.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Sketch.png new file mode 100644 index 0000000000..08d240a29e Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Sketch.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/Vehicles.png b/invokeai/app/services/style_preset_images/default_style_preset_images/Vehicles.png new file mode 100644 index 0000000000..73c4c8db08 Binary files /dev/null and b/invokeai/app/services/style_preset_images/default_style_preset_images/Vehicles.png differ diff --git a/invokeai/app/services/style_preset_images/default_style_preset_images/__init__.py b/invokeai/app/services/style_preset_images/default_style_preset_images/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/invokeai/app/services/style_preset_images/style_preset_images_base.py b/invokeai/app/services/style_preset_images/style_preset_images_base.py new file mode 100644 index 0000000000..d8158ad2ae --- /dev/null +++ b/invokeai/app/services/style_preset_images/style_preset_images_base.py @@ -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 diff --git a/invokeai/app/services/style_preset_images/style_preset_images_common.py b/invokeai/app/services/style_preset_images/style_preset_images_common.py new file mode 100644 index 0000000000..054a12b82b --- /dev/null +++ b/invokeai/app/services/style_preset_images/style_preset_images_common.py @@ -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) diff --git a/invokeai/app/services/style_preset_images/style_preset_images_disk.py b/invokeai/app/services/style_preset_images/style_preset_images_disk.py new file mode 100644 index 0000000000..cd2b29efd2 --- /dev/null +++ b/invokeai/app/services/style_preset_images/style_preset_images_disk.py @@ -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) diff --git a/invokeai/app/services/style_preset_records/__init__.py b/invokeai/app/services/style_preset_records/__init__.py new file mode 100644 index 0000000000..e69de29bb2 diff --git a/invokeai/app/services/style_preset_records/default_style_presets.json b/invokeai/app/services/style_preset_records/default_style_presets.json new file mode 100644 index 0000000000..1daadfa8ff --- /dev/null +++ b/invokeai/app/services/style_preset_records/default_style_presets.json @@ -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" + } + } +] diff --git a/invokeai/app/services/style_preset_records/style_preset_records_base.py b/invokeai/app/services/style_preset_records/style_preset_records_base.py new file mode 100644 index 0000000000..a4dee2fbbd --- /dev/null +++ b/invokeai/app/services/style_preset_records/style_preset_records_base.py @@ -0,0 +1,42 @@ +from abc import ABC, abstractmethod + +from invokeai.app.services.style_preset_records.style_preset_records_common import ( + PresetType, + StylePresetChanges, + StylePresetRecordDTO, + StylePresetWithoutId, +) + + +class StylePresetRecordsStorageBase(ABC): + """Base class for style preset storage services.""" + + @abstractmethod + def get(self, style_preset_id: str) -> StylePresetRecordDTO: + """Get style preset by id.""" + pass + + @abstractmethod + def create(self, style_preset: StylePresetWithoutId) -> StylePresetRecordDTO: + """Creates a style preset.""" + pass + + @abstractmethod + def create_many(self, style_presets: list[StylePresetWithoutId]) -> None: + """Creates many style presets.""" + pass + + @abstractmethod + def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO: + """Updates a style preset.""" + pass + + @abstractmethod + def delete(self, style_preset_id: str) -> None: + """Deletes a style preset.""" + pass + + @abstractmethod + def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]: + """Gets many workflows.""" + pass diff --git a/invokeai/app/services/style_preset_records/style_preset_records_common.py b/invokeai/app/services/style_preset_records/style_preset_records_common.py new file mode 100644 index 0000000000..0391b690cc --- /dev/null +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -0,0 +1,138 @@ +import codecs +import csv +import json +from enum import Enum +from typing import Any, Optional + +import pydantic +from fastapi import UploadFile +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter + +from invokeai.app.util.metaenum import MetaEnum + + +class StylePresetNotFoundError(Exception): + """Raised when a style preset is not found""" + + +class PresetData(BaseModel, extra="forbid"): + positive_prompt: str = Field(description="Positive prompt") + negative_prompt: str = Field(description="Negative prompt") + + +PresetDataValidator = TypeAdapter(PresetData) + + +class PresetType(str, Enum, metaclass=MetaEnum): + User = "user" + Default = "default" + Project = "project" + + +class StylePresetChanges(BaseModel, extra="forbid"): + name: Optional[str] = Field(default=None, description="The style preset's new name.") + preset_data: Optional[PresetData] = Field(default=None, description="The updated data for style preset.") + + +class StylePresetWithoutId(BaseModel): + name: str = Field(description="The name of the style preset.") + preset_data: PresetData = Field(description="The preset data") + type: PresetType = Field(description="The type of style preset") + + +class StylePresetRecordDTO(StylePresetWithoutId): + id: str = Field(description="The style preset ID.") + + @classmethod + def from_dict(cls, data: dict[str, Any]) -> "StylePresetRecordDTO": + data["preset_data"] = PresetDataValidator.validate_json(data.get("preset_data", "")) + return StylePresetRecordDTOValidator.validate_python(data) + + +StylePresetRecordDTOValidator = TypeAdapter(StylePresetRecordDTO) + + +class StylePresetRecordWithImage(StylePresetRecordDTO): + image: Optional[str] = Field(description="The path for image") + + +class StylePresetImportRow(BaseModel): + name: str = Field(min_length=1, description="The name of the preset.") + positive_prompt: str = Field( + default="", + description="The positive prompt for the preset.", + validation_alias=AliasChoices("positive_prompt", "prompt"), + ) + negative_prompt: str = Field(default="", description="The negative prompt for the preset.") + + model_config = ConfigDict(str_strip_whitespace=True, extra="forbid") + + +StylePresetImportList = list[StylePresetImportRow] +StylePresetImportListTypeAdapter = TypeAdapter(StylePresetImportList) + + +class UnsupportedFileTypeError(ValueError): + """Raised when an unsupported file type is encountered""" + + pass + + +class InvalidPresetImportDataError(ValueError): + """Raised when invalid preset import data is encountered""" + + pass + + +async def parse_presets_from_file(file: UploadFile) -> list[StylePresetWithoutId]: + """Parses style presets from a file. The file must be a CSV or JSON file. + + If CSV, the file must have the following columns: + - name + - prompt (or positive_prompt) + - negative_prompt + + If JSON, the file must be a list of objects with the following keys: + - name + - prompt (or positive_prompt) + - negative_prompt + + Args: + file (UploadFile): The file to parse. + + Returns: + list[StylePresetWithoutId]: The parsed style presets. + + Raises: + UnsupportedFileTypeError: If the file type is not supported. + InvalidPresetImportDataError: If the data in the file is invalid. + """ + if file.content_type not in ["text/csv", "application/json"]: + raise UnsupportedFileTypeError() + + if file.content_type == "text/csv": + csv_reader = csv.DictReader(codecs.iterdecode(file.file, "utf-8")) + data = list(csv_reader) + else: # file.content_type == "application/json": + json_data = await file.read() + data = json.loads(json_data) + + try: + imported_presets = StylePresetImportListTypeAdapter.validate_python(data) + + style_presets: list[StylePresetWithoutId] = [] + + for imported in imported_presets: + preset_data = PresetData(positive_prompt=imported.positive_prompt, negative_prompt=imported.negative_prompt) + style_preset = StylePresetWithoutId(name=imported.name, preset_data=preset_data, type=PresetType.User) + style_presets.append(style_preset) + except pydantic.ValidationError as e: + if file.content_type == "text/csv": + msg = "Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt' and name cannot be blank" + else: # file.content_type == "application/json": + msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt' and name cannot be blank" + raise InvalidPresetImportDataError(msg) from e + finally: + file.file.close() + + return style_presets diff --git a/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py new file mode 100644 index 0000000000..657d73b3bd --- /dev/null +++ b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py @@ -0,0 +1,215 @@ +import json +from pathlib import Path + +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.style_preset_records.style_preset_records_base import StylePresetRecordsStorageBase +from invokeai.app.services.style_preset_records.style_preset_records_common import ( + PresetType, + StylePresetChanges, + StylePresetNotFoundError, + StylePresetRecordDTO, + StylePresetWithoutId, +) +from invokeai.app.util.misc import uuid_string + + +class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase): + def __init__(self, db: SqliteDatabase) -> None: + super().__init__() + self._lock = db.lock + self._conn = db.conn + self._cursor = self._conn.cursor() + + def start(self, invoker: Invoker) -> None: + self._invoker = invoker + self._sync_default_style_presets() + + def get(self, style_preset_id: str) -> StylePresetRecordDTO: + """Gets a style preset by ID.""" + try: + self._lock.acquire() + self._cursor.execute( + """--sql + SELECT * + FROM style_presets + WHERE id = ?; + """, + (style_preset_id,), + ) + row = self._cursor.fetchone() + if row is None: + raise StylePresetNotFoundError(f"Style preset with id {style_preset_id} not found") + return StylePresetRecordDTO.from_dict(dict(row)) + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + + def create(self, style_preset: StylePresetWithoutId) -> StylePresetRecordDTO: + style_preset_id = uuid_string() + try: + self._lock.acquire() + self._cursor.execute( + """--sql + INSERT OR IGNORE INTO style_presets ( + id, + name, + preset_data, + type + ) + VALUES (?, ?, ?, ?); + """, + ( + style_preset_id, + style_preset.name, + style_preset.preset_data.model_dump_json(), + style_preset.type, + ), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + return self.get(style_preset_id) + + def create_many(self, style_presets: list[StylePresetWithoutId]) -> None: + style_preset_ids = [] + try: + self._lock.acquire() + for style_preset in style_presets: + style_preset_id = uuid_string() + style_preset_ids.append(style_preset_id) + self._cursor.execute( + """--sql + INSERT OR IGNORE INTO style_presets ( + id, + name, + preset_data, + type + ) + VALUES (?, ?, ?, ?); + """, + ( + style_preset_id, + style_preset.name, + style_preset.preset_data.model_dump_json(), + style_preset.type, + ), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + + return None + + def update(self, style_preset_id: str, changes: StylePresetChanges) -> StylePresetRecordDTO: + try: + self._lock.acquire() + # Change the name of a style preset + if changes.name is not None: + self._cursor.execute( + """--sql + UPDATE style_presets + SET name = ? + WHERE id = ?; + """, + (changes.name, style_preset_id), + ) + + # Change the preset data for a style preset + if changes.preset_data is not None: + self._cursor.execute( + """--sql + UPDATE style_presets + SET preset_data = ? + WHERE id = ?; + """, + (changes.preset_data.model_dump_json(), style_preset_id), + ) + + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + return self.get(style_preset_id) + + def delete(self, style_preset_id: str) -> None: + try: + self._lock.acquire() + self._cursor.execute( + """--sql + DELETE from style_presets + WHERE id = ?; + """, + (style_preset_id,), + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + return None + + def get_many(self, type: PresetType | None = None) -> list[StylePresetRecordDTO]: + try: + self._lock.acquire() + main_query = """ + SELECT + * + FROM style_presets + """ + + if type is not None: + main_query += "WHERE type = ? " + + main_query += "ORDER BY LOWER(name) ASC" + + if type is not None: + self._cursor.execute(main_query, (type,)) + else: + self._cursor.execute(main_query) + + rows = self._cursor.fetchall() + style_presets = [StylePresetRecordDTO.from_dict(dict(row)) for row in rows] + + return style_presets + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + + def _sync_default_style_presets(self) -> None: + """Syncs default style presets to the database. Internal use only.""" + + # First delete all existing default style presets + try: + self._lock.acquire() + self._cursor.execute( + """--sql + DELETE FROM style_presets + WHERE type = "default"; + """ + ) + self._conn.commit() + except Exception: + self._conn.rollback() + raise + finally: + self._lock.release() + # Next, parse and create the default style presets + with self._lock, open(Path(__file__).parent / Path("default_style_presets.json"), "r") as file: + presets = json.load(file) + for preset in presets: + style_preset = StylePresetWithoutId.model_validate(preset) + self.create(style_preset) diff --git a/invokeai/app/services/urls/urls_base.py b/invokeai/app/services/urls/urls_base.py index 477ef04624..b2e41db3e4 100644 --- a/invokeai/app/services/urls/urls_base.py +++ b/invokeai/app/services/urls/urls_base.py @@ -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 diff --git a/invokeai/app/services/urls/urls_default.py b/invokeai/app/services/urls/urls_default.py index d570521fb8..f62bebe901 100644 --- a/invokeai/app/services/urls/urls_default.py +++ b/invokeai/app/services/urls/urls_default.py @@ -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" diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a784f99b70..06aebb218f 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1141,6 +1141,8 @@ "imageSavingFailed": "Image Saving Failed", "imageUploaded": "Image Uploaded", "imageUploadFailed": "Image Upload Failed", + "importFailed": "Import Failed", + "importSuccessful": "Import Successful", "invalidUpload": "Invalid Upload", "loadedWithWarnings": "Workflow Loaded with Warnings", "maskSavedAssets": "Mask Saved to Assets", @@ -1689,6 +1691,52 @@ "missingUpscaleModel": "Missing upscale model", "missingTileControlNetModel": "No valid tile ControlNet models installed" }, + "stylePresets": { + "active": "Active", + "choosePromptTemplate": "Choose Prompt Template", + "clearTemplateSelection": "Clear Template Selection", + "copyTemplate": "Copy Template", + "createPromptTemplate": "Create Prompt Template", + "defaultTemplates": "Default Templates", + "deleteImage": "Delete Image", + "deleteTemplate": "Delete Template", + "deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.", + "exportPromptTemplates": "Export My Prompt Templates (CSV)", + "editTemplate": "Edit Template", + "exportDownloaded": "Export Downloaded", + "exportFailed": "Unable to generate and download CSV", + "flatten": "Flatten selected template into current prompt", + "importTemplates": "Import Prompt Templates (CSV/JSON)", + "acceptedColumnsKeys": "Accepted columns/keys:", + "nameColumn": "'name'", + "positivePromptColumn": "'prompt' or 'positive_prompt'", + "negativePromptColumn": "'negative_prompt'", + "insertPlaceholder": "Insert placeholder", + "myTemplates": "My Templates", + "name": "Name", + "negativePrompt": "Negative Prompt", + "noTemplates": "No templates", + "noMatchingTemplates": "No matching templates", + "promptTemplatesDesc1": "Prompt templates add text to the prompts you write in the prompt box.", + "promptTemplatesDesc2": "Use the placeholder string
{{placeholder}}
to specify where your prompt should be included in the template.", + "promptTemplatesDesc3": "If you omit the placeholder, the template will be appended to the end of your prompt.", + "positivePrompt": "Positive Prompt", + "preview": "Preview", + "private": "Private", + "searchByName": "Search by name", + "shared": "Shared", + "sharedTemplates": "Shared Templates", + "templateActions": "Template Actions", + "templateDeleted": "Prompt template deleted", + "toggleViewMode": "Toggle View Mode", + "type": "Type", + "unableToDeleteTemplate": "Unable to delete prompt template", + "updatePromptTemplate": "Update Prompt Template", + "uploadImage": "Upload Image", + "useForTemplate": "Use For Prompt Template", + "viewList": "View Template List", + "viewModeTooltip": "This is how your prompt will look with your currently selected template. To edit your prompt, click anywhere in the text box." + }, "upsell": { "inviteTeammates": "Invite Teammates", "professional": "Professional", diff --git a/invokeai/frontend/web/public/locales/it.json b/invokeai/frontend/web/public/locales/it.json index 4769d68dc1..886832b691 100644 --- a/invokeai/frontend/web/public/locales/it.json +++ b/invokeai/frontend/web/public/locales/it.json @@ -90,7 +90,7 @@ "disabled": "Disabilitato", "comparingDesc": "Confronta due immagini", "comparing": "Confronta", - "dontShowMeThese": "Non mostrarmi questi" + "dontShowMeThese": "Non mostrare più" }, "gallery": { "galleryImageSize": "Dimensione dell'immagine", @@ -701,7 +701,9 @@ "baseModelChanged": "Modello base modificato", "sessionRef": "Sessione: {{sessionId}}", "somethingWentWrong": "Qualcosa è andato storto", - "outOfMemoryErrorDesc": "Le impostazioni della generazione attuale superano la capacità del sistema. Modifica le impostazioni e riprova." + "outOfMemoryErrorDesc": "Le impostazioni della generazione attuale superano la capacità del sistema. Modifica le impostazioni e riprova.", + "importFailed": "Importazione non riuscita", + "importSuccessful": "Importazione riuscita" }, "tooltip": { "feature": { @@ -1526,7 +1528,7 @@ }, "upscaleModel": { "paragraphs": [ - "Il modello di ampliamento ridimensiona l'immagine alle dimensioni di uscita prima che vengano aggiunti i dettagli. È possibile utilizzare qualsiasi modello di ampliamento supportato, ma alcuni sono specializzati per diversi tipi di immagini, come foto o disegni al tratto." + "Il modello di ampliamento (Upscale), scala l'immagine alle dimensioni di uscita prima di aggiungere i dettagli. È possibile utilizzare qualsiasi modello di ampliamento supportato, ma alcuni sono specializzati per diversi tipi di immagini, come foto o disegni al tratto." ], "heading": "Modello di ampliamento" }, @@ -1735,12 +1737,52 @@ "missingUpscaleModel": "Modello per l’ampliamento mancante", "missingTileControlNetModel": "Nessun modello ControlNet Tile valido installato", "postProcessingModel": "Modello di post-elaborazione", - "postProcessingMissingModelWarning": "Visita Gestione modelli per installare un modello di post-elaborazione (da immagine a immagine)." + "postProcessingMissingModelWarning": "Visita Gestione modelli per installare un modello di post-elaborazione (da immagine a immagine).", + "exceedsMaxSize": "Le impostazioni di ampliamento superano il limite massimo delle dimensioni", + "exceedsMaxSizeDetails": "Il limite massimo di ampliamento è {{maxUpscaleDimension}}x{{maxUpscaleDimension}} pixel. Prova un'immagine più piccola o diminuisci la scala selezionata." }, "upsell": { "inviteTeammates": "Invita collaboratori", "shareAccess": "Condividi l'accesso", "professional": "Professionale", "professionalUpsell": "Disponibile nell'edizione Professional di Invoke. Fai clic qui o visita invoke.com/pricing per ulteriori dettagli." + }, + "stylePresets": { + "active": "Attivo", + "choosePromptTemplate": "Scegli un modello di prompt", + "clearTemplateSelection": "Cancella selezione modello", + "copyTemplate": "Copia modello", + "createPromptTemplate": "Crea modello di prompt", + "defaultTemplates": "Modelli predefiniti", + "deleteImage": "Elimina immagine", + "deleteTemplate": "Elimina modello", + "editTemplate": "Modifica modello", + "flatten": "Unisci il modello selezionato al prompt corrente", + "insertPlaceholder": "Inserisci segnaposto", + "myTemplates": "I miei modelli", + "name": "Nome", + "negativePrompt": "Prompt Negativo", + "noMatchingTemplates": "Nessun modello corrispondente", + "promptTemplatesDesc1": "I modelli di prompt aggiungono testo ai prompt che scrivi nelle caselle dei prompt.", + "promptTemplatesDesc3": "Se si omette il segnaposto, il modello verrà aggiunto alla fine del prompt.", + "positivePrompt": "Prompt Positivo", + "preview": "Anteprima", + "private": "Privato", + "searchByName": "Cerca per nome", + "shared": "Condiviso", + "sharedTemplates": "Modelli condivisi", + "templateDeleted": "Modello di prompt eliminato", + "toggleViewMode": "Attiva/disattiva visualizzazione", + "uploadImage": "Carica immagine", + "useForTemplate": "Usa per modello di prompt", + "viewList": "Visualizza l'elenco dei modelli", + "viewModeTooltip": "Ecco come apparirà il tuo prompt con il modello attualmente selezionato. Per modificare il tuo prompt, clicca in un punto qualsiasi della casella di testo.", + "deleteTemplate2": "Vuoi davvero eliminare questo modello? Questa operazione non può essere annullata.", + "unableToDeleteTemplate": "Impossibile eliminare il modello di prompt", + "updatePromptTemplate": "Aggiorna il modello di prompt", + "type": "Tipo", + "promptTemplatesDesc2": "Utilizza la stringa segnaposto
{{placeholder}}
per specificare dove inserire il tuo prompt nel modello.", + "importTemplates": "Importa modelli di prompt", + "importTemplatesDesc": "Il formato deve essere un CSV con colonne 'name' e 'prompt' o 'positive_prompt' e 'negative_prompt' incluse, oppure un file JSON con chiavi 'name' e 'prompt' o 'positive_prompt' e 'negative_prompt" } } diff --git a/invokeai/frontend/web/public/locales/zh_CN.json b/invokeai/frontend/web/public/locales/zh_CN.json index 0a46bc5b32..dad9ad1c21 100644 --- a/invokeai/frontend/web/public/locales/zh_CN.json +++ b/invokeai/frontend/web/public/locales/zh_CN.json @@ -493,7 +493,8 @@ "defaultSettingsSaved": "默认设置已保存", "huggingFacePlaceholder": "所有者或模型名称", "huggingFaceRepoID": "HuggingFace仓库ID", - "loraTriggerPhrases": "LoRA 触发词" + "loraTriggerPhrases": "LoRA 触发词", + "ipAdapters": "IP适配器" }, "parameters": { "images": "图像", @@ -1702,7 +1703,9 @@ "upscaleModelDesc": "图像放大(图像到图像转换)模型", "postProcessingMissingModelWarning": "请访问 模型管理器来安装一个后处理(图像到图像转换)模型.", "missingModelsWarning": "请访问模型管理器 安装所需的模型:", - "mainModelDesc": "主模型(SD1.5或SDXL架构)" + "mainModelDesc": "主模型(SD1.5或SDXL架构)", + "exceedsMaxSize": "放大设置超出了最大尺寸限制", + "exceedsMaxSizeDetails": "最大放大限制是 {{maxUpscaleDimension}}x{{maxUpscaleDimension}} 像素.请尝试一个较小的图像或减少您的缩放选择." }, "upsell": { "inviteTeammates": "邀请团队成员", diff --git a/invokeai/frontend/web/src/app/components/App.tsx b/invokeai/frontend/web/src/app/components/App.tsx index 760eddbee8..41f3d97051 100644 --- a/invokeai/frontend/web/src/app/components/App.tsx +++ b/invokeai/frontend/web/src/app/components/App.tsx @@ -13,11 +13,13 @@ import ChangeBoardModal from 'features/changeBoardModal/components/ChangeBoardMo import DeleteImageModal from 'features/deleteImageModal/components/DeleteImageModal'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; import { useStarterModelsToast } from 'features/modelManagerV2/hooks/useStarterModelsToast'; +import { StylePresetModal } from 'features/stylePresets/components/StylePresetForm/StylePresetModal'; import { configChanged } from 'features/system/store/configSlice'; import { languageSelector } from 'features/system/store/systemSelectors'; import InvokeTabs from 'features/ui/components/InvokeTabs'; import type { InvokeTabName } from 'features/ui/store/tabMap'; import { setActiveTab } from 'features/ui/store/uiSlice'; +import { useGetAndLoadLibraryWorkflow } from 'features/workflowLibrary/hooks/useGetAndLoadLibraryWorkflow'; import { AnimatePresence } from 'framer-motion'; import i18n from 'i18n'; import { size } from 'lodash-es'; @@ -36,10 +38,11 @@ interface Props { imageName: string; action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters'; }; + selectedWorkflowId?: string; destination?: InvokeTabName | undefined; } -const App = ({ config = DEFAULT_CONFIG, selectedImage, destination }: Props) => { +const App = ({ config = DEFAULT_CONFIG, selectedImage, selectedWorkflowId, destination }: Props) => { const language = useAppSelector(languageSelector); const logger = useLogger('system'); const dispatch = useAppDispatch(); @@ -70,6 +73,14 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, destination }: Props) => } }, [dispatch, config, logger]); + const { getAndLoadWorkflow } = useGetAndLoadLibraryWorkflow(); + + useEffect(() => { + if (selectedWorkflowId) { + getAndLoadWorkflow(selectedWorkflowId); + } + }, [selectedWorkflowId, getAndLoadWorkflow]); + useEffect(() => { if (destination) { dispatch(setActiveTab(destination)); @@ -104,6 +115,7 @@ const App = ({ config = DEFAULT_CONFIG, selectedImage, destination }: Props) => + ); diff --git a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx index 0a80b7e92d..5804902408 100644 --- a/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx +++ b/invokeai/frontend/web/src/app/components/InvokeAIUI.tsx @@ -44,6 +44,7 @@ interface Props extends PropsWithChildren { imageName: string; action: 'sendToImg2Img' | 'sendToCanvas' | 'useAllParameters'; }; + selectedWorkflowId?: string; destination?: InvokeTabName; customStarUi?: CustomStarUi; socketOptions?: Partial; @@ -64,6 +65,7 @@ const InvokeAIUI = ({ projectUrl, queueId, selectedImage, + selectedWorkflowId, destination, customStarUi, socketOptions, @@ -221,7 +223,12 @@ const InvokeAIUI = ({ }> - + diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts index 4633eb45a5..513c087496 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/promptChanged.ts @@ -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')) { diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 6ae2011355..cacea30e48 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -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) diff --git a/invokeai/frontend/web/src/app/types/invokeai.ts b/invokeai/frontend/web/src/app/types/invokeai.ts index 7de441699a..ffc4e1960b 100644 --- a/invokeai/frontend/web/src/app/types/invokeai.ts +++ b/invokeai/frontend/web/src/app/types/invokeai.ts @@ -71,6 +71,7 @@ export type AppConfig = { */ maxUpscaleDimension?: number; allowPrivateBoards: boolean; + allowPrivateStylePresets: boolean; disabledTabs: InvokeTabName[]; disabledFeatures: AppFeature[]; disabledSDFeatures: SDFeature[]; diff --git a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx index 1a23c458cf..229f9ba6d9 100644 --- a/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx +++ b/invokeai/frontend/web/src/common/components/IAIImageFallback.tsx @@ -47,6 +47,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => { userSelect: 'none', opacity: 0.7, color: 'base.500', + fontSize: 'md', ...sx, }), [sx] @@ -55,11 +56,7 @@ export const IAINoContentFallback = memo((props: IAINoImageFallbackProps) => { return ( {icon && } - {props.label && ( - - {props.label} - - )} + {props.label && {props.label}} ); }); diff --git a/invokeai/frontend/web/src/common/hooks/useCopyImageToClipboard.ts b/invokeai/frontend/web/src/common/hooks/useCopyImageToClipboard.ts index 233b841034..345ea98e13 100644 --- a/invokeai/frontend/web/src/common/hooks/useCopyImageToClipboard.ts +++ b/invokeai/frontend/web/src/common/hooks/useCopyImageToClipboard.ts @@ -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 }; diff --git a/invokeai/frontend/web/src/common/hooks/useImageUrlToBlob.ts b/invokeai/frontend/web/src/common/hooks/useImageUrlToBlob.ts deleted file mode 100644 index 31faf5f22f..0000000000 --- a/invokeai/frontend/web/src/common/hooks/useImageUrlToBlob.ts +++ /dev/null @@ -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 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((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((resolve) => { - canvas.toBlob(function (blob) { - resolve(blob); - }, 'image/png'); - }) - ); - }; - img.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous'; - img.src = url; - }), - [] - ); - - return imageUrlToBlob; -}; diff --git a/invokeai/frontend/web/src/common/util/convertImageUrlToBlob.ts b/invokeai/frontend/web/src/common/util/convertImageUrlToBlob.ts new file mode 100644 index 0000000000..42fdd46609 --- /dev/null +++ b/invokeai/frontend/web/src/common/util/convertImageUrlToBlob.ts @@ -0,0 +1,33 @@ +import { $authToken } from 'app/store/nanostores/authToken'; + +/** + * Converts an image URL to a Blob by creating an 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((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((resolve) => { + canvas.toBlob(function (blob) { + resolve(blob); + }, 'image/png'); + }) + ); + }; + img.crossOrigin = $authToken.get() ? 'use-credentials' : 'anonymous'; + img.src = url; + }); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx index ab12684c11..1ae4b7b6bc 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -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')} + : } + onClickCapture={createAsPreset} + isDisabled={isLoadingMetadata || !hasPrompts} + > + {t('stylePresets.useForTemplate')} + } onClickCapture={handleSendToImageToImage} id="send-to-img2img"> {t('parameters.sendToImg2Img')} diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts index 2978e08f4f..df67bd93bf 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useImageActions.ts @@ -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, + }; }; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildMultidiffusionUpscaleGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildMultidiffusionUpscaleGraph.ts index 2905f31b50..7c17b406de 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildMultidiffusionUpscaleGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildMultidiffusionUpscaleGraph.ts @@ -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 => { 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, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/addSDXLRefinerToGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/addSDXLRefinerToGraph.ts index 6fc406ca74..7809744afa 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/addSDXLRefinerToGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/addSDXLRefinerToGraph.ts @@ -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))); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasImageToImageGraph.ts index 5c89dcbf29..3d17da9b4a 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasImageToImageGraph.ts @@ -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 diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasInpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasInpaintGraph.ts index 20304b8830..39cdbb31cd 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasInpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasInpaintGraph.ts @@ -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: { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasOutpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasOutpaintGraph.ts index 2c85b20222..ce8f86223f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasOutpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasOutpaintGraph.ts @@ -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: { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLImageToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLImageToImageGraph.ts index b4549ff582..bcf2f8a93c 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLImageToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLImageToImageGraph.ts @@ -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 diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLInpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLInpaintGraph.ts index dfbe2436d2..e56e4d0ddb 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLInpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLInpaintGraph.ts @@ -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, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLOutpaintGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLOutpaintGraph.ts index d58796575c..095918ab71 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLOutpaintGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLOutpaintGraph.ts @@ -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, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLTextToImageGraph.ts index b9e8e011b3..2b37255070 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasSDXLTextToImageGraph.ts @@ -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 diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasTextToImageGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasTextToImageGraph.ts index fe33ab5cf3..352dc0771d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasTextToImageGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/canvas/buildCanvasTextToImageGraph.ts @@ -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 { }; /** - * 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, }; diff --git a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx index d75c98c064..0f32bdb435 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Core/ParamNegativePrompt.tsx @@ -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(null); const { t } = useTranslation(); const _onChange = useCallback( @@ -27,22 +43,34 @@ export const ParamNegativePrompt = memo(() => { return ( - +