refactor workflow thumbnails to be separate flow/endpoints

This commit is contained in:
Mary Hipp
2025-02-24 16:12:43 -05:00
committed by psychedelicious
parent d4423aa16f
commit ab4433da2f
14 changed files with 323 additions and 272 deletions

View File

@@ -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<HTMLInputElement>) => {
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 = () => {
<FormLabel>{t('nodes.workflowName')}</FormLabel>
<Input variant="darkFilled" value={name} onChange={handleChangeName} />
</FormControl>
<FormControl>
<FormLabel>{t('workflows.workflowThumbnail')}</FormLabel>
<WorkflowThumbnailEditor thumbnailUrl={data?.thumbnail_url || null} workflowId={id} />
</FormControl>
<FormControl>
<FormLabel>{t('nodes.workflowVersion')}</FormLabel>
<Input variant="darkFilled" value={version} onChange={handleChangeVersion} />
</FormControl>
<FormControl>
<FormLabel>Thumbnail</FormLabel>
<WorkflowThumbnailField imageUrl={thumbnail} onChange={handleChangeThumbnail} />
</FormControl>
<FormControl>
<FormLabel>{t('nodes.workflowAuthor')}</FormLabel>
<Input variant="darkFilled" value={author} onChange={handleChangeAuthor} />

View File

@@ -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<string | null>(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 (
<Flex alignItems="center" gap={4}>
<WorkflowThumbnailField imageUrl={thumbnailUrl} onChange={handleLocalThumbnailUrlChange} />
<Button size="sm" isLoading={isLoading || isDeleting} onClick={handleSaveChanges} isDisabled={!canSaveChanges}>
{t('common.saveChanges')}
</Button>
</Flex>
);
};

View File

@@ -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<File | null>(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"
/>
<IconButton
@@ -89,8 +89,8 @@ export const WorkflowThumbnailField = ({
<Tooltip label={t('stylePresets.uploadImage')}>
<Flex
as={Button}
w={65}
h={65}
w={100}
h={100}
opacity={0.3}
borderRadius="base"
alignItems="center"

View File

@@ -109,10 +109,6 @@ export const workflowSlice = createSlice({
state.name = action.payload;
state.isTouched = true;
},
workflowThumbnailChanged: (state, action: PayloadAction<string | null>) => {
state.thumbnail = action.payload;
state.isTouched = true;
},
workflowCategoryChanged: (state, action: PayloadAction<WorkflowCategory | undefined>) => {
if (action.payload) {
state.meta.category = action.payload;

View File

@@ -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,

View File

@@ -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<ToastId | undefined>();
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,