From 2d587547896bda30d2f5bf3f67358d91bc2abd2a Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Wed, 14 Aug 2024 11:09:11 -0400 Subject: [PATCH 01/11] feat(api): add endpoint to take a CSV, parse it, validate it, and create many style preset entries --- invokeai/app/api/routers/style_presets.py | 27 +++++++++++++++ .../style_preset_records_base.py | 5 +++ .../style_preset_records_common.py | 29 +++++++++++++++- .../style_preset_records_sqlite.py | 33 +++++++++++++++++++ 4 files changed, 93 insertions(+), 1 deletion(-) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index 786c522c20..d7673cc25d 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -16,9 +16,11 @@ from invokeai.app.services.style_preset_records.style_preset_records_common impo PresetData, PresetType, StylePresetChanges, + StylePresetImportValidationError, StylePresetNotFoundError, StylePresetRecordWithImage, StylePresetWithoutId, + parse_csv, ) @@ -225,3 +227,28 @@ async def get_style_preset_image( return response except Exception: raise HTTPException(status_code=404) + + +@style_presets_router.post( + "/import", + operation_id="import_style_presets", +) +async def import_style_presets(file: UploadFile = File(description="The file to import")): + if not file.filename.endswith(".csv"): + raise HTTPException(status_code=400, detail="Invalid file type") + + try: + parsed_data = parse_csv(file) + except StylePresetImportValidationError: + raise HTTPException( + status_code=400, detail="Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt'" + ) + + style_presets: list[StylePresetWithoutId] = [] + + for style_preset in parsed_data: + preset_data = PresetData(positive_prompt=style_preset.prompt, negative_prompt=style_preset.negative_prompt) + style_preset = StylePresetWithoutId(name=style_preset.name, preset_data=preset_data, type=PresetType.User) + style_presets.append(style_preset) + + ApiDependencies.invoker.services.style_preset_records.create_many(style_presets) 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 index 9e3a504e06..282388c7e4 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_base.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_base.py @@ -20,6 +20,11 @@ class StylePresetRecordsStorageBase(ABC): """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.""" 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 index 964489b54d..d20b019e3e 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_common.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -1,6 +1,9 @@ +import csv +import io from enum import Enum -from typing import Any, Optional +from typing import Any, Generator, Optional +from fastapi import UploadFile from pydantic import BaseModel, Field, TypeAdapter from invokeai.app.util.metaenum import MetaEnum @@ -10,6 +13,10 @@ class StylePresetNotFoundError(Exception): """Raised when a style preset is not found""" +class StylePresetImportValidationError(Exception): + """Raised when a style preset import is not valid""" + + class PresetData(BaseModel, extra="forbid"): positive_prompt: str = Field(description="Positive prompt") negative_prompt: str = Field(description="Negative prompt") @@ -49,3 +56,23 @@ StylePresetRecordDTOValidator = TypeAdapter(StylePresetRecordDTO) class StylePresetRecordWithImage(StylePresetRecordDTO): image: Optional[str] = Field(description="The path for image") + + +class StylePresetImportRow(BaseModel): + name: str + prompt: str + negative_prompt: str + + +def parse_csv(file: UploadFile) -> Generator[StylePresetImportRow, None, None]: + """Yield parsed and validated rows from the CSV file.""" + file_content = file.file.read().decode("utf-8") + csv_reader = csv.DictReader(io.StringIO(file_content)) + + for row in csv_reader: + if "name" not in row or "prompt" not in row or "negative_prompt" not in row: + raise StylePresetImportValidationError() + + yield StylePresetImportRow( + name=row["name"].strip(), prompt=row["prompt"].strip(), negative_prompt=row["negative_prompt"].strip() + ) 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 index a98ff462f2..952cf35ba9 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_sqlite.py @@ -75,6 +75,39 @@ class SqliteStylePresetRecordsStorage(StylePresetRecordsStorageBase): 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() From 76b0380b5f9597bf5411c72343a2601047f56e2e Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Wed, 14 Aug 2024 11:09:53 -0400 Subject: [PATCH 02/11] feat(ui): create component to upload CSV of style presets to import --- invokeai/frontend/web/public/locales/en.json | 4 + .../components/StylePresetImport.tsx | 73 +++++++++++++++++++ .../components/StylePresetMenu.tsx | 25 ++++--- .../services/api/endpoints/stylePresets.ts | 18 +++++ .../frontend/web/src/services/api/schema.ts | 59 +++++++++++++++ 5 files changed, 169 insertions(+), 10 deletions(-) create mode 100644 invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 5b99a7c11e..0093f38ec1 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", @@ -1701,6 +1703,8 @@ "deleteTemplate2": "Are you sure you want to delete this template? This cannot be undone.", "editTemplate": "Edit Template", "flatten": "Flatten selected template into current prompt", + "importTemplates": "Import Prompt Templates", + "importTemplatesDesc": "Format must be csv with columns: 'name', 'prompt', and 'negative_prompt' included", "insertPlaceholder": "Insert placeholder", "myTemplates": "My Templates", "name": "Name", diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx new file mode 100644 index 0000000000..00d55735c2 --- /dev/null +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx @@ -0,0 +1,73 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; +import { IconButton, spinAnimation, Text } from '@invoke-ai/ui-library'; +import { toast } from 'features/toast/toast'; +import type { ChangeEvent } from 'react'; +import { useCallback, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiSpinner, PiUploadBold } from 'react-icons/pi'; +import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; + +const loadingStyles: SystemStyleObject = { + svg: { animation: spinAnimation }, +}; + +export const StylePresetImport = () => { + const [importStylePresets, { isLoading }] = useImportStylePresetsMutation(); + const fileInputRef = useRef(null); + const { t } = useTranslation(); + + const handleClickUpload = useCallback(() => { + if (fileInputRef.current) { + fileInputRef.current.click(); + } + }, [fileInputRef]); + + const handleFileChange = useCallback( + async (event: ChangeEvent) => { + if (event.target.files) { + const file = event.target.files[0]; + if (!file) { + return; + } + + try { + await importStylePresets(file).unwrap(); + toast({ + status: 'success', + title: t('toast.importSuccessful'), + }); + } catch (error) { + toast({ + status: 'error', + title: t('toast.importFailed'), + }); + } + } + }, + [importStylePresets, t] + ); + + return ( +
+ + + : } + tooltip={ + <> + {t('stylePresets.importTemplates')} + {t('stylePresets.importTemplatesDesc')} + + } + aria-label={t('stylePresets.importTemplates')} + onClick={handleClickUpload} + size="md" + variant="link" + w={8} + h={8} + sx={isLoading ? loadingStyles : undefined} + isDisabled={isLoading} + /> +
+ ); +}; diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx index 021c274048..b024a246fd 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenu.tsx @@ -8,6 +8,7 @@ import { PiPlusBold } from 'react-icons/pi'; import type { StylePresetRecordWithImage } from 'services/api/endpoints/stylePresets'; import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; +import { StylePresetImport } from './StylePresetImport'; import { StylePresetList } from './StylePresetList'; import StylePresetSearch from './StylePresetSearch'; @@ -60,16 +61,20 @@ export const StylePresetMenu = () => { - } - tooltip={t('stylePresets.createPromptTemplate')} - aria-label={t('stylePresets.createPromptTemplate')} - onClick={handleClickAddNew} - size="md" - variant="link" - w={8} - h={8} - /> + + + + } + tooltip={t('stylePresets.createPromptTemplate')} + aria-label={t('stylePresets.createPromptTemplate')} + onClick={handleClickAddNew} + size="md" + variant="link" + w={8} + h={8} + /> + {data.presets.length === 0 && data.defaultPresets.length === 0 && ( diff --git a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts index 2e1b1a7108..7820fbd1f7 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/stylePresets.ts @@ -92,6 +92,23 @@ export const stylePresetsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'StylePreset', id: LIST_TAG }], }), + importStylePresets: build.mutation< + paths['/api/v1/style_presets/import']['post']['responses']['200']['content']['application/json'], + paths['/api/v1/style_presets/import']['post']['requestBody']['content']['multipart/form-data']['file'] + >({ + query: (file) => { + const formData = new FormData(); + + formData.append('file', file); + + return { + url: buildStylePresetsUrl('import'), + method: 'POST', + body: formData, + }; + }, + invalidatesTags: [{ type: 'StylePreset', id: LIST_TAG }], + }), }), }); @@ -100,4 +117,5 @@ export const { useDeleteStylePresetMutation, useUpdateStylePresetMutation, useListStylePresetsQuery, + useImportStylePresetsMutation, } = stylePresetsApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 02c1c88412..52e8f7dd13 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1344,6 +1344,23 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/style_presets/import": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Import Style Presets */ + post: operations["import_style_presets"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; }; export type webhooks = Record; export type components = { @@ -1998,6 +2015,15 @@ export type components = { */ prepend?: boolean; }; + /** Body_import_style_presets */ + Body_import_style_presets: { + /** + * File + * Format: binary + * @description The file to import + */ + file: Blob; + }; /** Body_parse_dynamicprompts */ Body_parse_dynamicprompts: { /** @@ -18083,4 +18109,37 @@ export interface operations { }; }; }; + import_style_presets: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_import_style_presets"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; } From 15415c6d85cb7efbd6604d65e7080841aba52dc7 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:40:42 +1000 Subject: [PATCH 03/11] feat(ui): use dropzone for style preset upload Easier to accept multiple file types and supper drag and drop in the future. --- .../components/StylePresetImport.tsx | 64 +++++++++---------- 1 file changed, 30 insertions(+), 34 deletions(-) diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx index 00d55735c2..9a248e0e0f 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetImport.tsx @@ -1,8 +1,8 @@ import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { IconButton, spinAnimation, Text } from '@invoke-ai/ui-library'; import { toast } from 'features/toast/toast'; -import type { ChangeEvent } from 'react'; -import { useCallback, useRef } from 'react'; +import { useCallback } from 'react'; +import { useDropzone } from 'react-dropzone'; import { useTranslation } from 'react-i18next'; import { PiSpinner, PiUploadBold } from 'react-icons/pi'; import { useImportStylePresetsMutation } from 'services/api/endpoints/stylePresets'; @@ -13,44 +13,39 @@ const loadingStyles: SystemStyleObject = { export const StylePresetImport = () => { const [importStylePresets, { isLoading }] = useImportStylePresetsMutation(); - const fileInputRef = useRef(null); const { t } = useTranslation(); - const handleClickUpload = useCallback(() => { - if (fileInputRef.current) { - fileInputRef.current.click(); - } - }, [fileInputRef]); - - const handleFileChange = useCallback( - async (event: ChangeEvent) => { - if (event.target.files) { - const file = event.target.files[0]; - if (!file) { - return; - } - - try { - await importStylePresets(file).unwrap(); - toast({ - status: 'success', - title: t('toast.importSuccessful'), - }); - } catch (error) { - toast({ - status: 'error', - title: t('toast.importFailed'), - }); - } + const onDropAccepted = useCallback( + async (files: File[]) => { + const file = files[0]; + if (!file) { + return; + } + try { + await importStylePresets(file).unwrap(); + toast({ + status: 'success', + title: t('toast.importSuccessful'), + }); + } catch (error) { + toast({ + status: 'error', + title: t('toast.importFailed'), + }); } }, [importStylePresets, t] ); - return ( -
- + const { getInputProps, getRootProps } = useDropzone({ + accept: { 'text/csv': ['.csv'], 'application/json': ['.json'] }, + onDropAccepted, + noDrag: true, + multiple: false, + }); + return ( + <> : } tooltip={ @@ -60,14 +55,15 @@ export const StylePresetImport = () => { } aria-label={t('stylePresets.importTemplates')} - onClick={handleClickUpload} size="md" variant="link" w={8} h={8} sx={isLoading ? loadingStyles : undefined} isDisabled={isLoading} + {...getRootProps()} /> -
+ + ); }; From deb917825e8a5d05422be0ed3a2a3650be601fd6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:44:13 +1000 Subject: [PATCH 04/11] feat(api): use pydantic validation during style preset import - Enforce name is present and not an empty string - Provide empty string as default for positive and negative prompt - Add `positive_prompt` as validation alias for `prompt` field - Strip whitespace automatically - Create `TypeAdapter` to validate the whole list in one go --- .../style_preset_records_common.py | 20 ++++++++++++++----- 1 file changed, 15 insertions(+), 5 deletions(-) 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 index d20b019e3e..11c5c3b8c0 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_common.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -4,7 +4,7 @@ from enum import Enum from typing import Any, Generator, Optional from fastapi import UploadFile -from pydantic import BaseModel, Field, TypeAdapter +from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter from invokeai.app.util.metaenum import MetaEnum @@ -59,9 +59,19 @@ class StylePresetRecordWithImage(StylePresetRecordDTO): class StylePresetImportRow(BaseModel): - name: str - prompt: str - negative_prompt: str + 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) + + +StylePresetImportList = list[StylePresetImportRow] +StylePresetImportListTypeAdapter = TypeAdapter(StylePresetImportList) def parse_csv(file: UploadFile) -> Generator[StylePresetImportRow, None, None]: @@ -74,5 +84,5 @@ def parse_csv(file: UploadFile) -> Generator[StylePresetImportRow, None, None]: raise StylePresetImportValidationError() yield StylePresetImportRow( - name=row["name"].strip(), prompt=row["prompt"].strip(), negative_prompt=row["negative_prompt"].strip() + name=row["name"].strip(), positive_prompt=row["prompt"].strip(), negative_prompt=row["negative_prompt"].strip() ) From 356661459bfaa617d723a165a94c0aacea272872 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:45:04 +1000 Subject: [PATCH 05/11] feat(api): support JSON for preset imports This allows us to support Fooocus format presets. --- invokeai/app/api/routers/style_presets.py | 48 ++++++++++++------- .../style_preset_records_common.py | 19 +------- 2 files changed, 33 insertions(+), 34 deletions(-) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index d7673cc25d..ccea914750 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -1,13 +1,15 @@ +import csv import io import json import traceback +from codecs import iterdecode from typing import Optional import pydantic from fastapi import APIRouter, File, Form, HTTPException, Path, UploadFile from fastapi.responses import FileResponse from PIL import Image -from pydantic import BaseModel, Field +from pydantic import BaseModel, Field, ValidationError from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.routers.model_manager import IMAGE_MAX_AGE @@ -16,11 +18,10 @@ from invokeai.app.services.style_preset_records.style_preset_records_common impo PresetData, PresetType, StylePresetChanges, - StylePresetImportValidationError, + StylePresetImportListTypeAdapter, StylePresetNotFoundError, StylePresetRecordWithImage, StylePresetWithoutId, - parse_csv, ) @@ -234,21 +235,36 @@ async def get_style_preset_image( operation_id="import_style_presets", ) async def import_style_presets(file: UploadFile = File(description="The file to import")): - if not file.filename.endswith(".csv"): - raise HTTPException(status_code=400, detail="Invalid file type") + if file.content_type not in ["text/csv", "application/json"]: + raise HTTPException(status_code=400, detail="Unsupported file type") try: - parsed_data = parse_csv(file) - except StylePresetImportValidationError: - raise HTTPException( - status_code=400, detail="Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt'" - ) + if file.content_type == "text/csv": + csv_reader = csv.DictReader(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) - style_presets: list[StylePresetWithoutId] = [] + imported_presets = StylePresetImportListTypeAdapter.validate_python(data) - for style_preset in parsed_data: - preset_data = PresetData(positive_prompt=style_preset.prompt, negative_prompt=style_preset.negative_prompt) - style_preset = StylePresetWithoutId(name=style_preset.name, preset_data=preset_data, type=PresetType.User) - style_presets.append(style_preset) + style_presets: list[StylePresetWithoutId] = [] - ApiDependencies.invoker.services.style_preset_records.create_many(style_presets) + 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) + ApiDependencies.invoker.services.style_preset_records.create_many(style_presets) + except ValidationError: + if file.content_type == "text/csv": + raise HTTPException( + status_code=400, + detail="Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt'", + ) + else: # file.content_type == "application/json": + raise HTTPException( + status_code=400, + detail="Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt'", + ) + finally: + file.file.close() 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 index 11c5c3b8c0..2d33a7ea76 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_common.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -1,9 +1,6 @@ -import csv -import io from enum import Enum -from typing import Any, Generator, Optional +from typing import Any, Optional -from fastapi import UploadFile from pydantic import AliasChoices, BaseModel, ConfigDict, Field, TypeAdapter from invokeai.app.util.metaenum import MetaEnum @@ -72,17 +69,3 @@ class StylePresetImportRow(BaseModel): StylePresetImportList = list[StylePresetImportRow] StylePresetImportListTypeAdapter = TypeAdapter(StylePresetImportList) - - -def parse_csv(file: UploadFile) -> Generator[StylePresetImportRow, None, None]: - """Yield parsed and validated rows from the CSV file.""" - file_content = file.file.read().decode("utf-8") - csv_reader = csv.DictReader(io.StringIO(file_content)) - - for row in csv_reader: - if "name" not in row or "prompt" not in row or "negative_prompt" not in row: - raise StylePresetImportValidationError() - - yield StylePresetImportRow( - name=row["name"].strip(), positive_prompt=row["prompt"].strip(), negative_prompt=row["negative_prompt"].strip() - ) From bcbf8b6bd84e883e483fd2c2603331cb4128c316 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:48:57 +1000 Subject: [PATCH 06/11] feat(ui): revert to using `{prompt}` for prompt template placeholder --- .../default_style_presets.json | 36 +++++++++---------- .../hooks/usePresetModifiedPrompts.ts | 2 +- 2 files changed, 19 insertions(+), 19 deletions(-) diff --git a/invokeai/app/services/style_preset_records/default_style_presets.json b/invokeai/app/services/style_preset_records/default_style_presets.json index aa9ede40dc..1daadfa8ff 100644 --- a/invokeai/app/services/style_preset_records/default_style_presets.json +++ b/invokeai/app/services/style_preset_records/default_style_presets.json @@ -3,7 +3,7 @@ "name": "Photography (General)", "type": "default", "preset_data": { - "positive_prompt": "[prompt]. photography. f/2.8 macro photo, bokeh, photorealism", + "positive_prompt": "{prompt}. photography. f/2.8 macro photo, bokeh, photorealism", "negative_prompt": "painting, digital art. sketch, blurry" } }, @@ -11,7 +11,7 @@ "name": "Photography (Studio Lighting)", "type": "default", "preset_data": { - "positive_prompt": "[prompt], photography. f/8 photo. centered subject, studio lighting.", + "positive_prompt": "{prompt}, photography. f/8 photo. centered subject, studio lighting.", "negative_prompt": "painting, digital art. sketch, blurry" } }, @@ -19,7 +19,7 @@ "name": "Photography (Landscape)", "type": "default", "preset_data": { - "positive_prompt": "[prompt], landscape photograph, f/12, lifelike, highly detailed.", + "positive_prompt": "{prompt}, landscape photograph, f/12, lifelike, highly detailed.", "negative_prompt": "painting, digital art. sketch, blurry" } }, @@ -27,7 +27,7 @@ "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.", + "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" } }, @@ -35,7 +35,7 @@ "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", + "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+" } }, @@ -43,7 +43,7 @@ "name": "Architectural Visualization", "type": "default", "preset_data": { - "positive_prompt": "[prompt]. architectural photography, f/12, luxury, aesthetically pleasing form and function.", + "positive_prompt": "{prompt}. architectural photography, f/12, luxury, aesthetically pleasing form and function.", "negative_prompt": "painting, digital art. sketch, blurry" } }, @@ -51,7 +51,7 @@ "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+", + "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.)++" } }, @@ -59,7 +59,7 @@ "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", + "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.)++" } }, @@ -67,7 +67,7 @@ "name": "Concept Art (Character)", "type": "default", "preset_data": { - "positive_prompt": "(character concept art)++, stylized painterly digital painting of [prompt], (painterly, impasto. Dry brush.)++", + "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.)++" } }, @@ -75,7 +75,7 @@ "name": "Concept Art (Painterly)", "type": "default", "preset_data": { - "positive_prompt": "[prompt] oil painting. high contrast. impasto. sfumato. chiaroscuro. Palette knife.", + "positive_prompt": "{prompt} oil painting. high contrast. impasto. sfumato. chiaroscuro. Palette knife.", "negative_prompt": "photo. smooth. border. frame" } }, @@ -83,7 +83,7 @@ "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", + "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." } }, @@ -91,7 +91,7 @@ "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", + "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." } }, @@ -99,7 +99,7 @@ "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.", + "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." } }, @@ -107,7 +107,7 @@ "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+++", + "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." } }, @@ -115,7 +115,7 @@ "name": "Line Art", "type": "default", "preset_data": { - "positive_prompt": "[prompt] Line art. bold outline. simplistic. white background. 2d", + "positive_prompt": "{prompt} Line art. bold outline. simplistic. white background. 2d", "negative_prompt": "photo. digital art. greyscale. solid black. painting" } }, @@ -123,7 +123,7 @@ "name": "Anime", "type": "default", "preset_data": { - "positive_prompt": "[prompt] anime++, bold outline, cel-shaded coloring, shounen, seinen", + "positive_prompt": "{prompt} anime++, bold outline, cel-shaded coloring, shounen, seinen", "negative_prompt": "(photo)+++. greyscale. solid black. painting" } }, @@ -131,7 +131,7 @@ "name": "Illustration", "type": "default", "preset_data": { - "positive_prompt": "[prompt] illustration, bold linework, illustrative details, vector art style, flat coloring", + "positive_prompt": "{prompt} illustration, bold linework, illustrative details, vector art style, flat coloring", "negative_prompt": "(photo)+++. greyscale. painting, black and white." } }, @@ -139,7 +139,7 @@ "name": "Vehicles", "type": "default", "preset_data": { - "positive_prompt": "A weird futuristic normal auto, [prompt] elegant design, nice color, nice wheels", + "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/frontend/web/src/features/stylePresets/hooks/usePresetModifiedPrompts.ts b/invokeai/frontend/web/src/features/stylePresets/hooks/usePresetModifiedPrompts.ts index 0b4d97d53b..121840db67 100644 --- a/invokeai/frontend/web/src/features/stylePresets/hooks/usePresetModifiedPrompts.ts +++ b/invokeai/frontend/web/src/features/stylePresets/hooks/usePresetModifiedPrompts.ts @@ -1,7 +1,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { useListStylePresetsQuery } from 'services/api/endpoints/stylePresets'; -export const PRESET_PLACEHOLDER = `[prompt]`; +export const PRESET_PLACEHOLDER = '{prompt}'; export const buildPresetModifiedPrompt = (presetPrompt: string, currentPrompt: string) => { return presetPrompt.includes(PRESET_PLACEHOLDER) From bd07c86db94951c72bfd9f12069a4f173de875d7 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 15 Aug 2024 14:52:24 +1000 Subject: [PATCH 07/11] feat(ui): make style preset menu trigger look like button --- .../stylePresets/components/StylePresetMenuTrigger.tsx | 8 ++++++++ 1 file changed, 8 insertions(+) diff --git a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuTrigger.tsx b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuTrigger.tsx index ac623a4963..bb1c494d88 100644 --- a/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuTrigger.tsx +++ b/invokeai/frontend/web/src/features/stylePresets/components/StylePresetMenuTrigger.tsx @@ -1,3 +1,4 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; import { Flex, IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $isMenuOpen } from 'features/stylePresets/store/isMenuOpen'; @@ -7,6 +8,10 @@ import { PiCaretDownBold } from 'react-icons/pi'; import { ActiveStylePreset } from './ActiveStylePreset'; +const _hover: SystemStyleObject = { + bg: 'base.750', +}; + export const StylePresetMenuTrigger = () => { const isMenuOpen = useStore($isMenuOpen); const { t } = useTranslation(); @@ -26,6 +31,9 @@ export const StylePresetMenuTrigger = () => { borderRadius="base" gap={1} role="button" + _hover={_hover} + transitionProperty="background-color" + transitionDuration="normal" > From 60d754d1dffccc0008c5edc4269a7f4ff9870689 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 15 Aug 2024 15:31:53 +1000 Subject: [PATCH 08/11] feat(api): tidy style presets import logic - Extract parsing into utility function - Log import errors - Forbid extra properties on the imported data --- invokeai/app/api/routers/style_presets.py | 46 +++-------- .../style_preset_records_common.py | 77 +++++++++++++++++-- 2 files changed, 83 insertions(+), 40 deletions(-) diff --git a/invokeai/app/api/routers/style_presets.py b/invokeai/app/api/routers/style_presets.py index ccea914750..14d8c666aa 100644 --- a/invokeai/app/api/routers/style_presets.py +++ b/invokeai/app/api/routers/style_presets.py @@ -1,27 +1,27 @@ -import csv import io import json import traceback -from codecs import iterdecode from typing import Optional import pydantic from fastapi import APIRouter, File, Form, HTTPException, Path, UploadFile from fastapi.responses import FileResponse from PIL import Image -from pydantic import BaseModel, Field, ValidationError +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, - StylePresetImportListTypeAdapter, StylePresetNotFoundError, StylePresetRecordWithImage, StylePresetWithoutId, + UnsupportedFileTypeError, + parse_presets_from_file, ) @@ -235,36 +235,12 @@ async def get_style_preset_image( operation_id="import_style_presets", ) async def import_style_presets(file: UploadFile = File(description="The file to import")): - if file.content_type not in ["text/csv", "application/json"]: - raise HTTPException(status_code=400, detail="Unsupported file type") - try: - if file.content_type == "text/csv": - csv_reader = csv.DictReader(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) - - 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) + style_presets = await parse_presets_from_file(file) ApiDependencies.invoker.services.style_preset_records.create_many(style_presets) - except ValidationError: - if file.content_type == "text/csv": - raise HTTPException( - status_code=400, - detail="Invalid CSV format: must include columns 'name', 'prompt', and 'negative_prompt'", - ) - else: # file.content_type == "application/json": - raise HTTPException( - status_code=400, - detail="Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt'", - ) - finally: - file.file.close() + 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/services/style_preset_records/style_preset_records_common.py b/invokeai/app/services/style_preset_records/style_preset_records_common.py index 2d33a7ea76..34a30d0377 100644 --- a/invokeai/app/services/style_preset_records/style_preset_records_common.py +++ b/invokeai/app/services/style_preset_records/style_preset_records_common.py @@ -1,6 +1,11 @@ +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 @@ -10,10 +15,6 @@ class StylePresetNotFoundError(Exception): """Raised when a style preset is not found""" -class StylePresetImportValidationError(Exception): - """Raised when a style preset import is not valid""" - - class PresetData(BaseModel, extra="forbid"): positive_prompt: str = Field(description="Positive prompt") negative_prompt: str = Field(description="Negative prompt") @@ -64,8 +65,74 @@ class StylePresetImportRow(BaseModel): ) negative_prompt: str = Field(default="", description="The negative prompt for the preset.") - model_config = ConfigDict(str_strip_whitespace=True) + 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'" + else: # file.content_type == "application/json": + msg = "Invalid JSON format: must be a list of objects with keys 'name', 'prompt', and 'negative_prompt'" + raise InvalidPresetImportDataError(msg) from e + finally: + file.file.close() + + return style_presets From 810be3e1d48812a8fba42bce483b7113984651ad Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 15 Aug 2024 09:04:40 -0400 Subject: [PATCH 09/11] update import directions to include JSON --- invokeai/frontend/web/public/locales/en.json | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0093f38ec1..ec0ae472bb 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1704,7 +1704,7 @@ "editTemplate": "Edit Template", "flatten": "Flatten selected template into current prompt", "importTemplates": "Import Prompt Templates", - "importTemplatesDesc": "Format must be csv with columns: 'name', 'prompt', and 'negative_prompt' included", + "importTemplatesDesc": "Format must be either a CSV with columns: 'name', 'prompt' or 'positive_prompt', and 'negative_prompt' included, or a JSON file with keys 'name', 'prompt' or 'positive_prompt', and 'negative_prompt", "insertPlaceholder": "Insert placeholder", "myTemplates": "My Templates", "name": "Name", From f5c99b14887e75a37a48f0da1db41834b207e0cb Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 15 Aug 2024 09:33:58 -0400 Subject: [PATCH 10/11] exclude jupyter notebooks from ruff --- pyproject.toml | 1 + 1 file changed, 1 insertion(+) diff --git a/pyproject.toml b/pyproject.toml index 733867949e..6da48e1d74 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -198,6 +198,7 @@ exclude = [ "dist", "invokeai/frontend/web/node_modules/", ".venv*", + "*.ipynb", ] [tool.ruff.lint] From dcd61e1f82eb46acb1cd26fcaa4dd8540314d5b4 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 15 Aug 2024 09:37:06 -0400 Subject: [PATCH 11/11] pin ruff version in python check gha --- .github/workflows/python-checks.yml | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) 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