add option for video upsell, rearrange navigation bar and gallery tabs

This commit is contained in:
Mary Hipp
2025-08-26 14:13:41 -04:00
committed by Mary Hipp Rogers
parent 0de5097207
commit 4845d31857
14 changed files with 244 additions and 60 deletions

View File

@@ -1280,7 +1280,8 @@
"noNodesInGraph": "No nodes in graph",
"systemDisconnected": "System disconnected",
"promptExpansionPending": "Prompt expansion in progress",
"promptExpansionResultPending": "Please accept or discard your prompt expansion result"
"promptExpansionResultPending": "Please accept or discard your prompt expansion result",
"videoIsDisabled": "Video generation is not enabled for {{accountType}} accounts."
},
"maskBlur": "Mask Blur",
"negativePromptPlaceholder": "Negative Prompt",
@@ -2593,7 +2594,7 @@
"panels": {
"launchpad": "Launchpad",
"workflowEditor": "Workflow Editor",
"imageViewer": "Image Viewer",
"imageViewer": "Viewer",
"canvas": "Canvas",
"video": "Video"
},
@@ -2602,6 +2603,15 @@
"upscalingTitle": "Upscale and add detail.",
"canvasTitle": "Edit and refine on Canvas.",
"generateTitle": "Generate images from text prompts.",
"videoTitle": "Generate videos from text prompts.",
"video": {
"startingFrameCalloutTitle": "Add a Starting Frame",
"startingFrameCalloutDesc": "Add an image to control the first frame of your video."
},
"addStartingFrame": {
"title": "Add a Starting Frame",
"description": "Add an image to control the first frame of your video."
},
"modelGuideText": "Want to learn what prompts work best for each model?",
"modelGuideLink": "Check out our Model Guide.",
"createNewWorkflowFromScratch": "Create a new Workflow from scratch",

View File

