diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index d2be674e53..9b36ee3897 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -36,6 +36,7 @@ from invokeai.app.services.style_preset_images.style_preset_images_disk import S 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.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData from invokeai.backend.util.logging import InvokeAILogger from invokeai.version.invokeai_version import __version__ @@ -83,6 +84,7 @@ class ApiDependencies: model_images_folder = config.models_path style_presets_folder = config.style_presets_path + workflow_thumbnails_folder = config.workflow_thumbnails_path db = init_db(config=config, logger=logger, image_files=image_files) @@ -120,6 +122,7 @@ class ApiDependencies: workflow_records = SqliteWorkflowRecordsStorage(db=db) style_preset_records = SqliteStylePresetRecordsStorage(db=db) style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images") + workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder / "thumbnails") services = InvocationServices( board_image_records=board_image_records, @@ -147,6 +150,7 @@ class ApiDependencies: conditioning=conditioning, style_preset_records=style_preset_records, style_preset_image_files=style_preset_image_files, + workflow_thumbnails=workflow_thumbnails, ) ApiDependencies.invoker = Invoker(services) diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index f82f235dd8..542892a105 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -1,18 +1,27 @@ +import io +import traceback from typing import Optional +import json -from fastapi import APIRouter, Body, HTTPException, Path, Query +from fastapi import APIRouter, Body, File, HTTPException, Path, Query, UploadFile, Form +from fastapi.responses import FileResponse +from PIL import Image from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.model_manager import IMAGE_MAX_AGE from invokeai.app.services.shared.pagination import PaginatedResults from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection from invokeai.app.services.workflow_records.workflow_records_common import ( - Workflow, + WorkflowValidator, WorkflowCategory, WorkflowNotFoundError, WorkflowRecordDTO, WorkflowRecordListItemDTO, WorkflowRecordOrderBy, - WorkflowWithoutID, + WorkflowWithoutIDValidator, +) +from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import ( + WorkflowThumbnailFileNotFoundException, ) workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"]) @@ -30,7 +39,10 @@ async def get_workflow( ) -> WorkflowRecordDTO: """Gets a workflow""" try: - return ApiDependencies.invoker.services.workflow_records.get(workflow_id) + thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow_id) + workflow = ApiDependencies.invoker.services.workflow_records.get(workflow_id) + workflow.thumbnail_url = thumbnail_url + return workflow except WorkflowNotFoundError: raise HTTPException(status_code=404, detail="Workflow not found") @@ -43,10 +55,42 @@ async def get_workflow( }, ) async def update_workflow( - workflow: Workflow = Body(description="The updated workflow", embed=True), + workflow: str = Form(description="The updated workflow"), + image: Optional[UploadFile] = File(description="The image file to upload", default=None), ) -> WorkflowRecordDTO: """Updates a workflow""" - return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow) + + # parsed_data = json.loads(workflow) + validated_workflow = WorkflowValidator.validate_json(workflow) + + 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.workflow_thumbnails.save( + workflow_id=validated_workflow.id, image=pil_image + ) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + else: + try: + ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id=validated_workflow.id) + except WorkflowThumbnailFileNotFoundException: + pass + + updated_workflow = ApiDependencies.invoker.services.workflow_records.update(workflow=validated_workflow) + thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(validated_workflow.id) + updated_workflow.thumbnail_url = thumbnail_url + return updated_workflow @workflows_router.delete( @@ -58,6 +102,7 @@ async def delete_workflow( ) -> None: """Deletes a workflow""" ApiDependencies.invoker.services.workflow_records.delete(workflow_id) + ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id) @workflows_router.post( @@ -68,10 +113,37 @@ async def delete_workflow( }, ) async def create_workflow( - workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True), + workflow: str = Form(description="The workflow to create"), + image: Optional[UploadFile] = File(description="The image file to upload", default=None), ) -> WorkflowRecordDTO: """Creates a workflow""" - return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow) + + # parsed_data = json.loads(workflow) + validated_workflow = WorkflowWithoutIDValidator.validate_json(workflow) + + new_workflow = ApiDependencies.invoker.services.workflow_records.create(workflow=validated_workflow) + + 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.workflow_thumbnails.save( + workflow_id=new_workflow.workflow_id, image=pil_image + ) + except ValueError as e: + raise HTTPException(status_code=409, detail=str(e)) + + thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(new_workflow.workflow_id) + new_workflow.thumbnail_url = thumbnail_url + return new_workflow @workflows_router.get( @@ -92,6 +164,35 @@ async def list_workflows( query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"), ) -> PaginatedResults[WorkflowRecordListItemDTO]: """Gets a page of workflows""" - return ApiDependencies.invoker.services.workflow_records.get_many( + workflows = ApiDependencies.invoker.services.workflow_records.get_many( order_by=order_by, direction=direction, page=page, per_page=per_page, query=query, category=category ) + for workflow in workflows.items: + workflow.thumbnail_url = ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow.workflow_id) + return workflows + + +@workflows_router.get( + "i/{workflow_id}/thumbnail", + operation_id="get_workflow_thumbnail", + responses={ + 200: {"description": "Thumbnail retrieved successfully"}, + 404: {"description": "Thumbnail not found"}, + }, +) +async def get_workflow_thumbnail( + workflow_id: str, +) -> FileResponse: + """Gets the thumbnail for a workflow""" + try: + path = ApiDependencies.invoker.services.workflow_thumbnails.get_path(workflow_id) + response = FileResponse( + path, + media_type="image/png", + filename=f"{workflow_id}.png", + content_disposition_type="inline", + ) + response.headers["Cache-Control"] = f"max-age={IMAGE_MAX_AGE}" + return response + except WorkflowThumbnailFileNotFoundException: + raise HTTPException(status_code=404, detail="Thumbnail not found") diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 712dd218e5..ec63021c69 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -72,6 +72,7 @@ class InvokeAIAppConfig(BaseSettings): 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. + workflow_thumbnails_dir: Path to directory for workflow thumbnails. 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` @@ -142,6 +143,7 @@ class InvokeAIAppConfig(BaseSettings): 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.") + workflow_thumbnails_dir: Path = Field(default=Path("workflow_thumbnails"), description="Path to directory for workflow thumbnails.") # LOGGING log_handlers: list[str] = Field(default=["console"], description='Log handler. Valid options are "console", "file=", "syslog=path|address:host:port", "http=".') @@ -304,6 +306,11 @@ class InvokeAIAppConfig(BaseSettings): """Path to the style presets directory, resolved to an absolute path..""" return self._resolve(self.style_presets_dir) + @property + def workflow_thumbnails_path(self) -> Path: + """Path to the workflow thumbnails directory, resolved to an absolute path..""" + return self._resolve(self.workflow_thumbnails_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 db693dc837..0b22f5feff 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -32,6 +32,7 @@ if TYPE_CHECKING: from invokeai.app.services.session_queue.session_queue_base import SessionQueueBase from invokeai.app.services.urls.urls_base import UrlServiceBase from invokeai.app.services.workflow_records.workflow_records_base import WorkflowRecordsStorageBase + from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import WorkflowThumbnailServiceBase from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData @@ -65,6 +66,7 @@ class InvocationServices: conditioning: "ObjectSerializerBase[ConditioningFieldData]", style_preset_records: "StylePresetRecordsStorageBase", style_preset_image_files: "StylePresetImageFileStorageBase", + workflow_thumbnails: "WorkflowThumbnailServiceBase", ): self.board_images = board_images self.board_image_records = board_image_records @@ -91,3 +93,4 @@ class InvocationServices: self.conditioning = conditioning self.style_preset_records = style_preset_records self.style_preset_image_files = style_preset_image_files + self.workflow_thumbnails = workflow_thumbnails diff --git a/invokeai/app/services/urls/urls_base.py b/invokeai/app/services/urls/urls_base.py index b2e41db3e4..a5602abb3b 100644 --- a/invokeai/app/services/urls/urls_base.py +++ b/invokeai/app/services/urls/urls_base.py @@ -18,3 +18,8 @@ class UrlServiceBase(ABC): def get_style_preset_image_url(self, style_preset_id: str) -> str: """Gets the URL for a style preset image""" pass + + @abstractmethod + def get_workflow_thumbnail_url(self, workflow_id: str) -> str: + """Gets the URL for a workflow thumbnail""" + pass diff --git a/invokeai/app/services/urls/urls_default.py b/invokeai/app/services/urls/urls_default.py index f62bebe901..2e4f36d9d5 100644 --- a/invokeai/app/services/urls/urls_default.py +++ b/invokeai/app/services/urls/urls_default.py @@ -22,3 +22,6 @@ class LocalUrlService(UrlServiceBase): def get_style_preset_image_url(self, style_preset_id: str) -> str: return f"{self._base_url}/style_presets/i/{style_preset_id}/image" + + def get_workflow_thumbnail_url(self, workflow_id: str) -> str: + return f"{self._base_url}/workflows/i/{workflow_id}/thumbnail" diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py index 55781b117d..a695b38915 100644 --- a/invokeai/app/services/workflow_records/workflow_records_common.py +++ b/invokeai/app/services/workflow_records/workflow_records_common.py @@ -101,6 +101,7 @@ class WorkflowRecordDTOBase(BaseModel): created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the workflow.") updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the workflow.") opened_at: Union[datetime.datetime, str] = Field(description="The opened timestamp of the workflow.") + thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.") class WorkflowRecordDTO(WorkflowRecordDTOBase): diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py new file mode 100644 index 0000000000..25174fdca4 --- /dev/null +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_common.py @@ -0,0 +1,47 @@ +from pathlib import Path + +from PIL import Image + + +class WorkflowThumbnailFileNotFoundException(Exception): + """Raised when a workflow thumbnail file is not found""" + + def __init__(self, message: str = "Workflow thumbnail file not found"): + self.message = message + super().__init__(self.message) + + +class WorkflowThumbnailFileSaveException(Exception): + """Raised when a workflow thumbnail file cannot be saved""" + + def __init__(self, message: str = "Workflow thumbnail file cannot be saved"): + self.message = message + super().__init__(self.message) + + +class WorkflowThumbnailFileDeleteException(Exception): + """Raised when a workflow thumbnail file cannot be deleted""" + + def __init__(self, message: str = "Workflow thumbnail file cannot be deleted"): + self.message = message + super().__init__(self.message) + + +class WorkflowThumbnailServiceBase: + """Base class for workflow thumbnail services""" + + def get_path(self, workflow_id: str) -> Path: + """Gets the path to a workflow thumbnail""" + raise NotImplementedError + + def get_url(self, workflow_id: str) -> str | None: + """Gets the URL of a workflow thumbnail""" + raise NotImplementedError + + def save(self, workflow_id: str, image: Image.Image) -> None: + """Saves a workflow thumbnail""" + raise NotImplementedError + + def delete(self, workflow_id: str) -> None: + """Deletes a workflow thumbnail""" + raise NotImplementedError diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py new file mode 100644 index 0000000000..271fde56f2 --- /dev/null +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py @@ -0,0 +1,80 @@ +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.workflow_thumbnails.workflow_thumbnails_common import ( + WorkflowThumbnailFileDeleteException, + WorkflowThumbnailFileNotFoundException, + WorkflowThumbnailFileSaveException, + WorkflowThumbnailServiceBase, +) +from invokeai.app.util.misc import uuid_string +from invokeai.app.util.thumbnails import make_thumbnail + + +class WorkflowThumbnailFileStorageDisk(WorkflowThumbnailServiceBase): + def __init__(self, thumbnails_path: Path): + self._workflow_thumbnail_folder = thumbnails_path + 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 WorkflowThumbnailFileNotFoundException from e + + def save(self, workflow_id: str, image: PILImageType) -> None: + try: + self._validate_storage_folders() + image_path = self._workflow_thumbnail_folder / (workflow_id + ".webp") + thumbnail = make_thumbnail(image, 512) + thumbnail.save(image_path, format="webp") + + except Exception as e: + raise WorkflowThumbnailFileSaveException from e + + def get_path(self, workflow_id: str) -> Path: + path = self._workflow_thumbnail_folder / (workflow_id + ".webp") + + return path + + def get_url(self, workflow_id: str) -> str | None: + path = self.get_path(workflow_id) + if not self._validate_path(path): + return + + url = self._invoker.services.urls.get_workflow_thumbnail_url(workflow_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, workflow_id: str) -> None: + try: + path = self.get_path(workflow_id) + + if not self._validate_path(path): + raise WorkflowThumbnailFileNotFoundException + + path.unlink() + + except WorkflowThumbnailFileNotFoundException as e: + raise WorkflowThumbnailFileNotFoundException from e + except Exception as e: + raise WorkflowThumbnailFileDeleteException 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._workflow_thumbnail_folder.mkdir(parents=True, exist_ok=True) diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx index a1e1166005..5ac0676d3c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowGeneralTab.tsx @@ -11,14 +11,17 @@ import { workflowNameChanged, workflowNotesChanged, workflowTagsChanged, + workflowThumbnailChanged, workflowVersionChanged, } from 'features/nodes/store/workflowSlice'; import type { ChangeEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { WorkflowThumbnailField } from './WorkflowThumbnailField'; + const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { - const { author, name, description, tags, version, contact, notes } = workflow; + const { author, name, description, tags, version, contact, notes, thumbnail } = workflow; return { name, @@ -28,11 +31,12 @@ const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { version, contact, notes, + thumbnail, }; }); const WorkflowGeneralTab = () => { - const { author, name, description, tags, version, contact, notes } = useAppSelector(selector); + const { author, name, description, tags, version, contact, notes, thumbnail } = useAppSelector(selector); const dispatch = useAppDispatch(); const handleChangeName = useCallback( @@ -79,6 +83,13 @@ const WorkflowGeneralTab = () => { [dispatch] ); + const handleChangeThumbnail = useCallback( + (localImageUrl: string | null) => { + dispatch(workflowThumbnailChanged(localImageUrl)); + }, + [dispatch] + ); + const { t } = useTranslation(); return ( @@ -89,10 +100,15 @@ const WorkflowGeneralTab = () => { {t('nodes.workflowName')} + {t('nodes.workflowVersion')} + + Thumbnail + + {t('nodes.workflowAuthor')} diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx new file mode 100644 index 0000000000..6398d1b7fc --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx @@ -0,0 +1,107 @@ +import { Box, Button, Flex, Icon, IconButton, Image, Tooltip } from '@invoke-ai/ui-library'; +import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; +import { useCallback, useEffect, useState } from 'react'; +import { useDropzone } from 'react-dropzone'; +import { useTranslation } from 'react-i18next'; +import { PiArrowCounterClockwiseBold, PiUploadSimpleBold } from 'react-icons/pi'; + +export const WorkflowThumbnailField = ({ + imageUrl, + onChange, +}: { + imageUrl: string | null; + onChange: (localImageUrl: string | null) => void; +}) => { + const [thumbnail, setThumbnail] = useState(null); + + const createThumbnailFromURL = useCallback(async () => { + let file = null; + if (imageUrl) { + try { + const blob = await convertImageUrlToBlob(imageUrl); + if (blob) { + file = new File([blob], 'style_preset.png', { type: 'image/png' }); + } + } catch (error) { + // do nothing + } + } + return file; + }, [imageUrl]); + + useEffect(() => { + createThumbnailFromURL().then(setThumbnail); + }, [createThumbnailFromURL]); + + const { t } = useTranslation(); + + const onDropAccepted = useCallback( + (files: File[]) => { + const file = files[0]; + if (file) { + setThumbnail(file); + onChange(URL.createObjectURL(file)); + } + }, + [onChange] + ); + + const handleResetImage = useCallback(() => { + setThumbnail(null); + }, []); + + const { getInputProps, getRootProps } = useDropzone({ + accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] }, + onDropAccepted, + noDrag: true, + multiple: false, + }); + + if (thumbnail) { + return ( + + + } + size="md" + variant="ghost" + /> + + ); + } + + return ( + <> + + + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts index 7dce3bfb26..82c1fec56b 100644 --- a/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/workflowSlice.ts @@ -109,6 +109,10 @@ export const workflowSlice = createSlice({ state.name = action.payload; state.isTouched = true; }, + workflowThumbnailChanged: (state, action: PayloadAction) => { + state.thumbnail = action.payload; + state.isTouched = true; + }, workflowCategoryChanged: (state, action: PayloadAction) => { if (action.payload) { state.meta.category = action.payload; @@ -309,6 +313,7 @@ export const { formElementNodeFieldDataChanged, formElementContainerDataChanged, formFieldInitialValuesChanged, + workflowThumbnailChanged, } = workflowSlice.actions; /* eslint-disable-next-line @typescript-eslint/no-explicit-any */ @@ -367,6 +372,7 @@ export const selectWorkflowOrderBy = createWorkflowSelector((workflow) => workfl export const selectWorkflowOrderDirection = createWorkflowSelector((workflow) => workflow.orderDirection); export const selectWorkflowDescription = createWorkflowSelector((workflow) => workflow.description); export const selectWorkflowForm = createWorkflowSelector((workflow) => workflow.form); +export const selectWorkflowThumbnail = createWorkflowSelector((workflow) => workflow.thumbnail); export const selectCleanEditor = createSelector([selectNodesSlice, selectWorkflowSlice], (nodes, workflow) => { const noNodes = !nodes.nodes.length; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts index 53119c051e..2dbeb5b1be 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts @@ -1,13 +1,20 @@ import type { ToastId } from '@invoke-ai/ui-library'; import { useToast } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; +import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; -import { formFieldInitialValuesChanged, workflowIDChanged, workflowSaved } from 'features/nodes/store/workflowSlice'; +import { + formFieldInitialValuesChanged, + selectWorkflowThumbnail, + workflowIDChanged, + workflowSaved, +} from 'features/nodes/store/workflowSlice'; import type { WorkflowV3 } from 'features/nodes/types/workflow'; import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/useGetFormInitialValues'; import { workflowUpdated } from 'features/workflowLibrary/store/actions'; import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; import { useCreateWorkflowMutation, useUpdateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; import type { SetRequired } from 'type-fest'; @@ -26,6 +33,7 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const getFormFieldInitialValues = useGetFormFieldInitialValues(); + const thumbnail = useSelector(selectWorkflowThumbnail); const [updateWorkflow, updateWorkflowResult] = useUpdateWorkflowMutation(); const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation(); const toast = useToast(); @@ -42,11 +50,13 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { isClosable: false, }); try { + const blob = thumbnail ? await convertImageUrlToBlob(thumbnail) : null; + const image = blob ? new File([blob], 'thumbnail.png', { type: 'image/png' }) : null; if (isWorkflowWithID(workflow)) { - await updateWorkflow(workflow).unwrap(); + await updateWorkflow({ workflow, image }).unwrap(); dispatch(workflowUpdated()); } else { - const data = await createWorkflow(workflow).unwrap(); + const data = await createWorkflow({ workflow, image }).unwrap(); dispatch(workflowIDChanged(data.workflow.id)); } dispatch(workflowSaved()); @@ -73,7 +83,7 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { toast.close(toastRef.current); } } - }, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow]); + }, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow, thumbnail]); return { saveWorkflow, isLoading: updateWorkflowResult.isLoading || createWorkflowResult.isLoading, diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts index 2c18fcdd90..01710bb657 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts @@ -1,9 +1,11 @@ import type { ToastId } from '@invoke-ai/ui-library'; import { useToast } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; +import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher'; import { formFieldInitialValuesChanged, + selectWorkflowThumbnail, workflowCategoryChanged, workflowIDChanged, workflowNameChanged, @@ -14,6 +16,7 @@ import { useGetFormFieldInitialValues } from 'features/workflowLibrary/hooks/use import { newWorkflowSaved } from 'features/workflowLibrary/store/actions'; import { useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; +import { useSelector } from 'react-redux'; import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows'; type SaveWorkflowAsArg = { @@ -37,6 +40,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation(); const getFormFieldInitialValues = useGetFormFieldInitialValues(); + const thumbnail = useSelector(selectWorkflowThumbnail); const toast = useToast(); const toastRef = useRef(); const saveWorkflowAs = useCallback( @@ -55,8 +59,10 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { workflow.id = undefined; workflow.name = newName; workflow.meta.category = category; + const blob = thumbnail ? await convertImageUrlToBlob(thumbnail) : null; + const image = blob ? new File([blob], 'thumbnail.png', { type: 'image/png' }) : null; - const data = await createWorkflow(workflow).unwrap(); + const data = await createWorkflow({ workflow, image }).unwrap(); dispatch(workflowIDChanged(data.workflow.id)); dispatch(workflowNameChanged(data.workflow.name)); dispatch(workflowCategoryChanged(data.workflow.meta.category)); @@ -86,7 +92,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { } } }, - [toast, t, createWorkflow, dispatch, getFormFieldInitialValues] + [toast, t, createWorkflow, dispatch, getFormFieldInitialValues, thumbnail] ); return { saveWorkflowAs, diff --git a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts index 0280e2ebc4..5d6e204b73 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts @@ -1,6 +1,7 @@ import type { paths } from 'services/api/schema'; import { api, buildV1Url, LIST_TAG } from '..'; +import { Workflow, WorkflowWithoutID } from '../types'; /** * Builds an endpoint URL for the workflows router @@ -41,13 +42,22 @@ export const workflowsApi = api.injectEndpoints({ }), createWorkflow: build.mutation< paths['/api/v1/workflows/']['post']['responses']['200']['content']['application/json'], - paths['/api/v1/workflows/']['post']['requestBody']['content']['application/json']['workflow'] + { workflow: WorkflowWithoutID; image: File | null } >({ - query: (workflow) => ({ - url: buildWorkflowsUrl(), - method: 'POST', - body: { workflow }, - }), + query: ({ workflow, image }) => { + const formData = new FormData(); + if (image) { + formData.append('image', image); + } + + formData.append('workflow', JSON.stringify(workflow)); + + return { + url: buildWorkflowsUrl(), + method: 'POST', + body: formData, + }; + }, invalidatesTags: [ { type: 'Workflow', id: LIST_TAG }, { type: 'WorkflowsRecent', id: LIST_TAG }, @@ -55,14 +65,22 @@ export const workflowsApi = api.injectEndpoints({ }), updateWorkflow: build.mutation< paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'], - paths['/api/v1/workflows/i/{workflow_id}']['patch']['requestBody']['content']['application/json']['workflow'] + { workflow: Workflow; image: File | null } >({ - query: (workflow) => ({ - url: buildWorkflowsUrl(`i/${workflow.id}`), - method: 'PATCH', - body: { workflow }, - }), - invalidatesTags: (response, error, workflow) => [ + query: ({ workflow, image }) => { + const formData = new FormData(); + if (image) { + formData.append('image', image); + } + formData.append('workflow', JSON.stringify(workflow)); + + return { + url: buildWorkflowsUrl(`i/${workflow.id}`), + method: 'PATCH', + body: formData, + }; + }, + invalidatesTags: (response, error, { workflow }) => [ { type: 'WorkflowsRecent', id: LIST_TAG }, { type: 'Workflow', id: LIST_TAG }, { type: 'Workflow', id: workflow.id }, diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 847d3e1149..b1401e3707 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1410,6 +1410,46 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/workflows/{workflow_id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Upload Workflow Thumbnail + * @description Uploads a thumbnail for a workflow + */ + post: operations["upload_workflow_thumbnail"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/workflowsi/{workflow_id}/thumbnail": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Workflow Thumbnail + * @description Gets the thumbnail for a workflow + */ + get: operations["get_workflow_thumbnail"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/style_presets/i/{style_preset_id}": { parameters: { query?: never; @@ -2211,8 +2251,16 @@ export type components = { }; /** Body_create_workflow */ Body_create_workflow: { - /** @description The workflow to create */ - workflow: components["schemas"]["WorkflowWithoutID"]; + /** + * Workflow + * @description The workflow to create + */ + workflow: string; + /** + * Image + * @description The image file to upload + */ + image?: Blob | null; }; /** Body_delete_images_from_list */ Body_delete_images_from_list: { @@ -2368,8 +2416,16 @@ export type components = { }; /** Body_update_workflow */ Body_update_workflow: { - /** @description The updated workflow */ - workflow: components["schemas"]["Workflow"]; + /** + * Workflow + * @description The updated workflow + */ + workflow: string; + /** + * Image + * @description The image file to upload + */ + image?: Blob | null; }; /** Body_upload_image */ Body_upload_image: { @@ -2381,6 +2437,15 @@ export type components = { /** @description The metadata to associate with the image */ metadata?: components["schemas"]["JsonValue"] | null; }; + /** Body_upload_workflow_thumbnail */ + Body_upload_workflow_thumbnail: { + /** + * File + * Format: binary + * @description The image file to upload + */ + file: Blob; + }; /** * Boolean Collection Primitive * @description A collection of boolean primitive values @@ -21087,6 +21152,11 @@ export type components = { * @description The opened timestamp of the workflow. */ opened_at: string; + /** + * Thumbnail Url + * @description The URL of the workflow thumbnail. + */ + thumbnail_url?: string | null; /** @description The workflow. */ workflow: components["schemas"]["Workflow"]; }; @@ -21117,6 +21187,11 @@ export type components = { * @description The opened timestamp of the workflow. */ opened_at: string; + /** + * Thumbnail Url + * @description The URL of the workflow thumbnail. + */ + thumbnail_url?: string | null; /** * Description * @description The description of the workflow. @@ -24143,7 +24218,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["Body_update_workflow"]; + "multipart/form-data": components["schemas"]["Body_update_workflow"]; }; }; responses: { @@ -24218,7 +24293,7 @@ export interface operations { }; requestBody: { content: { - "application/json": components["schemas"]["Body_create_workflow"]; + "multipart/form-data": components["schemas"]["Body_create_workflow"]; }; }; responses: { @@ -24242,6 +24317,86 @@ export interface operations { }; }; }; + upload_workflow_thumbnail: { + parameters: { + query?: never; + header?: never; + path: { + workflow_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "multipart/form-data": components["schemas"]["Body_upload_workflow_thumbnail"]; + }; + }; + responses: { + /** @description Thumbnail uploaded successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Invalid image format */ + 415: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_workflow_thumbnail: { + parameters: { + query?: never; + header?: never; + path: { + workflow_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Thumbnail retrieved successfully */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": unknown; + }; + }; + /** @description Thumbnail not found */ + 404: { + headers: { + [name: string]: unknown; + }; + content?: never; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_style_preset: { parameters: { query?: never; diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 9d12fb159c..4f0ff8fd0b 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -20,6 +20,9 @@ export type UpdateBoardArg = paths['/api/v1/boards/{board_id}']['patch']['parame export type GraphAndWorkflowResponse = paths['/api/v1/images/i/{image_name}/workflow']['get']['responses']['200']['content']['application/json']; +export type WorkflowWithoutID = S['WorkflowWithoutID']; +export type Workflow = S['Workflow']; + export type BatchConfig = paths['/api/v1/queue/{queue_id}/enqueue_batch']['post']['requestBody']['content']['application/json'];