From ab4433da2ff2b6dc8864f4334e1fa4e97c5f4821 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 24 Feb 2025 16:12:43 -0500 Subject: [PATCH] refactor workflow thumbnails to be separate flow/endpoints --- invokeai/app/api/dependencies.py | 2 +- invokeai/app/api/routers/workflows.py | 192 +++++++++--------- .../workflow_records_common.py | 9 +- .../workflow_thumbnails_disk.py | 2 +- invokeai/frontend/web/public/locales/en.json | 3 + .../sidePanel/workflow/WorkflowGeneralTab.tsx | 29 ++- .../WorkflowThumbnailEditor.tsx | 63 ++++++ .../WorkflowThumbnailField.tsx | 14 +- .../src/features/nodes/store/workflowSlice.ts | 4 - .../workflowLibrary/hooks/useSaveWorkflow.ts | 18 +- .../hooks/useSaveWorkflowAs.ts | 10 +- .../src/services/api/endpoints/workflows.ts | 72 ++++--- .../frontend/web/src/services/api/schema.ts | 174 ++++++++-------- .../frontend/web/src/services/api/types.ts | 3 - 14 files changed, 323 insertions(+), 272 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx rename invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/{ => WorkflowThumbnail}/WorkflowThumbnailField.tsx (94%) diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index 9b36ee3897..2d5a9004f7 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -122,7 +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") + workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder) services = InvocationServices( board_image_records=board_image_records, diff --git a/invokeai/app/api/routers/workflows.py b/invokeai/app/api/routers/workflows.py index 542892a105..fce98254a2 100644 --- a/invokeai/app/api/routers/workflows.py +++ b/invokeai/app/api/routers/workflows.py @@ -1,29 +1,25 @@ +from typing import Optional import io import traceback -from typing import Optional -import json - -from fastapi import APIRouter, Body, File, HTTPException, Path, Query, UploadFile, Form +from fastapi import APIRouter, Body, HTTPException, Path, Query, File, UploadFile 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 ( - WorkflowValidator, + Workflow, WorkflowCategory, WorkflowNotFoundError, WorkflowRecordDTO, - WorkflowRecordListItemDTO, + WorkflowRecordListItemWithThumbnailDTO, WorkflowRecordOrderBy, - WorkflowWithoutIDValidator, -) -from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_common import ( - WorkflowThumbnailFileNotFoundException, + WorkflowWithoutID, + WorkflowRecordWithThumbnailDTO, ) +IMAGE_MAX_AGE = 31536000 workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"]) @@ -31,18 +27,17 @@ workflows_router = APIRouter(prefix="/v1/workflows", tags=["workflows"]) "/i/{workflow_id}", operation_id="get_workflow", responses={ - 200: {"model": WorkflowRecordDTO}, + 200: {"model": WorkflowRecordWithThumbnailDTO}, }, ) async def get_workflow( workflow_id: str = Path(description="The workflow to get"), -) -> WorkflowRecordDTO: +) -> WorkflowRecordWithThumbnailDTO: """Gets a workflow""" try: 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 + return WorkflowRecordWithThumbnailDTO(thumbnail_url=thumbnail_url, **workflow.model_dump()) except WorkflowNotFoundError: raise HTTPException(status_code=404, detail="Workflow not found") @@ -55,42 +50,10 @@ async def get_workflow( }, ) async def update_workflow( - workflow: str = Form(description="The updated workflow"), - image: Optional[UploadFile] = File(description="The image file to upload", default=None), + workflow: Workflow = Body(description="The updated workflow", embed=True), ) -> WorkflowRecordDTO: """Updates a 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 + return ApiDependencies.invoker.services.workflow_records.update(workflow=workflow) @workflows_router.delete( @@ -101,8 +64,8 @@ async def delete_workflow( workflow_id: str = Path(description="The workflow to delete"), ) -> None: """Deletes a workflow""" - ApiDependencies.invoker.services.workflow_records.delete(workflow_id) ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id) + ApiDependencies.invoker.services.workflow_records.delete(workflow_id) @workflows_router.post( @@ -113,44 +76,17 @@ async def delete_workflow( }, ) async def create_workflow( - workflow: str = Form(description="The workflow to create"), - image: Optional[UploadFile] = File(description="The image file to upload", default=None), + workflow: WorkflowWithoutID = Body(description="The workflow to create", embed=True), ) -> WorkflowRecordDTO: """Creates a 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 + return ApiDependencies.invoker.services.workflow_records.create(workflow=workflow) @workflows_router.get( "/", operation_id="list_workflows", responses={ - 200: {"model": PaginatedResults[WorkflowRecordListItemDTO]}, + 200: {"model": PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]}, }, ) async def list_workflows( @@ -162,37 +98,111 @@ async def list_workflows( direction: SQLiteDirection = Query(default=SQLiteDirection.Ascending, description="The direction to order by"), category: WorkflowCategory = Query(default=WorkflowCategory.User, description="The category of workflow to get"), query: Optional[str] = Query(default=None, description="The text to query by (matches name and description)"), -) -> PaginatedResults[WorkflowRecordListItemDTO]: +) -> PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]: """Gets a page of workflows""" + workflows_with_thumbnails: list[WorkflowRecordListItemWithThumbnailDTO] = [] 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_with_thumbnails.append( + WorkflowRecordListItemWithThumbnailDTO( + thumbnail_url=ApiDependencies.invoker.services.workflow_thumbnails.get_url(workflow.workflow_id), + **workflow.model_dump(), + ) + ) + return PaginatedResults[WorkflowRecordListItemWithThumbnailDTO]( + items=workflows_with_thumbnails, + total=workflows.total, + page=workflows.page, + pages=workflows.pages, + per_page=workflows.per_page, + ) + + +@workflows_router.put( + "/i/{workflow_id}/thumbnail", + operation_id="set_workflow_thumbnail", + responses={ + 200: {"model": WorkflowRecordDTO}, + }, +) +async def set_workflow_thumbnail( + workflow_id: str = Path(description="The workflow to update"), + image: UploadFile = File(description="The image file to upload"), +): + """Sets a workflow's thumbnail image""" + try: + ApiDependencies.invoker.services.workflow_records.get(workflow_id) + except WorkflowNotFoundError: + raise HTTPException(status_code=404, detail="Workflow not found") + + 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, pil_image) + except Exception as e: + raise HTTPException(status_code=500, detail=str(e)) + + +@workflows_router.delete( + "/i/{workflow_id}/thumbnail", + operation_id="delete_workflow_thumbnail", + responses={ + 200: {"model": WorkflowRecordDTO}, + }, +) +async def delete_workflow_thumbnail( + workflow_id: str = Path(description="The workflow to update"), +): + """Removes a workflow's thumbnail image""" + try: + ApiDependencies.invoker.services.workflow_records.get(workflow_id) + except WorkflowNotFoundError: + raise HTTPException(status_code=404, detail="Workflow not found") + + try: + ApiDependencies.invoker.services.workflow_thumbnails.delete(workflow_id) + except ValueError as e: + raise HTTPException(status_code=500, detail=str(e)) @workflows_router.get( - "i/{workflow_id}/thumbnail", + "/i/{workflow_id}/thumbnail", operation_id="get_workflow_thumbnail", responses={ - 200: {"description": "Thumbnail retrieved successfully"}, - 404: {"description": "Thumbnail not found"}, + 200: { + "description": "The workflow thumbnail was fetched successfully", + }, + 400: {"description": "Bad request"}, + 404: {"description": "The workflow thumbnail could not be found"}, }, + status_code=200, ) async def get_workflow_thumbnail( - workflow_id: str, + workflow_id: str = Path(description="The id of the workflow thumbnail to get"), ) -> FileResponse: - """Gets the thumbnail for a workflow""" + """Gets a workflow's thumbnail image""" + try: path = ApiDependencies.invoker.services.workflow_thumbnails.get_path(workflow_id) + response = FileResponse( path, media_type="image/png", - filename=f"{workflow_id}.png", + filename=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") + except Exception: + raise HTTPException(status_code=404) \ No newline at end of file diff --git a/invokeai/app/services/workflow_records/workflow_records_common.py b/invokeai/app/services/workflow_records/workflow_records_common.py index a695b38915..28b2427123 100644 --- a/invokeai/app/services/workflow_records/workflow_records_common.py +++ b/invokeai/app/services/workflow_records/workflow_records_common.py @@ -101,7 +101,6 @@ 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): @@ -122,3 +121,11 @@ class WorkflowRecordListItemDTO(WorkflowRecordDTOBase): WorkflowRecordListItemDTOValidator = TypeAdapter(WorkflowRecordListItemDTO) + + +class WorkflowRecordWithThumbnailDTO(WorkflowRecordDTO): + thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.") + + +class WorkflowRecordListItemWithThumbnailDTO(WorkflowRecordListItemDTO): + thumbnail_url: str | None = Field(default=None, description="The URL of the workflow thumbnail.") diff --git a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py index 271fde56f2..39e53e29a2 100644 --- a/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py +++ b/invokeai/app/services/workflow_thumbnails/workflow_thumbnails_disk.py @@ -34,7 +34,7 @@ class WorkflowThumbnailFileStorageDisk(WorkflowThumbnailServiceBase): try: self._validate_storage_folders() image_path = self._workflow_thumbnail_folder / (workflow_id + ".webp") - thumbnail = make_thumbnail(image, 512) + thumbnail = make_thumbnail(image, 256) thumbnail.save(image_path, format="webp") except Exception as e: diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index ee2465b98a..f36193ba93 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -149,6 +149,7 @@ "safetensors": "Safetensors", "save": "Save", "saveAs": "Save As", + "saveChanges": "Save Changes", "settingsLabel": "Settings", "simple": "Simple", "somethingWentWrong": "Something went wrong", @@ -1721,6 +1722,8 @@ "copyShareLinkForWorkflow": "Copy Share Link for Workflow", "delete": "Delete", "openLibrary": "Open Library", + "workflowThumbnail": "Workflow Thumbnail", + "saveChanges": "Save Changes", "builder": { "deleteAllElements": "Delete All Form Elements", "resetAllNodeFields": "Reset All Node Fields", 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 5ac0676d3c..16b0f850d1 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 @@ -1,5 +1,6 @@ import type { FormControlProps } from '@invoke-ai/ui-library'; import { Flex, FormControl, FormControlGroup, FormLabel, Input, Textarea } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import ScrollableContent from 'common/components/OverlayScrollbars/ScrollableContent'; @@ -11,19 +12,20 @@ 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 { useGetWorkflowQuery } from 'services/api/endpoints/workflows'; -import { WorkflowThumbnailField } from './WorkflowThumbnailField'; +import { WorkflowThumbnailEditor } from './WorkflowThumbnail/WorkflowThumbnailEditor'; const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { - const { author, name, description, tags, version, contact, notes, thumbnail } = workflow; + const { id, author, name, description, tags, version, contact, notes } = workflow; return { + id, name, author, description, @@ -31,14 +33,15 @@ const selector = createMemoizedSelector(selectWorkflowSlice, (workflow) => { version, contact, notes, - thumbnail, }; }); const WorkflowGeneralTab = () => { - const { author, name, description, tags, version, contact, notes, thumbnail } = useAppSelector(selector); + const { id, author, name, description, tags, version, contact, notes } = useAppSelector(selector); const dispatch = useAppDispatch(); + const { data } = useGetWorkflowQuery(id ?? skipToken); + const handleChangeName = useCallback( (e: ChangeEvent) => { dispatch(workflowNameChanged(e.target.value)); @@ -83,13 +86,6 @@ const WorkflowGeneralTab = () => { [dispatch] ); - const handleChangeThumbnail = useCallback( - (localImageUrl: string | null) => { - dispatch(workflowThumbnailChanged(localImageUrl)); - }, - [dispatch] - ); - const { t } = useTranslation(); return ( @@ -100,15 +96,14 @@ const WorkflowGeneralTab = () => { {t('nodes.workflowName')} - + + {t('workflows.workflowThumbnail')} + + {t('nodes.workflowVersion')} - - Thumbnail - - {t('nodes.workflowAuthor')} diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx new file mode 100644 index 0000000000..e926c0263a --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailEditor.tsx @@ -0,0 +1,63 @@ +import { Button, Flex } from '@invoke-ai/ui-library'; +import { convertImageUrlToBlob } from 'common/util/convertImageUrlToBlob'; +import { toast } from 'features/toast/toast'; +import { useCallback, useState } from 'react'; +import { useTranslation } from 'react-i18next'; +import { useDeleteWorkflowThumbnailMutation, useSetWorkflowThumbnailMutation } from 'services/api/endpoints/workflows'; + +import { WorkflowThumbnailField } from './WorkflowThumbnailField'; + +export const WorkflowThumbnailEditor = ({ + workflowId, + thumbnailUrl, +}: { + workflowId?: string; + thumbnailUrl: string | null; +}) => { + const { t } = useTranslation(); + + const [localThumbnailUrl, setLocalThumbnailUrl] = useState(null); + const [canSaveChanges, setCanSaveChanges] = useState(false); + + const [setThumbnail, { isLoading }] = useSetWorkflowThumbnailMutation(); + const [deleteThumbnail, { isLoading: isDeleting }] = useDeleteWorkflowThumbnailMutation(); + + const handleLocalThumbnailUrlChange = useCallback((url: string | null) => { + setLocalThumbnailUrl(url); + setCanSaveChanges(true); + }, []); + + const handleSaveChanges = useCallback(async () => { + if (!workflowId) { + return; + } + + try { + if (localThumbnailUrl) { + const blob = await convertImageUrlToBlob(localThumbnailUrl); + if (!blob) { + return; + } + const file = new File([blob], 'workflow_thumbnail.png', { type: 'image/png' }); + await setThumbnail({ workflow_id: workflowId, image: file }).unwrap(); + } else { + await deleteThumbnail(workflowId).unwrap(); + } + + setCanSaveChanges(false); + toast({ status: 'success', title: 'Workflow thumbnail updated' }); + } catch (error) { + toast({ status: 'error', title: 'Failed to update thumbnail' }); + } + }, [deleteThumbnail, setThumbnail, workflowId, localThumbnailUrl]); + + return ( + + + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx similarity index 94% rename from invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx rename to invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx index 6398d1b7fc..1eb1a2e3c1 100644 --- a/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnailField.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/sidePanel/workflow/WorkflowThumbnail/WorkflowThumbnailField.tsx @@ -10,7 +10,7 @@ export const WorkflowThumbnailField = ({ onChange, }: { imageUrl: string | null; - onChange: (localImageUrl: string | null) => void; + onChange: (localThumbnailUrl: string | null) => void; }) => { const [thumbnail, setThumbnail] = useState(null); @@ -48,7 +48,8 @@ export const WorkflowThumbnailField = ({ const handleResetImage = useCallback(() => { setThumbnail(null); - }, []); + onChange(null); + }, [onChange]); const { getInputProps, getRootProps } = useDropzone({ accept: { 'image/png': ['.png'], 'image/jpeg': ['.jpg', '.jpeg', '.png'] }, @@ -64,9 +65,8 @@ export const WorkflowThumbnailField = ({ src={URL.createObjectURL(thumbnail)} objectFit="cover" objectPosition="50% 50%" - w={65} - h={65} - minWidth={65} + w={100} + h={100} borderRadius="base" /> ) => { - state.thumbnail = action.payload; - state.isTouched = true; - }, workflowCategoryChanged: (state, action: PayloadAction) => { if (action.payload) { state.meta.category = action.payload; diff --git a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts index 2dbeb5b1be..53119c051e 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflow.ts @@ -1,20 +1,13 @@ 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, - workflowIDChanged, - workflowSaved, -} from 'features/nodes/store/workflowSlice'; +import { formFieldInitialValuesChanged, 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'; @@ -33,7 +26,6 @@ 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(); @@ -50,13 +42,11 @@ 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, image }).unwrap(); + await updateWorkflow(workflow).unwrap(); dispatch(workflowUpdated()); } else { - const data = await createWorkflow({ workflow, image }).unwrap(); + const data = await createWorkflow(workflow).unwrap(); dispatch(workflowIDChanged(data.workflow.id)); } dispatch(workflowSaved()); @@ -83,7 +73,7 @@ export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => { toast.close(toastRef.current); } } - }, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow, thumbnail]); + }, [toast, t, dispatch, getFormFieldInitialValues, updateWorkflow, createWorkflow]); 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 01710bb657..2c18fcdd90 100644 --- a/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts +++ b/invokeai/frontend/web/src/features/workflowLibrary/hooks/useSaveWorkflowAs.ts @@ -1,11 +1,9 @@ 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, @@ -16,7 +14,6 @@ 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 = { @@ -40,7 +37,6 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { const [createWorkflow, createWorkflowResult] = useCreateWorkflowMutation(); const getFormFieldInitialValues = useGetFormFieldInitialValues(); - const thumbnail = useSelector(selectWorkflowThumbnail); const toast = useToast(); const toastRef = useRef(); const saveWorkflowAs = useCallback( @@ -59,10 +55,8 @@ 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, image }).unwrap(); + const data = await createWorkflow(workflow).unwrap(); dispatch(workflowIDChanged(data.workflow.id)); dispatch(workflowNameChanged(data.workflow.name)); dispatch(workflowCategoryChanged(data.workflow.meta.category)); @@ -92,7 +86,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => { } } }, - [toast, t, createWorkflow, dispatch, getFormFieldInitialValues, thumbnail] + [toast, t, createWorkflow, dispatch, getFormFieldInitialValues] ); 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 5d6e204b73..23d4763f85 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/workflows.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/workflows.ts @@ -1,7 +1,6 @@ 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 @@ -42,22 +41,13 @@ export const workflowsApi = api.injectEndpoints({ }), createWorkflow: build.mutation< paths['/api/v1/workflows/']['post']['responses']['200']['content']['application/json'], - { workflow: WorkflowWithoutID; image: File | null } + paths['/api/v1/workflows/']['post']['requestBody']['content']['application/json']['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, - }; - }, + query: (workflow) => ({ + url: buildWorkflowsUrl(), + method: 'POST', + body: { workflow }, + }), invalidatesTags: [ { type: 'Workflow', id: LIST_TAG }, { type: 'WorkflowsRecent', id: LIST_TAG }, @@ -65,22 +55,14 @@ export const workflowsApi = api.injectEndpoints({ }), updateWorkflow: build.mutation< paths['/api/v1/workflows/i/{workflow_id}']['patch']['responses']['200']['content']['application/json'], - { workflow: Workflow; image: File | null } + paths['/api/v1/workflows/i/{workflow_id}']['patch']['requestBody']['content']['application/json']['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 }) => [ + query: (workflow) => ({ + url: buildWorkflowsUrl(`i/${workflow.id}`), + method: 'PATCH', + body: { workflow }, + }), + invalidatesTags: (response, error, workflow) => [ { type: 'WorkflowsRecent', id: LIST_TAG }, { type: 'Workflow', id: LIST_TAG }, { type: 'Workflow', id: workflow.id }, @@ -96,13 +78,41 @@ export const workflowsApi = api.injectEndpoints({ }), providesTags: ['FetchOnReconnect', { type: 'Workflow', id: LIST_TAG }], }), + setWorkflowThumbnail: build.mutation({ + query: ({ workflow_id, image }) => { + const formData = new FormData(); + formData.append('image', image); + return { + url: buildWorkflowsUrl(`i/${workflow_id}/thumbnail`), + method: 'PUT', + body: formData, + }; + }, + invalidatesTags: (result, error, { workflow_id }) => [ + { type: 'Workflow', id: workflow_id }, + { type: 'WorkflowsRecent', id: LIST_TAG }, + ], + }), + deleteWorkflowThumbnail: build.mutation({ + query: (workflow_id) => ({ + url: buildWorkflowsUrl(`i/${workflow_id}/thumbnail`), + method: 'DELETE', + }), + invalidatesTags: (result, error, workflow_id) => [ + { type: 'Workflow', id: workflow_id }, + { type: 'WorkflowsRecent', id: LIST_TAG }, + ], + }), }), }); export const { useLazyGetWorkflowQuery, + useGetWorkflowQuery, useCreateWorkflowMutation, useDeleteWorkflowMutation, useUpdateWorkflowMutation, useListWorkflowsQuery, + useSetWorkflowThumbnailMutation, + useDeleteWorkflowThumbnailMutation, } = workflowsApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index b1401e3707..98bb7326f5 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1410,7 +1410,7 @@ export type paths = { patch?: never; trace?: never; }; - "/api/v1/workflows/{workflow_id}/thumbnail": { + "/api/v1/workflows/i/{workflow_id}/thumbnail": { parameters: { query?: never; header?: never; @@ -1418,33 +1418,17 @@ export type paths = { cookie?: never; }; get?: never; - put?: never; /** - * Upload Workflow Thumbnail - * @description Uploads a thumbnail for a workflow + * Set Workflow Thumbnail + * @description Sets a workflow's thumbnail image */ - 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; + put: operations["set_workflow_thumbnail"]; post?: never; - delete?: never; + /** + * Delete Workflow Thumbnail + * @description Removes a workflow's thumbnail image + */ + delete: operations["delete_workflow_thumbnail"]; options?: never; head?: never; patch?: never; @@ -2251,16 +2235,8 @@ export type components = { }; /** Body_create_workflow */ Body_create_workflow: { - /** - * Workflow - * @description The workflow to create - */ - workflow: string; - /** - * Image - * @description The image file to upload - */ - image?: Blob | null; + /** @description The workflow to create */ + workflow: components["schemas"]["WorkflowWithoutID"]; }; /** Body_delete_images_from_list */ Body_delete_images_from_list: { @@ -2377,6 +2353,15 @@ export type components = { */ image_names: string[]; }; + /** Body_set_workflow_thumbnail */ + Body_set_workflow_thumbnail: { + /** + * Image + * Format: binary + * @description The image file to upload + */ + image: Blob; + }; /** Body_star_images_in_list */ Body_star_images_in_list: { /** @@ -2416,16 +2401,8 @@ export type components = { }; /** Body_update_workflow */ Body_update_workflow: { - /** - * Workflow - * @description The updated workflow - */ - workflow: string; - /** - * Image - * @description The image file to upload - */ - image?: Blob | null; + /** @description The updated workflow */ + workflow: components["schemas"]["Workflow"]; }; /** Body_upload_image */ Body_upload_image: { @@ -2437,15 +2414,6 @@ 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 @@ -16367,8 +16335,8 @@ export type components = { /** Ui Order */ ui_order: number | null; }; - /** PaginatedResults[WorkflowRecordListItemDTO] */ - PaginatedResults_WorkflowRecordListItemDTO_: { + /** PaginatedResults[WorkflowRecordListItemWithThumbnailDTO] */ + PaginatedResults_WorkflowRecordListItemWithThumbnailDTO_: { /** * Page * @description Current Page @@ -16393,7 +16361,7 @@ export type components = { * Items * @description Items */ - items: components["schemas"]["WorkflowRecordListItemDTO"][]; + items: components["schemas"]["WorkflowRecordListItemWithThumbnailDTO"][]; }; /** * Pair Tile with Image @@ -21152,16 +21120,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"]; }; - /** WorkflowRecordListItemDTO */ - WorkflowRecordListItemDTO: { + /** WorkflowRecordListItemWithThumbnailDTO */ + WorkflowRecordListItemWithThumbnailDTO: { /** * Workflow Id * @description The id of the workflow. @@ -21187,11 +21150,6 @@ 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. @@ -21199,6 +21157,11 @@ export type components = { description: string; /** @description The description of the workflow. */ category: components["schemas"]["WorkflowCategory"]; + /** + * Thumbnail Url + * @description The URL of the workflow thumbnail. + */ + thumbnail_url?: string | null; }; /** * WorkflowRecordOrderBy @@ -21206,6 +21169,41 @@ export type components = { * @enum {string} */ WorkflowRecordOrderBy: "created_at" | "updated_at" | "opened_at" | "name"; + /** WorkflowRecordWithThumbnailDTO */ + WorkflowRecordWithThumbnailDTO: { + /** + * Workflow Id + * @description The id of the workflow. + */ + workflow_id: string; + /** + * Name + * @description The name of the workflow. + */ + name: string; + /** + * Created At + * @description The created timestamp of the workflow. + */ + created_at: string; + /** + * Updated At + * @description The updated timestamp of the workflow. + */ + updated_at: string; + /** + * Opened At + * @description The opened timestamp of the workflow. + */ + opened_at: string; + /** @description The workflow. */ + workflow: components["schemas"]["Workflow"]; + /** + * Thumbnail Url + * @description The URL of the workflow thumbnail. + */ + thumbnail_url?: string | null; + }; /** WorkflowWithoutID */ WorkflowWithoutID: { /** @@ -24163,7 +24161,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["WorkflowRecordDTO"]; + "application/json": components["schemas"]["WorkflowRecordWithThumbnailDTO"]; }; }; /** @description Validation Error */ @@ -24218,7 +24216,7 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": components["schemas"]["Body_update_workflow"]; + "application/json": components["schemas"]["Body_update_workflow"]; }; }; responses: { @@ -24270,7 +24268,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["PaginatedResults_WorkflowRecordListItemDTO_"]; + "application/json": components["schemas"]["PaginatedResults_WorkflowRecordListItemWithThumbnailDTO_"]; }; }; /** @description Validation Error */ @@ -24293,7 +24291,7 @@ export interface operations { }; requestBody: { content: { - "multipart/form-data": components["schemas"]["Body_create_workflow"]; + "application/json": components["schemas"]["Body_create_workflow"]; }; }; responses: { @@ -24317,37 +24315,31 @@ export interface operations { }; }; }; - upload_workflow_thumbnail: { + set_workflow_thumbnail: { parameters: { query?: never; header?: never; path: { + /** @description The workflow to update */ workflow_id: string; }; cookie?: never; }; requestBody: { content: { - "multipart/form-data": components["schemas"]["Body_upload_workflow_thumbnail"]; + "multipart/form-data": components["schemas"]["Body_set_workflow_thumbnail"]; }; }; responses: { - /** @description Thumbnail uploaded successfully */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["WorkflowRecordDTO"]; }; }; - /** @description Invalid image format */ - 415: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; /** @description Validation Error */ 422: { headers: { @@ -24359,33 +24351,27 @@ export interface operations { }; }; }; - get_workflow_thumbnail: { + delete_workflow_thumbnail: { parameters: { query?: never; header?: never; path: { + /** @description The workflow to update */ workflow_id: string; }; cookie?: never; }; requestBody?: never; responses: { - /** @description Thumbnail retrieved successfully */ + /** @description Successful Response */ 200: { headers: { [name: string]: unknown; }; content: { - "application/json": unknown; + "application/json": components["schemas"]["WorkflowRecordDTO"]; }; }; - /** @description Thumbnail not found */ - 404: { - headers: { - [name: string]: unknown; - }; - content?: never; - }; /** @description Validation Error */ 422: { headers: { diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 4f0ff8fd0b..9d12fb159c 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -20,9 +20,6 @@ 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'];