@@ -1,16 +1,14 @@
import 'i18n';
import type { Middleware } from '@reduxjs/toolkit';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import type { InvokeAIUIProps } from 'app/components/types';
import { $didStudioInit } from 'app/hooks/useStudioInitAction';
import type { LoggingOverrides } from 'app/logging/logger';
import { $loggingOverrides, configureLogging } from 'app/logging/logger';
import { addStorageListeners } from 'app/store/enhancers/reduxRemember/driver';
import { $accountSettingsLink } from 'app/store/nanostores/accountSettingsLink';
import { $accountTypeText } from 'app/store/nanostores/accountTypeText';
import { $authToken } from 'app/store/nanostores/authToken';
import { $baseUrl } from 'app/store/nanostores/baseUrl';
import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
import type { CustomStarUi } from 'app/store/nanostores/customStarUI';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { $isDebugging } from 'app/store/nanostores/isDebugging';
import { $logo } from 'app/store/nanostores/logo';
@@ -20,11 +18,10 @@ import { $projectId, $projectName, $projectUrl } from 'app/store/nanostores/proj
import { $queueId, DEFAULT_QUEUE_ID } from 'app/store/nanostores/queueId';
import { $store } from 'app/store/nanostores/store';
import { $toastMap } from 'app/store/nanostores/toastMap';
import { $videoUpsellComponent } from 'app/store/nanostores/videoUpsellComponent';
import { $whatsNew } from 'app/store/nanostores/whatsNew';
import { createStore } from 'app/store/store';
import type { PartialAppConfig } from 'app/types/invokeai';
import Loading from 'common/components/Loading/Loading';
import type { WorkflowSortOption, WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import {
$workflowLibraryCategoriesOptions,
$workflowLibrarySortOptions,
@@ -33,47 +30,13 @@ import {
DEFAULT_WORKFLOW_LIBRARY_SORT_OPTIONS,
DEFAULT_WORKFLOW_LIBRARY_TAG_CATEGORIES,
} from 'features/nodes/store/workflowLibrarySlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { ToastConfig } from 'features/toast/toast';
import type { PropsWithChildren, ReactNode } from 'react';
import React, { lazy, memo, useEffect, useLayoutEffect, useState } from 'react';
import { Provider } from 'react-redux';
import { addMiddleware, resetMiddlewares } from 'redux-dynamic-middlewares';
import { $socketOptions } from 'services/events/stores';
import type { ManagerOptions, SocketOptions } from 'socket.io-client';
const App = lazy(() => import('./App'));
interface Props extends PropsWithChildren {
apiUrl?: string;
openAPISchemaUrl?: string;
token?: string;
config?: PartialAppConfig;
customNavComponent?: ReactNode;
accountSettingsLink?: string;
middleware?: Middleware[];
projectId?: string;
projectName?: string;
projectUrl?: string;
queueId?: string;
studioInitAction?: StudioInitAction;
customStarUi?: CustomStarUi;
socketOptions?: Partial<ManagerOptions & SocketOptions>;
isDebugging?: boolean;
logo?: ReactNode;
toastMap?: Record<string, ToastConfig>;
whatsNew?: ReactNode[];
workflowCategories?: WorkflowCategory[];
workflowTagCategories?: WorkflowTagCategory[];
workflowSortOptions?: WorkflowSortOption[];
loggingOverrides?: LoggingOverrides;
/**
* If provided, overrides in-app navigation to the model manager
*/
onClickGoToModelManager?: () => void;
storagePersistDebounce?: number;
}
const InvokeAIUI = ({
apiUrl,
openAPISchemaUrl,
@@ -92,6 +55,8 @@ const InvokeAIUI = ({
isDebugging = false,
logo,
toastMap,
accountTypeText,
videoUpsellComponent,
workflowCategories,
workflowTagCategories,
workflowSortOptions,
@@ -99,7 +64,7 @@ const InvokeAIUI = ({
onClickGoToModelManager,
whatsNew,
storagePersistDebounce = 300,
}: Props) => {
}: InvokeAIUIProps) => {
const [store, setStore] = useState<ReturnType<typeof createStore> | undefined>(undefined);
const [didRehydrate, setDidRehydrate] = useState(false);
@@ -180,6 +145,26 @@ const InvokeAIUI = ({
};
}, [customStarUi]);
useEffect(() => {
if (accountTypeText) {
$accountTypeText.set(accountTypeText);
}
return () => {
$accountTypeText.set('');
};
}, [accountTypeText]);
useEffect(() => {
if (videoUpsellComponent) {
$videoUpsellComponent.set(videoUpsellComponent);
}
return () => {
$videoUpsellComponent.set(undefined);
};
}, [videoUpsellComponent]);
useEffect(() => {
if (customNavComponent) {
$customNavComponent.set(customNavComponent);

View File

@@ -0,0 +1,43 @@
import type { Middleware } from '@reduxjs/toolkit';
import type { StudioInitAction } from 'app/hooks/useStudioInitAction';
import type { LoggingOverrides } from 'app/logging/logger';
import type { CustomStarUi } from 'app/store/nanostores/customStarUI';
import type { PartialAppConfig } from 'app/types/invokeai';
import type { SocketOptions } from 'dgram';
import type { WorkflowSortOption, WorkflowTagCategory } from 'features/nodes/store/workflowLibrarySlice';
import type { WorkflowCategory } from 'features/nodes/types/workflow';
import type { ToastConfig } from 'features/toast/toast';
import type { PropsWithChildren, ReactNode } from 'react';
import type { ManagerOptions } from 'socket.io-client';
export interface InvokeAIUIProps extends PropsWithChildren {
apiUrl?: string;
openAPISchemaUrl?: string;
token?: string;
config?: PartialAppConfig;
customNavComponent?: ReactNode;
accountSettingsLink?: string;
middleware?: Middleware[];
projectId?: string;
projectName?: string;
projectUrl?: string;
queueId?: string;
studioInitAction?: StudioInitAction;
customStarUi?: CustomStarUi;
socketOptions?: Partial<ManagerOptions & SocketOptions>;
isDebugging?: boolean;
logo?: ReactNode;
toastMap?: Record<string, ToastConfig>;
accountTypeText?: string;
videoUpsellComponent?: ReactNode;
whatsNew?: ReactNode[];
workflowCategories?: WorkflowCategory[];
workflowTagCategories?: WorkflowTagCategory[];
workflowSortOptions?: WorkflowSortOption[];
loggingOverrides?: LoggingOverrides;
/**
* If provided, overrides in-app navigation to the model manager
*/
onClickGoToModelManager?: () => void;
storagePersistDebounce?: number;
}

View File

@@ -0,0 +1,3 @@
import { atom } from 'nanostores';
export const $accountTypeText = atom<string>('');

View File

@@ -0,0 +1,4 @@
import { atom } from 'nanostores';
import type { ReactNode } from 'react';
export const $videoUpsellComponent = atom<ReactNode | undefined>(undefined);

View File

@@ -80,6 +80,7 @@ export const zAppConfig = z.object({
allowClientSideUpload: z.boolean(),
allowPublishWorkflows: z.boolean(),
allowPromptExpansion: z.boolean(),
allowVideo: z.boolean(),
disabledTabs: z.array(zTabName),
disabledFeatures: z.array(zAppFeature),
disabledSDFeatures: z.array(zSDFeature),
@@ -140,8 +141,9 @@ export const getDefaultAppConfig = (): AppConfig => ({
allowClientSideUpload: false,
allowPublishWorkflows: false,
allowPromptExpansion: false,
allowVideo: false, // used to determine if video is enabled vs upsell
shouldShowCredits: false,
disabledTabs: ['video'],
disabledTabs: ['video'], // used to determine if video functionality is visible
disabledFeatures: ['lightbox', 'faceRestore', 'batches'] satisfies AppFeature[],
disabledSDFeatures: ['variation', 'symmetry', 'hires', 'perlinNoise', 'noiseThreshold'] satisfies SDFeature[],
sd: {

View File

@@ -82,14 +82,7 @@ export const GalleryPanel = memo(() => {
>
{t('parameters.images')}
</Button>
<Button
tooltip={t('gallery.assetsTab')}
onClick={handleClickAssets}
data-testid="assets-tab"
colorScheme={galleryView === 'assets' ? 'invokeBlue' : undefined}
>
{t('gallery.assets')}
</Button>
{isVideoEnabled && (
<Button
tooltip={t('gallery.videosTab')}
@@ -100,6 +93,14 @@ export const GalleryPanel = memo(() => {
{t('gallery.videos')}
</Button>
)}
<Button
tooltip={t('gallery.assetsTab')}
onClick={handleClickAssets}
data-testid="assets-tab"
colorScheme={galleryView === 'assets' ? 'invokeBlue' : undefined}
>
{t('gallery.assets')}
</Button>
</ButtonGroup>
<Flex flexGrow={1} flexBasis={0} justifyContent="flex-end">
<GalleryUploadButton />

View File

@@ -1,6 +1,8 @@
import { Box, Flex, forwardRef, Grid, GridItem, Spinner, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { $accountTypeText } from 'app/store/nanostores/accountTypeText';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
import { getFocusedRegion } from 'common/hooks/focus';
import { useRangeBasedVideoFetching } from 'features/gallery/hooks/useRangeBasedVideoFetching';
@@ -8,6 +10,7 @@ import type { selectGetVideoIdsQueryArgs } from 'features/gallery/store/galleryS
import { selectGalleryImageMinimumWidth, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors';
import { selectionChanged } from 'features/gallery/store/gallerySlice';
import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData';
import { selectAllowVideo } from 'features/system/store/configSlice';
import type { MutableRefObject } from 'react';
import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react';
import type {
@@ -287,6 +290,19 @@ export const VideoGallery = memo(() => {
const context = useMemo<GridContext>(() => ({ videoIds, queryArgs }), [videoIds, queryArgs]);
const isVideoEnabled = useAppSelector(selectAllowVideo);
const accountTypeText = useStore($accountTypeText);
if (!isVideoEnabled) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center" flexDirection="column" gap={4}>
<Text fontSize="sm" color="base.300">
Video generation is not enabled for {accountTypeText} accounts
</Text>
</Flex>
);
}
if (isLoading) {
return (
<Flex w="full" h="full" alignItems="center" justifyContent="center" gap={4}>

View File

@@ -1,6 +1,7 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { EMPTY_ARRAY } from 'app/store/constants';
import { $accountTypeText } from 'app/store/nanostores/accountTypeText';
import { $false } from 'app/store/nanostores/util';
import type { AppDispatch, AppStore } from 'app/store/store';
import { useAppSelector, useAppStore } from 'app/store/storeHooks';
@@ -39,7 +40,7 @@ import { selectVideoSlice, type VideoState } from 'features/parameters/store/vid
import type { ParameterModel } from 'features/parameters/types/parameterSchemas';
import { getGridSize } from 'features/parameters/util/optimalDimension';
import { promptExpansionApi, type PromptExpansionRequestState } from 'features/prompt/PromptExpansion/state';
import { selectConfigSlice } from 'features/system/store/configSlice';
import { selectAllowVideo, selectConfigSlice } from 'features/system/store/configSlice';
import { selectActiveTab } from 'features/ui/store/uiSelectors';
import type { TabName } from 'features/ui/store/uiTypes';
import i18n from 'i18next';
@@ -93,6 +94,7 @@ const debouncedUpdateReasons = debounce(
store: AppStore,
isInPublishFlow: boolean,
isChatGPT4oHighModelDisabled: (model: ParameterModel) => boolean,
isVideoEnabled: boolean,
promptExpansionRequest: PromptExpansionRequestState,
video: VideoState
) => {
@@ -152,6 +154,7 @@ const debouncedUpdateReasons = debounce(
params,
promptExpansionRequest,
dynamicPrompts,
isVideoEnabled,
});
$reasonsWhyCannotEnqueue.set(reasons);
} else {
@@ -183,6 +186,7 @@ export const useReadinessWatcher = () => {
const canvasIsCompositing = useStore(canvasManager?.compositor.$isBusy ?? $false);
const isInPublishFlow = useStore($isInPublishFlow);
const { isChatGPT4oHighModelDisabled } = useIsModelDisabled();
const isVideoEnabled = useAppSelector(selectAllowVideo);
const promptExpansionRequest = useStore(promptExpansionApi.$state);
const video = useAppSelector(selectVideoSlice);
useEffect(() => {
@@ -206,6 +210,7 @@ export const useReadinessWatcher = () => {
store,
isInPublishFlow,
isChatGPT4oHighModelDisabled,
isVideoEnabled,
promptExpansionRequest,
video
);
@@ -229,6 +234,7 @@ export const useReadinessWatcher = () => {
workflowSettings,
isInPublishFlow,
isChatGPT4oHighModelDisabled,
isVideoEnabled,
promptExpansionRequest,
video,
]);
@@ -242,10 +248,16 @@ const getReasonsWhyCannotEnqueueVideoTab = (arg: {
params: ParamsState;
dynamicPrompts: DynamicPromptsState;
promptExpansionRequest: PromptExpansionRequestState;
isVideoEnabled: boolean;
}) => {
const { isConnected, video, params, dynamicPrompts, promptExpansionRequest } = arg;
const { isConnected, video, params, dynamicPrompts, promptExpansionRequest, isVideoEnabled } = arg;
const { positivePrompt } = params;
const reasons: Reason[] = [];
const accountTypeText = $accountTypeText.get();
if (!isVideoEnabled) {
reasons.push({ content: i18n.t('parameters.invoke.videoIsDisabled', { accountType: accountTypeText }) });
}
if (!isConnected) {
reasons.push(disconnectedReason(i18n.t));

View File

@@ -71,6 +71,8 @@ export const selectIsModelsTabDisabled = createConfigSelector((config) => config
export const selectIsClientSideUploadEnabled = createConfigSelector((config) => config.allowClientSideUpload);
export const selectAllowPublishWorkflows = createConfigSelector((config) => config.allowPublishWorkflows);
export const selectAllowPromptExpansion = createConfigSelector((config) => config.allowPromptExpansion);
export const selectAllowVideo = createConfigSelector((config) => config.allowVideo);
export const selectIsLocal = createSelector(selectConfigSlice, (config) => config.isLocal);
export const selectShouldShowCredits = createConfigSelector((config) => config.shouldShowCredits);
const selectDisabledTabs = createConfigSelector((config) => config.disabledTabs);

View File

@@ -1,4 +1,4 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import { Divider, Flex, Spacer } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $customNavComponent } from 'app/store/nanostores/customNavComponent';
import { useAppSelector } from 'app/store/storeHooks';
@@ -48,13 +48,15 @@ export const VerticalNavBar = memo(() => {
{withGenerateTab && <TabButton tab="generate" icon={<PiTextAaBold />} label={t('ui.tabs.generate')} />}
{withCanvasTab && <TabButton tab="canvas" icon={<PiBoundingBoxBold />} label={t('ui.tabs.canvas')} />}
{withUpscalingTab && <TabButton tab="upscaling" icon={<PiFrameCornersBold />} label={t('ui.tabs.upscaling')} />}
{withWorkflowsTab && <TabButton tab="workflows" icon={<PiFlowArrowBold />} label={t('ui.tabs.workflows')} />}
{withModelsTab && <TabButton tab="models" icon={<PiCubeBold />} label={t('ui.tabs.models')} />}
{withQueueTab && <TabButton tab="queue" icon={<PiQueueBold />} label={t('ui.tabs.queue')} />}
{withVideoTab && <TabButton tab="video" icon={<PiVideoBold />} label={t('ui.tabs.video')} />}
{withWorkflowsTab && <TabButton tab="workflows" icon={<PiFlowArrowBold />} label={t('ui.tabs.workflows')} />}
</Flex>
<Spacer />
<StatusIndicator />
{withModelsTab && <TabButton tab="models" icon={<PiCubeBold />} label={t('ui.tabs.models')} />}
{withQueueTab && <TabButton tab="queue" icon={<PiQueueBold />} label={t('ui.tabs.queue')} />}
<Divider borderColor="base.200" />
<Notifications />
<VideosModalButton />
{customNavComponent ? customNavComponent : <SettingsMenu />}

View File

@@ -0,0 +1,50 @@
import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library';
import { useAppStore } from 'app/store/storeHooks';
import { useImageUploadButton } from 'common/hooks/useImageUploadButton';
import { imageDTOToImageWithDims } from 'features/controlLayers/store/util';
import { videoFrameFromImageDndTarget } from 'features/dnd/dnd';
import { DndDropTarget } from 'features/dnd/DndDropTarget';
import { startingFrameImageChanged } from 'features/parameters/store/videoSlice';
import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiUploadBold, PiVideoBold } from 'react-icons/pi';
import type { ImageDTO } from 'services/api/types';
const dndTargetData = videoFrameFromImageDndTarget.getData({ frame: 'start' });
export const LaunchpadStartingFrameButton = memo((props: { extraAction?: () => void }) => {
const { t } = useTranslation();
const { dispatch } = useAppStore();
const uploadOptions = useMemo(
() =>
({
onUpload: (imageDTO: ImageDTO) => {
dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO)));
props.extraAction?.();
},
allowMultiple: false,
}) as const,
[dispatch, props]
);
const uploadApi = useImageUploadButton(uploadOptions);
return (
<LaunchpadButton {...uploadApi.getUploadButtonProps()} position="relative" gap={8}>
<Icon as={PiVideoBold} boxSize={8} color="base.500" />
<Flex flexDir="column" alignItems="flex-start" gap={2}>
<Heading size="sm">{t('ui.launchpad.addStartingFrame.title')}</Heading>
<Text>{t('ui.launchpad.addStartingFrame.description')}</Text>
</Flex>
<Flex position="absolute" right={3} bottom={3}>
<PiUploadBold />
<input {...uploadApi.getUploadInputProps()} />
</Flex>
<DndDropTarget dndTarget={videoFrameFromImageDndTarget} dndTargetData={dndTargetData} label="Drop" />
</LaunchpadButton>
);
});
LaunchpadStartingFrameButton.displayName = 'LaunchpadStartingFrameButton';

View File

@@ -0,0 +1,54 @@
import { Button, Flex, Grid, Text } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { $videoUpsellComponent } from 'app/store/nanostores/videoUpsellComponent';
import { useAppSelector } from 'app/store/storeHooks';
import { VideoModelPicker } from 'features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker';
import { selectAllowVideo } from 'features/system/store/configSlice';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { LaunchpadContainer } from './LaunchpadContainer';
import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton';
import { LaunchpadStartingFrameButton } from './LaunchpadStartingFrameButton';
export const VideoLaunchpadPanel = memo(() => {
const { t } = useTranslation();
const isVideoEnabled = useAppSelector(selectAllowVideo);
const videoUpsellComponent = useStore($videoUpsellComponent);
if (!isVideoEnabled) {
return (
<LaunchpadContainer heading={t('ui.launchpad.videoTitle')}>
<Grid gridTemplateColumns="1fr 1fr" gap={8}>
{videoUpsellComponent}
</Grid>
</LaunchpadContainer>
);
}
return (
<LaunchpadContainer heading={t('ui.launchpad.videoTitle')}>
<Grid gridTemplateColumns="1fr 1fr" gap={8}>
<VideoModelPicker />
<Flex flexDir="column" gap={2} justifyContent="center">
<Text>
{t('ui.launchpad.modelGuideText')}{' '}
<Button
as="a"
variant="link"
href="https://support.invoke.ai/support/solutions/articles/151000216086-model-guide"
target="_blank"
rel="noopener noreferrer"
size="sm"
>
{t('ui.launchpad.modelGuideLink')}
</Button>
</Text>
</Flex>
</Grid>
<LaunchpadGenerateFromTextButton />
<LaunchpadStartingFrameButton />
</LaunchpadContainer>
);
});
VideoLaunchpadPanel.displayName = 'VideoLaunchpadPanel';

View File

@@ -21,7 +21,6 @@ import { memo, useCallback, useEffect } from 'react';
import { DockviewTab } from './DockviewTab';
import { DockviewTabLaunchpad } from './DockviewTabLaunchpad';
import { DockviewTabProgress } from './DockviewTabProgress';
import { GenerateLaunchpadPanel } from './GenerateLaunchpadPanel';
import { navigationApi } from './navigation-api';
import { PanelHotkeysLogical } from './PanelHotkeysLogical';
import {
@@ -43,6 +42,7 @@ import {
SETTINGS_PANEL_ID,
VIEWER_PANEL_ID,
} from './shared';
import { VideoLaunchpadPanel } from './VideoLaunchpadPanel';
import { VideoTabLeftPanel } from './VideoTabLeftPanel';
const tabComponents = {
@@ -52,7 +52,7 @@ const tabComponents = {
};
const mainPanelComponents: AutoLayoutDockviewComponents = {
[LAUNCHPAD_PANEL_ID]: withPanelContainer(GenerateLaunchpadPanel),
[LAUNCHPAD_PANEL_ID]: withPanelContainer(VideoLaunchpadPanel),
[VIEWER_PANEL_ID]: withPanelContainer(ImageViewerPanel),
};