From 887ef4ece09bf3215d92d498f05e662b7e618a61 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 7 Aug 2025 15:26:28 -0400 Subject: [PATCH 01/55] video_output support --- invokeai/app/invocations/fields.py | 7 +++++++ invokeai/app/invocations/primitives.py | 28 ++++++++++++++++++++++++++ invokeai/invocation_api/__init__.py | 4 ++++ 3 files changed, 39 insertions(+) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index fb6d6af03d8..1d0b1a1ed0d 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -70,6 +70,7 @@ class UIType(str, Enum, metaclass=MetaEnum): # region Misc Field Types Scheduler = "SchedulerField" Any = "AnyField" + Video = "VideoField" # endregion # region Internal Field Types @@ -224,6 +225,12 @@ class ImageField(BaseModel): image_name: str = Field(description="The name of the image") +class VideoField(BaseModel): + """A video primitive field""" + + video_name: str = Field(description="The name of the video") + + class BoardField(BaseModel): """A board primitive field""" diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index 10703a620cd..9308b4e2c81 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -27,6 +27,7 @@ SD3ConditioningField, TensorField, UIComponent, + VideoField, ) from invokeai.app.services.images.images_common import ImageDTO from invokeai.app.services.shared.invocation_context import InvocationContext @@ -287,6 +288,33 @@ def invoke(self, context: InvocationContext) -> ImageCollectionOutput: return ImageCollectionOutput(collection=self.collection) +# endregion + +# region Video + + +@invocation_output("video_output") +class VideoOutput(BaseInvocationOutput): + """Base class for nodes that output a single video""" + + video: VideoField = OutputField(description="The output video") + width: int = OutputField(description="The width of the video in pixels") + height: int = OutputField(description="The height of the video in pixels") + duration_seconds: float = OutputField(description="The duration of the video in seconds") + + @classmethod + def build(cls, video_name: str, width: int, height: int, duration_seconds: float) -> "VideoOutput": + return cls( + video=VideoField(video_name=video_name), + width=width, + height=height, + duration_seconds=duration_seconds, + ) + + + + + # endregion # region DenoiseMask diff --git a/invokeai/invocation_api/__init__.py b/invokeai/invocation_api/__init__.py index 9069f6d4a7a..6094b28c5dc 100644 --- a/invokeai/invocation_api/__init__.py +++ b/invokeai/invocation_api/__init__.py @@ -29,6 +29,7 @@ OutputField, UIComponent, UIType, + VideoField, WithBoard, WithMetadata, WithWorkflow, @@ -67,6 +68,7 @@ LatentsOutput, StringCollectionOutput, StringOutput, + VideoOutput, ) from invokeai.app.invocations.scheduler import SchedulerOutput from invokeai.app.services.boards.boards_common import BoardDTO @@ -113,6 +115,7 @@ "OutputField", "UIComponent", "UIType", + "VideoField", "WithBoard", "WithMetadata", "WithWorkflow", @@ -154,6 +157,7 @@ "LatentsOutput", "StringCollectionOutput", "StringOutput", + "VideoOutput", # invokeai.app.services.image_records.image_records_common "ImageCategory", # invokeai.app.services.boards.boards_common From cc7dd79c4d1d7450f4e1e596b0236b841c63dca5 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 8 Aug 2025 16:42:46 -0400 Subject: [PATCH 02/55] rough rough POC of video tab --- invokeai/frontend/web/package.json | 1 + invokeai/frontend/web/pnpm-lock.yaml | 343 ++++++++++++++++++ invokeai/frontend/web/public/locales/en.json | 10 +- invokeai/frontend/web/src/app/store/store.ts | 3 + .../frontend/web/src/common/hooks/focus.ts | 1 + .../web/src/common/hooks/useGlobalHotkeys.ts | 1 + .../ImageMenuItemSendToVideo.tsx | 31 ++ .../SingleSelectionMenuItems.tsx | 7 +- .../graph/generation/buildRunwayVideoGraph.ts | 118 ++++++ .../parameters/components/Prompts/Prompts.tsx | 1 + .../features/parameters/store/videoSlice.ts | 72 ++++ .../features/queue/hooks/useEnqueueVideo.ts | 102 ++++++ .../web/src/features/queue/hooks/useInvoke.ts | 6 +- .../VideoFirstFrameImage.tsx | 71 ++++ .../VideoLastFrameImage.tsx | 70 ++++ .../VideoSettingsAccordion.tsx | 41 +++ .../src/features/system/store/configSlice.ts | 3 + .../src/features/ui/components/AppContent.tsx | 4 + .../ParametersPanels/ParametersPanelVideo.tsx | 46 +++ .../features/ui/components/VerticalNavBar.tsx | 4 + .../ui/layouts/DockviewTabLaunchpad.tsx | 2 + .../features/ui/layouts/VideoPlayerPanel.tsx | 35 ++ .../features/ui/layouts/VideoTabLeftPanel.tsx | 16 + .../src/features/ui/layouts/navigation-api.ts | 4 + .../ui/layouts/video-tab-auto-layout.tsx | 276 ++++++++++++++ .../web/src/features/ui/store/uiTypes.ts | 2 +- .../services/events/onInvocationComplete.tsx | 15 + 27 files changed, 1278 insertions(+), 7 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts create mode 100644 invokeai/frontend/web/src/features/parameters/store/videoSlice.ts create mode 100644 invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts create mode 100644 invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx create mode 100644 invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoLastFrameImage.tsx create mode 100644 invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx create mode 100644 invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx create mode 100644 invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx create mode 100644 invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx create mode 100644 invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index 246c5d8ae79..168f0205a0f 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -87,6 +87,7 @@ "react-hotkeys-hook": "4.5.0", "react-i18next": "^15.5.3", "react-icons": "^5.5.0", + "react-player": "^3.3.1", "react-redux": "9.2.0", "react-resizable-panels": "^3.0.3", "react-textarea-autosize": "^8.5.9", diff --git a/invokeai/frontend/web/pnpm-lock.yaml b/invokeai/frontend/web/pnpm-lock.yaml index e835a5db79b..4dc389b815b 100644 --- a/invokeai/frontend/web/pnpm-lock.yaml +++ b/invokeai/frontend/web/pnpm-lock.yaml @@ -152,6 +152,9 @@ importers: react-icons: specifier: ^5.5.0 version: 5.5.0(react@18.3.1) + react-player: + specifier: ^3.3.1 + version: 3.3.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) react-redux: specifier: 9.2.0 version: 9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1) @@ -937,6 +940,31 @@ packages: '@microsoft/tsdoc@0.15.1': resolution: {integrity: sha512-4aErSrCR/On/e5G2hDP0wjooqDdauzEbIq8hIkIe5pXV0rtWJZvdCEKL0ykZxex+IxIwBp0eGeV48hQN07dXtw==} + '@mux/mux-data-google-ima@0.2.8': + resolution: {integrity: sha512-0ZEkHdcZ6bS8QtcjFcoJeZxJTpX7qRIledf4q1trMWPznugvtajCjCM2kieK/pzkZj1JM6liDRFs1PJSfVUs2A==} + + '@mux/mux-player-react@3.5.3': + resolution: {integrity: sha512-f0McZbIXYDkzecFwhhkf0JgEInPnsOClgBqBhkdhRlLRdrAzMATib+D3Di3rPkRHNH7rc/WWORvSxgJz6m6zkA==} + peerDependencies: + '@types/react': ^17.0.0 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0 + '@types/react-dom': '*' + react: ^17.0.2 || ^17.0.0-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0 + react-dom: ^17.0.2 || ^17.0.2-0 || ^18 || ^18.0.0-0 || ^19 || ^19.0.0-0 + peerDependenciesMeta: + '@types/react': + optional: true + '@types/react-dom': + optional: true + + '@mux/mux-player@3.5.3': + resolution: {integrity: sha512-uXKFXbdtioAi+clSVfD60Rw4r7OvA62u2jV6aar9loW9qMsmKv8LU+8uaIaWQjyAORp6E0S37GOVjo72T6O2eQ==} + + '@mux/mux-video@0.26.1': + resolution: {integrity: sha512-gkMdBAgNlB4+krANZHkQFzYWjWeNsJz69y1/hnPtmNQnpvW+O7oc71OffcZrbblyibSxWMQ6MQpYmBVjXlp6sA==} + + '@mux/playback-core@0.30.1': + resolution: {integrity: sha512-rnO1NE9xHDyzbAkmE6ygJYcD7cyyMt7xXqWTykxlceaoSXLjUqgp42HDio7Lcidto4x/O4FIa7ztjV2aCBCXgQ==} + '@nanostores/react@0.7.3': resolution: {integrity: sha512-/XuLAMENRu/Q71biW4AZ4qmU070vkZgiQ28gaTSNRPm2SZF5zGAR81zPE1MaMB4SeOp6ZTst92NBaG75XSspNg==} engines: {node: ^18.0.0 || >=20.0.0} @@ -1453,6 +1481,9 @@ packages: typescript: optional: true + '@svta/common-media-library@0.12.4': + resolution: {integrity: sha512-9EuOoaNmz7JrfGwjsrD9SxF9otU5TNMnbLu1yU4BeLK0W5cDxVXXR58Z89q9u2AnHjIctscjMTYdlqQ1gojTuw==} + '@swc/core-darwin-arm64@1.12.9': resolution: {integrity: sha512-GACFEp4nD6V+TZNR2JwbMZRHB+Yyvp14FrcmB6UCUYmhuNWjkxi+CLnEvdbuiKyQYv0zA+TRpCHZ+whEs6gwfA==} engines: {node: '>=10'} @@ -1707,6 +1738,12 @@ packages: resolution: {integrity: sha512-YzfhzcTnZVPiLfP/oeKtDp2evwvHLMe0LOy7oe+hb9KKIumLNohYS9Hgp1ifwpu42YWxhZE8yieggz6JpqO/1w==} engines: {node: ^18.18.0 || ^20.9.0 || >=21.1.0} + '@vercel/edge@1.2.2': + resolution: {integrity: sha512-1+y+f6rk0Yc9ss9bRDgz/gdpLimwoRteKHhrcgHvEpjbP1nyT3ByqEMWm2BTcpIO5UtDmIFXc8zdq4LR190PDA==} + + '@vimeo/player@2.29.0': + resolution: {integrity: sha512-9JjvjeqUndb9otCCFd0/+2ESsLk7VkDE6sxOBy9iy2ukezuQbplVRi+g9g59yAurKofbmTi/KcKxBGO/22zWRw==} + '@vitejs/plugin-react-swc@3.10.2': resolution: {integrity: sha512-xD3Rdvrt5LgANug7WekBn1KhcvLn1H3jNBfJRL3reeOIua/WnZOEV5qi5qIBq5T8R0jUDmRtxuvk4bPhzGHDWw==} peerDependencies: @@ -1959,6 +1996,15 @@ packages: base64-js@1.5.1: resolution: {integrity: sha512-AKpaYlHn8t4SVbOHCy+b5+KKgvR4vrsD8vbvrbiQJps7fKDTkjkDry6ji0rUJjC0kzbNePLwzxq8iypo41qeWA==} + bcp-47-match@2.0.3: + resolution: {integrity: sha512-JtTezzbAibu8G0R9op9zb3vcWZd9JF6M0xOYGPn0fNCd7wOpRB1mU2mH9T8gaBGbAAyIIVgB2G7xG0GP98zMAQ==} + + bcp-47-normalize@2.3.0: + resolution: {integrity: sha512-8I/wfzqQvttUFz7HVJgIZ7+dj3vUaIyIxYXaTRP1YWoSDfzt6TUmxaKZeuXR62qBmYr+nvuWINFRl6pZ5DlN4Q==} + + bcp-47@2.1.0: + resolution: {integrity: sha512-9IIS3UPrvIa1Ej+lVDdDwO7zLehjqsaByECw0bu2RRGP73jALm6FYbzI5gWbgHLvNdkvfXB5YrSbocZdOS0c0w==} + better-opn@3.0.2: resolution: {integrity: sha512-aVNobHnJqLiUelTaHat9DZ1qM2w0C0Eym4LPI/3JxOnSokGVdsl1T1kN7TFvsEAD8G47A6VKQ0TVHqbBnYMJlQ==} engines: {node: '>=12.0.0'} @@ -2014,6 +2060,14 @@ packages: caniuse-lite@1.0.30001727: resolution: {integrity: sha512-pB68nIHmbN6L/4C6MH1DokyR3bYqFwjaSs/sWDHGj4CTcFtQUQMuJftVwWkXq7mNWOybD3KhUv3oWHoGxgP14Q==} + castable-video@1.1.10: + resolution: {integrity: sha512-/T1I0A4VG769wTEZ8gWuy1Crn9saAfRTd1UYTb8xbOPlN78+zOi/1nU2dD5koNkfE5VWvgabkIqrGKmyNXOjSQ==} + + ce-la-react@0.3.1: + resolution: {integrity: sha512-g0YwpZDPIwTwFumGTzNHcgJA6VhFfFCJkSNdUdC04br2UfU+56JDrJrJva3FZ7MToB4NDHAFBiPE/PZdNl1mQA==} + peerDependencies: + react: '>=17.0.0' + chai@5.2.0: resolution: {integrity: sha512-mCuXncKXk5iCLhfhwTc0izo0gtEmpz5CtG2y8GiOINBlMVS6v8TMRc5TaLWKS6692m9+dVVfzgeVxR5UxWHTYw==} engines: {node: '>=12'} @@ -2064,12 +2118,18 @@ packages: resolution: {integrity: sha512-JQHZ2QMW6l3aH/j6xCqQThY/9OH4D/9ls34cgkUBiEeocRTU04tHfKPBsUK1PqZCUQM7GiA0IIXJSuXHI64Kbg==} engines: {node: '>=0.8'} + cloudflare-video-element@1.3.3: + resolution: {integrity: sha512-qrHzwLmUhisoIuEoKc7iBbdzBNj2Pi7ThHslU/9U/6PY9DEvo4mh/U+w7OVuzXT9ks7ZXfARvDBfPAaMGF/hIg==} + cmdk@1.1.1: resolution: {integrity: sha512-Vsv7kFaXm+ptHDMZ7izaRsP70GgrW9NBNGswt9OZaVBLlE0SNpDq8eu/VGXyF9r7M0azK3Wy7OlYXsuyYLFzHg==} peerDependencies: react: ^18 || ^19 || ^19.0.0-rc react-dom: ^18 || ^19 || ^19.0.0-rc + codem-isoboxer@0.3.10: + resolution: {integrity: sha512-eNk3TRV+xQMJ1PEj0FQGY8KD4m0GPxT487XJ+Iftm7mVa9WpPFDMWqPt+46buiP5j5Wzqe5oMIhqBcAeKfygSA==} + color-convert@2.0.1: resolution: {integrity: sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ==} engines: {node: '>=7.0.0'} @@ -2139,6 +2199,9 @@ packages: csstype@3.1.3: resolution: {integrity: sha512-M1uQkMl8rQK/szD0LNhtqxIPLpimGm8sOBwU7lLnCpSbTyY3yeU1Vc7l4KT5zT4s/yOxHH5O7tIuuLOCnLADRw==} + custom-media-element@1.4.5: + resolution: {integrity: sha512-cjrsQufETwxjvwZbYbKBCJNvmQ2++G9AvT45zDi7NXL9k2PdVcs2h0jQz96J6G4TMKRCcEsoJ+QTgQD00Igtjw==} + d3-color@3.1.0: resolution: {integrity: sha512-zg/chbXyeBtMQ1LbD/WSoW2DpC3I0mpmPdW+ynRTj/x2DAWYrIY7qeZIHidozwV24m4iavr15lNwIwLxRmOxhA==} engines: {node: '>=12'} @@ -2177,6 +2240,12 @@ packages: resolution: {integrity: sha512-b8AmV3kfQaqWAuacbPuNbL6vahnOJflOhexLzMMNLga62+/nh0JzvJ0aO/5a5MVgUFGS7Hu1P9P03o3fJkDCyw==} engines: {node: '>=12'} + dash-video-element@0.1.6: + resolution: {integrity: sha512-4gHShaQjcFv6diX5EzB6qAdUGKlIUGGZY8J8yp2pQkWqR0jX4c6plYy0cFraN7mr0DZINe8ujDN1fssDYxJjcg==} + + dashjs@5.0.3: + resolution: {integrity: sha512-TXndNnCUjFjF2nYBxDVba+hWRpVkadkQ8flLp7kHkem+5+wZTfRShJCnVkPUosmjS0YPE9fVNLbYPJxHBeQZvA==} + data-view-buffer@1.0.2: resolution: {integrity: sha512-EmKO5V3OLXh1rtK2wgXRansaK1/mtVdTUEiEI0W8RkvgT05kfxaH29PliLnpLP73yYO6142Q72QNa8Wx/A5CqQ==} engines: {node: '>= 0.4'} @@ -2751,9 +2820,18 @@ packages: resolution: {integrity: sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw==} hasBin: true + hls-video-element@1.5.6: + resolution: {integrity: sha512-KPdvSR+oBJPiCVb+m6pd2mn3rJEjNbaK8pGhSkxFI2pmyvZIeTVQrPbEO9PT/juwXHwhvCoKJnNxAuFwJG9H5A==} + + hls.js@1.6.9: + resolution: {integrity: sha512-q7qPrri6GRwjcNd7EkFCmhiJ6PBIxeUsdxKbquBkQZpg9jAnp6zSAeN9eEWFlOB09J8JfzAQGoXL5ZEAltjO9g==} + hoist-non-react-statics@3.3.2: resolution: {integrity: sha512-/gGivxi8JPKWNm/W0jSmzcMPpfpPLc3dY/6GxhX2hQ9iGj3aDfklV4ET7NjKpSinLpJ5vafa9iiGIEZg10SfBw==} + html-entities@2.6.0: + resolution: {integrity: sha512-kig+rMn/QOVRvr7c86gQ8lWXq+Hkv6CbAH1hLu+RG338StTpE8Z0b44SDVaqVu7HGKf27frdmUYEs9hTUX/cLQ==} + html-escaper@2.0.2: resolution: {integrity: sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg==} @@ -2792,6 +2870,9 @@ packages: resolution: {integrity: sha512-Hs59xBNfUIunMFgWAbGX5cq6893IbWg4KnrjbYwX3tx0ztorVgTDA6B2sxf8ejHJ4wz8BqGUMYlnzNBer5NvGg==} engines: {node: '>= 4'} + immediate@3.0.6: + resolution: {integrity: sha512-XXOFtyqDjNDAQxVfYxuF7g9Il/IbWmmlQg2MYKOH8ExIT1qg6xc4zyS3HaEEATgs1btfzxq15ciUiY7gjSXRGQ==} + immer@10.1.1: resolution: {integrity: sha512-s2MPrmjovJcoMaHtx6K11Ra7oD05NT97w1IC5zpMkT6Atjr7H8LjaDd81iIxUYpMKSRRNMJE703M1Fhr/TctHw==} @@ -2803,6 +2884,9 @@ packages: resolution: {integrity: sha512-rKtvo6a868b5Hu3heneU+L4yEQ4jYKLtjpnPeUdK7h0yzXGmyBTypknlkCvHFBqfX9YlorEiMM6Dnq/5atfHkw==} engines: {node: '>=8'} + imsc@1.1.5: + resolution: {integrity: sha512-V8je+CGkcvGhgl2C1GlhqFFiUOIEdwXbXLiu1Fcubvvbo+g9inauqT3l0pNYXGoLPBj3jxtZz9t+wCopMkwadQ==} + imurmurhash@0.1.4: resolution: {integrity: sha512-JmXMZ6wuvDmLiHEml9ykzqO6lwFbof0GG4IkcGaENdCRDDmMVnny7s5HsIgHCbaq0w2MyPhDqkhTUgS2LU2PHA==} engines: {node: '>=0.8.19'} @@ -2825,6 +2909,12 @@ packages: resolution: {integrity: sha512-4gd7VpWNQNB4UKKCFFVcp1AVv+FMOgs9NKzjHKusc8jTMhd5eL1NqQqOpE0KzMds804/yHlglp3uxgluOqAPLw==} engines: {node: '>= 0.4'} + is-alphabetical@2.0.1: + resolution: {integrity: sha512-FWyyY60MeTNyeSRpkM2Iry0G9hpr7/9kD40mD/cGQEuilcZYS4okz8SN2Q6rLCJ8gbCt6fN+rC+6tMGS99LaxQ==} + + is-alphanumerical@2.0.1: + resolution: {integrity: sha512-hmbYhX/9MUMF5uh7tOXyK/n0ZvWpad5caBA17GsC6vyuCqaWliRG5K1qS9inmUhEMaOBIW7/whAnSwveW/LtZw==} + is-array-buffer@3.0.5: resolution: {integrity: sha512-DDfANUiiG2wC1qawP66qlTugJeL5HyzMpfr8lLK+jMQirGzNod0B12cFB/9q838Ru27sBwfw78/rdoU7RERz6A==} engines: {node: '>= 0.4'} @@ -2860,6 +2950,9 @@ packages: resolution: {integrity: sha512-PwwhEakHVKTdRNVOw+/Gyh0+MzlCl4R6qKvkhuvLtPMggI1WAHt9sOwZxQLSGpUaDnrdyDsomoRgNnCfKNSXXg==} engines: {node: '>= 0.4'} + is-decimal@2.0.1: + resolution: {integrity: sha512-AAB9hiomQs5DXWcRB1rqsxGUstbRroFOPPVAomNk/3XHR5JyEZChOyTWe2oayKnsSsr/kcGqF+z6yuH6HHpN0A==} + is-docker@2.2.1: resolution: {integrity: sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ==} engines: {node: '>=8'} @@ -3064,6 +3157,9 @@ packages: resolution: {integrity: sha512-+bT2uH4E5LGE7h/n3evcS/sQlJXCpIp6ym8OWJ5eV6+67Dsql/LaaT7qJBAt2rzfoa/5QBGBhxDix1dMt2kQKQ==} engines: {node: '>= 0.8.0'} + lie@3.1.1: + resolution: {integrity: sha512-RiNhHysUjhrDQntfYSfY4MU24coXXdEOgw9WGcKHNeEwffDYbF//u87M1EWaMGzuFoSbqW0C9C6lEEhDOAswfw==} + lines-and-columns@1.2.4: resolution: {integrity: sha512-7ylylesZQ/PV29jhEDl3Ufjo6ZX7gCqJr5F7PKrqc93v7fzSymt1BpwEU8nAUXs8qzzvqhbjhK5QZg6Mt/HkBg==} @@ -3088,6 +3184,9 @@ packages: resolution: {integrity: sha512-WunYko2W1NcdfAFpuLUoucsgULmgDBRkdxHxWQ7mK0cQqwPiy8E1enjuRBrhLtZkB5iScJ1XIPdhVEFK8aOLSg==} engines: {node: '>=14'} + localforage@1.10.0: + resolution: {integrity: sha512-14/H1aX7hzBBmmh7sGPd+AOMkkIrHM3Z1PAyGgZigA1H1p5O5ANnMyWzvpAETtG68/dC4pC0ncy3+PPGzXZHPg==} + locate-path@6.0.0: resolution: {integrity: sha512-iPZK6eYjbxRu3uB4/WZ3EsEIMJFMqAoopl3R+zuq0UjcAm/MO6KCweDgPfP3elTztoKP3KtnVHxTn2NHBSDVUw==} engines: {node: '>=10'} @@ -3154,6 +3253,12 @@ packages: mdn-data@2.0.14: resolution: {integrity: sha512-dn6wd0uw5GsdswPFfsgMp5NSB0/aDe6fK94YJV/AJDYXL6HVLWBsxeq7js7Ad+mU2K9LAlwpk6kN2D5mwCPVow==} + media-chrome@4.11.1: + resolution: {integrity: sha512-+2niDc4qOwlpFAjwxg1OaizK/zKV6y7QqGm4nBFEVlSaG0ZBgOmfc4IXAPiirZqAlZGaFFUaMqCl1SpGU0/naA==} + + media-tracks@0.3.3: + resolution: {integrity: sha512-9P2FuUHnZZ3iji+2RQk7Zkh5AmZTnOG5fODACnjhCVveX1McY3jmCRHofIEI+yTBqplz7LXy48c7fQ3Uigp88w==} + memoize-one@6.0.0: resolution: {integrity: sha512-rkpe71W0N0c0Xz6QD0eJETuWAJGnJ9afsl1srmwPrI+yBCkge5EycXXbYRyvL29zZVUWQCY7InPRCv3GDXuZNw==} @@ -3219,6 +3324,12 @@ packages: muggle-string@0.4.1: resolution: {integrity: sha512-VNTrAak/KhO2i8dqqnqnAHOa3cYBwXEZe9h+D5h/1ZqFSTEFHdM65lR7RoIqq3tBBYavsOXV84NoHXZ0AkPyqQ==} + mux-embed@5.11.0: + resolution: {integrity: sha512-uczzXVraqMRmyYmpGh2zthTmBKvvc5D5yaVKQRgGhFOnF7E4nkhqNkdkQc4C0WTPzdqdPl5OtCelNWMF4tg5RQ==} + + mux-embed@5.9.0: + resolution: {integrity: sha512-wmunL3uoPhma/tWy8PrDPZkvJpXvSFBwbD3KkC4PG8Ztjfb1X3hRJwGUAQyRz7z99b/ovLm2UTTitrkvStjH4w==} + nano-css@5.6.2: resolution: {integrity: sha512-+6bHaC8dSDGALM1HJjOHVXpuastdu2xFoZlC77Jh4cg+33Zcgm+Gxd+1xsnpZK14eyHObSp82+ll5y3SX75liw==} peerDependencies: @@ -3243,6 +3354,9 @@ packages: resolution: {integrity: sha512-kNZ9xnoJYKg/AfxjrVL4SS0fKX++4awQReGqWnwTRHxeHGZ1FJFVgTqr/eMrNQdp0Tz7M7tG/TDaX8QfHDwVCw==} engines: {node: ^20.0.0 || >=22.0.0} + native-promise-only@0.8.1: + resolution: {integrity: sha512-zkVhZUA3y8mbz652WrL5x0fB0ehrBkulWT3TomAQ9iDtyXZvzKeEA6GPxAItBYeNYl5yngKRX612qHOhvMkDeg==} + natural-compare@1.4.0: resolution: {integrity: sha512-OWND8ei3VtNC9h7V60qff3SVobHr996CTwgxubgyQYEpg290h9J0buyECNNJexkFm5sOajh5G116RYA1c8ZMSw==} @@ -3433,6 +3547,9 @@ packages: pkg-types@2.2.0: resolution: {integrity: sha512-2SM/GZGAEkPp3KWORxQZns4M+WSeXbC2HEvmOIJe3Cmiv6ieAJvdVhDldtHqM5J1Y7MrR1XhkBT/rMlhh9FdqQ==} + player.style@0.1.9: + resolution: {integrity: sha512-aFmIhHMrnAP8YliFYFMnRw+5AlHqBvnqWy4vHGo2kFxlC+XjmTXqgg62qSxlE8ubAY83c0ViEZGYglSJi6mGCA==} + pluralize@8.0.0: resolution: {integrity: sha512-Nc3IT5yHzflTfbjgqWcCPpo7DaKy4FnpB0l/zCAW0Tc7jxAiuqSxHasntB3D7887LSrA93kDJ9IXovxJYxyLCA==} engines: {node: '>=4'} @@ -3575,6 +3692,13 @@ packages: react-is@17.0.2: resolution: {integrity: sha512-w2GsyukL62IJnlaff/nRegPQR94C/XXamvMWmSHRJ4y7Ts/4ocGRmTHvOs8PSE6pB3dWOrD/nueuU5sduBsQ4w==} + react-player@3.3.1: + resolution: {integrity: sha512-wE/xLloneXZ1keelFCaNeIFVNUp4/7YoUjfHjwF945aQzsbDKiIB0LQuCchGL+la0Y1IybxnR0R6Cm3AiqInMw==} + peerDependencies: + '@types/react': ^17.0.0 || ^18 || ^19 + react: ^17.0.2 || ^18 || ^19 + react-dom: ^17.0.2 || ^18 || ^19 + react-redux@9.2.0: resolution: {integrity: sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==} peerDependencies: @@ -3809,6 +3933,9 @@ packages: resolution: {integrity: sha512-b3rppTKm9T+PsVCBEOUR46GWI7fdOs00VKZ1+9c1EWDaDMvjQc6tUwuFyIprgGgTcWoVHSKrU8H31ZHA2e0RHA==} engines: {node: '>=10'} + sax@1.2.1: + resolution: {integrity: sha512-8I2a3LovHTOpm7NV5yOyO8IHqgVsfK4+UuySrXU8YXkSRX7k6hCV9b3HrkKCr3nMpgj+0bmocaJJWpvp1oc7ZA==} + scheduler@0.23.2: resolution: {integrity: sha512-UOShsPwz7NrMUqhR6t0hWjFduvOzbtv7toDH1/hIrfRNIDBnnBWd0CwJTGvTpngVlmwGCdP9/Zl/tVrDqcuYzQ==} @@ -3931,6 +4058,9 @@ packages: resolution: {integrity: sha512-qxQJTx2ryR0Dw0ITYyekNQWpz6f8dGd7vffGNflQQ3Iqj9NJ6qiZ7ELpZsJ/QBhIVAiDfXdag3+Gp8RvWa62AA==} engines: {node: '>=12'} + spotify-audio-element@1.0.2: + resolution: {integrity: sha512-YEovyyeJTJMzdSVqFw/Fx19e1gdcD4bmZZ/fWS0Ji58KTpvAT2rophgK87ocqpy6eJNSmIHikhgbRjGWumgZew==} + sprintf-js@1.0.3: resolution: {integrity: sha512-D9cPgkvLlV3t3IzL0D0YLvGA9Ahk4PcvVwUbN0dSGr1aP0Nrt4AEnTUbuGvquEC0mA64Gqt1fzirlRs5ibXx8g==} @@ -4039,6 +4169,9 @@ packages: stylis@4.3.6: resolution: {integrity: sha512-yQ3rwFWRfwNUY7H5vpU0wfdkNSnvnJinhF9830Swlaxl03zsOjCfmX0ugac+3LtK0lYSgwL/KXc8oYL3mG4YFQ==} + super-media-element@1.4.2: + resolution: {integrity: sha512-9pP/CVNp4NF2MNlRzLwQkjiTgKKe9WYXrLh9+8QokWmMxz+zt2mf1utkWLco26IuA3AfVcTb//qtlTIjY3VHxA==} + supports-color@10.0.0: resolution: {integrity: sha512-HRVVSbCCMbj7/kdWF9Q+bbckjBHLtHMEoJWlkmYzzdwhYMkjkOwubLM6t7NbWKjgKamGDrWL1++KrjUO1t9oAQ==} engines: {node: '>=18'} @@ -4063,6 +4196,9 @@ packages: resolution: {integrity: sha512-dTEWWNu6JmeVXY0ZYoPuH5cRIwc0MeGbJwah9KUNYSJwommQpCzTySTpEe8Gs1J23aeWEuAobe4Ag7EHVt/LOg==} engines: {node: '>=10'} + tiktok-video-element@0.1.0: + resolution: {integrity: sha512-PVWUlpDdQ/LPXi7x4/furfD7Xh1L72CgkGCaMsZBIjvxucMGm1DDPJdM9IhWBFfo6tuR4cYVO/v596r6GG/lvQ==} + tiny-invariant@1.3.3: resolution: {integrity: sha512-+FbBPE1o9QAYvviau/qC5SE3caw21q3xkvWKBtja5vgqOWIHHJ3ioaq1VPfn/Szqctz2bU/oYeKd9/z5BL+PVg==} @@ -4148,6 +4284,9 @@ packages: tslib@2.8.1: resolution: {integrity: sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w==} + twitch-video-element@0.1.2: + resolution: {integrity: sha512-/up4KiWiTYiav+CUo+/DbV8JhP4COwEKSo8h1H/Zft/5NzZ/ZiIQ48h7erFKvwzalN0GfkEGGIfwIzAO0h7FHQ==} + type-check@0.4.0: resolution: {integrity: sha512-XleUoc9uwGXqjWwXaUTZAmzMcFZ5858QA2vvx1Ur5xIcixXIP+8LnFDgRplU30us6teqdlskFfu+ae4K79Ooew==} engines: {node: '>= 0.8.0'} @@ -4182,6 +4321,10 @@ packages: engines: {node: '>=14.17'} hasBin: true + ua-parser-js@1.0.40: + resolution: {integrity: sha512-z6PJ8Lml+v3ichVojCiB8toQJBuwR42ySM4ezjXIqXK3M0HczmKQ3LF4rhU55PfD99KEEXQG6yb7iOMyvYuHew==} + hasBin: true + ufo@1.6.1: resolution: {integrity: sha512-9a4/uxlTWJ4+a5i0ooc1rU7C7YOw3wT+UGqdeNNHWnOF9qcMBgLRS+4IYUqbczewFx4mLEig6gawh7X6mFlEkA==} @@ -4286,6 +4429,9 @@ packages: resolution: {integrity: sha512-0/A9rDy9P7cJ+8w1c9WD9V//9Wj15Ce2MPz8Ri6032usz+NfePxx5AcN3bN+r6ZL6jEo066/yNYB3tn4pQEx+A==} hasBin: true + vimeo-video-element@1.5.3: + resolution: {integrity: sha512-OQWyGS9nTouMqfRJyvmAm/n6IRhZ7x3EfPAef+Q+inGBeHa3SylDbtyeB/rEBd4B/T/lcYBW3rjaD9W2DRYkiQ==} + vite-node@3.2.4: resolution: {integrity: sha512-EbKSKh+bh1E1IFxeO0pg1n4dvoOTt0UDiXMd/qn++r98+jPO1xtJilvXldeuQ8giIB5IkpjCgMleHMNEsGH6pg==} engines: {node: ^18.0.0 || ^20.0.0 || >=22.0.0} @@ -4401,6 +4547,10 @@ packages: wcwidth@1.0.1: resolution: {integrity: sha512-XHPEwS0q6TaxcvG85+8EYkbiCux2XtWG2mkc47Ng2A77BQu9+DqIOJldST4HgPkuea7dvKSj5VgX3P1d4rW8Tg==} + weakmap-polyfill@2.0.4: + resolution: {integrity: sha512-ZzxBf288iALJseijWelmECm/1x7ZwQn3sMYIkDr2VvZp7r6SEKuT8D0O9Wiq6L9Nl5mazrOMcmiZE/2NCenaxw==} + engines: {node: '>=8.10.0'} + webidl-conversions@3.0.1: resolution: {integrity: sha512-2JAn3z8AR6rjK8Sm8orRC0h/bcl/DqL7tRPdGZ4I1CjdF+EaMLmYxBHyXuKL849eucPFhvBoxMsflfOb8kxaeQ==} @@ -4436,6 +4586,9 @@ packages: engines: {node: '>=8'} hasBin: true + wistia-video-element@1.3.3: + resolution: {integrity: sha512-ZVC8HH8uV3mQGcSz10MACLDalao/0YdVverNN4GNFsOXiumfqSiZnRVc8WZEywgVckBkR7+yerQYESYPDzvTfQ==} + word-wrap@1.2.5: resolution: {integrity: sha512-BN22B5eaMMI9UMtjrGd5g5eCYPpCPDUy0FJXbYsaT5zYxjFOckS53SQDE3pWkVoWpHXVb3BrYcEN4Twa55B5cA==} engines: {node: '>=0.10.0'} @@ -4509,6 +4662,9 @@ packages: resolution: {integrity: sha512-AyeEbWOu/TAXdxlV9wmGcR0+yh2j3vYPGOECcIj2S7MkrLyC7ne+oye2BKTItt0ii2PHk4cDy+95+LshzbXnGg==} engines: {node: '>=12.20'} + youtube-video-element@1.6.1: + resolution: {integrity: sha512-FDRgXlPxpe1bh6HlhL6GfJVcvVNaZKCcLEZ90X1G3Iu+z2g2cIhm2OWj9abPZq1Zqit6SY7Gwh13H9g7acoBnQ==} + zod-validation-error@3.5.3: resolution: {integrity: sha512-OT5Y8lbUadqVZCsnyFaTQ4/O2mys4tj7PqhdbBCp7McPwvIEKfPtdA6QfPeFQK2/Rz5LgwmAXRJTugBNBi0btw==} engines: {node: '>=18.0.0'} @@ -5235,6 +5391,43 @@ snapshots: '@microsoft/tsdoc@0.15.1': {} + '@mux/mux-data-google-ima@0.2.8': + dependencies: + mux-embed: 5.9.0 + + '@mux/mux-player-react@3.5.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1)': + dependencies: + '@mux/mux-player': 3.5.3(react@18.3.1) + '@mux/playback-core': 0.30.1 + prop-types: 15.8.1 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + optionalDependencies: + '@types/react': 18.3.23 + '@types/react-dom': 18.3.7(@types/react@18.3.23) + + '@mux/mux-player@3.5.3(react@18.3.1)': + dependencies: + '@mux/mux-video': 0.26.1 + '@mux/playback-core': 0.30.1 + media-chrome: 4.11.1(react@18.3.1) + player.style: 0.1.9(react@18.3.1) + transitivePeerDependencies: + - react + + '@mux/mux-video@0.26.1': + dependencies: + '@mux/mux-data-google-ima': 0.2.8 + '@mux/playback-core': 0.30.1 + castable-video: 1.1.10 + custom-media-element: 1.4.5 + media-tracks: 0.3.3 + + '@mux/playback-core@0.30.1': + dependencies: + hls.js: 1.6.9 + mux-embed: 5.11.0 + '@nanostores/react@0.7.3(nanostores@0.11.4)(react@18.3.1)': dependencies: nanostores: 0.11.4 @@ -5690,6 +5883,8 @@ snapshots: optionalDependencies: typescript: 5.8.3 + '@svta/common-media-library@0.12.4': {} + '@swc/core-darwin-arm64@1.12.9': optional: true @@ -5971,6 +6166,13 @@ snapshots: '@typescript-eslint/types': 8.37.0 eslint-visitor-keys: 4.2.1 + '@vercel/edge@1.2.2': {} + + '@vimeo/player@2.29.0': + dependencies: + native-promise-only: 0.8.1 + weakmap-polyfill: 2.0.4 + '@vitejs/plugin-react-swc@3.10.2(vite@7.0.5(@types/node@22.16.0)(jiti@2.4.2))': dependencies: '@rolldown/pluginutils': 1.0.0-beta.11 @@ -6304,6 +6506,19 @@ snapshots: base64-js@1.5.1: {} + bcp-47-match@2.0.3: {} + + bcp-47-normalize@2.3.0: + dependencies: + bcp-47: 2.1.0 + bcp-47-match: 2.0.3 + + bcp-47@2.1.0: + dependencies: + is-alphabetical: 2.0.1 + is-alphanumerical: 2.0.1 + is-decimal: 2.0.1 + better-opn@3.0.2: dependencies: open: 8.4.2 @@ -6366,6 +6581,14 @@ snapshots: caniuse-lite@1.0.30001727: {} + castable-video@1.1.10: + dependencies: + custom-media-element: 1.4.5 + + ce-la-react@0.3.1(react@18.3.1): + dependencies: + react: 18.3.1 + chai@5.2.0: dependencies: assertion-error: 2.0.1 @@ -6423,6 +6646,8 @@ snapshots: clone@1.0.4: {} + cloudflare-video-element@1.3.3: {} + cmdk@1.1.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@radix-ui/react-compose-refs': 1.1.2(@types/react@18.3.23)(react@18.3.1) @@ -6435,6 +6660,8 @@ snapshots: - '@types/react' - '@types/react-dom' + codem-isoboxer@0.3.10: {} + color-convert@2.0.1: dependencies: color-name: 1.1.4 @@ -6510,6 +6737,8 @@ snapshots: csstype@3.1.3: {} + custom-media-element@1.4.5: {} + d3-color@3.1.0: {} d3-dispatch@3.0.1: {} @@ -6546,6 +6775,24 @@ snapshots: d3-selection: 3.0.0 d3-transition: 3.0.1(d3-selection@3.0.0) + dash-video-element@0.1.6: + dependencies: + custom-media-element: 1.4.5 + dashjs: 5.0.3 + + dashjs@5.0.3: + dependencies: + '@svta/common-media-library': 0.12.4 + bcp-47-match: 2.0.3 + bcp-47-normalize: 2.3.0 + codem-isoboxer: 0.3.10 + fast-deep-equal: 3.1.3 + html-entities: 2.6.0 + imsc: 1.1.5 + localforage: 1.10.0 + path-browserify: 1.0.1 + ua-parser-js: 1.0.40 + data-view-buffer@1.0.2: dependencies: call-bound: 1.0.4 @@ -7244,10 +7491,20 @@ snapshots: he@1.2.0: {} + hls-video-element@1.5.6: + dependencies: + custom-media-element: 1.4.5 + hls.js: 1.6.9 + media-tracks: 0.3.3 + + hls.js@1.6.9: {} + hoist-non-react-statics@3.3.2: dependencies: react-is: 16.13.1 + html-entities@2.6.0: {} + html-escaper@2.0.2: {} html-parse-stringify@3.0.1: @@ -7283,6 +7540,8 @@ snapshots: ignore@7.0.5: {} + immediate@3.0.6: {} + immer@10.1.1: {} import-fresh@3.3.1: @@ -7292,6 +7551,10 @@ snapshots: import-lazy@4.0.0: {} + imsc@1.1.5: + dependencies: + sax: 1.2.1 + imurmurhash@0.1.4: {} indent-string@4.0.0: {} @@ -7310,6 +7573,13 @@ snapshots: hasown: 2.0.2 side-channel: 1.1.0 + is-alphabetical@2.0.1: {} + + is-alphanumerical@2.0.1: + dependencies: + is-alphabetical: 2.0.1 + is-decimal: 2.0.1 + is-array-buffer@3.0.5: dependencies: call-bind: 1.0.8 @@ -7352,6 +7622,8 @@ snapshots: call-bound: 1.0.4 has-tostringtag: 1.0.2 + is-decimal@2.0.1: {} + is-docker@2.2.1: {} is-extglob@2.1.1: {} @@ -7553,6 +7825,10 @@ snapshots: prelude-ls: 1.2.1 type-check: 0.4.0 + lie@3.1.1: + dependencies: + immediate: 3.0.6 + lines-and-columns@1.2.4: {} linkify-react@4.3.1(linkifyjs@4.3.1)(react@18.3.1): @@ -7575,6 +7851,10 @@ snapshots: pkg-types: 2.2.0 quansync: 0.2.10 + localforage@1.10.0: + dependencies: + lie: 3.1.1 + locate-path@6.0.0: dependencies: p-locate: 5.0.0 @@ -7634,6 +7914,15 @@ snapshots: mdn-data@2.0.14: {} + media-chrome@4.11.1(react@18.3.1): + dependencies: + '@vercel/edge': 1.2.2 + ce-la-react: 0.3.1(react@18.3.1) + transitivePeerDependencies: + - react + + media-tracks@0.3.3: {} + memoize-one@6.0.0: {} merge2@1.4.1: {} @@ -7690,6 +7979,10 @@ snapshots: muggle-string@0.4.1: {} + mux-embed@5.11.0: {} + + mux-embed@5.9.0: {} + nano-css@5.6.2(react-dom@18.3.1(react@18.3.1))(react@18.3.1): dependencies: '@jridgewell/sourcemap-codec': 1.5.4 @@ -7711,6 +8004,8 @@ snapshots: nanostores@1.0.1: {} + native-promise-only@0.8.1: {} + natural-compare@1.4.0: {} nearley@2.20.1: @@ -7929,6 +8224,12 @@ snapshots: exsolve: 1.0.7 pathe: 2.0.3 + player.style@0.1.9(react@18.3.1): + dependencies: + media-chrome: 4.11.1(react@18.3.1) + transitivePeerDependencies: + - react + pluralize@8.0.0: {} possible-typed-array-names@1.1.0: {} @@ -8066,6 +8367,24 @@ snapshots: react-is@17.0.2: {} + react-player@3.3.1(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1): + dependencies: + '@mux/mux-player-react': 3.5.3(@types/react-dom@18.3.7(@types/react@18.3.23))(@types/react@18.3.23)(react-dom@18.3.1(react@18.3.1))(react@18.3.1) + '@types/react': 18.3.23 + cloudflare-video-element: 1.3.3 + dash-video-element: 0.1.6 + hls-video-element: 1.5.6 + react: 18.3.1 + react-dom: 18.3.1(react@18.3.1) + spotify-audio-element: 1.0.2 + tiktok-video-element: 0.1.0 + twitch-video-element: 0.1.2 + vimeo-video-element: 1.5.3 + wistia-video-element: 1.3.3 + youtube-video-element: 1.6.1 + transitivePeerDependencies: + - '@types/react-dom' + react-redux@9.2.0(@types/react@18.3.23)(react@18.3.1)(redux@5.0.1): dependencies: '@types/use-sync-external-store': 0.0.6 @@ -8360,6 +8679,8 @@ snapshots: safe-stable-stringify@2.5.0: {} + sax@1.2.1: {} + scheduler@0.23.2: dependencies: loose-envify: 1.4.0 @@ -8484,6 +8805,8 @@ snapshots: split-on-first@3.0.0: {} + spotify-audio-element@1.0.2: {} + sprintf-js@1.0.3: {} stable-hash@0.0.6: {} @@ -8627,6 +8950,8 @@ snapshots: stylis@4.3.6: {} + super-media-element@1.4.2: {} + supports-color@10.0.0: {} supports-color@7.2.0: @@ -8647,6 +8972,8 @@ snapshots: throttle-debounce@3.0.1: {} + tiktok-video-element@0.1.0: {} + tiny-invariant@1.3.3: {} tinybench@2.9.0: {} @@ -8709,6 +9036,8 @@ snapshots: tslib@2.8.1: {} + twitch-video-element@0.1.2: {} + type-check@0.4.0: dependencies: prelude-ls: 1.2.1 @@ -8752,6 +9081,8 @@ snapshots: typescript@5.8.3: {} + ua-parser-js@1.0.40: {} + ufo@1.6.1: {} unbox-primitive@1.1.0: @@ -8834,6 +9165,10 @@ snapshots: uuid@11.1.0: {} + vimeo-video-element@1.5.3: + dependencies: + '@vimeo/player': 2.29.0 + vite-node@3.2.4(@types/node@22.16.0)(jiti@2.4.2): dependencies: cac: 6.7.14 @@ -8962,6 +9297,8 @@ snapshots: dependencies: defaults: 1.0.4 + weakmap-polyfill@2.0.4: {} + webidl-conversions@3.0.1: {} webpack-virtual-modules@0.6.2: {} @@ -9021,6 +9358,10 @@ snapshots: siginfo: 2.0.0 stackback: 0.0.2 + wistia-video-element@1.3.3: + dependencies: + super-media-element: 1.4.2 + word-wrap@1.2.5: {} wrap-ansi@7.0.0: @@ -9067,6 +9408,8 @@ snapshots: yocto-queue@1.2.1: {} + youtube-video-element@1.6.1: {} + zod-validation-error@3.5.3(zod@3.25.76): dependencies: zod: 3.25.76 diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 3f6eb807966..2f87ee3726f 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1247,7 +1247,6 @@ "modelIncompatibleScaledBboxWidth": "Scaled bbox width is {{width}} but {{model}} requires multiple of {{multiple}}", "modelIncompatibleScaledBboxHeight": "Scaled bbox height is {{height}} but {{model}} requires multiple of {{multiple}}", "fluxModelMultipleControlLoRAs": "Can only use 1 Control LoRA at a time", - "fluxKontextMultipleReferenceImages": "Can only use 1 Reference Image at a time with FLUX Kontext via BFL API", "canvasIsFiltering": "Canvas is busy (filtering)", "canvasIsTransforming": "Canvas is busy (transforming)", "canvasIsRasterizing": "Canvas is busy (rasterizing)", @@ -1278,6 +1277,7 @@ "imageActions": "Image Actions", "sendToCanvas": "Send To Canvas", "sendToUpscale": "Send To Upscale", + "sendToVideo": "Send To Video", "showOptionsPanel": "Show Side Panel (O or T)", "shuffle": "Shuffle Seed", "steps": "Steps", @@ -2558,13 +2558,15 @@ "queue": "Queue", "upscaling": "Upscaling", "upscalingTab": "$t(ui.tabs.upscaling) $t(common.tab)", + "video": "Video", "gallery": "Gallery" }, "panels": { "launchpad": "Launchpad", "workflowEditor": "Workflow Editor", "imageViewer": "Image Viewer", - "canvas": "Canvas" + "canvas": "Canvas", + "video": "Video" }, "launchpad": { "workflowsTitle": "Go deep with Workflows.", @@ -2645,6 +2647,10 @@ } } }, + "video": { + "noVideoSelected": "No video selected", + "selectFromGallery": "Select a video from the gallery to play" + }, "system": { "enableLogging": "Enable Logging", "logLevel": { diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index b1c9bf37782..40d0bfe2705 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -54,6 +54,7 @@ import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { stateSanitizer } from './middleware/devtools/stateSanitizer'; import { addArchivedOrDeletedBoardListener } from './middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener'; import { addImageUploadedFulfilledListener } from './middleware/listenerMiddleware/listeners/imageUploaded'; +import { videoSliceConfig } from 'features/parameters/store/videoSlice'; export const listenerMiddleware = createListenerMiddleware(); @@ -78,6 +79,7 @@ const SLICE_CONFIGS = { [systemSliceConfig.slice.reducerPath]: systemSliceConfig, [uiSliceConfig.slice.reducerPath]: uiSliceConfig, [upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig, + [videoSliceConfig.slice.reducerPath]: videoSliceConfig, [workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig, [workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig, }; @@ -111,6 +113,7 @@ const ALL_REDUCERS = { [systemSliceConfig.slice.reducerPath]: systemSliceConfig.slice.reducer, [uiSliceConfig.slice.reducerPath]: uiSliceConfig.slice.reducer, [upscaleSliceConfig.slice.reducerPath]: upscaleSliceConfig.slice.reducer, + [videoSliceConfig.slice.reducerPath]: videoSliceConfig.slice.reducer, [workflowLibrarySliceConfig.slice.reducerPath]: workflowLibrarySliceConfig.slice.reducer, [workflowSettingsSliceConfig.slice.reducerPath]: workflowSettingsSliceConfig.slice.reducer, }; diff --git a/invokeai/frontend/web/src/common/hooks/focus.ts b/invokeai/frontend/web/src/common/hooks/focus.ts index 4e093c5c631..8a04608a13d 100644 --- a/invokeai/frontend/web/src/common/hooks/focus.ts +++ b/invokeai/frontend/web/src/common/hooks/focus.ts @@ -37,6 +37,7 @@ const REGION_NAMES = [ 'workflows', 'progress', 'settings', + 'video', ] as const; /** * The names of the focus regions. diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index 4ffc9a36307..bbc08e69ed6 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -122,6 +122,7 @@ export const useGlobalHotkeys = () => { dependencies: [dispatch, isModelManagerEnabled], }); + const deleteImageModalApi = useDeleteImageModalApi(); useRegisteredHotkeys({ id: 'deleteSelection', diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx new file mode 100644 index 00000000000..7d50c60823b --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx @@ -0,0 +1,31 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { navigationApi } from 'features/ui/layouts/navigation-api'; +import { setCurrentVideo } from 'features/ui/layouts/video-store'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiVideoBold } from 'react-icons/pi'; + +export const ImageMenuItemSendToVideo = memo(() => { + const { t } = useTranslation(); + const imageDTO = useImageDTOContext(); + + const onClick = useCallback(() => { + // For now, we'll use the image URL as a video source + // In a real implementation, you might want to convert the image to video or use a different approach + setCurrentVideo(imageDTO.image_url); + navigationApi.switchToTab('video'); + }, [imageDTO.image_url]); + + return ( + } + onClickCapture={onClick} + aria-label={"Send to Video"} + > + Send to Video + + ); +}); + +ImageMenuItemSendToVideo.displayName = 'ImageMenuItemSendToVideo'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx index c43c8db70a7..dae7fc3bbc7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -14,16 +14,16 @@ import { ImageMenuItemOpenInNewTab } from 'features/gallery/components/ImageCont import { ImageMenuItemOpenInViewer } from 'features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer'; import { ImageMenuItemSelectForCompare } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare'; import { ImageMenuItemSendToUpscale } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale'; +import { ImageMenuItemSendToVideo } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo'; import { ImageMenuItemStarUnstar } from 'features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar'; +import { ImageMenuItemUseAsPromptTemplate } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsPromptTemplate'; import { ImageMenuItemUseAsRefImage } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage'; import { ImageMenuItemUseForPromptGeneration } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration'; import { ImageDTOContextProvider } from 'features/gallery/contexts/ImageDTOContext'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import { memo } from 'react'; import type { ImageDTO } from 'services/api/types'; import { ImageMenuItemMetadataRecallActionsUpscaleTab } from './ImageMenuItemMetadataRecallActionsUpscaleTab'; -import { ImageMenuItemUseAsPromptTemplate } from './ImageMenuItemUseAsPromptTemplate'; type SingleSelectionMenuItemsProps = { imageDTO: ImageDTO; @@ -48,6 +48,7 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) = {tab === 'upscaling' && } + {(tab === 'canvas' || tab === 'generate') && } @@ -65,4 +66,4 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) = ); }; -export default memo(SingleSelectionMenuItems); +export default SingleSelectionMenuItems; diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts new file mode 100644 index 00000000000..bb136887fed --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts @@ -0,0 +1,118 @@ +import { logger } from 'app/logging/logger'; +import type { RootState } from 'app/store/store'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { selectVideoFirstFrameImage, selectVideoLastFrameImage } from 'features/parameters/store/videoSlice'; +import { zImageField } from 'features/nodes/types/common'; +import { Graph } from 'features/nodes/util/graph/generation/Graph'; +import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; +import type { GraphBuilderArg, GraphBuilderReturn } from 'features/nodes/util/graph/types'; +import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; +import { t } from 'i18next'; +import { assert } from 'tsafe'; + +const log = logger('system'); + +// Default video parameters - these could be moved to a video params slice in the future +const DEFAULT_VIDEO_DURATION = 5; +const DEFAULT_VIDEO_ASPECT_RATIO = "1280:768"; // Default landscape +const DEFAULT_ENHANCE_PROMPT = true; + +// Video parameter extraction helper +const getVideoParameters = (state: RootState) => { + // In the future, these could come from a dedicated video parameters slice + // For now, we use defaults but allow them to be overridden by any video-specific state + return { + duration: DEFAULT_VIDEO_DURATION, + aspectRatio: DEFAULT_VIDEO_ASPECT_RATIO, + enhancePrompt: DEFAULT_ENHANCE_PROMPT, + }; +}; + +export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => { + const { generationMode, state, manager } = arg; + + log.debug({ generationMode, manager: manager?.id }, 'Building Runway video graph'); + + // Runway video generation supports text-to-video and image-to-video + // We can support multiple generation modes depending on whether frame images are provided + const supportedModes = ['txt2img'] as const; + if (!supportedModes.includes(generationMode as any)) { + throw new UnsupportedGenerationModeError(t('toast.runwayIncompatibleGenerationMode')); + } + + const params = selectParamsSlice(state); + const prompts = selectPresetModifiedPrompts(state); + const videoFirstFrameImage = selectVideoFirstFrameImage(state); + const videoLastFrameImage = selectVideoLastFrameImage(state); + const videoParams = getVideoParameters(state); + + // Get seed from params + const { seed, shouldRandomizeSeed } = params; + const finalSeed = shouldRandomizeSeed ? undefined : seed; + + // Determine if this is image-to-video or text-to-video + const hasFrameImages = videoFirstFrameImage || videoLastFrameImage; + + const g = new Graph(getPrefixedId('runway_video_graph')); + + const positivePrompt = g.addNode({ + id: getPrefixedId('positive_prompt'), + type: 'string', + value: prompts.positive, + }); + + // Create the runway video generation node + const runwayVideoNode = g.addNode({ + id: getPrefixedId('runway_generate_video'), + // @ts-expect-error: This node is not available in the OSS application + type: 'runway_generate_video', + duration: videoParams.duration, + aspect_ratio: videoParams.aspectRatio, + seed: finalSeed, + }); + + // @ts-expect-error: This node is not available in the OSS application + g.addEdge(positivePrompt, 'value', runwayVideoNode, 'prompt'); + + + // Add first frame image if provided + if (videoFirstFrameImage) { + const firstFrameImageField = zImageField.parse(videoFirstFrameImage); + // @ts-expect-error: This connection is specific to runway node + runwayVideoNode.first_frame_image = firstFrameImageField; + } + + // Add last frame image if provided + if (videoLastFrameImage) { + const lastFrameImageField = zImageField.parse(videoLastFrameImage); + // @ts-expect-error: This connection is specific to runway node + runwayVideoNode.last_frame_image = lastFrameImageField; + } + + // Set up metadata + g.upsertMetadata({ + positive_prompt: prompts.positive, + negative_prompt: prompts.negative || '', + video_duration: videoParams.duration, + video_aspect_ratio: videoParams.aspectRatio, + seed: finalSeed, + enhance_prompt: videoParams.enhancePrompt, + generation_type: hasFrameImages ? 'image-to-video' : 'text-to-video', + }); + + // Add video frame images to metadata if they exist + if (hasFrameImages) { + g.upsertMetadata({ + first_frame_image: videoFirstFrameImage, + last_frame_image: videoLastFrameImage, + }, 'merge'); + } + + g.setMetadataReceivingNode(runwayVideoNode); + + return { + g, + positivePrompt, + }; +}; diff --git a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx index efec7a857df..3f93bec9552 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx @@ -9,6 +9,7 @@ import { memo } from 'react'; export const Prompts = memo(() => { const modelSupportsNegativePrompt = useAppSelector(selectModelSupportsNegativePrompt); const hasNegativePrompt = useAppSelector(selectHasNegativePrompt); + return ( diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts new file mode 100644 index 00000000000..93b0dd12fc2 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -0,0 +1,72 @@ +import type { PayloadAction, Selector } from '@reduxjs/toolkit'; +import { createSelector, createSlice } from '@reduxjs/toolkit'; +import type { RootState } from 'app/store/store'; +import type { SliceConfig } from 'app/store/types'; +import { isPlainObject } from 'es-toolkit'; +import type { ImageWithDims } from 'features/controlLayers/store/types'; +import { zImageWithDims } from 'features/controlLayers/store/types'; +import { assert } from 'tsafe'; +import z from 'zod'; + +const zVideoState = z.object({ + _version: z.literal(1), + videoFirstFrameImage: zImageWithDims.nullable(), + videoLastFrameImage: zImageWithDims.nullable(), + generatedVideoUrl: z.string().nullable(), +}); + +export type VideoState = z.infer; + +const getInitialState = (): VideoState => ({ + _version: 1, + videoFirstFrameImage: null, + videoLastFrameImage: null, + generatedVideoUrl: null, +}); + +const slice = createSlice({ + name: 'video', + initialState: getInitialState(), + reducers: { + videoFirstFrameImageChanged: (state, action: PayloadAction) => { + state.videoFirstFrameImage = action.payload; + }, + + videoLastFrameImageChanged: (state, action: PayloadAction) => { + state.videoLastFrameImage = action.payload; + }, + + generatedVideoUrlChanged: (state, action: PayloadAction) => { + state.generatedVideoUrl = action.payload; + }, + + }, +}); + +export const { + videoFirstFrameImageChanged, + videoLastFrameImageChanged, + generatedVideoUrlChanged, +} = slice.actions; + +export const videoSliceConfig: SliceConfig = { + slice, + schema: zVideoState, + getInitialState, + persistConfig: { + migrate: (state) => { + assert(isPlainObject(state)); + if (!('_version' in state)) { + state._version = 1; + } + return zVideoState.parse(state); + }, + }, +}; + +export const selectVideoSlice = (state: RootState) => state.video; +const createVideoSelector = (selector: Selector) => createSelector(selectVideoSlice, selector); + +export const selectVideoFirstFrameImage = createVideoSelector((video) => video.videoFirstFrameImage); +export const selectVideoLastFrameImage = createVideoSelector((video) => video.videoLastFrameImage); +export const selectGeneratedVideoUrl = createVideoSelector((video) => video.generatedVideoUrl); \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts new file mode 100644 index 00000000000..49b98029481 --- /dev/null +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts @@ -0,0 +1,102 @@ +import type { AlertStatus } from '@invoke-ai/ui-library'; +import { createAction } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; +import type { AppStore } from 'app/store/store'; +import { useAppStore } from 'app/store/storeHooks'; +import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; +import { withResult, withResultAsync } from 'common/util/result'; +import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; +import { buildRunwayVideoGraph } from 'features/nodes/util/graph/generation/buildRunwayVideoGraph'; +import { selectCanvasDestination } from 'features/nodes/util/graph/graphBuilderUtils'; +import type { GraphBuilderArg } from 'features/nodes/util/graph/types'; +import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; +import { toast } from 'features/toast/toast'; +import { useCallback } from 'react'; +import { serializeError } from 'serialize-error'; +import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue'; +import { AssertionError } from 'tsafe'; + +const log = logger('generation'); +export const enqueueRequestedCanvas = createAction('app/enqueueRequestedCanvas'); + +const enqueueVideo = async (store: AppStore, prepend: boolean) => { + const { dispatch, getState } = store; + + dispatch(enqueueRequestedCanvas()); + + const state = getState(); + + const destination = selectCanvasDestination(state); + + const buildGraphResult = await withResultAsync(async () => { + + const graphBuilderArg: GraphBuilderArg = { generationMode: 'txt2img', state, manager: null }; + + return await buildRunwayVideoGraph(graphBuilderArg); + }); + + if (buildGraphResult.isErr()) { + let title = 'Failed to build graph'; + let status: AlertStatus = 'error'; + let description: string | null = null; + if (buildGraphResult.error instanceof AssertionError) { + description = extractMessageFromAssertionError(buildGraphResult.error); + } else if (buildGraphResult.error instanceof UnsupportedGenerationModeError) { + title = 'Unsupported generation mode'; + description = buildGraphResult.error.message; + status = 'warning'; + } + const error = serializeError(buildGraphResult.error); + log.error({ error }, 'Failed to build graph'); + toast({ + status, + title, + description, + }); + return; + } + + const { g, seed, positivePrompt } = buildGraphResult.value; + + const prepareBatchResult = withResult(() => + prepareLinearUIBatch({ + state, + g, + prepend, + seedNode: seed, + positivePromptNode: positivePrompt, + origin: 'canvas', + destination, + }) + ); + + if (prepareBatchResult.isErr()) { + log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch'); + return; + } + + const batchConfig = prepareBatchResult.value; + + const req = dispatch( + queueApi.endpoints.enqueueBatch.initiate(batchConfig, { + ...enqueueMutationFixedCacheKeyOptions, + track: false, + }) + ); + + const enqueueResult = await req.unwrap(); + + return { batchConfig, enqueueResult }; +}; + +export const useEnqueueVideo = () => { + const store = useAppStore(); + const enqueue = useCallback( + (prepend: boolean) => { + + return enqueueVideo(store, prepend); + }, + [ store] + ); + return enqueue; +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts index 24c2f355107..3f1d29cf78a 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useInvoke.ts @@ -16,6 +16,7 @@ import { enqueueMutationFixedCacheKeyOptions, useEnqueueBatchMutation } from 'se import { useEnqueueCanvas } from './useEnqueueCanvas'; import { useEnqueueGenerate } from './useEnqueueGenerate'; import { useEnqueueUpscaling } from './useEnqueueUpscaling'; +import { useEnqueueVideo } from './useEnqueueVideo'; const log = logger('generation'); @@ -27,6 +28,7 @@ export const useInvoke = () => { const enqueueCanvas = useEnqueueCanvas(); const enqueueGenerate = useEnqueueGenerate(); const enqueueUpscaling = useEnqueueUpscaling(); + const enqueueVideo = useEnqueueVideo(); const saveAllImagesToGallery = useAppSelector(selectSaveAllImagesToGallery); const [_, { isLoading }] = useEnqueueBatchMutation({ @@ -50,6 +52,8 @@ export const useInvoke = () => { return await enqueueGenerate(prepend); case 'upscaling': return await enqueueUpscaling(prepend); + case 'video': + return await enqueueVideo(prepend); default: throw new Error(`No enqueue handler for tab: ${tabName}`); } @@ -59,7 +63,7 @@ export const useInvoke = () => { log.error({ error: serializeError(result.error) }, 'Failed to enqueue batch'); } }, - [enqueueCanvas, enqueueGenerate, enqueueUpscaling, enqueueWorkflows, isReady, tabName] + [enqueueCanvas, enqueueGenerate, enqueueUpscaling, enqueueVideo, enqueueWorkflows, isReady, tabName] ); const enqueueBack = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx new file mode 100644 index 00000000000..8aed8340dc2 --- /dev/null +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx @@ -0,0 +1,71 @@ +import { Flex, FormLabel, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import type { SetUpscaleInitialImageDndTargetData } from 'features/dnd/dnd'; +import { setUpscaleInitialImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { DndImage } from 'features/dnd/DndImage'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; +import { selectVideoFirstFrameImage, videoFirstFrameImageChanged } from 'features/parameters/store/videoSlice'; +import { t } from 'i18next'; +import { useCallback, useMemo } from 'react'; +import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; +import { useImageDTO } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +export const VideoFirstFrameImage = () => { + const dispatch = useAppDispatch(); + const videoFirstFrameImage = useAppSelector(selectVideoFirstFrameImage); + const imageDTO = useImageDTO(videoFirstFrameImage?.image_name); + + + const onReset = useCallback(() => { + dispatch(videoFirstFrameImageChanged(null)); + }, [dispatch]); + + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + dispatch(videoFirstFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + }, + [dispatch] + ); + + return ( + + First Frame Image + + {!imageDTO && } + {imageDTO && ( + <> + + + } + tooltip={t('common.reset')} + /> + + {`${imageDTO.width}x${imageDTO.height}`} + + )} + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoLastFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoLastFrameImage.tsx new file mode 100644 index 00000000000..4a0511eff3f --- /dev/null +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoLastFrameImage.tsx @@ -0,0 +1,70 @@ +import { Flex, FormLabel, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; +import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import type { SetUpscaleInitialImageDndTargetData } from 'features/dnd/dnd'; +import { setUpscaleInitialImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; +import { DndImage } from 'features/dnd/DndImage'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import { selectVideoLastFrameImage, videoLastFrameImageChanged } from 'features/parameters/store/videoSlice'; +import { t } from 'i18next'; +import { useCallback, useMemo } from 'react'; +import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; +import { useImageDTO } from 'services/api/endpoints/images'; +import type { ImageDTO } from 'services/api/types'; + +export const VideoLastFrameImage = () => { + const dispatch = useAppDispatch(); + const videoLastFrameImage = useAppSelector(selectVideoLastFrameImage); + const imageDTO = useImageDTO(videoLastFrameImage?.image_name); + + + const onReset = useCallback(() => { + dispatch(videoLastFrameImageChanged(null)); + }, [dispatch]); + + const onUpload = useCallback( + (imageDTO: ImageDTO) => { + dispatch(videoLastFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + }, + [dispatch] + ); + + return ( + + Last Frame Image + + {!imageDTO && } + {imageDTO && ( + <> + + + } + tooltip={t('common.reset')} + /> + + {`${imageDTO.width}x${imageDTO.height}`} + + )} + + + + ); +}; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx new file mode 100644 index 00000000000..dc859219a11 --- /dev/null +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx @@ -0,0 +1,41 @@ +import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library'; +import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { VideoFirstFrameImage } from './VideoFirstFrameImage'; +import { VideoLastFrameImage } from './VideoLastFrameImage'; + + +export const VideoSettingsAccordion = memo(() => { + const { t } = useTranslation(); + const { isOpen: isOpenAccordion, onToggle: onToggleAccordion } = useStandaloneAccordionToggle({ + id: 'video-settings', + defaultIsOpen: true, + }); + + + return ( + + + + + + + + + + + + + + + ); +}); + +VideoSettingsAccordion.displayName = 'VideoSettingsAccordion'; diff --git a/invokeai/frontend/web/src/features/system/store/configSlice.ts b/invokeai/frontend/web/src/features/system/store/configSlice.ts index 5123107be8f..50423461fd4 100644 --- a/invokeai/frontend/web/src/features/system/store/configSlice.ts +++ b/invokeai/frontend/web/src/features/system/store/configSlice.ts @@ -87,3 +87,6 @@ export const selectWithModelsTab = createSelector(selectDidLoad, selectDisabledT export const selectWithQueueTab = createSelector(selectDidLoad, selectDisabledTabs, (didLoad, disabledTabs) => didLoad ? !disabledTabs.includes('queue') : false ); +export const selectWithVideoTab = createSelector(selectDidLoad, selectDisabledTabs, (didLoad, disabledTabs) => + didLoad ? !disabledTabs.includes('video') : false +); diff --git a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx index a1ff80f6779..5650e316bfb 100644 --- a/invokeai/frontend/web/src/features/ui/components/AppContent.tsx +++ b/invokeai/frontend/web/src/features/ui/components/AppContent.tsx @@ -11,6 +11,7 @@ import { selectWithModelsTab, selectWithQueueTab, selectWithUpscalingTab, + selectWithVideoTab, selectWithWorkflowsTab, } from 'features/system/store/configSlice'; import { VerticalNavBar } from 'features/ui/components/VerticalNavBar'; @@ -20,6 +21,7 @@ import { ModelsTabAutoLayout } from 'features/ui/layouts/models-tab-auto-layout' import { navigationApi } from 'features/ui/layouts/navigation-api'; import { QueueTabAutoLayout } from 'features/ui/layouts/queue-tab-auto-layout'; import { UpscalingTabAutoLayout } from 'features/ui/layouts/upscaling-tab-auto-layout'; +import { VideoTabAutoLayout } from 'features/ui/layouts/video-tab-auto-layout'; import { WorkflowsTabAutoLayout } from 'features/ui/layouts/workflows-tab-auto-layout'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo } from 'react'; @@ -42,6 +44,7 @@ const TabContent = memo(() => { const withWorkflowsTab = useAppSelector(selectWithWorkflowsTab); const withModelsTab = useAppSelector(selectWithModelsTab); const withQueueTab = useAppSelector(selectWithQueueTab); + const withVideoTab = useAppSelector(selectWithVideoTab); return ( @@ -51,6 +54,7 @@ const TabContent = memo(() => { {withWorkflowsTab && tab === 'workflows' && } {withModelsTab && tab === 'models' && } {withQueueTab && tab === 'queue' && } + {withVideoTab && tab === 'video' && } ); diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx new file mode 100644 index 00000000000..5604c8d132b --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx @@ -0,0 +1,46 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants'; +import { Prompts } from 'features/parameters/components/Prompts/Prompts'; +import { VideoSettingsAccordion } from 'features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion'; +import { StylePresetMenu } from 'features/stylePresets/components/StylePresetMenu'; +import { StylePresetMenuTrigger } from 'features/stylePresets/components/StylePresetMenuTrigger'; +import { $isStylePresetsMenuOpen } from 'features/stylePresets/store/stylePresetSlice'; +import { OverlayScrollbarsComponent } from 'overlayscrollbars-react'; +import type { CSSProperties } from 'react'; +import { memo } from 'react'; + +const overlayScrollbarsStyles: CSSProperties = { + height: '100%', + width: '100%', +}; + +export const ParametersPanelVideo = memo(() => { + const isStylePresetsMenuOpen = useStore($isStylePresetsMenuOpen); + + + return ( + + + + + {isStylePresetsMenuOpen && ( + + + + + + )} + + + + + + + + + + ); +}); + +ParametersPanelVideo.displayName = 'ParametersPanelVideo'; diff --git a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx index 92055a28c11..b1a92fa6033 100644 --- a/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx +++ b/invokeai/frontend/web/src/features/ui/components/VerticalNavBar.tsx @@ -12,6 +12,7 @@ import { selectWithModelsTab, selectWithQueueTab, selectWithUpscalingTab, + selectWithVideoTab, selectWithWorkflowsTab, } from 'features/system/store/configSlice'; import { memo } from 'react'; @@ -23,6 +24,7 @@ import { PiFrameCornersBold, PiQueueBold, PiTextAaBold, + PiVideoBold, } from 'react-icons/pi'; import { Notifications } from './Notifications'; @@ -37,6 +39,7 @@ export const VerticalNavBar = memo(() => { const withWorkflowsTab = useAppSelector(selectWithWorkflowsTab); const withModelsTab = useAppSelector(selectWithModelsTab); const withQueueTab = useAppSelector(selectWithQueueTab); + const withVideoTab = useAppSelector(selectWithVideoTab); return ( @@ -48,6 +51,7 @@ export const VerticalNavBar = memo(() => { {withWorkflowsTab && } label={t('ui.tabs.workflows')} />} {withModelsTab && } label={t('ui.tabs.models')} />} {withQueueTab && } label={t('ui.tabs.queue')} />} + {withVideoTab && } label={t('ui.tabs.video')} />} diff --git a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx index fe7588aaeda..47cc9b9ac67 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/DockviewTabLaunchpad.tsx @@ -15,6 +15,7 @@ import { PiFrameCornersBold, PiQueueBold, PiTextAaBold, + PiVideoBold, } from 'react-icons/pi'; import type { DockviewPanelParameters } from './auto-layout-context'; @@ -26,6 +27,7 @@ const TAB_ICONS: Record = { workflows: PiFlowArrowBold, models: PiCubeBold, queue: PiQueueBold, + video: PiVideoBold, }; export const DockviewTabLaunchpad = memo((props: IDockviewPanelHeaderProps) => { diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx new file mode 100644 index 00000000000..8883214317d --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx @@ -0,0 +1,35 @@ +import { Box, Flex, Text } from '@invoke-ai/ui-library'; +import { useFocusRegion } from 'common/hooks/focus'; +import { memo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import ReactPlayer from 'react-player'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectGeneratedVideoUrl } from 'features/parameters/store/videoSlice'; + + +export const VideoPlayerPanel = memo(() => { + const { t } = useTranslation(); + const ref = useRef(null); + const generatedVideoUrl = useAppSelector(selectGeneratedVideoUrl); + + useFocusRegion('video', ref); + + + + return ( + + + {generatedVideoUrl && } + {!generatedVideoUrl && No video generated} + + + ); +}); + +VideoPlayerPanel.displayName = 'VideoPlayerPanel'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx new file mode 100644 index 00000000000..e15ac786474 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx @@ -0,0 +1,16 @@ +import { Box, Flex } from '@invoke-ai/ui-library'; +import QueueControls from 'features/queue/components/QueueControls'; +import { memo } from 'react'; +import { ParametersPanelVideo } from '../components/ParametersPanels/ParametersPanelVideo'; + +export const VideoTabLeftPanel = memo(() => { + return ( + + + + + + + ); +}); +VideoTabLeftPanel.displayName = 'VideoTabLeftPanel'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index daa191acd79..10afb4eb95c 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -238,6 +238,8 @@ export class NavigationApi { this._app.storage.set(key, api.toJSON()); } + console.log('api.panels', api.panels); + for (const panel of api.panels) { this._registerPanel(tab, panel.id, panel); } @@ -445,6 +447,7 @@ export class NavigationApi { */ getPanel = (tab: TabName, panelId: string): PanelType | undefined => { const key = this._getPanelKey(tab, panelId); + console.log('key', key); return this.panels.get(key); }; @@ -486,6 +489,7 @@ export class NavigationApi { */ toggleLeftPanel = (): boolean => { const activeTab = this._app?.activeTab.get() ?? null; + console.log('activeTab', activeTab); if (!activeTab) { log.warn('No active tab found to toggle left panel'); return false; diff --git a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx new file mode 100644 index 00000000000..37f0c3775e6 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx @@ -0,0 +1,276 @@ +import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps } from 'dockview'; +import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; +import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; +import { GalleryPanel } from 'features/gallery/components/Gallery'; +import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; +import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; +import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; +import type { + AutoLayoutDockviewComponents, + AutoLayoutGridviewComponents, + PanelParameters, + RootLayoutGridviewComponents, +} from 'features/ui/layouts/auto-layout-context'; +import { AutoLayoutProvider, useAutoLayoutContext, withPanelContainer } from 'features/ui/layouts/auto-layout-context'; +import type { TabName } from 'features/ui/store/uiTypes'; +import { dockviewTheme } from 'features/ui/styles/theme'; +import { t } from 'i18next'; +import { memo, useCallback, useEffect } from 'react'; + +import { DockviewTab } from './DockviewTab'; +import { DockviewTabLaunchpad } from './DockviewTabLaunchpad'; +import { DockviewTabProgress } from './DockviewTabProgress'; +import { navigationApi } from './navigation-api'; +import { PanelHotkeysLogical } from './PanelHotkeysLogical'; +import { + BOARD_PANEL_DEFAULT_HEIGHT_PX, + BOARD_PANEL_MIN_HEIGHT_PX, + BOARDS_PANEL_ID, + DOCKVIEW_TAB_ID, + DOCKVIEW_TAB_LAUNCHPAD_ID, + DOCKVIEW_TAB_PROGRESS_ID, + GALLERY_PANEL_DEFAULT_HEIGHT_PX, + GALLERY_PANEL_ID, + GALLERY_PANEL_MIN_HEIGHT_PX, + LAUNCHPAD_PANEL_ID, + LEFT_PANEL_ID, + LEFT_PANEL_MIN_SIZE_PX, + MAIN_PANEL_ID, + RIGHT_PANEL_ID, + RIGHT_PANEL_MIN_SIZE_PX, + SETTINGS_PANEL_ID, + VIEWER_PANEL_ID, +} from './shared'; +import { VideoTabLeftPanel } from './VideoTabLeftPanel'; +import { GenerateLaunchpadPanel } from './GenerateLaunchpadPanel'; +import { VideoPlayerPanel } from './VideoPlayerPanel'; + +const tabComponents = { + [DOCKVIEW_TAB_ID]: DockviewTab, + [DOCKVIEW_TAB_PROGRESS_ID]: DockviewTabProgress, + [DOCKVIEW_TAB_LAUNCHPAD_ID]: DockviewTabLaunchpad, +}; + +const mainPanelComponents: AutoLayoutDockviewComponents = { + [LAUNCHPAD_PANEL_ID]: withPanelContainer(GenerateLaunchpadPanel), + [VIEWER_PANEL_ID]: withPanelContainer(VideoPlayerPanel), +}; + +const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { + navigationApi.registerContainer(tab, 'main', api, () => { + const launchpad = api.addPanel({ + id: LAUNCHPAD_PANEL_ID, + component: LAUNCHPAD_PANEL_ID, + title: t('ui.panels.launchpad'), + tabComponent: DOCKVIEW_TAB_LAUNCHPAD_ID, + params: { + tab, + focusRegion: 'launchpad', + }, + }); + + api.addPanel({ + id: VIEWER_PANEL_ID, + component: VIEWER_PANEL_ID, + title: t('ui.panels.imageViewer'), + tabComponent: DOCKVIEW_TAB_PROGRESS_ID, + params: { + tab, + focusRegion: 'viewer', + }, + position: { + direction: 'within', + referencePanel: launchpad.id, + }, + }); + + launchpad.api.setActive(); + }); +}; + +const MainPanel = memo(() => { + const { tab } = useAutoLayoutContext(); + + const onReady = useCallback( + ({ api }) => { + initializeMainPanelLayout(tab, api); + }, + [tab] + ); + return ( + <> + + + + + + ); +}); +MainPanel.displayName = 'MainPanel'; + +const rightPanelComponents: AutoLayoutGridviewComponents = { + [BOARDS_PANEL_ID]: withPanelContainer(BoardsPanel), + [GALLERY_PANEL_ID]: withPanelContainer(GalleryPanel), +}; + +const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'right', api, () => { + const gallery = api.addPanel({ + id: GALLERY_PANEL_ID, + component: GALLERY_PANEL_ID, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'gallery', + }, + }); + + const boards = api.addPanel({ + id: BOARDS_PANEL_ID, + component: BOARDS_PANEL_ID, + minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'boards', + }, + position: { + direction: 'above', + referencePanel: gallery.id, + }, + }); + + gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX }); + boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX }); + }); +}; + +const RightPanel = memo(() => { + const { tab } = useAutoLayoutContext(); + + const onReady = useCallback( + ({ api }) => { + initializeRightPanelLayout(tab, api); + }, + [tab] + ); + return ( + + ); +}); +RightPanel.displayName = 'RightPanel'; + +const leftPanelComponents: AutoLayoutGridviewComponents = { + [SETTINGS_PANEL_ID]: withPanelContainer(VideoTabLeftPanel), +}; + +const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'left', api, () => { + api.addPanel({ + id: SETTINGS_PANEL_ID, + component: SETTINGS_PANEL_ID, + params: { + tab, + focusRegion: 'settings', + }, + }); + }); +}; + +const LeftPanel = memo(() => { + const { tab } = useAutoLayoutContext(); + + const onReady = useCallback( + ({ api }) => { + initializeLeftPanelLayout(tab, api); + }, + [tab] + ); + return ( + + ); +}); +LeftPanel.displayName = 'LeftPanel'; + +const rootPanelComponents: RootLayoutGridviewComponents = { + [LEFT_PANEL_ID]: LeftPanel, + [MAIN_PANEL_ID]: MainPanel, + [RIGHT_PANEL_ID]: RightPanel, +}; + +const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'root', api, () => { + const main = api.addPanel({ + id: MAIN_PANEL_ID, + component: MAIN_PANEL_ID, + priority: LayoutPriority.High, + }); + + const left = api.addPanel({ + id: LEFT_PANEL_ID, + component: LEFT_PANEL_ID, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + position: { + direction: 'left', + referencePanel: main.id, + }, + }); + + const right = api.addPanel({ + id: RIGHT_PANEL_ID, + component: RIGHT_PANEL_ID, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + position: { + direction: 'right', + referencePanel: main.id, + }, + }); + + left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); + right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); + }); +}; + +export const VideoTabAutoLayout = memo(() => { + const onReady = useCallback(({ api }) => { + initializeRootPanelLayout('video', api); + }, []); + + useEffect( + () => () => { + navigationApi.unregisterTab('video'); + }, + [] + ); + + return ( + + + + ); +}); +VideoTabAutoLayout.displayName = 'VideoTabAutoLayout'; diff --git a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts index c6f5399b588..23800f36287 100644 --- a/invokeai/frontend/web/src/features/ui/store/uiTypes.ts +++ b/invokeai/frontend/web/src/features/ui/store/uiTypes.ts @@ -1,7 +1,7 @@ import { isPlainObject } from 'es-toolkit'; import { z } from 'zod'; -export const zTabName = z.enum(['generate', 'canvas', 'upscaling', 'workflows', 'models', 'queue']); +export const zTabName = z.enum(['generate', 'canvas', 'upscaling', 'workflows', 'models', 'queue', 'video']); export type TabName = z.infer; const zPartialDimensions = z.object({ diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index d7972ba0a49..667109698e6 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -12,6 +12,7 @@ import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gal import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; +import { generatedVideoUrlChanged } from 'features/parameters/store/videoSlice'; import type { LRUCache } from 'lru-cache'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; @@ -204,6 +205,15 @@ export const buildOnInvocationComplete = ( return imageDTOs; }; + const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise => { + // @ts-expect-error: This is a workaround to get the video name from the result + if (data.invocation.type === 'runway_generate_video') { + // @ts-expect-error: This is a workaround to get the video name from the result + return data.result.video.video_name; + } + return null; + }; + return async (data: S['InvocationCompleteEvent']) => { if (finishedQueueItemIds.has(data.item_id)) { log.trace({ data } as JsonObject, `Received event for already-finished queue item ${data.item_id}`); @@ -225,6 +235,11 @@ export const buildOnInvocationComplete = ( await addImagesToGallery(data); + const videoUrl = await getResultVideoDTOs(data); + if (videoUrl) { + dispatch(generatedVideoUrlChanged(videoUrl)); + } + $lastProgressEvent.set(null); }; }; From 89aca84b94e44825b8d1edf027dfcab99ba08189 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 11 Aug 2025 13:40:51 -0400 Subject: [PATCH 03/55] split out RunwayVideoOutput from VideoOutput --- invokeai/app/invocations/primitives.py | 25 ++++++++++++++++++++----- invokeai/invocation_api/__init__.py | 2 ++ 2 files changed, 22 insertions(+), 5 deletions(-) diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index 9308b4e2c81..d65a682f43b 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -293,19 +293,35 @@ def invoke(self, context: InvocationContext) -> ImageCollectionOutput: # region Video +@invocation_output("runway_video_output") +class RunwayVideoOutput(BaseInvocationOutput): + """Base class for nodes that output a runway result""" + + video_url: str = OutputField(description="The output video url") + runway_task_id: str = OutputField(description="The runway task id") + + + @classmethod + def build(cls, video_url: str, runway_task_id: str) -> "RunwayVideoOutput": + return cls( + video_url=video_url, + runway_task_id=runway_task_id, + ) + @invocation_output("video_output") class VideoOutput(BaseInvocationOutput): - """Base class for nodes that output a single video""" + """Base class for nodes that output a video""" - video: VideoField = OutputField(description="The output video") + video_id: str = OutputField(description="The output video id") width: int = OutputField(description="The width of the video in pixels") height: int = OutputField(description="The height of the video in pixels") duration_seconds: float = OutputField(description="The duration of the video in seconds") + @classmethod - def build(cls, video_name: str, width: int, height: int, duration_seconds: float) -> "VideoOutput": + def build(cls, video_id: str, width: int, height: int, duration_seconds: float) -> "VideoOutput": return cls( - video=VideoField(video_name=video_name), + video_id=video_id, width=width, height=height, duration_seconds=duration_seconds, @@ -314,7 +330,6 @@ def build(cls, video_name: str, width: int, height: int, duration_seconds: float - # endregion # region DenoiseMask diff --git a/invokeai/invocation_api/__init__.py b/invokeai/invocation_api/__init__.py index 6094b28c5dc..863ed6825a4 100644 --- a/invokeai/invocation_api/__init__.py +++ b/invokeai/invocation_api/__init__.py @@ -68,6 +68,7 @@ LatentsOutput, StringCollectionOutput, StringOutput, + RunwayVideoOutput, VideoOutput, ) from invokeai.app.invocations.scheduler import SchedulerOutput @@ -157,6 +158,7 @@ "LatentsOutput", "StringCollectionOutput", "StringOutput", + "RunwayVideoOutput", "VideoOutput", # invokeai.app.services.image_records.image_records_common "ImageCategory", From 7e146d46ea0b898881c66377eda174070b2add68 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 11 Aug 2025 14:01:04 -0400 Subject: [PATCH 04/55] update VideoField --- invokeai/app/invocations/primitives.py | 12 ++---------- 1 file changed, 2 insertions(+), 10 deletions(-) diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index d65a682f43b..63187ea0d9d 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -299,7 +299,6 @@ class RunwayVideoOutput(BaseInvocationOutput): video_url: str = OutputField(description="The output video url") runway_task_id: str = OutputField(description="The runway task id") - @classmethod def build(cls, video_url: str, runway_task_id: str) -> "RunwayVideoOutput": @@ -312,19 +311,12 @@ def build(cls, video_url: str, runway_task_id: str) -> "RunwayVideoOutput": class VideoOutput(BaseInvocationOutput): """Base class for nodes that output a video""" - video_id: str = OutputField(description="The output video id") - width: int = OutputField(description="The width of the video in pixels") - height: int = OutputField(description="The height of the video in pixels") - duration_seconds: float = OutputField(description="The duration of the video in seconds") + video: VideoField = OutputField(description="The output video") - @classmethod def build(cls, video_id: str, width: int, height: int, duration_seconds: float) -> "VideoOutput": return cls( - video_id=video_id, - width=width, - height=height, - duration_seconds=duration_seconds, + video=VideoField(video_id=video_id, width=width, height=height, duration_seconds=duration_seconds), ) From 5c2cdee54a820b3c621df56c6f7665f3215bb171 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 11 Aug 2025 16:58:11 -0400 Subject: [PATCH 05/55] push up updates for VideoField --- invokeai/app/invocations/fields.py | 5 ++++- 1 file changed, 4 insertions(+), 1 deletion(-) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index 1d0b1a1ed0d..e387a0691cb 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -228,7 +228,10 @@ class ImageField(BaseModel): class VideoField(BaseModel): """A video primitive field""" - video_name: str = Field(description="The name of the video") + video_id: str = Field(description="The id of the video") + width: int = Field(description="The width of the video in pixels") + height: int = Field(description="The height of the video in pixels") + duration_seconds: float = Field(description="The duration of the video in seconds") class BoardField(BaseModel): From d519af93dfa94fcbcd7a5de676afa46c74daa9e4 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Mon, 11 Aug 2025 16:58:57 -0400 Subject: [PATCH 06/55] build out adhoc video saving graph --- .../ImageMenuItemSendToVideo.tsx | 10 +- .../graph/generation/buildSaveVideoGraph.ts | 118 ++++++++++++++++++ .../features/parameters/store/videoSlice.ts | 15 ++- .../features/ui/layouts/VideoPlayerPanel.tsx | 54 +++++--- .../frontend/web/src/features/video/graph.ts | 25 ++++ .../web/src/features/video/saveVideo.ts | 41 ++++++ .../frontend/web/src/features/video/state.ts | 98 +++++++++++++++ .../frontend/web/src/services/api/schema.ts | 60 ++++++++- .../services/events/onInvocationComplete.tsx | 12 +- 9 files changed, 397 insertions(+), 36 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSaveVideoGraph.ts create mode 100644 invokeai/frontend/web/src/features/video/graph.ts create mode 100644 invokeai/frontend/web/src/features/video/saveVideo.ts create mode 100644 invokeai/frontend/web/src/features/video/state.ts diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx index 7d50c60823b..caf87cab472 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx @@ -1,21 +1,21 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { selectVideoFirstFrameImage, videoFirstFrameImageChanged } from 'features/parameters/store/videoSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; -import { setCurrentVideo } from 'features/ui/layouts/video-store'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiVideoBold } from 'react-icons/pi'; +import { useDispatch } from 'react-redux'; export const ImageMenuItemSendToVideo = memo(() => { const { t } = useTranslation(); const imageDTO = useImageDTOContext(); + const dispatch = useDispatch(); const onClick = useCallback(() => { - // For now, we'll use the image URL as a video source - // In a real implementation, you might want to convert the image to video or use a different approach - setCurrentVideo(imageDTO.image_url); + dispatch(videoFirstFrameImageChanged(imageDTO)); navigationApi.switchToTab('video'); - }, [imageDTO.image_url]); + }, [imageDTO]); return ( { + // In the future, these could come from a dedicated video parameters slice + // For now, we use defaults but allow them to be overridden by any video-specific state + return { + duration: DEFAULT_VIDEO_DURATION, + aspectRatio: DEFAULT_VIDEO_ASPECT_RATIO, + enhancePrompt: DEFAULT_ENHANCE_PROMPT, + }; +}; + +export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => { + const { generationMode, state, manager } = arg; + + log.debug({ generationMode, manager: manager?.id }, 'Building Runway video graph'); + + // Runway video generation supports text-to-video and image-to-video + // We can support multiple generation modes depending on whether frame images are provided + const supportedModes = ['txt2img'] as const; + if (!supportedModes.includes(generationMode as any)) { + throw new UnsupportedGenerationModeError(t('toast.runwayIncompatibleGenerationMode')); + } + + const params = selectParamsSlice(state); + const prompts = selectPresetModifiedPrompts(state); + const videoFirstFrameImage = selectVideoFirstFrameImage(state); + const videoLastFrameImage = selectVideoLastFrameImage(state); + const videoParams = getVideoParameters(state); + + // Get seed from params + const { seed, shouldRandomizeSeed } = params; + const finalSeed = shouldRandomizeSeed ? undefined : seed; + + // Determine if this is image-to-video or text-to-video + const hasFrameImages = videoFirstFrameImage || videoLastFrameImage; + + const g = new Graph(getPrefixedId('runway_video_graph')); + + const positivePrompt = g.addNode({ + id: getPrefixedId('positive_prompt'), + type: 'string', + value: prompts.positive, + }); + + // Create the runway video generation node + const runwayVideoNode = g.addNode({ + id: getPrefixedId('runway_generate_video'), + // @ts-expect-error: This node is not available in the OSS application + type: 'runway_generate_video', + duration: videoParams.duration, + aspect_ratio: videoParams.aspectRatio, + seed: finalSeed, + }); + + // @ts-expect-error: This node is not available in the OSS application + g.addEdge(positivePrompt, 'value', runwayVideoNode, 'prompt'); + + + // Add first frame image if provided + if (videoFirstFrameImage) { + const firstFrameImageField = zImageField.parse(videoFirstFrameImage); + // @ts-expect-error: This connection is specific to runway node + runwayVideoNode.first_frame_image = firstFrameImageField; + } + + // Add last frame image if provided + if (videoLastFrameImage) { + const lastFrameImageField = zImageField.parse(videoLastFrameImage); + // @ts-expect-error: This connection is specific to runway node + runwayVideoNode.last_frame_image = lastFrameImageField; + } + + // Set up metadata + g.upsertMetadata({ + positive_prompt: prompts.positive, + negative_prompt: prompts.negative || '', + video_duration: videoParams.duration, + video_aspect_ratio: videoParams.aspectRatio, + seed: finalSeed, + enhance_prompt: videoParams.enhancePrompt, + generation_type: hasFrameImages ? 'image-to-video' : 'text-to-video', + }); + + // Add video frame images to metadata if they exist + if (hasFrameImages) { + g.upsertMetadata({ + first_frame_image: videoFirstFrameImage, + last_frame_image: videoLastFrameImage, + }, 'merge'); + } + + g.setMetadataReceivingNode(runwayVideoNode); + + return { + g, + positivePrompt, + }; +}; diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 93b0dd12fc2..a06fee3a64e 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -12,7 +12,10 @@ const zVideoState = z.object({ _version: z.literal(1), videoFirstFrameImage: zImageWithDims.nullable(), videoLastFrameImage: zImageWithDims.nullable(), - generatedVideoUrl: z.string().nullable(), + generatedVideo: z.object({ + url: z.string(), + taskId: z.number(), + }).nullable(), }); export type VideoState = z.infer; @@ -21,7 +24,7 @@ const getInitialState = (): VideoState => ({ _version: 1, videoFirstFrameImage: null, videoLastFrameImage: null, - generatedVideoUrl: null, + generatedVideo: null, }); const slice = createSlice({ @@ -36,8 +39,8 @@ const slice = createSlice({ state.videoLastFrameImage = action.payload; }, - generatedVideoUrlChanged: (state, action: PayloadAction) => { - state.generatedVideoUrl = action.payload; + generatedVideoChanged: (state, action: PayloadAction<{ url: string, taskId: number } | null>) => { + state.generatedVideo = action.payload; }, }, @@ -46,7 +49,7 @@ const slice = createSlice({ export const { videoFirstFrameImageChanged, videoLastFrameImageChanged, - generatedVideoUrlChanged, + generatedVideoChanged, } = slice.actions; export const videoSliceConfig: SliceConfig = { @@ -69,4 +72,4 @@ const createVideoSelector = (selector: Selector) => createSele export const selectVideoFirstFrameImage = createVideoSelector((video) => video.videoFirstFrameImage); export const selectVideoLastFrameImage = createVideoSelector((video) => video.videoLastFrameImage); -export const selectGeneratedVideoUrl = createVideoSelector((video) => video.generatedVideoUrl); \ No newline at end of file +export const selectGeneratedVideo = createVideoSelector((video) => video.generatedVideo); \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx index 8883214317d..8bd25b44830 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx @@ -1,33 +1,55 @@ -import { Box, Flex, Text } from '@invoke-ai/ui-library'; +import { Box, Button, Flex, Text } from '@invoke-ai/ui-library'; import { useFocusRegion } from 'common/hooks/focus'; -import { memo, useRef } from 'react'; +import { memo, useCallback, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import ReactPlayer from 'react-player'; -import { useAppSelector } from 'app/store/storeHooks'; -import { selectGeneratedVideoUrl } from 'features/parameters/store/videoSlice'; +import { useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { selectGeneratedVideo } from 'features/parameters/store/videoSlice'; +import { PiCheckBold } from 'react-icons/pi'; +import { useDispatch } from 'react-redux'; +import { saveVideo } from 'features/video/saveVideo'; export const VideoPlayerPanel = memo(() => { const { t } = useTranslation(); const ref = useRef(null); - const generatedVideoUrl = useAppSelector(selectGeneratedVideoUrl); - + const generatedVideo = useAppSelector(selectGeneratedVideo); + useFocusRegion('video', ref); + const { dispatch, getState } = useAppStore(); + + const handleSaveVideo = useCallback(() => { + console.log('generatedVideo', generatedVideo); + if (!generatedVideo?.taskId) { + return + } + console.log('saving video', generatedVideo.taskId); + saveVideo({ dispatch, getState, taskId: `${generatedVideo.taskId}` }); + }, [dispatch, getState, generatedVideo]); + return ( - - {generatedVideoUrl && } - {!generatedVideoUrl && No video generated} - + + {generatedVideo && + <> + + + + + + + } + {!generatedVideo && No video generated} + ); }); diff --git a/invokeai/frontend/web/src/features/video/graph.ts b/invokeai/frontend/web/src/features/video/graph.ts new file mode 100644 index 00000000000..2daa0ed3969 --- /dev/null +++ b/invokeai/frontend/web/src/features/video/graph.ts @@ -0,0 +1,25 @@ +import type { RootState } from 'app/store/store'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { Graph } from 'features/nodes/util/graph/generation/Graph'; +import { assert } from 'tsafe'; + +export const buildSaveVideoGraph = ({ + state, +}: { + state: RootState; +}): { graph: Graph; outputNodeId: string } => { + + const taskId = state.video.generatedVideo?.taskId; + + assert(taskId, 'No task ID found in state'); + + const graph = new Graph(getPrefixedId('save-video-graph')); + const outputNode = graph.addNode({ + // @ts-expect-error: These nodes are not available in the OSS application + type: 'save_runway_video', + id: getPrefixedId('save_runway_video'), + runway_task_id: taskId, + }); + return { graph, outputNodeId: outputNode.id }; + +}; diff --git a/invokeai/frontend/web/src/features/video/saveVideo.ts b/invokeai/frontend/web/src/features/video/saveVideo.ts new file mode 100644 index 00000000000..f3b2c96caa8 --- /dev/null +++ b/invokeai/frontend/web/src/features/video/saveVideo.ts @@ -0,0 +1,41 @@ +import type { AppDispatch, AppGetState } from 'app/store/store'; +import { toast } from 'features/toast/toast'; +import { t } from 'i18next'; +import { buildRunGraphDependencies, runGraph } from 'services/api/run-graph'; +import { $socket } from 'services/events/stores'; +import { assert } from 'tsafe'; +import { buildSaveVideoGraph } from './graph'; +import { saveVideoApi } from './state'; + + + +export const saveVideo = async (arg: { dispatch: AppDispatch; getState: AppGetState; taskId?: string }) => { + const { dispatch, getState, taskId } = arg; + const socket = $socket.get(); + if (!socket) { + return; + } + const { graph, outputNodeId } = buildSaveVideoGraph({ + state: getState(), + }); + const dependencies = buildRunGraphDependencies(dispatch, socket); + try { + const { output } = await runGraph({ + graph, + outputNodeId, + dependencies, + options: { + prepend: true, + }, + }); + assert(output.type === 'string_output'); + saveVideoApi.setSuccess(output.value); + } catch { + saveVideoApi.reset(); + toast({ + id: 'SAVE_VIDEO_FAILED', + title: t('toast.saveVideoFailed'), + status: 'error', + }); + } +}; diff --git a/invokeai/frontend/web/src/features/video/state.ts b/invokeai/frontend/web/src/features/video/state.ts new file mode 100644 index 00000000000..75150afba99 --- /dev/null +++ b/invokeai/frontend/web/src/features/video/state.ts @@ -0,0 +1,98 @@ +import { deepClone } from 'common/util/deepClone'; +import { atom } from 'nanostores'; +import type { ImageDTO } from 'services/api/types'; + +type SuccessState = { + isSuccess: true; + isError: false; + isPending: false; + result: string; + error: null; + imageDTO?: ImageDTO; +}; + +type ErrorState = { + isSuccess: false; + isError: true; + isPending: false; + result: null; + error: Error; + imageDTO?: ImageDTO; +}; + +type PendingState = { + isSuccess: false; + isError: false; + isPending: true; + result: null; + error: null; + imageDTO?: ImageDTO; +}; + +type IdleState = { + isSuccess: false; + isError: false; + isPending: false; + result: null; + error: null; + imageDTO?: ImageDTO; +}; + +export type PromptExpansionRequestState = IdleState | PendingState | SuccessState | ErrorState; + +const IDLE_STATE: IdleState = { + isSuccess: false, + isError: false, + isPending: false, + result: null, + error: null, + imageDTO: undefined, +}; + +const $state = atom(deepClone(IDLE_STATE)); + +const reset = () => { + $state.set(deepClone(IDLE_STATE)); +}; + +const setPending = (imageDTO?: ImageDTO) => { + $state.set({ + ...$state.get(), + isSuccess: false, + isError: false, + isPending: true, + result: null, + error: null, + imageDTO, + }); +}; + +const setSuccess = (result: string) => { + $state.set({ + ...$state.get(), + isSuccess: true, + isError: false, + isPending: false, + result, + error: null, + }); +}; + +const setError = (error: Error) => { + $state.set({ + ...$state.get(), + isSuccess: false, + isError: true, + isPending: false, + result: null, + error, + }); +}; + +export const saveVideoApi = { + $state, + reset, + setPending, + setSuccess, + setError, +}; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index c8d48a674c8..1d36c316af8 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -9094,7 +9094,7 @@ export type components = { * @description The results of node executions */ results: { - [key: string]: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"]; + [key: string]: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["RunwayVideoOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"]; }; /** * Errors @@ -11880,7 +11880,7 @@ export type components = { * Result * @description The result of the invocation */ - result: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"]; + result: components["schemas"]["BooleanCollectionOutput"] | components["schemas"]["BooleanOutput"] | components["schemas"]["BoundingBoxCollectionOutput"] | components["schemas"]["BoundingBoxOutput"] | components["schemas"]["CLIPOutput"] | components["schemas"]["CLIPSkipInvocationOutput"] | components["schemas"]["CalculateImageTilesOutput"] | components["schemas"]["CogView4ConditioningOutput"] | components["schemas"]["CogView4ModelLoaderOutput"] | components["schemas"]["CollectInvocationOutput"] | components["schemas"]["ColorCollectionOutput"] | components["schemas"]["ColorOutput"] | components["schemas"]["ConditioningCollectionOutput"] | components["schemas"]["ConditioningOutput"] | components["schemas"]["ControlOutput"] | components["schemas"]["DenoiseMaskOutput"] | components["schemas"]["FaceMaskOutput"] | components["schemas"]["FaceOffOutput"] | components["schemas"]["FloatCollectionOutput"] | components["schemas"]["FloatGeneratorOutput"] | components["schemas"]["FloatOutput"] | components["schemas"]["FluxConditioningCollectionOutput"] | components["schemas"]["FluxConditioningOutput"] | components["schemas"]["FluxControlLoRALoaderOutput"] | components["schemas"]["FluxControlNetOutput"] | components["schemas"]["FluxFillOutput"] | components["schemas"]["FluxKontextOutput"] | components["schemas"]["FluxLoRALoaderOutput"] | components["schemas"]["FluxModelLoaderOutput"] | components["schemas"]["FluxReduxOutput"] | components["schemas"]["GradientMaskOutput"] | components["schemas"]["IPAdapterOutput"] | components["schemas"]["IdealSizeOutput"] | components["schemas"]["ImageCollectionOutput"] | components["schemas"]["ImageGeneratorOutput"] | components["schemas"]["ImageOutput"] | components["schemas"]["ImagePanelCoordinateOutput"] | components["schemas"]["IntegerCollectionOutput"] | components["schemas"]["IntegerGeneratorOutput"] | components["schemas"]["IntegerOutput"] | components["schemas"]["IterateInvocationOutput"] | components["schemas"]["LatentsCollectionOutput"] | components["schemas"]["LatentsMetaOutput"] | components["schemas"]["LatentsOutput"] | components["schemas"]["LoRALoaderOutput"] | components["schemas"]["LoRASelectorOutput"] | components["schemas"]["MDControlListOutput"] | components["schemas"]["MDIPAdapterListOutput"] | components["schemas"]["MDT2IAdapterListOutput"] | components["schemas"]["MaskOutput"] | components["schemas"]["MetadataItemOutput"] | components["schemas"]["MetadataOutput"] | components["schemas"]["MetadataToLorasCollectionOutput"] | components["schemas"]["MetadataToModelOutput"] | components["schemas"]["MetadataToSDXLModelOutput"] | components["schemas"]["ModelIdentifierOutput"] | components["schemas"]["ModelLoaderOutput"] | components["schemas"]["NoiseOutput"] | components["schemas"]["PairTileImageOutput"] | components["schemas"]["RunwayVideoOutput"] | components["schemas"]["SD3ConditioningOutput"] | components["schemas"]["SDXLLoRALoaderOutput"] | components["schemas"]["SDXLModelLoaderOutput"] | components["schemas"]["SDXLRefinerModelLoaderOutput"] | components["schemas"]["SchedulerOutput"] | components["schemas"]["Sd3ModelLoaderOutput"] | components["schemas"]["SeamlessModeOutput"] | components["schemas"]["String2Output"] | components["schemas"]["StringCollectionOutput"] | components["schemas"]["StringGeneratorOutput"] | components["schemas"]["StringOutput"] | components["schemas"]["StringPosNegOutput"] | components["schemas"]["T2IAdapterOutput"] | components["schemas"]["TileToPropertiesOutput"] | components["schemas"]["UNetOutput"] | components["schemas"]["VAEOutput"] | components["schemas"]["VideoOutput"]; }; /** * InvocationErrorEvent @@ -18285,6 +18285,28 @@ export type components = { */ type: "round_float"; }; + /** + * RunwayVideoOutput + * @description Base class for nodes that output a runway result + */ + RunwayVideoOutput: { + /** + * Video Url + * @description The output video url + */ + video_url: string; + /** + * Runway Task Id + * @description The runway task id + */ + runway_task_id: string; + /** + * type + * @default runway_video_output + * @constant + */ + type: "runway_video_output"; + }; /** SAMPoint */ SAMPoint: { /** @@ -21459,7 +21481,7 @@ export type components = { * used, and the type will be ignored. They are included here for backwards compatibility. * @enum {string} */ - UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "SchedulerField" | "AnyField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; + UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "SchedulerField" | "AnyField" | "VideoField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; /** UNetField */ UNetField: { /** @description Info to load unet submodel */ @@ -21870,6 +21892,38 @@ export type components = { */ output_fields: components["schemas"]["FieldIdentifier"][]; }; + /** + * VideoOutput + * @description Base class for nodes that output a video + */ + VideoOutput: { + /** + * Video Id + * @description The output video id + */ + video_id: string; + /** + * Width + * @description The width of the video in pixels + */ + width: number; + /** + * Height + * @description The height of the video in pixels + */ + height: number; + /** + * Duration Seconds + * @description The duration of the video in seconds + */ + duration_seconds: number; + /** + * type + * @default video_output + * @constant + */ + type: "video_output"; + }; /** Workflow */ Workflow: { /** diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 667109698e6..a886ae7db06 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -12,7 +12,7 @@ import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gal import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; -import { generatedVideoUrlChanged } from 'features/parameters/store/videoSlice'; +import { generatedVideoChanged } from 'features/parameters/store/videoSlice'; import type { LRUCache } from 'lru-cache'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; @@ -205,11 +205,11 @@ export const buildOnInvocationComplete = ( return imageDTOs; }; - const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise => { + const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise<{url: string , taskId: number} | null> => { // @ts-expect-error: This is a workaround to get the video name from the result if (data.invocation.type === 'runway_generate_video') { // @ts-expect-error: This is a workaround to get the video name from the result - return data.result.video.video_name; + return {url: data.result.video_url, taskId: data.result.runway_task_id}; } return null; }; @@ -235,9 +235,9 @@ export const buildOnInvocationComplete = ( await addImagesToGallery(data); - const videoUrl = await getResultVideoDTOs(data); - if (videoUrl) { - dispatch(generatedVideoUrlChanged(videoUrl)); + const videoResult = await getResultVideoDTOs(data); + if (videoResult) { + dispatch(generatedVideoChanged(videoResult)); } $lastProgressEvent.set(null); From d5107baed4663bb1c727e18415965b70cc5f6c3d Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Tue, 12 Aug 2025 11:10:08 -0400 Subject: [PATCH 07/55] combine nodes that generate and save videos --- .../web/src/features/nodes/types/common.ts | 8 ++ .../graph/generation/buildSaveVideoGraph.ts | 118 ------------------ .../features/parameters/store/videoSlice.ts | 8 +- .../features/ui/layouts/VideoPlayerPanel.tsx | 24 +--- .../frontend/web/src/features/video/graph.ts | 25 ---- .../web/src/features/video/saveVideo.ts | 41 ------ .../frontend/web/src/features/video/state.ts | 98 --------------- .../frontend/web/src/services/api/types.ts | 1 + .../services/events/onInvocationComplete.tsx | 6 +- 9 files changed, 19 insertions(+), 310 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSaveVideoGraph.ts delete mode 100644 invokeai/frontend/web/src/features/video/graph.ts delete mode 100644 invokeai/frontend/web/src/features/video/saveVideo.ts delete mode 100644 invokeai/frontend/web/src/features/video/state.ts diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index ea6ad790a00..aec694759a8 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -188,3 +188,11 @@ export const zImageOutput = z.object({ }); export type ImageOutput = z.infer; // #endregion + +// #region ImageOutput +export const zVideoOutput = z.object({ + video_id: z.string().trim().min(1), + type: z.literal('video_output'), +}); +export type VideoOutput = z.infer; +// #endregion diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSaveVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSaveVideoGraph.ts deleted file mode 100644 index bb136887fed..00000000000 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildSaveVideoGraph.ts +++ /dev/null @@ -1,118 +0,0 @@ -import { logger } from 'app/logging/logger'; -import type { RootState } from 'app/store/store'; -import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; -import { selectVideoFirstFrameImage, selectVideoLastFrameImage } from 'features/parameters/store/videoSlice'; -import { zImageField } from 'features/nodes/types/common'; -import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; -import type { GraphBuilderArg, GraphBuilderReturn } from 'features/nodes/util/graph/types'; -import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; -import { t } from 'i18next'; -import { assert } from 'tsafe'; - -const log = logger('system'); - -// Default video parameters - these could be moved to a video params slice in the future -const DEFAULT_VIDEO_DURATION = 5; -const DEFAULT_VIDEO_ASPECT_RATIO = "1280:768"; // Default landscape -const DEFAULT_ENHANCE_PROMPT = true; - -// Video parameter extraction helper -const getVideoParameters = (state: RootState) => { - // In the future, these could come from a dedicated video parameters slice - // For now, we use defaults but allow them to be overridden by any video-specific state - return { - duration: DEFAULT_VIDEO_DURATION, - aspectRatio: DEFAULT_VIDEO_ASPECT_RATIO, - enhancePrompt: DEFAULT_ENHANCE_PROMPT, - }; -}; - -export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => { - const { generationMode, state, manager } = arg; - - log.debug({ generationMode, manager: manager?.id }, 'Building Runway video graph'); - - // Runway video generation supports text-to-video and image-to-video - // We can support multiple generation modes depending on whether frame images are provided - const supportedModes = ['txt2img'] as const; - if (!supportedModes.includes(generationMode as any)) { - throw new UnsupportedGenerationModeError(t('toast.runwayIncompatibleGenerationMode')); - } - - const params = selectParamsSlice(state); - const prompts = selectPresetModifiedPrompts(state); - const videoFirstFrameImage = selectVideoFirstFrameImage(state); - const videoLastFrameImage = selectVideoLastFrameImage(state); - const videoParams = getVideoParameters(state); - - // Get seed from params - const { seed, shouldRandomizeSeed } = params; - const finalSeed = shouldRandomizeSeed ? undefined : seed; - - // Determine if this is image-to-video or text-to-video - const hasFrameImages = videoFirstFrameImage || videoLastFrameImage; - - const g = new Graph(getPrefixedId('runway_video_graph')); - - const positivePrompt = g.addNode({ - id: getPrefixedId('positive_prompt'), - type: 'string', - value: prompts.positive, - }); - - // Create the runway video generation node - const runwayVideoNode = g.addNode({ - id: getPrefixedId('runway_generate_video'), - // @ts-expect-error: This node is not available in the OSS application - type: 'runway_generate_video', - duration: videoParams.duration, - aspect_ratio: videoParams.aspectRatio, - seed: finalSeed, - }); - - // @ts-expect-error: This node is not available in the OSS application - g.addEdge(positivePrompt, 'value', runwayVideoNode, 'prompt'); - - - // Add first frame image if provided - if (videoFirstFrameImage) { - const firstFrameImageField = zImageField.parse(videoFirstFrameImage); - // @ts-expect-error: This connection is specific to runway node - runwayVideoNode.first_frame_image = firstFrameImageField; - } - - // Add last frame image if provided - if (videoLastFrameImage) { - const lastFrameImageField = zImageField.parse(videoLastFrameImage); - // @ts-expect-error: This connection is specific to runway node - runwayVideoNode.last_frame_image = lastFrameImageField; - } - - // Set up metadata - g.upsertMetadata({ - positive_prompt: prompts.positive, - negative_prompt: prompts.negative || '', - video_duration: videoParams.duration, - video_aspect_ratio: videoParams.aspectRatio, - seed: finalSeed, - enhance_prompt: videoParams.enhancePrompt, - generation_type: hasFrameImages ? 'image-to-video' : 'text-to-video', - }); - - // Add video frame images to metadata if they exist - if (hasFrameImages) { - g.upsertMetadata({ - first_frame_image: videoFirstFrameImage, - last_frame_image: videoLastFrameImage, - }, 'merge'); - } - - g.setMetadataReceivingNode(runwayVideoNode); - - return { - g, - positivePrompt, - }; -}; diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index a06fee3a64e..6d51c0c5003 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -5,6 +5,7 @@ import type { SliceConfig } from 'app/store/types'; import { isPlainObject } from 'es-toolkit'; import type { ImageWithDims } from 'features/controlLayers/store/types'; import { zImageWithDims } from 'features/controlLayers/store/types'; +import { VideoOutput, zVideoOutput } from 'features/nodes/types/common'; import { assert } from 'tsafe'; import z from 'zod'; @@ -12,10 +13,7 @@ const zVideoState = z.object({ _version: z.literal(1), videoFirstFrameImage: zImageWithDims.nullable(), videoLastFrameImage: zImageWithDims.nullable(), - generatedVideo: z.object({ - url: z.string(), - taskId: z.number(), - }).nullable(), + generatedVideo: zVideoOutput.nullable(), }); export type VideoState = z.infer; @@ -39,7 +37,7 @@ const slice = createSlice({ state.videoLastFrameImage = action.payload; }, - generatedVideoChanged: (state, action: PayloadAction<{ url: string, taskId: number } | null>) => { + generatedVideoChanged: (state, action: PayloadAction) => { state.generatedVideo = action.payload; }, diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx index 8bd25b44830..4c5b59c46fd 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx @@ -1,13 +1,10 @@ -import { Box, Button, Flex, Text } from '@invoke-ai/ui-library'; +import { Box, Flex, Text } from '@invoke-ai/ui-library'; import { useFocusRegion } from 'common/hooks/focus'; -import { memo, useCallback, useRef } from 'react'; +import { memo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import ReactPlayer from 'react-player'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { selectGeneratedVideo } from 'features/parameters/store/videoSlice'; -import { PiCheckBold } from 'react-icons/pi'; -import { useDispatch } from 'react-redux'; -import { saveVideo } from 'features/video/saveVideo'; export const VideoPlayerPanel = memo(() => { @@ -17,18 +14,6 @@ export const VideoPlayerPanel = memo(() => { useFocusRegion('video', ref); - const { dispatch, getState } = useAppStore(); - - const handleSaveVideo = useCallback(() => { - console.log('generatedVideo', generatedVideo); - if (!generatedVideo?.taskId) { - return - } - console.log('saving video', generatedVideo.taskId); - saveVideo({ dispatch, getState, taskId: `${generatedVideo.taskId}` }); - }, [dispatch, getState, generatedVideo]); - - return ( @@ -36,16 +21,15 @@ export const VideoPlayerPanel = memo(() => { {generatedVideo && <> - + /> */} - } {!generatedVideo && No video generated} diff --git a/invokeai/frontend/web/src/features/video/graph.ts b/invokeai/frontend/web/src/features/video/graph.ts deleted file mode 100644 index 2daa0ed3969..00000000000 --- a/invokeai/frontend/web/src/features/video/graph.ts +++ /dev/null @@ -1,25 +0,0 @@ -import type { RootState } from 'app/store/store'; -import { getPrefixedId } from 'features/controlLayers/konva/util'; -import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { assert } from 'tsafe'; - -export const buildSaveVideoGraph = ({ - state, -}: { - state: RootState; -}): { graph: Graph; outputNodeId: string } => { - - const taskId = state.video.generatedVideo?.taskId; - - assert(taskId, 'No task ID found in state'); - - const graph = new Graph(getPrefixedId('save-video-graph')); - const outputNode = graph.addNode({ - // @ts-expect-error: These nodes are not available in the OSS application - type: 'save_runway_video', - id: getPrefixedId('save_runway_video'), - runway_task_id: taskId, - }); - return { graph, outputNodeId: outputNode.id }; - -}; diff --git a/invokeai/frontend/web/src/features/video/saveVideo.ts b/invokeai/frontend/web/src/features/video/saveVideo.ts deleted file mode 100644 index f3b2c96caa8..00000000000 --- a/invokeai/frontend/web/src/features/video/saveVideo.ts +++ /dev/null @@ -1,41 +0,0 @@ -import type { AppDispatch, AppGetState } from 'app/store/store'; -import { toast } from 'features/toast/toast'; -import { t } from 'i18next'; -import { buildRunGraphDependencies, runGraph } from 'services/api/run-graph'; -import { $socket } from 'services/events/stores'; -import { assert } from 'tsafe'; -import { buildSaveVideoGraph } from './graph'; -import { saveVideoApi } from './state'; - - - -export const saveVideo = async (arg: { dispatch: AppDispatch; getState: AppGetState; taskId?: string }) => { - const { dispatch, getState, taskId } = arg; - const socket = $socket.get(); - if (!socket) { - return; - } - const { graph, outputNodeId } = buildSaveVideoGraph({ - state: getState(), - }); - const dependencies = buildRunGraphDependencies(dispatch, socket); - try { - const { output } = await runGraph({ - graph, - outputNodeId, - dependencies, - options: { - prepend: true, - }, - }); - assert(output.type === 'string_output'); - saveVideoApi.setSuccess(output.value); - } catch { - saveVideoApi.reset(); - toast({ - id: 'SAVE_VIDEO_FAILED', - title: t('toast.saveVideoFailed'), - status: 'error', - }); - } -}; diff --git a/invokeai/frontend/web/src/features/video/state.ts b/invokeai/frontend/web/src/features/video/state.ts deleted file mode 100644 index 75150afba99..00000000000 --- a/invokeai/frontend/web/src/features/video/state.ts +++ /dev/null @@ -1,98 +0,0 @@ -import { deepClone } from 'common/util/deepClone'; -import { atom } from 'nanostores'; -import type { ImageDTO } from 'services/api/types'; - -type SuccessState = { - isSuccess: true; - isError: false; - isPending: false; - result: string; - error: null; - imageDTO?: ImageDTO; -}; - -type ErrorState = { - isSuccess: false; - isError: true; - isPending: false; - result: null; - error: Error; - imageDTO?: ImageDTO; -}; - -type PendingState = { - isSuccess: false; - isError: false; - isPending: true; - result: null; - error: null; - imageDTO?: ImageDTO; -}; - -type IdleState = { - isSuccess: false; - isError: false; - isPending: false; - result: null; - error: null; - imageDTO?: ImageDTO; -}; - -export type PromptExpansionRequestState = IdleState | PendingState | SuccessState | ErrorState; - -const IDLE_STATE: IdleState = { - isSuccess: false, - isError: false, - isPending: false, - result: null, - error: null, - imageDTO: undefined, -}; - -const $state = atom(deepClone(IDLE_STATE)); - -const reset = () => { - $state.set(deepClone(IDLE_STATE)); -}; - -const setPending = (imageDTO?: ImageDTO) => { - $state.set({ - ...$state.get(), - isSuccess: false, - isError: false, - isPending: true, - result: null, - error: null, - imageDTO, - }); -}; - -const setSuccess = (result: string) => { - $state.set({ - ...$state.get(), - isSuccess: true, - isError: false, - isPending: false, - result, - error: null, - }); -}; - -const setError = (error: Error) => { - $state.set({ - ...$state.get(), - isSuccess: false, - isError: true, - isPending: false, - result: null, - error, - }); -}; - -export const saveVideoApi = { - $state, - reset, - setPending, - setSuccess, - setError, -}; diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 98f0d980348..99d461199cd 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -373,6 +373,7 @@ export type OutputFields = Extract< // Node Outputs export type ImageOutput = S['ImageOutput']; +export type VideoOutput = S['VideoOutput']; export type BoardRecordOrderBy = S['BoardRecordOrderBy']; export type StarterModel = S['StarterModel']; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index a886ae7db06..429e871dfc3 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -16,7 +16,7 @@ import { generatedVideoChanged } from 'features/parameters/store/videoSlice'; import type { LRUCache } from 'lru-cache'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO, S } from 'services/api/types'; +import type { ImageDTO, S, VideoOutput } from 'services/api/types'; import { getCategories } from 'services/api/util'; import { insertImageIntoNamesResult } from 'services/api/util/optimisticUpdates'; import { $lastProgressEvent } from 'services/events/stores'; @@ -205,11 +205,11 @@ export const buildOnInvocationComplete = ( return imageDTOs; }; - const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise<{url: string , taskId: number} | null> => { + const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise => { // @ts-expect-error: This is a workaround to get the video name from the result if (data.invocation.type === 'runway_generate_video') { // @ts-expect-error: This is a workaround to get the video name from the result - return {url: data.result.video_url, taskId: data.result.runway_task_id}; + return {videoId: data.result.video_id}; } return null; }; From 8943af9ec44bc0ffb1be2c148ed797134280be1d Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 14 Aug 2025 11:21:18 -0400 Subject: [PATCH 08/55] add video models --- invokeai/app/services/videos_common.py | 179 +++++++++++++++++++++++++ 1 file changed, 179 insertions(+) create mode 100644 invokeai/app/services/videos_common.py diff --git a/invokeai/app/services/videos_common.py b/invokeai/app/services/videos_common.py new file mode 100644 index 00000000000..a1b8d762287 --- /dev/null +++ b/invokeai/app/services/videos_common.py @@ -0,0 +1,179 @@ +import datetime +from typing import Optional, Union + +from pydantic import BaseModel, Field, StrictBool, StrictStr + +from invokeai.app.util.misc import get_iso_timestamp +from invokeai.app.util.model_exclude_null import BaseModelExcludeNull + +VIDEO_DTO_COLS = ", ".join( + [ + "videos." + c + for c in [ + "video_id", + "width", + "height", + "session_id", + "node_id", + "is_intermediate", + "created_at", + "updated_at", + "deleted_at", + "starred", + ] + ] +) + + +class VideoRecord(BaseModelExcludeNull): + """Deserialized video record without metadata.""" + + video_id: str = Field(description="The unique id of the video.") + """The unique id of the video.""" + width: int = Field(description="The width of the video in px.") + """The actual width of the video in px. This may be different from the width in metadata.""" + height: int = Field(description="The height of the video in px.") + """The actual height of the video in px. This may be different from the height in metadata.""" + created_at: Union[datetime.datetime, str] = Field(description="The created timestamp of the video.") + """The created timestamp of the video.""" + updated_at: Union[datetime.datetime, str] = Field(description="The updated timestamp of the video.") + """The updated timestamp of the video.""" + deleted_at: Optional[Union[datetime.datetime, str]] = Field( + default=None, description="The deleted timestamp of the video." + ) + """The deleted timestamp of the video.""" + is_intermediate: bool = Field(description="Whether this is an intermediate video.") + """Whether this is an intermediate video.""" + session_id: Optional[str] = Field( + default=None, + description="The session ID that generated this video, if it is a generated video.", + ) + """The session ID that generated this video, if it is a generated video.""" + node_id: Optional[str] = Field( + default=None, + description="The node ID that generated this video, if it is a generated video.", + ) + """The node ID that generated this video, if it is a generated video.""" + starred: bool = Field(description="Whether this video is starred.") + """Whether this video is starred.""" + + +class VideoRecordChanges(BaseModelExcludeNull): + """A set of changes to apply to a video record. + + Only limited changes are valid: + - `session_id`: change the session associated with a video + - `is_intermediate`: change the video's `is_intermediate` flag + - `starred`: change whether the video is starred + """ + + session_id: Optional[StrictStr] = Field( + default=None, + description="The video's new session ID.", + ) + """The video's new session ID.""" + is_intermediate: Optional[StrictBool] = Field(default=None, description="The video's new `is_intermediate` flag.") + """The video's new `is_intermediate` flag.""" + starred: Optional[StrictBool] = Field(default=None, description="The video's new `starred` state") + """The video's new `starred` state.""" + + +def deserialize_video_record(video_dict: dict) -> VideoRecord: + """Deserializes a video record.""" + + # Retrieve all the values, setting "reasonable" defaults if they are not present. + video_id = video_dict.get("video_id", "unknown") + width = video_dict.get("width", 0) + height = video_dict.get("height", 0) + session_id = video_dict.get("session_id", None) + node_id = video_dict.get("node_id", None) + created_at = video_dict.get("created_at", get_iso_timestamp()) + updated_at = video_dict.get("updated_at", get_iso_timestamp()) + deleted_at = video_dict.get("deleted_at", get_iso_timestamp()) + is_intermediate = video_dict.get("is_intermediate", False) + starred = video_dict.get("starred", False) + + return VideoRecord( + video_id=video_id, + width=width, + height=height, + session_id=session_id, + node_id=node_id, + created_at=created_at, + updated_at=updated_at, + deleted_at=deleted_at, + is_intermediate=is_intermediate, + starred=starred, + ) + + +class VideoCollectionCounts(BaseModel): + starred_count: int = Field(description="The number of starred videos in the collection.") + unstarred_count: int = Field(description="The number of unstarred videos in the collection.") + + +class VideoIdsResult(BaseModel): + """Response containing ordered video ids with metadata for optimistic updates.""" + + video_ids: list[str] = Field(description="Ordered list of video ids") + starred_count: int = Field(description="Number of starred videos (when starred_first=True)") + total_count: int = Field(description="Total number of videos matching the query") + + +class VideoUrlsDTO(BaseModelExcludeNull): + """The URLs for an image and its thumbnail.""" + + video_id: str = Field(description="The unique id of the video.") + """The unique id of the video.""" + video_url: str = Field(description="The URL of the video.") + """The URL of the video.""" + thumbnail_url: str = Field(description="The URL of the video's thumbnail.") + """The URL of the video's thumbnail.""" + + +class VideoDTO(VideoRecord, VideoUrlsDTO): + """Deserialized video record, enriched for the frontend.""" + + board_id: Optional[str] = Field( + default=None, description="The id of the board the image belongs to, if one exists." + ) + """The id of the board the image belongs to, if one exists.""" + + +def video_record_to_dto( + video_record: VideoRecord, + video_url: str, + thumbnail_url: str, + board_id: Optional[str], +) -> VideoDTO: + """Converts a video record to a video DTO.""" + return VideoDTO( + **video_record.model_dump(), + video_url=video_url, + thumbnail_url=thumbnail_url, + board_id=board_id, + ) + + +class ResultWithAffectedBoards(BaseModel): + affected_boards: list[str] = Field(description="The ids of boards affected by the delete operation") + + +class DeleteVideosResult(ResultWithAffectedBoards): + deleted_videos: list[str] = Field(description="The ids of the videos that were deleted") + + +class StarredVideosResult(ResultWithAffectedBoards): + starred_videos: list[str] = Field(description="The ids of the videos that were starred") + + +class UnstarredVideosResult(ResultWithAffectedBoards): + unstarred_videos: list[str] = Field(description="The ids of the videos that were unstarred") + + +class AddVideosToBoardResult(ResultWithAffectedBoards): + added_videos: list[str] = Field(description="The video ids that were added to the board") + + +class RemoveVideosFromBoardResult(ResultWithAffectedBoards): + removed_videos: list[str] = Field(description="The video ids that were removed from their board") From d3d6494df9b48fac267c40bc30528440dce94461 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 14 Aug 2025 12:26:13 -0400 Subject: [PATCH 09/55] add noop video router --- invokeai/app/api/routers/videos.py | 118 +++ invokeai/app/api_app.py | 2 + .../frontend/web/src/services/api/schema.ts | 769 +++++++++++++++++- 3 files changed, 844 insertions(+), 45 deletions(-) create mode 100644 invokeai/app/api/routers/videos.py diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py new file mode 100644 index 00000000000..7e97ac2992f --- /dev/null +++ b/invokeai/app/api/routers/videos.py @@ -0,0 +1,118 @@ +from typing import Optional + +from fastapi import Body, HTTPException, Path, Query +from fastapi.routing import APIRouter +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection +from invokeai.app.services.shared.pagination import OffsetPaginatedResults + +from invokeai.app.services.videos_common import VideoDTO, VideoRecordChanges, DeleteVideosResult, StarredVideosResult, UnstarredVideosResult, VideoIdsResult + +videos_router = APIRouter(prefix="/v1/videos", tags=["videos"]) + + + +@videos_router.patch( + "/i/{video_id}", + operation_id="update_video", + response_model=VideoDTO, +) +async def update_video( + video_id: str = Path(description="The id of the video to update"), + video_changes: VideoRecordChanges = Body(description="The changes to apply to the video"), +) -> VideoDTO: + """Updates a video""" + + raise HTTPException(status_code=501, detail="Not implemented") + + +@videos_router.get( + "/i/{video_id}", + operation_id="get_video_dto", + response_model=VideoDTO, +) +async def get_video_dto( + video_id: str = Path(description="The id of the video to get"), +) -> VideoDTO: + """Gets a video's DTO""" + + raise HTTPException(status_code=501, detail="Not implemented") + + +@videos_router.post("/delete", operation_id="delete_videos_from_list", response_model=DeleteVideosResult) +async def delete_videos_from_list( + video_ids: list[str] = Body(description="The list of ids of videos to delete", embed=True), +) -> DeleteVideosResult: + + raise HTTPException(status_code=501, detail="Not implemented") + + + +@videos_router.post("/star", operation_id="star_videos_in_list", response_model=StarredVideosResult) +async def star_videos_in_list( + video_ids: list[str] = Body(description="The list of ids of videos to star", embed=True), +) -> StarredVideosResult: + raise HTTPException(status_code=501, detail="Not implemented") + + +@videos_router.post("/unstar", operation_id="unstar_videos_in_list", response_model=UnstarredVideosResult) +async def unstar_videos_in_list( + video_ids: list[str] = Body(description="The list of ids of videos to unstar", embed=True), +) -> UnstarredVideosResult: + raise HTTPException(status_code=501, detail="Not implemented") + + +@videos_router.delete( + "/uncategorized", operation_id="delete_uncategorized_videos", response_model=DeleteVideosResult +) +async def delete_uncategorized_videos( +) -> DeleteVideosResult: + """Deletes all videos that are uncategorized""" + + raise HTTPException(status_code=501, detail="Not implemented") + + +@videos_router.get("/", operation_id="list_video_dtos", response_model=OffsetPaginatedResults[VideoDTO]) +async def list_video_dtos( + is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate videos."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find videos without a board.", + ), + offset: int = Query(default=0, description="The page offset"), + limit: int = Query(default=10, description="The number of videos per page"), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"), + search_term: Optional[str] = Query(default=None, description="The term to search for"), +) -> OffsetPaginatedResults[VideoDTO]: + """Lists video DTOs""" + + raise HTTPException(status_code=501, detail="Not implemented") + + +@videos_router.get("/ids", operation_id="get_video_ids") +async def get_video_ids( + is_intermediate: Optional[bool] = Query(default=None, description="Whether to list intermediate videos."), + board_id: Optional[str] = Query( + default=None, + description="The board id to filter by. Use 'none' to find videos without a board.", + ), + order_dir: SQLiteDirection = Query(default=SQLiteDirection.Descending, description="The order of sort"), + starred_first: bool = Query(default=True, description="Whether to sort by starred videos first"), + search_term: Optional[str] = Query(default=None, description="The term to search for"), +) -> VideoIdsResult: + """Gets ordered list of video ids with metadata for optimistic updates""" + + raise HTTPException(status_code=501, detail="Not implemented") + + +@videos_router.post( + "/videos_by_ids", + operation_id="get_videos_by_ids", + responses={200: {"model": list[VideoDTO]}}, +) +async def get_videos_by_ids( + video_ids: list[str] = Body(embed=True, description="Object containing list of video ids to fetch DTOs for"), +) -> list[VideoDTO]: + """Gets video DTOs for the specified video ids. Maintains order of input ids.""" + + raise HTTPException(status_code=501, detail="Not implemented") \ No newline at end of file diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 335327f532b..eea32316e41 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -28,6 +28,7 @@ style_presets, utilities, workflows, + videos, ) from invokeai.app.api.sockets import SocketIO from invokeai.app.services.config.config_default import get_config @@ -125,6 +126,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(model_manager.model_manager_router, prefix="/api") app.include_router(download_queue.download_queue_router, prefix="/api") app.include_router(images.images_router, prefix="/api") +app.include_router(videos.videos_router, prefix="/api") app.include_router(boards.boards_router, prefix="/api") app.include_router(board_images.board_images_router, prefix="/api") app.include_router(model_relationships.model_relationships_router, prefix="/api") diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 1d36c316af8..2ba92f39718 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -792,6 +792,161 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/videos/i/{video_id}": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Video Dto + * @description Gets a video's DTO + */ + get: operations["get_video_dto"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + /** + * Update Video + * @description Updates a video + */ + patch: operations["update_video"]; + trace?: never; + }; + "/api/v1/videos/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Delete Videos From List */ + post: operations["delete_videos_from_list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/videos/star": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Star Videos In List */ + post: operations["star_videos_in_list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/videos/unstar": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Unstar Videos In List */ + post: operations["unstar_videos_in_list"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/videos/uncategorized": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + post?: never; + /** + * Delete Uncategorized Videos + * @description Deletes all videos that are uncategorized + */ + delete: operations["delete_uncategorized_videos"]; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/videos/": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * List Video Dtos + * @description Lists video DTOs + */ + get: operations["list_video_dtos"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/videos/ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** + * Get Video Ids + * @description Gets ordered list of video ids with metadata for optimistic updates + */ + get: operations["get_video_ids"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/videos/videos_by_ids": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Get Videos By Ids + * @description Gets video DTOs for the specified video ids. Maintains order of input ids. + */ + post: operations["get_videos_by_ids"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/boards/": { parameters: { query?: never; @@ -2669,6 +2824,14 @@ export type components = { */ image_names: string[]; }; + /** Body_delete_videos_from_list */ + Body_delete_videos_from_list: { + /** + * Video Ids + * @description The list of ids of videos to delete + */ + video_ids: string[]; + }; /** Body_do_hf_login */ Body_do_hf_login: { /** @@ -2736,6 +2899,14 @@ export type components = { */ image_names: string[]; }; + /** Body_get_videos_by_ids */ + Body_get_videos_by_ids: { + /** + * Video Ids + * @description Object containing list of video ids to fetch DTOs for + */ + video_ids: string[]; + }; /** Body_import_style_presets */ Body_import_style_presets: { /** @@ -2803,6 +2974,14 @@ export type components = { */ image_names: string[]; }; + /** Body_star_videos_in_list */ + Body_star_videos_in_list: { + /** + * Video Ids + * @description The list of ids of videos to star + */ + video_ids: string[]; + }; /** Body_unstar_images_in_list */ Body_unstar_images_in_list: { /** @@ -2811,6 +2990,14 @@ export type components = { */ image_names: string[]; }; + /** Body_unstar_videos_in_list */ + Body_unstar_videos_in_list: { + /** + * Video Ids + * @description The list of ids of videos to unstar + */ + video_ids: string[]; + }; /** Body_update_model_image */ Body_update_model_image: { /** @@ -6066,6 +6253,19 @@ export type components = { */ deleted_images: string[]; }; + /** DeleteVideosResult */ + DeleteVideosResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Deleted Videos + * @description The ids of the videos that were deleted + */ + deleted_videos: string[]; + }; /** * Denoise - SD1.5, SDXL * @description Denoises noisy latents to decodable images @@ -17365,6 +17565,29 @@ export type components = { */ items: components["schemas"]["ImageDTO"][]; }; + /** OffsetPaginatedResults[VideoDTO] */ + OffsetPaginatedResults_VideoDTO_: { + /** + * Limit + * @description Limit of items to get + */ + limit: number; + /** + * Offset + * @description Offset from which to retrieve items + */ + offset: number; + /** + * Total + * @description Total number of items in result + */ + total: number; + /** + * Items + * @description Items + */ + items: components["schemas"]["VideoDTO"][]; + }; /** * OutputFieldJSONSchemaExtra * @description Extra attributes to be added to input fields and their OpenAPI schema. Used by the workflow editor @@ -19996,6 +20219,19 @@ export type components = { */ starred_images: string[]; }; + /** StarredVideosResult */ + StarredVideosResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Starred Videos + * @description The ids of the videos that were starred + */ + starred_videos: string[]; + }; /** StarterModel */ StarterModel: { /** Description */ @@ -21621,6 +21857,19 @@ export type components = { */ unstarred_images: string[]; }; + /** UnstarredVideosResult */ + UnstarredVideosResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Unstarred Videos + * @description The ids of the videos that were unstarred + */ + unstarred_videos: string[]; + }; /** Upscaler */ Upscaler: { /** @@ -21893,85 +22142,211 @@ export type components = { output_fields: components["schemas"]["FieldIdentifier"][]; }; /** - * VideoOutput - * @description Base class for nodes that output a video + * VideoDTO + * @description Deserialized video record, enriched for the frontend. */ - VideoOutput: { + VideoDTO: { /** * Video Id - * @description The output video id + * @description The unique id of the video. */ video_id: string; + /** + * Video Url + * @description The URL of the video. + */ + video_url: string; + /** + * Thumbnail Url + * @description The URL of the video's thumbnail. + */ + thumbnail_url: string; /** * Width - * @description The width of the video in pixels + * @description The width of the video in px. */ width: number; /** * Height - * @description The height of the video in pixels + * @description The height of the video in px. */ height: number; /** - * Duration Seconds - * @description The duration of the video in seconds + * Created At + * @description The created timestamp of the video. */ - duration_seconds: number; + created_at: string; /** - * type - * @default video_output - * @constant + * Updated At + * @description The updated timestamp of the video. */ - type: "video_output"; - }; - /** Workflow */ - Workflow: { + updated_at: string; /** - * Name - * @description The name of the workflow. + * Deleted At + * @description The deleted timestamp of the video. */ - name: string; + deleted_at?: string | null; /** - * Author - * @description The author of the workflow. + * Is Intermediate + * @description Whether this is an intermediate video. */ - author: string; + is_intermediate: boolean; /** - * Description - * @description The description of the workflow. + * Session Id + * @description The session ID that generated this video, if it is a generated video. */ - description: string; + session_id?: string | null; /** - * Version - * @description The version of the workflow. + * Node Id + * @description The node ID that generated this video, if it is a generated video. */ - version: string; + node_id?: string | null; /** - * Contact - * @description The contact of the workflow. + * Starred + * @description Whether this video is starred. */ - contact: string; + starred: boolean; /** - * Tags - * @description The tags of the workflow. + * Board Id + * @description The id of the board the image belongs to, if one exists. */ - tags: string; + board_id?: string | null; + }; + /** + * VideoField + * @description A video primitive field + */ + VideoField: { /** - * Notes - * @description The notes of the workflow. + * Video Id + * @description The id of the video */ - notes: string; + video_id: string; /** - * Exposedfields - * @description The exposed fields of the workflow. + * Width + * @description The width of the video in pixels */ - exposedFields: components["schemas"]["ExposedField"][]; - /** @description The meta of the workflow. */ - meta: components["schemas"]["WorkflowMeta"]; + width: number; /** - * Nodes - * @description The nodes of the workflow. - */ + * Height + * @description The height of the video in pixels + */ + height: number; + /** + * Duration Seconds + * @description The duration of the video in seconds + */ + duration_seconds: number; + }; + /** + * VideoIdsResult + * @description Response containing ordered video ids with metadata for optimistic updates. + */ + VideoIdsResult: { + /** + * Video Ids + * @description Ordered list of video ids + */ + video_ids: string[]; + /** + * Starred Count + * @description Number of starred videos (when starred_first=True) + */ + starred_count: number; + /** + * Total Count + * @description Total number of videos matching the query + */ + total_count: number; + }; + /** + * VideoOutput + * @description Base class for nodes that output a video + */ + VideoOutput: { + /** @description The output video */ + video: components["schemas"]["VideoField"]; + /** + * type + * @default video_output + * @constant + */ + type: "video_output"; + }; + /** + * VideoRecordChanges + * @description A set of changes to apply to a video record. + * + * Only limited changes are valid: + * - `session_id`: change the session associated with a video + * - `is_intermediate`: change the video's `is_intermediate` flag + * - `starred`: change whether the video is starred + */ + VideoRecordChanges: { + /** + * Session Id + * @description The video's new session ID. + */ + session_id?: string | null; + /** + * Is Intermediate + * @description The video's new `is_intermediate` flag. + */ + is_intermediate?: boolean | null; + /** + * Starred + * @description The video's new `starred` state + */ + starred?: boolean | null; + }; + /** Workflow */ + Workflow: { + /** + * Name + * @description The name of the workflow. + */ + name: string; + /** + * Author + * @description The author of the workflow. + */ + author: string; + /** + * Description + * @description The description of the workflow. + */ + description: string; + /** + * Version + * @description The version of the workflow. + */ + version: string; + /** + * Contact + * @description The contact of the workflow. + */ + contact: string; + /** + * Tags + * @description The tags of the workflow. + */ + tags: string; + /** + * Notes + * @description The notes of the workflow. + */ + notes: string; + /** + * Exposedfields + * @description The exposed fields of the workflow. + */ + exposedFields: components["schemas"]["ExposedField"][]; + /** @description The meta of the workflow. */ + meta: components["schemas"]["WorkflowMeta"]; + /** + * Nodes + * @description The nodes of the workflow. + */ nodes: { [key: string]: components["schemas"]["JsonValue"]; }[]; @@ -24121,6 +24496,310 @@ export interface operations { }; }; }; + get_video_dto: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the video to get */ + video_id: string; + }; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VideoDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + update_video: { + parameters: { + query?: never; + header?: never; + path: { + /** @description The id of the video to update */ + video_id: string; + }; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["VideoRecordChanges"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VideoDTO"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_videos_from_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_delete_videos_from_list"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeleteVideosResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + star_videos_in_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_star_videos_in_list"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["StarredVideosResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + unstar_videos_in_list: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_unstar_videos_in_list"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["UnstarredVideosResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + delete_uncategorized_videos: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["DeleteVideosResult"]; + }; + }; + }; + }; + list_video_dtos: { + parameters: { + query?: { + /** @description Whether to list intermediate videos. */ + is_intermediate?: boolean | null; + /** @description The board id to filter by. Use 'none' to find videos without a board. */ + board_id?: string | null; + /** @description The page offset */ + offset?: number; + /** @description The number of videos per page */ + limit?: number; + /** @description The order of sort */ + order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description Whether to sort by starred videos first */ + starred_first?: boolean; + /** @description The term to search for */ + search_term?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["OffsetPaginatedResults_VideoDTO_"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_video_ids: { + parameters: { + query?: { + /** @description Whether to list intermediate videos. */ + is_intermediate?: boolean | null; + /** @description The board id to filter by. Use 'none' to find videos without a board. */ + board_id?: string | null; + /** @description The order of sort */ + order_dir?: components["schemas"]["SQLiteDirection"]; + /** @description Whether to sort by starred videos first */ + starred_first?: boolean; + /** @description The term to search for */ + search_term?: string | null; + }; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VideoIdsResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + get_videos_by_ids: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_get_videos_by_ids"]; + }; + }; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["VideoDTO"][]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; list_boards: { parameters: { query?: { From dfa3cbc31ae6588f7433d6b3519b90a3d1dd4c80 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 14 Aug 2025 16:17:00 -0400 Subject: [PATCH 10/55] integrating video into gallery - thinking maybe a new category of image would make more senes --- invokeai/frontend/web/public/locales/en.json | 4 +- .../features/gallery/components/Gallery.tsx | 15 +- .../components/ImageGrid/GalleryVideo.tsx | 272 +++++++++ .../gallery/components/NewGallery.tsx | 8 +- .../gallery/components/VideoGallery.tsx | 578 ++++++++++++++++++ .../components/use-gallery-video-ids.ts | 21 + .../hooks/useRangeBasedVideoFetching.ts | 78 +++ .../gallery/store/gallerySelectors.ts | 31 +- .../web/src/features/gallery/store/types.ts | 2 +- .../features/ui/layouts/VideoPlayerPanel.tsx | 28 +- .../web/src/services/api/endpoints/videos.ts | 88 +++ .../frontend/web/src/services/api/index.ts | 2 + .../frontend/web/src/services/api/types.ts | 25 + .../services/events/onInvocationComplete.tsx | 2 +- 14 files changed, 1138 insertions(+), 16 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts create mode 100644 invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts create mode 100644 invokeai/frontend/web/src/services/api/endpoints/videos.ts diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 2f87ee3726f..4dae21ffa52 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -411,7 +411,9 @@ "openViewer": "Open Viewer", "closeViewer": "Close Viewer", "move": "Move", - "useForPromptGeneration": "Use for Prompt Generation" + "useForPromptGeneration": "Use for Prompt Generation", + "videos": "Videos", + "videosTab": "Videos you've created and saved within Invoke." }, "hotkeys": { "hotkeys": "Hotkeys", diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 9c099cf5e2a..5033e32c330 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -18,6 +18,7 @@ import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettings import { GalleryUploadButton } from './GalleryUploadButton'; import { GallerySearch } from './ImageGrid/GallerySearch'; import { NewGallery } from './NewGallery'; +import { VideoGallery } from './VideoGallery'; const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '100%' }; @@ -42,6 +43,10 @@ export const GalleryPanel = memo(() => { dispatch(galleryViewChanged('assets')); }, [dispatch]); + const handleClickVideos = useCallback(() => { + dispatch(galleryViewChanged('videos')); + }, [dispatch]); + const handleClickSearch = useCallback(() => { onResetSearchTerm(); if (!searchDisclosure.isOpen && galleryPanel.$isCollapsed.get()) { @@ -83,6 +88,14 @@ export const GalleryPanel = memo(() => { > {t('gallery.assets')} + @@ -109,7 +122,7 @@ export const GalleryPanel = memo(() => { - + {galleryView === 'images' ? : galleryView === 'videos' ? : } ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx new file mode 100644 index 00000000000..7a2c899e0e5 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -0,0 +1,272 @@ +import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; +import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import { Flex, Icon, Image } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import type { AppDispatch, AppGetState } from 'app/store/store'; +import { useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { uniq } from 'es-toolkit'; +import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; +import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage'; +import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage'; +import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; +import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; +import { firefoxDndFix } from 'features/dnd/util'; +import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; +import { + selectGetImageNamesQueryArgs, + selectGetVideoIdsQueryArgs, + selectSelectedBoardId, + selectSelection, +} from 'features/gallery/store/gallerySelectors'; +import { imageToCompareChanged, selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { navigationApi } from 'features/ui/layouts/navigation-api'; +import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; +import type { MouseEvent, MouseEventHandler } from 'react'; +import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import { PiImageBold } from 'react-icons/pi'; +import { imagesApi } from 'services/api/endpoints/images'; +import { videosApi } from 'services/api/endpoints/videos'; +import type { ImageDTO, VideoDTO } from 'services/api/types'; + +const galleryImageContainerSX = { + containerType: 'inline-size', + w: 'full', + h: 'full', + '.gallery-image-size-badge': { + '@container (max-width: 80px)': { + '&': { display: 'none' }, + }, + }, + '&[data-is-dragging=true]': { + opacity: 0.3, + }, + userSelect: 'none', + webkitUserSelect: 'none', + position: 'relative', + justifyContent: 'center', + alignItems: 'center', + aspectRatio: '1/1', + '::before': { + content: '""', + display: 'inline-block', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + pointerEvents: 'none', + borderRadius: 'base', + }, + '&[data-selected=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + }, + '&[data-selected-for-compare=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, + '&:hover::before': { + boxShadow: + 'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)', + }, + '&:hover[data-selected=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + }, + '&:hover[data-selected-for-compare=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, +} satisfies SystemStyleObject; + +interface Props { + videoDTO: VideoDTO; +} + +const buildOnClick = + (videoId: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => { + const { shiftKey, ctrlKey, metaKey, altKey } = e; + const state = getState(); + const queryArgs = selectGetVideoIdsQueryArgs(state); + const videoIds = videosApi.endpoints.getVideoIds.select(queryArgs)(state).data?.video_ids ?? []; + + // If we don't have the image names cached, we can't perform selection operations + // This can happen if the user clicks on an image before the names are loaded + if (videoIds.length === 0) { + // For basic click without modifiers, we can still set selection + if (!shiftKey && !ctrlKey && !metaKey && !altKey) { + dispatch(selectionChanged([videoId])); + } + return; + } + + const selection = state.gallery.selection; + + if (shiftKey) { + const rangeEndVideoId = videoId; + const lastSelectedVideoId = selection.at(-1); + const lastClickedIndex = videoIds.findIndex((id) => id === lastSelectedVideoId); + const currentClickedIndex = videoIds.findIndex((id) => id === rangeEndVideoId); + if (lastClickedIndex > -1 && currentClickedIndex > -1) { + // We have a valid range! + const start = Math.min(lastClickedIndex, currentClickedIndex); + const end = Math.max(lastClickedIndex, currentClickedIndex); + const videosToSelect = videoIds.slice(start, end + 1); + dispatch(selectionChanged(uniq(selection.concat(videosToSelect)))); + } + } else if (ctrlKey || metaKey) { + if (selection.some((n) => n === videoId) && selection.length > 1) { + dispatch(selectionChanged(uniq(selection.filter((n) => n !== videoId)))); + } else { + dispatch(selectionChanged(uniq(selection.concat(videoId)))); + } + } else { + dispatch(selectionChanged([videoId])); + } + }; + +export const GalleryVideo = memo(({ videoDTO }: Props) => { + const store = useAppStore(); + const [isDragging, setIsDragging] = useState(false); + const [dragPreviewState, setDragPreviewState] = useState< + DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null + >(null); + const ref = useRef(null); + + const selectIsSelected = useMemo( + () => createSelector(selectGallerySlice, (gallery) => gallery.selection.includes(videoDTO.video_id)), + [videoDTO.video_id] + ); + const isSelected = useAppSelector(selectIsSelected); + + useEffect(() => { + const element = ref.current; + if (!element) { + return; + } + return combine( + firefoxDndFix(element), + draggable({ + element, + // getInitialData: () => { + // const selection = selectSelection(store.getState()); + // const boardId = selectSelectedBoardId(store.getState()); + // // When we have multiple images selected, and the dragged image is part of the selection, initiate a + // // multi-image drag. + // if (selection.length > 1 && selection.includes(videoDTO.video_id)) { + // return multipleImageDndSource.getData({ + // image_names: selection, + // board_id: boardId, + // }); + // } + + // // Otherwise, initiate a single-image drag + // return singleImageDndSource.getData({ videoDTO }, videoDTO.video_id); + // }, + // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. + onDragStart: ({ source }) => { + // When we start dragging a single image, set the dragging state to true. This is only called when this + // specific image is dragged. + if (singleImageDndSource.typeGuard(source.data)) { + setIsDragging(true); + return; + } + }, + onGenerateDragPreview: (args) => { + if (multipleImageDndSource.typeGuard(args.source.data)) { + setMultipleImageDragPreview({ + multipleImageDndData: args.source.data, + onGenerateDragPreviewArgs: args, + setDragPreviewState, + }); + } else if (singleImageDndSource.typeGuard(args.source.data)) { + setSingleImageDragPreview({ + singleImageDndData: args.source.data, + onGenerateDragPreviewArgs: args, + setDragPreviewState, + }); + } + }, + }), + // monitorForElements({ + // // This is a "global" drag start event, meaning that it is called for all drag events. + // onDragStart: ({ source }) => { + // // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the + // // selection. This is called for all drag events. + // if ( + // multipleImageDndSource.typeGuard(source.data) && + // source.data.payload.video_ids.includes(videoDTO.video_id) + // ) { + // setIsDragging(true); + // } + // }, + // onDrop: () => { + // // Always set the dragging state to false when a drop event occurs. + // setIsDragging(false); + // }, + // }) + ); + }, [videoDTO, store]); + + const [isHovered, setIsHovered] = useState(false); + + const onMouseOver = useCallback(() => { + setIsHovered(true); + }, []); + + const onMouseOut = useCallback(() => { + setIsHovered(false); + }, []); + + const onClick = useMemo(() => buildOnClick(videoDTO.video_id, store.dispatch, store.getState), [videoDTO, store]); + + const onDoubleClick = useCallback>(() => { + store.dispatch(imageToCompareChanged(null)); + navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID); + }, [store]); + + // useImageContextMenu(videoDTO, ref); + + return ( + <> + + } + objectFit="contain" + maxW="full" + maxH="full" + borderRadius="base" + /> + {/* */} + + {dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null} + {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} + + ); +}); + +GalleryVideo.displayName = 'GalleryVideo'; + +export const GalleryVideoPlaceholder = memo((props: FlexProps) => ( + + + +)); + +GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder'; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 79074a935fa..9f5aeddd632 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -7,6 +7,7 @@ import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBased import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, + selectGalleryView, selectImageToCompare, selectLastSelectedImage, selectSelection, @@ -32,6 +33,7 @@ import { useDebounce } from 'use-debounce'; import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; import { useGalleryImageNames } from './use-gallery-image-names'; +import { useGalleryVideoIds } from './use-gallery-video-ids'; const log = logger('gallery'); @@ -526,9 +528,11 @@ export const NewGallery = memo(() => { const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const rootRef = useRef(null); + const galleryView = useAppSelector(selectGalleryView); // Get the ordered list of image names - this is our primary data source for virtualization const { queryArgs, imageNames, isLoading } = useGalleryImageNames(); + const { queryArgs: videoQueryArgs, videoIds, isLoading: isLoadingVideos } = useGalleryVideoIds(); // Use range-based fetching for bulk loading image DTOs into cache based on the visible range const { onRangeChanged } = useRangeBasedImageFetching({ @@ -553,7 +557,7 @@ export const NewGallery = memo(() => { [onRangeChanged] ); - const context = useMemo(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]); + const context = useMemo(() => ({ imageNames, queryArgs, videoIds, videoQueryArgs }), [imageNames, queryArgs, videoIds, videoQueryArgs]); if (isLoading) { return ( @@ -578,7 +582,7 @@ export const NewGallery = memo(() => { ref={virtuosoRef} context={context} - data={imageNames} + data={galleryView === 'images' ? imageNames : videoIds} increaseViewportBy={4096} itemContent={itemContent} computeItemKey={computeItemKey} diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx new file mode 100644 index 00000000000..2cfbe3579d4 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -0,0 +1,578 @@ +import { Box, Flex, forwardRef, Grid, GridItem, Spinner, Text } from '@invoke-ai/ui-library'; +import { createSelector } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; +import { useAppSelector, useAppStore } from 'app/store/storeHooks'; +import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; +import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; +import type { selectGetImageNamesQueryArgs, selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { + selectGalleryImageMinimumWidth, + selectGalleryView, + selectImageToCompare, + selectLastSelectedImage, + selectSelectionCount, +} from 'features/gallery/store/gallerySelectors'; +import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import type { MutableRefObject, RefObject } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { + GridComponents, + GridComputeItemKey, + GridItemContent, + ListRange, + ScrollSeekConfiguration, + VirtuosoGridHandle, +} from 'react-virtuoso'; +import { VirtuosoGrid } from 'react-virtuoso'; +import { useDebounce } from 'use-debounce'; + +import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; +import { useGalleryImageNames } from './use-gallery-image-names'; +import { useGalleryVideoIds } from './use-gallery-video-ids'; +import { videosApi } from 'services/api/endpoints/videos'; +import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; +import { useRangeBasedVideoFetching } from '../hooks/useRangeBasedVideoFetching'; +import { GalleryVideo } from './ImageGrid/GalleryVideo'; + +const log = logger('gallery'); + +type ListVideoIdsQueryArgs = ReturnType; + +type GridContext = { + queryArgs: ListVideoIdsQueryArgs; + videoIds: string[]; +}; + +const VideoAtPosition = memo(({ videoId }: { index: number; videoId: string }) => { + /* + * We rely on the useRangeBasedImageFetching to fetch all image DTOs, caching them with RTK Query. + * + * In this component, we just want to consume that cache. Unforutnately, RTK Query does not provide a way to + * subscribe to a query without triggering a new fetch. + * + * There is a hack, though: + * - https://github.com/reduxjs/redux-toolkit/discussions/4213 + * + * This essentially means "subscribe to the query once it has some data". + */ + + // Use `currentData` instead of `data` to prevent a flash of previous image rendered at this index + const { currentData: videoDTO, isUninitialized } = videosApi.endpoints.getVideoDTO.useQueryState(videoId); + videosApi.endpoints.getVideoDTO.useQuerySubscription(videoId, { skip: isUninitialized }); + + if (!videoDTO) { + return ; + } + + return ; +}); +VideoAtPosition.displayName = 'VideoAtPosition'; + +const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { + return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; +}; + +/** + * Calculate how many images fit in a row based on the current grid layout. + * + * TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value + * changes. Cache this calculation. + */ +const getVideosPerRow = (rootEl: HTMLDivElement): number => { + // Start from root and find virtuoso grid elements + const gridElement = rootEl.querySelector('.virtuoso-grid-list'); + + if (!gridElement) { + return 0; + } + + const firstGridItem = gridElement.querySelector('.virtuoso-grid-item'); + + if (!firstGridItem) { + return 0; + } + + const itemRect = firstGridItem.getBoundingClientRect(); + const containerRect = gridElement.getBoundingClientRect(); + + // Get the computed gap from CSS + const gridStyle = window.getComputedStyle(gridElement); + const gapValue = gridStyle.gap; + const gap = parseFloat(gapValue); + + if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) { + return 0; + } + + /** + * You might be tempted to just do some simple math like: + * const imagesPerRow = Math.floor(containerRect.width / itemRect.width); + * + * But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases. + * + * Instead, we use a more robust approach that iteratively calculates how many images fit in the row. + */ + let videosPerRow = 0; + let spaceUsed = 0; + + // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes + // this, without the possibility of accidentally adding an extra column. + while (spaceUsed + itemRect.width <= containerRect.width + 1) { + videosPerRow++; // Increment the number of images + spaceUsed += itemRect.width; // Add image size to the used space + if (spaceUsed + gap <= containerRect.width) { + spaceUsed += gap; // Add gap size to the used space after each image except after the last image + } + } + + return Math.max(1, videosPerRow); +}; + +/** + * Scroll the item at the given index into view if it is not currently visible. + */ +const scrollIntoView = ( + targetVideoId: string, + videoIds: string[], + rootEl: HTMLDivElement, + virtuosoGridHandle: VirtuosoGridHandle, + range: ListRange +) => { + if (range.endIndex === 0) { + // No range is rendered; no need to scroll to anything. + return; + } + + const targetIndex = videoIds.findIndex((id) => id === targetVideoId); + + if (targetIndex === -1) { + // The image isn't in the currently rendered list. + return; + } + + const targetItem = rootEl.querySelector( + `.virtuoso-grid-item:has([data-video-id="${targetVideoId}"])` + ) as HTMLElement; + + if (!targetItem) { + if (targetIndex > range.endIndex) { + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'start', + }); + } else if (targetIndex < range.startIndex) { + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'end', + }); + } else { + log.debug( + `Unable to find video ${targetVideoId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` + ); + } + return; + } + + // We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport. + // Check if it is in the viewport and scroll if necessary. + + const itemRect = targetItem.getBoundingClientRect(); + const rootRect = rootEl.getBoundingClientRect(); + + if (itemRect.top < rootRect.top) { + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'start', + }); + } else if (itemRect.bottom > rootRect.bottom) { + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'end', + }); + } else { + // Image is already in view + } + + return; +}; + +/** + * Get the index of the image in the list of image names. + * If the image name is not found, return 0. + * If no image name is provided, return 0. + */ +const getVideoIndex = (videoId: string | undefined | null, videoIds: string[]) => { + if (!videoId || videoIds.length === 0) { + return 0; + } + const index = videoIds.findIndex((n) => n === videoId); + return index >= 0 ? index : 0; +}; + +/** + * Handles keyboard navigation for the gallery. + */ +const useKeyboardNavigation = ( + videoIds: string[], + virtuosoRef: React.RefObject, + rootRef: React.RefObject +) => { + const { dispatch, getState } = useAppStore(); + + const handleKeyDown = useCallback( + (event: KeyboardEvent) => { + if (getFocusedRegion() !== 'gallery') { + // Only handle keyboard navigation when the gallery is focused + return; + } + // Only handle arrow keys + if (!['ArrowUp', 'ArrowDown', 'ArrowLeft', 'ArrowRight'].includes(event.key)) { + return; + } + // Don't interfere if user is typing in an input + if (event.target instanceof HTMLInputElement || event.target instanceof HTMLTextAreaElement) { + return; + } + + const rootEl = rootRef.current; + const virtuosoGridHandle = virtuosoRef.current; + + if (!rootEl || !virtuosoGridHandle) { + return; + } + + if (videoIds.length === 0) { + return; + } + + const videosPerRow = getVideosPerRow(rootEl); + + if (videosPerRow === 0) { + // This can happen if the grid is not yet rendered or has no items + return; + } + + event.preventDefault(); + + const state = getState(); + const videoId = event.altKey + ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, + // we start from the last selected image + (selectImageToCompare(state) ?? selectLastSelectedImage(state)) + : selectLastSelectedImage(state); + + const currentIndex = getVideoIndex(videoId, videoIds); + + let newIndex = currentIndex; + + switch (event.key) { + case 'ArrowLeft': + if (currentIndex > 0) { + newIndex = currentIndex - 1; + // } else { + // // Wrap to last image + // newIndex = imageNames.length - 1; + } + break; + case 'ArrowRight': + if (currentIndex < videoIds.length - 1) { + newIndex = currentIndex + 1; + // } else { + // // Wrap to first image + // newIndex = 0; + } + break; + case 'ArrowUp': + // If on first row, stay on current image + if (currentIndex < videosPerRow) { + newIndex = currentIndex; + } else { + newIndex = Math.max(0, currentIndex - videosPerRow); + } + break; + case 'ArrowDown': + // If no images below, stay on current image + if (currentIndex >= videoIds.length - videosPerRow) { + newIndex = currentIndex; + } else { + newIndex = Math.min(videoIds.length - 1, currentIndex + videosPerRow); + } + break; + } + + if (newIndex !== currentIndex && newIndex >= 0 && newIndex < videoIds.length) { + const newVideoId = videoIds[newIndex]; + if (newVideoId) { + + dispatch(selectionChanged([newVideoId])); + + } + } + }, + [rootRef, virtuosoRef, videoIds, getState, dispatch] + ); + + useRegisteredHotkeys({ + id: 'galleryNavLeft', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavRight', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavUp', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavDown', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavLeftAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavRightAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavUpAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); + + useRegisteredHotkeys({ + id: 'galleryNavDownAlt', + category: 'gallery', + callback: handleKeyDown, + options: { preventDefault: true }, + dependencies: [handleKeyDown], + }); +}; + +/** + * Keeps the last selected image in view when the gallery is scrolled. + * This is useful for keyboard navigation and ensuring the user can see their selection. + * It only tracks the last selected image, not the image to compare. + */ +const useKeepSelectedVideoInView = ( + videoIds: string[], + virtuosoRef: React.RefObject, + rootRef: React.RefObject, + rangeRef: MutableRefObject +) => { + const targetVideoId = useAppSelector(selectLastSelectedImage); + + useEffect(() => { + const virtuosoGridHandle = virtuosoRef.current; + const rootEl = rootRef.current; + const range = rangeRef.current; + + if (!virtuosoGridHandle || !rootEl || !targetVideoId || !videoIds || videoIds.length === 0) { + return; + } + scrollIntoView(targetVideoId, videoIds, rootEl, virtuosoGridHandle, range); + }, [targetVideoId, videoIds, rangeRef, rootRef, virtuosoRef]); +}; + +/** + * Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element. + */ +const useScrollableGallery = (rootRef: RefObject) => { + const [scroller, scrollerRef] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + events: { + initialized(osInstance) { + // force overflow styles + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + viewport.style.overflowY = `var(--os-viewport-overflow-y)`; + }, + }, + options: { + scrollbars: { + visibility: 'auto', + autoHide: 'scroll', + autoHideDelay: 1300, + theme: 'os-theme-dark', + }, + }, + }); + + useEffect(() => { + const { current: root } = rootRef; + + if (scroller && root) { + initialize({ + target: root, + elements: { + viewport: scroller, + }, + }); + } + + return () => { + osInstance()?.destroy(); + }; + }, [scroller, initialize, osInstance, rootRef]); + + return scrollerRef; +}; + + + +export const VideoGallery = memo(() => { + const virtuosoRef = useRef(null); + const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); + const rootRef = useRef(null); + const galleryView = useAppSelector(selectGalleryView); + + // Get the ordered list of image names - this is our primary data source for virtualization + const { queryArgs, videoIds, isLoading } = useGalleryVideoIds(); + + // Use range-based fetching for bulk loading image DTOs into cache based on the visible range + const { onRangeChanged } = useRangeBasedVideoFetching({ + videoIds, + enabled: !isLoading, + }); + + useKeepSelectedVideoInView(videoIds, virtuosoRef, rootRef, rangeRef); + useKeyboardNavigation(videoIds, virtuosoRef, rootRef); + const scrollerRef = useScrollableGallery(rootRef); + + /* + * We have to keep track of the visible range for keep-selected-image-in-view functionality and push the range to + * the range-based image fetching hook. + */ + const handleRangeChanged = useCallback( + (range: ListRange) => { + rangeRef.current = range; + onRangeChanged(range); + }, + [onRangeChanged] + ); + + const context = useMemo(() => ({ videoIds, queryArgs }), [videoIds, queryArgs]); + + if (isLoading) { + return ( + + + Loading gallery... + + ); + } + + if (videoIds.length === 0) { + return ( + + No videos found + + ); + } + + return ( + // This wrapper component is necessary to initialize the overlay scrollbars! + + + ref={virtuosoRef} + context={context} + data={videoIds} + increaseViewportBy={4096} + itemContent={itemContent} + computeItemKey={computeItemKey} + components={components} + style={style} + scrollerRef={scrollerRef} + scrollSeekConfiguration={scrollSeekConfiguration} + rangeChanged={handleRangeChanged} + /> + + + ); +}); + +VideoGallery.displayName = 'VideoGallery'; + +const scrollSeekConfiguration: ScrollSeekConfiguration = { + enter: (velocity) => { + return Math.abs(velocity) > 2048; + }, + exit: (velocity) => { + return velocity === 0; + }, +}; + +// Styles +const style = { height: '100%', width: '100%' }; + +const selectGridTemplateColumns = createSelector( + selectGalleryImageMinimumWidth, + (galleryImageMinimumWidth) => `repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))` +); + +// Grid components +const ListComponent: GridComponents['List'] = forwardRef(({ context: _, ...rest }, ref) => { + const _gridTemplateColumns = useAppSelector(selectGridTemplateColumns); + const [gridTemplateColumns] = useDebounce(_gridTemplateColumns, 300); + + return ; +}); +ListComponent.displayName = 'ListComponent'; + +const itemContent: GridItemContent = (index, videoId) => { + return ; +}; + +const ItemComponent: GridComponents['Item'] = forwardRef(({ context: _, ...rest }, ref) => ( + +)); +ItemComponent.displayName = 'ItemComponent'; + +const ScrollSeekPlaceholderComponent: GridComponents['ScrollSeekPlaceholder'] = (props) => ( + + + +); + +ScrollSeekPlaceholderComponent.displayName = 'ScrollSeekPlaceholderComponent'; + +const components: GridComponents = { + Item: ItemComponent, + List: ListComponent, + ScrollSeekPlaceholder: ScrollSeekPlaceholderComponent, +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts new file mode 100644 index 00000000000..8b224e2b53d --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts @@ -0,0 +1,21 @@ +import { EMPTY_ARRAY } from 'app/store/constants'; +import { useAppSelector } from 'app/store/storeHooks'; +import { selectGetImageNamesQueryArgs, selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { useGetVideoIdsQuery } from 'services/api/endpoints/videos'; +import { useDebounce } from 'use-debounce'; + +const getVideoIdsQueryOptions = { + refetchOnReconnect: true, + selectFromResult: ({ currentData, isLoading, isFetching }) => ({ + videoIds: currentData?.video_ids ?? EMPTY_ARRAY, + isLoading, + isFetching, + }), +} satisfies Parameters[1]; + +export const useGalleryVideoIds = () => { + const _queryArgs = useAppSelector(selectGetVideoIdsQueryArgs); + const [queryArgs] = useDebounce(_queryArgs, 300); + const { videoIds, isLoading, isFetching } = useGetVideoIdsQuery(queryArgs, getVideoIdsQueryOptions); + return { videoIds, isLoading, isFetching, queryArgs }; +}; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts new file mode 100644 index 00000000000..7caf341b47c --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts @@ -0,0 +1,78 @@ +import { useAppStore } from 'app/store/storeHooks'; +import { useCallback, useEffect, useState } from 'react'; +import type { ListRange } from 'react-virtuoso'; +import { videosApi, useGetVideoDTOsByNamesMutation } from 'services/api/endpoints/videos'; +import { useThrottledCallback } from 'use-debounce'; + +interface UseRangeBasedVideoFetchingArgs { + videoIds: string[]; + enabled: boolean; +} + +interface UseRangeBasedVideoFetchingReturn { + onRangeChanged: (range: ListRange) => void; +} + +const getUncachedIds = (videoIds: string[], cachedVideoIds: string[], ranges: ListRange[]): string[] => { + const uncachedIdsSet = new Set(); + const cachedVideoIdsSet = new Set(cachedVideoIds); + + for (const range of ranges) { + for (let i = range.startIndex; i <= range.endIndex; i++) { + const id = videoIds[i]!; + if (id && !cachedVideoIdsSet.has(id)) { + uncachedIdsSet.add(id); + } + } + } + + return Array.from(uncachedIdsSet); +}; + +/** + * Hook for bulk fetching image DTOs based on the visible range from virtuoso. + * Individual image components should use `useGetImageDTOQuery(imageName)` to get their specific DTO. + * This hook ensures DTOs are bulk fetched and cached efficiently. + */ +export const useRangeBasedVideoFetching = ({ + videoIds, + enabled, +}: UseRangeBasedVideoFetchingArgs): UseRangeBasedVideoFetchingReturn => { + const store = useAppStore(); + const [getVideoDTOsByNames] = useGetVideoDTOsByNamesMutation(); + const [lastRange, setLastRange] = useState(null); + const [pendingRanges, setPendingRanges] = useState([]); + + const fetchVideos = useCallback( + (ranges: ListRange[], videoIds: string[]) => { + if (!enabled) { + return; + } + const cachedVideoIds = videosApi.util.selectCachedArgsForQuery(store.getState(), 'getVideoDTO'); + const uncachedIds = getUncachedIds(videoIds, cachedVideoIds, ranges); + console.log('uncachedIds', uncachedIds); + if (uncachedIds.length === 0) { + return; + } + getVideoDTOsByNames({ video_ids: uncachedIds }); + setPendingRanges([]); + }, + [enabled, getVideoDTOsByNames, store] + ); + + const throttledFetchVideos = useThrottledCallback(fetchVideos, 500); + + const onRangeChanged = useCallback((range: ListRange) => { + setLastRange(range); + setPendingRanges((prev) => [...prev, range]); + }, []); + + useEffect(() => { + const combinedRanges = lastRange ? [...pendingRanges, lastRange] : pendingRanges; + throttledFetchVideos(combinedRanges, videoIds); + }, [videoIds, lastRange, pendingRanges, throttledFetchVideos]); + + return { + onRangeChanged, + }; +}; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index 490305f8afe..cca1ee4938f 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -2,7 +2,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { createMemoizedSelector } from 'app/store/createMemoizedSelector'; import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; -import type { GetImageNamesArgs, ListBoardsArgs } from 'services/api/types'; +import type { GetImageNamesArgs, GetVideoIdsArgs, ListBoardsArgs } from 'services/api/types'; export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); @@ -20,9 +20,15 @@ export const selectAutoAddBoardId = createSelector(selectGallerySlice, (gallery) export const selectAutoSwitch = createSelector(selectGallerySlice, (gallery) => gallery.shouldAutoSwitch); export const selectSelectedBoardId = createSelector(selectGallerySlice, (gallery) => gallery.selectedBoardId); export const selectGalleryView = createSelector(selectGallerySlice, (gallery) => gallery.galleryView); -const selectGalleryQueryCategories = createSelector(selectGalleryView, (galleryView) => - galleryView === 'images' ? IMAGE_CATEGORIES : ASSETS_CATEGORIES -); +const selectGalleryQueryCategories = createSelector(selectGalleryView, (galleryView) => { + if (galleryView === 'images') { + return IMAGE_CATEGORIES; + } + if (galleryView === 'videos') { + return []; + } + return ASSETS_CATEGORIES; +}); const selectGallerySearchTerm = createSelector(selectGallerySlice, (gallery) => gallery.searchTerm); const selectGalleryOrderDir = createSelector(selectGallerySlice, (gallery) => gallery.orderDir); const selectGalleryStarredFirst = createSelector(selectGallerySlice, (gallery) => gallery.starredFirst); @@ -44,6 +50,23 @@ export const selectGetImageNamesQueryArgs = createMemoizedSelector( is_intermediate: false, }) ); + +export const selectGetVideoIdsQueryArgs = createMemoizedSelector( + [ + selectSelectedBoardId, + selectGallerySearchTerm, + selectGalleryOrderDir, + selectGalleryStarredFirst, + ], + (board_id, search_term, order_dir, starred_first): GetVideoIdsArgs => ({ + board_id, + search_term, + order_dir, + starred_first, + is_intermediate: false, + }) +); + export const selectAutoAssignBoardOnClick = createSelector( selectGallerySlice, (gallery) => gallery.autoAssignBoardOnClick diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index addeefe870f..419306d1ecb 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -1,7 +1,7 @@ import type { ImageCategory } from 'services/api/types'; import z from 'zod'; -const zGalleryView = z.enum(['images', 'assets']); +const zGalleryView = z.enum(['images', 'assets', 'videos']); export type GalleryView = z.infer; const zBoardId = z.string(); // TS hack to get autocomplete for "none" but accept any string diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx index 4c5b59c46fd..bba10915f6b 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx @@ -1,38 +1,54 @@ import { Box, Flex, Text } from '@invoke-ai/ui-library'; import { useFocusRegion } from 'common/hooks/focus'; -import { memo, useRef } from 'react'; +import { memo, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import ReactPlayer from 'react-player'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { selectGeneratedVideo } from 'features/parameters/store/videoSlice'; +import { useGetVideoDTOQuery } from 'services/api/endpoints/videos'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useImageDTO } from 'services/api/endpoints/images'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; export const VideoPlayerPanel = memo(() => { const { t } = useTranslation(); const ref = useRef(null); const generatedVideo = useAppSelector(selectGeneratedVideo); + const lastSelectedVideoId = useAppSelector(selectLastSelectedImage); + const {data: videoDTO} = useGetVideoDTOQuery(lastSelectedVideoId ?? skipToken); useFocusRegion('video', ref); + const videoUrl = useMemo(() => { + // if (generatedVideo) { + // return generatedVideo.video_url; + // } + if (!videoDTO) { + return null; + } + return videoDTO.video_url; + }, [videoDTO]); + return ( - {generatedVideo && + {videoUrl && <> - {/* */} + /> } - {!generatedVideo && No video generated} + {!videoUrl && No video generated} ); diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts new file mode 100644 index 00000000000..1de126457d4 --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -0,0 +1,88 @@ +import type { paths } from 'services/api/schema'; +import type { + GetVideoIdsArgs, + GetVideoIdsResult, + VideoDTO, +} from 'services/api/types'; +import stableHash from 'stable-hash'; +import type { Param0 } from 'tsafe'; + +import { api, buildV1Url, LIST_TAG } from '..'; + +/** + * Builds an endpoint URL for the videos router + * @example + * buildVideosUrl('some-path') + * // '/api/v1/videos/some-path' + */ +const buildVideosUrl = (path: string = '', query?: Parameters[1]) => + buildV1Url(`videos/${path}`, query); + +export const videosApi = api.injectEndpoints({ + endpoints: (build) => ({ + /** + * Video Queries + */ + + getVideoDTO: build.query({ + query: (video_id) => ({ url: buildVideosUrl(`i/${video_id}`) }), + providesTags: (result, error, video_id) => [{ type: 'Video', id: video_id }], + }), + + + /** + * Get ordered list of image names for selection operations + */ + getVideoIds: build.query({ + query: (queryArgs) => ({ + url: buildVideosUrl('ids', queryArgs), + method: 'GET', + }), + providesTags: (result, error, queryArgs) => [ + 'VideoIdList', + 'FetchOnReconnect', + { type: 'VideoIdList', id: stableHash(queryArgs) }, + ], + }), + /** + * Get image DTOs for the specified image names. Maintains order of input names. + */ + getVideoDTOsByNames: build.mutation< + paths['/api/v1/videos/videos_by_ids']['post']['responses']['200']['content']['application/json'], + paths['/api/v1/videos/videos_by_ids']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => ({ + url: buildVideosUrl('videos_by_ids'), + method: 'POST', + body, + }), + // Don't provide cache tags - we'll manually upsert into individual getImageDTO caches + async onQueryStarted(_, { dispatch, queryFulfilled }) { + try { + const { data: videoDTOs } = await queryFulfilled; + + // Upsert each DTO into the individual image cache + const updates: Param0 = []; + for (const videoDTO of videoDTOs) { + updates.push({ + endpointName: 'getVideoDTO', + arg: videoDTO.video_id, + value: videoDTO, + }); + } + dispatch(videosApi.util.upsertQueryEntries(updates)); + } catch { + // Handle error if needed + } + }, + }), + }), +}); + +export const { + useGetVideoDTOQuery, + useGetVideoIdsQuery, + useGetVideoDTOsByNamesMutation, +} = videosApi; + + diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 82e7208dbbe..5029a40fbcb 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -54,6 +54,8 @@ const tagTypes = [ 'StylePreset', 'Schema', 'QueueCountsByDestination', + 'Video', + 'VideoIdList', // This is invalidated on reconnect. It should be used for queries that have changing data, // especially related to the queue and generation. 'FetchOnReconnect', diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 99d461199cd..6406a46a70c 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -14,6 +14,11 @@ export type GetImageNamesResult = paths['/api/v1/images/names']['get']['responses']['200']['content']['application/json']; export type GetImageNamesArgs = NonNullable; +export type GetVideoIdsResult = + paths['/api/v1/videos/ids']['get']['responses']['200']['content']['application/json']; +export type GetVideoIdsArgs = NonNullable; + + export type ListBoardsArgs = NonNullable; export type CreateBoardArg = paths['/api/v1/boards/']['post']['parameters']['query']; @@ -68,6 +73,26 @@ assert>(); export type BoardDTO = S['BoardDTO']; export type OffsetPaginatedResults_ImageDTO_ = S['OffsetPaginatedResults_ImageDTO_']; +// Videos +const _zVideoDTO = z.object({ + video_id: z.string(), + video_url: z.string(), + thumbnail_url: z.string(), + width: z.number().int().gt(0), + height: z.number().int().gt(0), + created_at: z.string(), + updated_at: z.string(), + deleted_at: z.string().nullish(), + starred: z.boolean(), + board_id: z.string().nullish(), + is_intermediate: z.boolean(), + session_id: z.string().nullish(), + node_id: z.string().nullish(), +}); +export type VideoDTO = z.infer; +assert>(); +export type OffsetPaginatedResults_VideoDTO_ = S['OffsetPaginatedResults_VideoDTO_']; + // Models export type ModelType = S['ModelType']; export type BaseModelType = S['BaseModelType']; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 429e871dfc3..733e143ed7f 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -237,7 +237,7 @@ export const buildOnInvocationComplete = ( const videoResult = await getResultVideoDTOs(data); if (videoResult) { - dispatch(generatedVideoChanged(videoResult)); + dispatch(generatedVideoChanged({ video_id: videoResult.video.video_id, type: 'video_output' })); } $lastProgressEvent.set(null); From 0ac61f76db5be6e43dac67b32a52658c380f13b9 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 15 Aug 2025 14:56:51 -0400 Subject: [PATCH 11/55] add duration and aspect ratio to video settings --- invokeai/frontend/web/public/locales/en.json | 1 + .../controlLayers/store/paramsSlice.ts | 8 +++ .../src/features/controlLayers/store/types.ts | 16 +++++ .../ImageMenuItemSendToVideo.tsx | 4 +- .../graph/generation/buildRunwayVideoGraph.ts | 66 ++++------------- .../DimensionsAspectRatioSelect.tsx | 10 ++- .../Dimensions/DimensionsHeight.tsx | 7 +- .../components/Dimensions/DimensionsWidth.tsx | 4 +- .../components/Video/ParamDuration.tsx | 42 +++++++++++ .../features/parameters/store/videoSlice.ts | 20 ++---- .../parameters/types/parameterSchemas.ts | 5 ++ ...tFrameImage.tsx => StartingFrameImage.tsx} | 24 +++---- .../VideoFirstFrameImage.tsx | 71 ------------------- .../VideoSettingsAccordion.tsx | 40 +++++++---- .../frontend/web/src/services/api/schema.ts | 2 +- 15 files changed, 147 insertions(+), 173 deletions(-) create mode 100644 invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx rename invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/{VideoLastFrameImage.tsx => StartingFrameImage.tsx} (71%) delete mode 100644 invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 4dae21ffa52..af101c94c07 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1187,6 +1187,7 @@ }, "parameters": { "aspect": "Aspect", + "duration": "Duration", "lockAspectRatio": "Lock Aspect Ratio", "swapDimensions": "Swap Dimensions", "setToOptimalSize": "Optimize size for model", diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 2a2eb3d8a01..cc124605bc7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -16,6 +16,8 @@ import { isChatGPT4oAspectRatioID, isFluxKontextAspectRatioID, isImagenAspectRatioID, + isRunwayAspectRatioID, + RUNWAY_ASPECT_RATIOS, zParamsState, } from 'features/controlLayers/store/types'; import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; @@ -28,6 +30,7 @@ import type { ParameterCLIPGEmbedModel, ParameterCLIPLEmbedModel, ParameterControlLoRAModel, + ParameterDuration, ParameterGuidance, ParameterModel, ParameterNegativePrompt, @@ -355,6 +358,9 @@ const slice = createSlice({ state.dimensions.rect.height = bboxDims.height; } }, + setVideoDuration: (state, action: PayloadAction) => { + state.videoDuration = action.payload; + }, paramsReset: (state) => resetState(state), }, }); @@ -456,6 +462,7 @@ export const { syncedToOptimalDimension, paramsReset, + setVideoDuration, } = slice.actions; export const paramsSliceConfig: SliceConfig = { @@ -556,6 +563,7 @@ export const selectHeight = createParamsSelector((params) => params.dimensions.r export const selectAspectRatioID = createParamsSelector((params) => params.dimensions.aspectRatio.id); export const selectAspectRatioValue = createParamsSelector((params) => params.dimensions.aspectRatio.value); export const selectAspectRatioIsLocked = createParamsSelector((params) => params.dimensions.aspectRatio.isLocked); +export const selectVideoDuration = createParamsSelector((params) => params.videoDuration); export const selectMainModelConfig = createSelector( selectModelConfigsQuery, diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index bcae4cb1277..5e5b1ad78c8 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -9,6 +9,7 @@ import { zParameterCLIPGEmbedModel, zParameterCLIPLEmbedModel, zParameterControlLoRAModel, + zParameterDuration, zParameterGuidance, zParameterImageDimension, zParameterMaskBlurMethod, @@ -461,6 +462,19 @@ export const FLUX_KONTEXT_ASPECT_RATIOS: Record; +export const isRunwayAspectRatioID = (v: unknown): v is RunwayAspectRatio => + zRunwayAspectRatioID.safeParse(v).success; +export const RUNWAY_ASPECT_RATIOS: Record = { + '16:9': { width: 1280, height: 720 }, + '4:3': { width: 1104, height: 832 }, + '1:1': { width: 960, height: 960 }, + '3:4': { width: 832, height: 1104 }, + '9:16': { width: 720, height: 1280 }, + '21:9': { width: 1584, height: 672 }, +}; + const zAspectRatioConfig = z.object({ id: zAspectRatioID, value: z.number().gt(0), @@ -545,6 +559,7 @@ export const zParamsState = z.object({ clipGEmbedModel: zParameterCLIPGEmbedModel.nullable(), controlLora: zParameterControlLoRAModel.nullable(), dimensions: zDimensionsState, + videoDuration: zParameterDuration, }); export type ParamsState = z.infer; export const getInitialParamsState = (): ParamsState => ({ @@ -595,6 +610,7 @@ export const getInitialParamsState = (): ParamsState => ({ rect: { x: 0, y: 0, width: 512, height: 512 }, aspectRatio: deepClone(DEFAULT_ASPECT_RATIO_CONFIG), }, + videoDuration: 5, }); const zInpaintMasks = z.object({ diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx index caf87cab472..0f00c93a0c4 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx @@ -1,6 +1,6 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; -import { selectVideoFirstFrameImage, videoFirstFrameImageChanged } from 'features/parameters/store/videoSlice'; +import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -13,7 +13,7 @@ export const ImageMenuItemSendToVideo = memo(() => { const dispatch = useDispatch(); const onClick = useCallback(() => { - dispatch(videoFirstFrameImageChanged(imageDTO)); + dispatch(startingFrameImageChanged(imageDTO)); navigationApi.switchToTab('video'); }, [imageDTO]); diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts index bb136887fed..7e68048e52f 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts @@ -2,7 +2,7 @@ import { logger } from 'app/logging/logger'; import type { RootState } from 'app/store/store'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; -import { selectVideoFirstFrameImage, selectVideoLastFrameImage } from 'features/parameters/store/videoSlice'; +import { selectStartingFrameImage } from 'features/parameters/store/videoSlice'; import { zImageField } from 'features/nodes/types/common'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; @@ -13,29 +13,11 @@ import { assert } from 'tsafe'; const log = logger('system'); -// Default video parameters - these could be moved to a video params slice in the future -const DEFAULT_VIDEO_DURATION = 5; -const DEFAULT_VIDEO_ASPECT_RATIO = "1280:768"; // Default landscape -const DEFAULT_ENHANCE_PROMPT = true; - -// Video parameter extraction helper -const getVideoParameters = (state: RootState) => { - // In the future, these could come from a dedicated video parameters slice - // For now, we use defaults but allow them to be overridden by any video-specific state - return { - duration: DEFAULT_VIDEO_DURATION, - aspectRatio: DEFAULT_VIDEO_ASPECT_RATIO, - enhancePrompt: DEFAULT_ENHANCE_PROMPT, - }; -}; - export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => { const { generationMode, state, manager } = arg; log.debug({ generationMode, manager: manager?.id }, 'Building Runway video graph'); - // Runway video generation supports text-to-video and image-to-video - // We can support multiple generation modes depending on whether frame images are provided const supportedModes = ['txt2img'] as const; if (!supportedModes.includes(generationMode as any)) { throw new UnsupportedGenerationModeError(t('toast.runwayIncompatibleGenerationMode')); @@ -43,17 +25,15 @@ export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn const params = selectParamsSlice(state); const prompts = selectPresetModifiedPrompts(state); - const videoFirstFrameImage = selectVideoFirstFrameImage(state); - const videoLastFrameImage = selectVideoLastFrameImage(state); - const videoParams = getVideoParameters(state); + const startingFrameImage = selectStartingFrameImage(state); + + assert(startingFrameImage, 'Video starting frame is required for runway video generation'); + const firstFrameImageField = zImageField.parse(startingFrameImage); // Get seed from params const { seed, shouldRandomizeSeed } = params; const finalSeed = shouldRandomizeSeed ? undefined : seed; - // Determine if this is image-to-video or text-to-video - const hasFrameImages = videoFirstFrameImage || videoLastFrameImage; - const g = new Graph(getPrefixedId('runway_video_graph')); const positivePrompt = g.addNode({ @@ -67,47 +47,27 @@ export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn id: getPrefixedId('runway_generate_video'), // @ts-expect-error: This node is not available in the OSS application type: 'runway_generate_video', - duration: videoParams.duration, - aspect_ratio: videoParams.aspectRatio, + duration: params.videoDuration, + aspect_ratio: params.dimensions.aspectRatio.id, seed: finalSeed, + first_frame_image: firstFrameImageField, }); // @ts-expect-error: This node is not available in the OSS application g.addEdge(positivePrompt, 'value', runwayVideoNode, 'prompt'); - - // Add first frame image if provided - if (videoFirstFrameImage) { - const firstFrameImageField = zImageField.parse(videoFirstFrameImage); - // @ts-expect-error: This connection is specific to runway node - runwayVideoNode.first_frame_image = firstFrameImageField; - } - - // Add last frame image if provided - if (videoLastFrameImage) { - const lastFrameImageField = zImageField.parse(videoLastFrameImage); - // @ts-expect-error: This connection is specific to runway node - runwayVideoNode.last_frame_image = lastFrameImageField; - } - // Set up metadata g.upsertMetadata({ positive_prompt: prompts.positive, negative_prompt: prompts.negative || '', - video_duration: videoParams.duration, - video_aspect_ratio: videoParams.aspectRatio, + video_duration: params.videoDuration, + video_aspect_ratio: params.dimensions.aspectRatio.id, seed: finalSeed, - enhance_prompt: videoParams.enhancePrompt, - generation_type: hasFrameImages ? 'image-to-video' : 'text-to-video', + generation_type: 'image-to-video', + first_frame_image: startingFrameImage, }); - // Add video frame images to metadata if they exist - if (hasFrameImages) { - g.upsertMetadata({ - first_frame_image: videoFirstFrameImage, - last_frame_image: videoLastFrameImage, - }, 'merge'); - } + g.setMetadataReceivingNode(runwayVideoNode); diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx index 35944314ab5..e44214e3f75 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx @@ -15,7 +15,9 @@ import { zChatGPT4oAspectRatioID, zFluxKontextAspectRatioID, zImagen3AspectRatioID, -} from 'features/controlLayers/store/types'; + zRunwayAspectRatioID, + } from 'features/controlLayers/store/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { ChangeEventHandler } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -29,6 +31,7 @@ export const DimensionsAspectRatioSelect = memo(() => { const isChatGPT4o = useAppSelector(selectIsChatGPT4o); const isImagen4 = useAppSelector(selectIsImagen4); const isFluxKontext = useAppSelector(selectIsFluxKontext); + const activeTab = useAppSelector(selectActiveTab); const options = useMemo(() => { // Imagen3 and ChatGPT4o have different aspect ratio options, and do not support freeform sizes if (isImagen3 || isImagen4) { @@ -40,9 +43,12 @@ export const DimensionsAspectRatioSelect = memo(() => { if (isFluxKontext) { return zFluxKontextAspectRatioID.options; } + if (activeTab === 'video') { + return zRunwayAspectRatioID.options; + } // All other models return zAspectRatioID.options; - }, [isImagen3, isChatGPT4o, isImagen4, isFluxKontext]); + }, [isImagen3, isChatGPT4o, isImagen4, isFluxKontext, activeTab]); const onChange = useCallback>( (e) => { diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx index ba58947b4d8..c7d752102e1 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx @@ -3,9 +3,11 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { heightChanged, selectHeight } from 'features/controlLayers/store/paramsSlice'; import { selectGridSize, selectOptimalDimension } from 'features/controlLayers/store/selectors'; +import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel'; import { selectHeightConfig } from 'features/system/store/configSlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; export const DimensionsHeight = memo(() => { const { t } = useTranslation(); @@ -14,7 +16,8 @@ export const DimensionsHeight = memo(() => { const height = useAppSelector(selectHeight); const config = useAppSelector(selectHeightConfig); const gridSize = useAppSelector(selectGridSize); - + const activeTab = useAppSelector(selectActiveTab); + const isApiModel = useIsApiModel(); const onChange = useCallback( (v: number) => { dispatch(heightChanged({ height: v })); @@ -28,7 +31,7 @@ export const DimensionsHeight = memo(() => { ); return ( - + {t('parameters.height')} diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx index 1e09daaa5d0..63d71570268 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx @@ -7,6 +7,7 @@ import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel'; import { selectWidthConfig } from 'features/system/store/configSlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; export const DimensionsWidth = memo(() => { const { t } = useTranslation(); @@ -16,6 +17,7 @@ export const DimensionsWidth = memo(() => { const config = useAppSelector(selectWidthConfig); const isApiModel = useIsApiModel(); const gridSize = useAppSelector(selectGridSize); + const activeTab = useAppSelector(selectActiveTab); const onChange = useCallback( (v: number) => { @@ -30,7 +32,7 @@ export const DimensionsWidth = memo(() => { ); return ( - + {t('parameters.width')} diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx new file mode 100644 index 00000000000..12530c9f7b0 --- /dev/null +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx @@ -0,0 +1,42 @@ +import { FormControl, FormLabel, Select } from "@invoke-ai/ui-library"; +import { useAppDispatch, useAppSelector } from "app/store/storeHooks"; +import { selectVideoDuration, setVideoDuration } from "features/controlLayers/store/paramsSlice"; +import { isParameterDuration, ParameterDuration } from "features/parameters/types/parameterSchemas"; +import { ChangeEventHandler, useCallback, useMemo } from "react"; +import { useTranslation } from "react-i18next"; +import { PiCaretDownBold } from "react-icons/pi"; + +const options: { label: string; value: ParameterDuration }[] = [ + { label: '5 seconds', value: 5 }, + { label: '10 seconds', value: 10 }, + ]; + +export const ParamDuration = () => { + const videoDuration = useAppSelector(selectVideoDuration); + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const onChange = useCallback>( + (e) => { + if (!isParameterDuration(e.target.value)) { + return; + } + + dispatch(setVideoDuration(e.target.value)); + }, + [dispatch] + ); + + const value = useMemo(() => options.find((o) => o.value === videoDuration), [videoDuration]); + + return + {t('parameters.duration')} + + ; +}; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 6d51c0c5003..170ab33ede6 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -11,8 +11,7 @@ import z from 'zod'; const zVideoState = z.object({ _version: z.literal(1), - videoFirstFrameImage: zImageWithDims.nullable(), - videoLastFrameImage: zImageWithDims.nullable(), + startingFrameImage: zImageWithDims.nullable(), generatedVideo: zVideoOutput.nullable(), }); @@ -20,8 +19,7 @@ export type VideoState = z.infer; const getInitialState = (): VideoState => ({ _version: 1, - videoFirstFrameImage: null, - videoLastFrameImage: null, + startingFrameImage: null, generatedVideo: null, }); @@ -29,12 +27,8 @@ const slice = createSlice({ name: 'video', initialState: getInitialState(), reducers: { - videoFirstFrameImageChanged: (state, action: PayloadAction) => { - state.videoFirstFrameImage = action.payload; - }, - - videoLastFrameImageChanged: (state, action: PayloadAction) => { - state.videoLastFrameImage = action.payload; + startingFrameImageChanged: (state, action: PayloadAction) => { + state.startingFrameImage = action.payload; }, generatedVideoChanged: (state, action: PayloadAction) => { @@ -45,8 +39,7 @@ const slice = createSlice({ }); export const { - videoFirstFrameImageChanged, - videoLastFrameImageChanged, + startingFrameImageChanged, generatedVideoChanged, } = slice.actions; @@ -68,6 +61,5 @@ export const videoSliceConfig: SliceConfig = { export const selectVideoSlice = (state: RootState) => state.video; const createVideoSelector = (selector: Selector) => createSelector(selectVideoSlice, selector); -export const selectVideoFirstFrameImage = createVideoSelector((video) => video.videoFirstFrameImage); -export const selectVideoLastFrameImage = createVideoSelector((video) => video.videoLastFrameImage); +export const selectStartingFrameImage = createVideoSelector((video) => video.startingFrameImage); export const selectGeneratedVideo = createVideoSelector((video) => video.generatedVideo); \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts index d1bccc691ab..b4cbde790d8 100644 --- a/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts +++ b/invokeai/frontend/web/src/features/parameters/types/parameterSchemas.ts @@ -145,6 +145,11 @@ export const [zParameterSeamlessY, isParameterSeamlessY] = buildParameter(z.bool export type ParameterSeamlessY = z.infer; // #endregion +// #region Duration +export const [zParameterDuration, isParameterDuration] = buildParameter(z.union([z.literal(5), z.literal(10)])); +export type ParameterDuration = z.infer; +// #endregion + // #region Precision export const [zParameterPrecision, isParameterPrecision] = buildParameter(z.enum(['fp16', 'fp32'])); export type ParameterPrecision = z.infer; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoLastFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx similarity index 71% rename from invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoLastFrameImage.tsx rename to invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index 4a0511eff3f..1afcaa074ee 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoLastFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -1,39 +1,35 @@ -import { Flex, FormLabel, Text } from '@invoke-ai/ui-library'; +import { Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import type { SetUpscaleInitialImageDndTargetData } from 'features/dnd/dnd'; -import { setUpscaleInitialImageDndTarget } from 'features/dnd/dnd'; -import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; -import { selectVideoLastFrameImage, videoLastFrameImageChanged } from 'features/parameters/store/videoSlice'; +import { selectStartingFrameImage, startingFrameImageChanged } from 'features/parameters/store/videoSlice'; import { t } from 'i18next'; -import { useCallback, useMemo } from 'react'; +import { useCallback } from 'react'; import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; import { useImageDTO } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; -export const VideoLastFrameImage = () => { +export const StartingFrameImage = () => { const dispatch = useAppDispatch(); - const videoLastFrameImage = useAppSelector(selectVideoLastFrameImage); - const imageDTO = useImageDTO(videoLastFrameImage?.image_name); - + const startingFrameImage = useAppSelector(selectStartingFrameImage); + const imageDTO = useImageDTO(startingFrameImage?.image_name); + const onReset = useCallback(() => { - dispatch(videoLastFrameImageChanged(null)); + dispatch(startingFrameImageChanged(null)); }, [dispatch]); const onUpload = useCallback( (imageDTO: ImageDTO) => { - dispatch(videoLastFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); }, [dispatch] ); return ( - Last Frame Image {!imageDTO && } {imageDTO && ( @@ -63,7 +59,7 @@ export const VideoLastFrameImage = () => { >{`${imageDTO.width}x${imageDTO.height}`} )} - + ); diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx deleted file mode 100644 index 8aed8340dc2..00000000000 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoFirstFrameImage.tsx +++ /dev/null @@ -1,71 +0,0 @@ -import { Flex, FormLabel, Text } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; -import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import type { SetUpscaleInitialImageDndTargetData } from 'features/dnd/dnd'; -import { setUpscaleInitialImageDndTarget } from 'features/dnd/dnd'; -import { DndDropTarget } from 'features/dnd/DndDropTarget'; -import { DndImage } from 'features/dnd/DndImage'; -import { DndImageIcon } from 'features/dnd/DndImageIcon'; -import { selectUpscaleInitialImage, upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; -import { selectVideoFirstFrameImage, videoFirstFrameImageChanged } from 'features/parameters/store/videoSlice'; -import { t } from 'i18next'; -import { useCallback, useMemo } from 'react'; -import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; -import { useImageDTO } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; - -export const VideoFirstFrameImage = () => { - const dispatch = useAppDispatch(); - const videoFirstFrameImage = useAppSelector(selectVideoFirstFrameImage); - const imageDTO = useImageDTO(videoFirstFrameImage?.image_name); - - - const onReset = useCallback(() => { - dispatch(videoFirstFrameImageChanged(null)); - }, [dispatch]); - - const onUpload = useCallback( - (imageDTO: ImageDTO) => { - dispatch(videoFirstFrameImageChanged(imageDTOToImageWithDims(imageDTO))); - }, - [dispatch] - ); - - return ( - - First Frame Image - - {!imageDTO && } - {imageDTO && ( - <> - - - } - tooltip={t('common.reset')} - /> - - {`${imageDTO.width}x${imageDTO.height}`} - - )} - - - - ); -}; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx index dc859219a11..12b31fcf91c 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx @@ -1,10 +1,15 @@ import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { VideoFirstFrameImage } from './VideoFirstFrameImage'; -import { VideoLastFrameImage } from './VideoLastFrameImage'; +import { ParamDuration } from 'features/parameters/components/Video/ParamDuration'; +import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed'; +import { Dimensions } from 'features/parameters/components/Dimensions/Dimensions'; +import { RUNWAY_ASPECT_RATIOS } from 'features/controlLayers/store/types'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { widthChanged, heightChanged, aspectRatioIdChanged, aspectRatioLockToggled } from 'features/controlLayers/store/paramsSlice'; +import { StartingFrameImage } from './StartingFrameImage'; export const VideoSettingsAccordion = memo(() => { @@ -13,26 +18,35 @@ export const VideoSettingsAccordion = memo(() => { id: 'video-settings', defaultIsOpen: true, }); + const dispatch = useAppDispatch(); + + useEffect(() => { // hack to get the default aspect ratio for runway models outside paramsSlice + const { width, height } = RUNWAY_ASPECT_RATIOS['16:9']; + dispatch(widthChanged({ width, updateAspectRatio: true, clamp: true })); + dispatch(heightChanged({ height, updateAspectRatio: true, clamp: true })); + dispatch(aspectRatioIdChanged({ id: '16:9' })); + dispatch(aspectRatioLockToggled()); + }, [dispatch]); return ( - - - - - - - - + + + + + + + + + - ); diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 2ba92f39718..a5ac96bbefb 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -21717,7 +21717,7 @@ export type components = { * used, and the type will be ignored. They are included here for backwards compatibility. * @enum {string} */ - UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "SchedulerField" | "AnyField" | "VideoField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; + UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "RunwayModelField" | "SchedulerField" | "AnyField" | "VideoField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; /** UNetField */ UNetField: { /** @description Info to load unet submodel */ From 741139b0bd67d0aa123e3295404331e7b8c23372 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Mon, 18 Aug 2025 20:52:52 +1000 Subject: [PATCH 12/55] feat(ui): add dnd target for video start frame --- invokeai/frontend/web/src/features/dnd/dnd.ts | 26 +++++++++++++++++++ .../StartingFrameImage.tsx | 7 +++-- 2 files changed, 31 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index b7f7dc4f723..a66410c2613 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -22,6 +22,7 @@ import { import { fieldImageCollectionValueChanged } from 'features/nodes/store/nodesSlice'; import { selectFieldInputInstanceSafe, selectNodesSlice } from 'features/nodes/store/selectors'; import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'features/nodes/types/field'; +import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; import { expandPrompt } from 'features/prompt/PromptExpansion/expand'; import { promptExpansionApi } from 'features/prompt/PromptExpansion/state'; import type { ImageDTO } from 'services/api/types'; @@ -548,6 +549,30 @@ export const promptGenerationFromImageDndTarget: DndTarget< }; //#endregion +//#region Video Frame From Image +const _videoFrameFromImage = buildTypeAndKey('video-frame-from-image'); +type VideoFrameFromImageDndTargetData = DndData< + typeof _videoFrameFromImage.type, + typeof _videoFrameFromImage.key, + { frame: 'start' | 'end' } +>; +export const videoFrameFromImageDndTarget: DndTarget = { + ..._videoFrameFromImage, + typeGuard: buildTypeGuard(_videoFrameFromImage.key), + getData: buildGetData(_videoFrameFromImage.key, _videoFrameFromImage.type), + isValid: ({ sourceData }) => { + if (singleImageDndSource.typeGuard(sourceData)) { + return true; + } + return false; + }, + handler: ({ sourceData, dispatch }) => { + const { imageDTO } = sourceData.payload; + dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + }, +}; +//#endregion + export const dndTargets = [ setGlobalReferenceImageDndTarget, addGlobalReferenceImageDndTarget, @@ -562,6 +587,7 @@ export const dndTargets = [ addImageToBoardDndTarget, removeImageFromBoardDndTarget, promptGenerationFromImageDndTarget, + videoFrameFromImageDndTarget, ] as const; export type AnyDndTarget = (typeof dndTargets)[number]; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx index 1afcaa074ee..318ebcfa077 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/StartingFrameImage.tsx @@ -2,6 +2,8 @@ import { Flex, Text } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { UploadImageIconButton } from 'common/hooks/useImageUploadButton'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { videoFrameFromImageDndTarget } from 'features/dnd/dnd'; +import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { DndImage } from 'features/dnd/DndImage'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; import { selectStartingFrameImage, startingFrameImageChanged } from 'features/parameters/store/videoSlice'; @@ -11,11 +13,12 @@ import { PiArrowCounterClockwiseBold } from 'react-icons/pi'; import { useImageDTO } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; +const dndTargetData = videoFrameFromImageDndTarget.getData({ frame: 'start' }); + export const StartingFrameImage = () => { const dispatch = useAppDispatch(); const startingFrameImage = useAppSelector(selectStartingFrameImage); const imageDTO = useImageDTO(startingFrameImage?.image_name); - const onReset = useCallback(() => { dispatch(startingFrameImageChanged(null)); @@ -59,7 +62,7 @@ export const StartingFrameImage = () => { >{`${imageDTO.width}x${imageDTO.height}`} )} - + ); From 39fe97e7811cf5f76c88e301d1473dac3829259f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 19 Aug 2025 15:19:48 +1000 Subject: [PATCH 13/55] feat(nodes): update VideoField & VideoOutput --- invokeai/app/invocations/fields.py | 3 --- invokeai/app/invocations/primitives.py | 10 ++++++++-- 2 files changed, 8 insertions(+), 5 deletions(-) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index e387a0691cb..5f319a9bfbd 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -229,9 +229,6 @@ class VideoField(BaseModel): """A video primitive field""" video_id: str = Field(description="The id of the video") - width: int = Field(description="The width of the video in pixels") - height: int = Field(description="The height of the video in pixels") - duration_seconds: float = Field(description="The duration of the video in seconds") class BoardField(BaseModel): diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index 63187ea0d9d..c4cdba153fa 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -312,11 +312,17 @@ class VideoOutput(BaseInvocationOutput): """Base class for nodes that output a video""" video: VideoField = OutputField(description="The output video") - + width: int = OutputField(description="The width of the video in pixels") + height: int = OutputField(description="The height of the video in pixels") + duration_seconds: float = OutputField(description="The duration of the video in seconds") + @classmethod def build(cls, video_id: str, width: int, height: int, duration_seconds: float) -> "VideoOutput": return cls( - video=VideoField(video_id=video_id, width=width, height=height, duration_seconds=duration_seconds), + video=VideoField(video_id=video_id), + width=width, + height=height, + duration_seconds=duration_seconds, ) From ea9ac91f1cfa9e30fdd5aaa2ce52fd128d50ec27 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 19 Aug 2025 17:51:39 +1000 Subject: [PATCH 14/55] chore: ruff --- invokeai/app/api/routers/videos.py | 25 +++++++++++++------------ invokeai/app/api_app.py | 4 ++-- invokeai/app/invocations/primitives.py | 3 +-- invokeai/invocation_api/__init__.py | 2 +- 4 files changed, 17 insertions(+), 17 deletions(-) diff --git a/invokeai/app/api/routers/videos.py b/invokeai/app/api/routers/videos.py index 7e97ac2992f..36ead345c9a 100644 --- a/invokeai/app/api/routers/videos.py +++ b/invokeai/app/api/routers/videos.py @@ -2,15 +2,21 @@ from fastapi import Body, HTTPException, Path, Query from fastapi.routing import APIRouter -from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection -from invokeai.app.services.shared.pagination import OffsetPaginatedResults -from invokeai.app.services.videos_common import VideoDTO, VideoRecordChanges, DeleteVideosResult, StarredVideosResult, UnstarredVideosResult, VideoIdsResult +from invokeai.app.services.shared.pagination import OffsetPaginatedResults +from invokeai.app.services.shared.sqlite.sqlite_common import SQLiteDirection +from invokeai.app.services.videos_common import ( + DeleteVideosResult, + StarredVideosResult, + UnstarredVideosResult, + VideoDTO, + VideoIdsResult, + VideoRecordChanges, +) videos_router = APIRouter(prefix="/v1/videos", tags=["videos"]) - @videos_router.patch( "/i/{video_id}", operation_id="update_video", @@ -42,10 +48,8 @@ async def get_video_dto( async def delete_videos_from_list( video_ids: list[str] = Body(description="The list of ids of videos to delete", embed=True), ) -> DeleteVideosResult: - raise HTTPException(status_code=501, detail="Not implemented") - @videos_router.post("/star", operation_id="star_videos_in_list", response_model=StarredVideosResult) async def star_videos_in_list( @@ -61,11 +65,8 @@ async def unstar_videos_in_list( raise HTTPException(status_code=501, detail="Not implemented") -@videos_router.delete( - "/uncategorized", operation_id="delete_uncategorized_videos", response_model=DeleteVideosResult -) -async def delete_uncategorized_videos( -) -> DeleteVideosResult: +@videos_router.delete("/uncategorized", operation_id="delete_uncategorized_videos", response_model=DeleteVideosResult) +async def delete_uncategorized_videos() -> DeleteVideosResult: """Deletes all videos that are uncategorized""" raise HTTPException(status_code=501, detail="Not implemented") @@ -115,4 +116,4 @@ async def get_videos_by_ids( ) -> list[VideoDTO]: """Gets video DTOs for the specified video ids. Maintains order of input ids.""" - raise HTTPException(status_code=501, detail="Not implemented") \ No newline at end of file + raise HTTPException(status_code=501, detail="Not implemented") diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index eea32316e41..2ff66c6982d 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -27,8 +27,8 @@ session_queue, style_presets, utilities, - workflows, videos, + workflows, ) from invokeai.app.api.sockets import SocketIO from invokeai.app.services.config.config_default import get_config @@ -126,7 +126,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(model_manager.model_manager_router, prefix="/api") app.include_router(download_queue.download_queue_router, prefix="/api") app.include_router(images.images_router, prefix="/api") -app.include_router(videos.videos_router, prefix="/api") +app.include_router(videos.videos_router, prefix="/api") app.include_router(boards.boards_router, prefix="/api") app.include_router(board_images.board_images_router, prefix="/api") app.include_router(model_relationships.model_relationships_router, prefix="/api") diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index c4cdba153fa..9d07efb03ca 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -307,6 +307,7 @@ def build(cls, video_url: str, runway_task_id: str) -> "RunwayVideoOutput": runway_task_id=runway_task_id, ) + @invocation_output("video_output") class VideoOutput(BaseInvocationOutput): """Base class for nodes that output a video""" @@ -326,8 +327,6 @@ def build(cls, video_id: str, width: int, height: int, duration_seconds: float) ) - - # endregion # region DenoiseMask diff --git a/invokeai/invocation_api/__init__.py b/invokeai/invocation_api/__init__.py index 863ed6825a4..88f4fe99854 100644 --- a/invokeai/invocation_api/__init__.py +++ b/invokeai/invocation_api/__init__.py @@ -66,9 +66,9 @@ IntegerOutput, LatentsCollectionOutput, LatentsOutput, + RunwayVideoOutput, StringCollectionOutput, StringOutput, - RunwayVideoOutput, VideoOutput, ) from invokeai.app.invocations.scheduler import SchedulerOutput From f672cfa257a26b95341a1a0d263e46e5a54162c6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 19 Aug 2025 19:06:30 +1000 Subject: [PATCH 15/55] feat(ui): fiddle w/ video stuff --- .../web/src/features/nodes/types/common.ts | 10 +++ .../features/parameters/store/videoSlice.ts | 18 +++-- .../features/queue/hooks/useEnqueueVideo.ts | 67 +++++++++++-------- .../features/ui/layouts/VideoPlayerPanel.tsx | 38 +++++------ .../ui/layouts/video-tab-auto-layout.tsx | 5 +- .../frontend/web/src/services/api/schema.ts | 32 ++++----- .../services/events/onInvocationComplete.tsx | 27 +++++--- 7 files changed, 110 insertions(+), 87 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index aec694759a8..b11c5a05bbb 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -1,3 +1,6 @@ +import type { S } from 'services/api/types'; +import type { Equals } from 'tsafe'; +import { assert } from 'tsafe'; import { z } from 'zod'; // #region Field data schemas @@ -11,6 +14,13 @@ type ImageFieldCollection = z.infer; export const isImageFieldCollection = (field: unknown): field is ImageFieldCollection => zImageFieldCollection.safeParse(field).success; +export const zVideoField = z.object({ + video_id: z.string().trim().min(1), +}); +export type VideoField = z.infer; +export const isVideoField = (field: unknown): field is VideoField => zVideoField.safeParse(field).success; +assert>(); + export const zBoardField = z.object({ board_id: z.string().trim().min(1), }); diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 170ab33ede6..372d546039b 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -5,14 +5,15 @@ import type { SliceConfig } from 'app/store/types'; import { isPlainObject } from 'es-toolkit'; import type { ImageWithDims } from 'features/controlLayers/store/types'; import { zImageWithDims } from 'features/controlLayers/store/types'; -import { VideoOutput, zVideoOutput } from 'features/nodes/types/common'; +import type { VideoField } from 'features/nodes/types/common'; +import { zVideoField } from 'features/nodes/types/common'; import { assert } from 'tsafe'; import z from 'zod'; const zVideoState = z.object({ _version: z.literal(1), startingFrameImage: zImageWithDims.nullable(), - generatedVideo: zVideoOutput.nullable(), + generatedVideo: zVideoField.nullable(), }); export type VideoState = z.infer; @@ -31,17 +32,14 @@ const slice = createSlice({ state.startingFrameImage = action.payload; }, - generatedVideoChanged: (state, action: PayloadAction) => { - state.generatedVideo = action.payload; + generatedVideoChanged: (state, action: PayloadAction<{ videoField: VideoField | null }>) => { + const { videoField } = action.payload; + state.generatedVideo = videoField; }, - }, }); -export const { - startingFrameImageChanged, - generatedVideoChanged, -} = slice.actions; +export const { startingFrameImageChanged, generatedVideoChanged } = slice.actions; export const videoSliceConfig: SliceConfig = { slice, @@ -62,4 +60,4 @@ export const selectVideoSlice = (state: RootState) => state.video; const createVideoSelector = (selector: Selector) => createSelector(selectVideoSlice, selector); export const selectStartingFrameImage = createVideoSelector((video) => video.startingFrameImage); -export const selectGeneratedVideo = createVideoSelector((video) => video.generatedVideo); \ No newline at end of file +export const selectGeneratedVideo = createVideoSelector((video) => video.generatedVideo); diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts index 49b98029481..e804c84c01a 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts @@ -4,8 +4,7 @@ import { logger } from 'app/logging/logger'; import type { AppStore } from 'app/store/store'; import { useAppStore } from 'app/store/storeHooks'; import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; -import { withResult, withResultAsync } from 'common/util/result'; -import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; +import { withResultAsync } from 'common/util/result'; import { buildRunwayVideoGraph } from 'features/nodes/util/graph/generation/buildRunwayVideoGraph'; import { selectCanvasDestination } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderArg } from 'features/nodes/util/graph/types'; @@ -19,7 +18,7 @@ import { AssertionError } from 'tsafe'; const log = logger('generation'); export const enqueueRequestedCanvas = createAction('app/enqueueRequestedCanvas'); -const enqueueVideo = async (store: AppStore, prepend: boolean) => { +const enqueueVideo = async (store: AppStore, prepend: boolean) => { const { dispatch, getState } = store; dispatch(enqueueRequestedCanvas()); @@ -29,7 +28,6 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { const destination = selectCanvasDestination(state); const buildGraphResult = await withResultAsync(async () => { - const graphBuilderArg: GraphBuilderArg = { generationMode: 'txt2img', state, manager: null }; return await buildRunwayVideoGraph(graphBuilderArg); @@ -58,30 +56,44 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { const { g, seed, positivePrompt } = buildGraphResult.value; - const prepareBatchResult = withResult(() => - prepareLinearUIBatch({ - state, - g, - prepend, - seedNode: seed, - positivePromptNode: positivePrompt, - origin: 'canvas', + // const prepareBatchResult = withResult(() => + // prepareLinearUIBatch({ + // state, + // g, + // prepend, + // seedNode: seed, + // positivePromptNode: positivePrompt, + // origin: 'canvas', + // destination, + // }) + // ); + + // if (prepareBatchResult.isErr()) { + // log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch'); + // return; + // } + + // const batchConfig = prepareBatchResult.value; + + + const batchConfig = { + prepend, + batch: { + graph: g.getGraph(), + runs: 1, + origin, destination, - }) - ); - - if (prepareBatchResult.isErr()) { - log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch'); - return; - } - - const batchConfig = prepareBatchResult.value; + }, + }; const req = dispatch( - queueApi.endpoints.enqueueBatch.initiate(batchConfig, { - ...enqueueMutationFixedCacheKeyOptions, - track: false, - }) + queueApi.endpoints.enqueueBatch.initiate( + batchConfig, + { + ...enqueueMutationFixedCacheKeyOptions, + track: false, + } + ) ); const enqueueResult = await req.unwrap(); @@ -93,10 +105,9 @@ export const useEnqueueVideo = () => { const store = useAppStore(); const enqueue = useCallback( (prepend: boolean) => { - return enqueueVideo(store, prepend); }, - [ store] + [store] ); return enqueue; -}; \ No newline at end of file +}; diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx index bba10915f6b..2d6f2e5945b 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx @@ -1,22 +1,20 @@ import { Box, Flex, Text } from '@invoke-ai/ui-library'; +import { skipToken } from '@reduxjs/toolkit/query'; +import { useAppSelector } from 'app/store/storeHooks'; import { useFocusRegion } from 'common/hooks/focus'; +import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectGeneratedVideo } from 'features/parameters/store/videoSlice'; import { memo, useMemo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import ReactPlayer from 'react-player'; -import { useAppSelector, useAppStore } from 'app/store/storeHooks'; -import { selectGeneratedVideo } from 'features/parameters/store/videoSlice'; import { useGetVideoDTOQuery } from 'services/api/endpoints/videos'; -import { skipToken } from '@reduxjs/toolkit/query'; -import { useImageDTO } from 'services/api/endpoints/images'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; - export const VideoPlayerPanel = memo(() => { const { t } = useTranslation(); const ref = useRef(null); const generatedVideo = useAppSelector(selectGeneratedVideo); const lastSelectedVideoId = useAppSelector(selectLastSelectedImage); - const {data: videoDTO} = useGetVideoDTOQuery(lastSelectedVideoId ?? skipToken); + const { data: videoDTO } = useGetVideoDTOQuery(generatedVideo?.video_id ?? skipToken); useFocusRegion('video', ref); @@ -30,28 +28,30 @@ export const VideoPlayerPanel = memo(() => { return videoDTO.video_url; }, [videoDTO]); - return ( - - {videoUrl && + {videoUrl && ( <> - - + + style={{ + position: 'absolute', + top: '50%', + left: '50%', + transform: 'translate(-50%, -50%)', + maxWidth: '900px', + }} + /> - - - } + + )} {!videoUrl && No video generated} - ); }); -VideoPlayerPanel.displayName = 'VideoPlayerPanel'; \ No newline at end of file +VideoPlayerPanel.displayName = 'VideoPlayerPanel'; diff --git a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx index 37f0c3775e6..b4ec7339b61 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx @@ -2,7 +2,6 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; import { GalleryPanel } from 'features/gallery/components/Gallery'; -import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import type { @@ -20,6 +19,7 @@ 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 { @@ -41,9 +41,8 @@ import { SETTINGS_PANEL_ID, VIEWER_PANEL_ID, } from './shared'; -import { VideoTabLeftPanel } from './VideoTabLeftPanel'; -import { GenerateLaunchpadPanel } from './GenerateLaunchpadPanel'; import { VideoPlayerPanel } from './VideoPlayerPanel'; +import { VideoTabLeftPanel } from './VideoTabLeftPanel'; const tabComponents = { [DOCKVIEW_TAB_ID]: DockviewTab, diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index a5ac96bbefb..6a4c91e847c 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -21717,7 +21717,7 @@ export type components = { * used, and the type will be ignored. They are included here for backwards compatibility. * @enum {string} */ - UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "RunwayModelField" | "SchedulerField" | "AnyField" | "VideoField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; + UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "SchedulerField" | "AnyField" | "VideoField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; /** UNetField */ UNetField: { /** @description Info to load unet submodel */ @@ -22222,21 +22222,6 @@ export type components = { * @description The id of the video */ video_id: string; - /** - * Width - * @description The width of the video in pixels - */ - width: number; - /** - * Height - * @description The height of the video in pixels - */ - height: number; - /** - * Duration Seconds - * @description The duration of the video in seconds - */ - duration_seconds: number; }; /** * VideoIdsResult @@ -22266,6 +22251,21 @@ export type components = { VideoOutput: { /** @description The output video */ video: components["schemas"]["VideoField"]; + /** + * Width + * @description The width of the video in pixels + */ + width: number; + /** + * Height + * @description The height of the video in pixels + */ + height: number; + /** + * Duration Seconds + * @description The duration of the video in seconds + */ + duration_seconds: number; /** * type * @default video_output diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 733e143ed7f..bdf02cc26d2 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -10,13 +10,14 @@ import { } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; -import { isImageField, isImageFieldCollection } from 'features/nodes/types/common'; +import type { VideoField } from 'features/nodes/types/common'; +import { isImageField, isImageFieldCollection, isVideoField } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; import { generatedVideoChanged } from 'features/parameters/store/videoSlice'; import type { LRUCache } from 'lru-cache'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO, S, VideoOutput } from 'services/api/types'; +import type { ImageDTO, S } from 'services/api/types'; import { getCategories } from 'services/api/util'; import { insertImageIntoNamesResult } from 'services/api/util/optimisticUpdates'; import { $lastProgressEvent } from 'services/events/stores'; @@ -205,13 +206,17 @@ export const buildOnInvocationComplete = ( return imageDTOs; }; - const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise => { - // @ts-expect-error: This is a workaround to get the video name from the result - if (data.invocation.type === 'runway_generate_video') { - // @ts-expect-error: This is a workaround to get the video name from the result - return {videoId: data.result.video_id}; + const getResultVideoFields = (data: S['InvocationCompleteEvent']): VideoField[] => { + const { result } = data; + const videoFields: VideoField[] = []; + + for (const [_name, value] of objectEntries(result)) { + if (isVideoField(value)) { + videoFields.push(value); + } } - return null; + + return videoFields; }; return async (data: S['InvocationCompleteEvent']) => { @@ -235,9 +240,9 @@ export const buildOnInvocationComplete = ( await addImagesToGallery(data); - const videoResult = await getResultVideoDTOs(data); - if (videoResult) { - dispatch(generatedVideoChanged({ video_id: videoResult.video.video_id, type: 'video_output' })); + const videoField = getResultVideoFields(data)[0]; + if (videoField) { + dispatch(generatedVideoChanged({ videoField })); } $lastProgressEvent.set(null); From d21a460b7044170ae8e1a53f52f2b942942bacba Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 19 Aug 2025 20:01:55 +1000 Subject: [PATCH 16/55] feat(ui): fiddle w/ video stuff --- .../features/ui/layouts/VideoPlayerPanel.tsx | 18 ++++-------------- 1 file changed, 4 insertions(+), 14 deletions(-) diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx index 2d6f2e5945b..70c3d5d95f8 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx @@ -14,27 +14,17 @@ export const VideoPlayerPanel = memo(() => { const ref = useRef(null); const generatedVideo = useAppSelector(selectGeneratedVideo); const lastSelectedVideoId = useAppSelector(selectLastSelectedImage); - const { data: videoDTO } = useGetVideoDTOQuery(generatedVideo?.video_id ?? skipToken); + const { data: videoDTO } = useGetVideoDTOQuery(lastSelectedVideoId ?? skipToken); useFocusRegion('video', ref); - const videoUrl = useMemo(() => { - // if (generatedVideo) { - // return generatedVideo.video_url; - // } - if (!videoDTO) { - return null; - } - return videoDTO.video_url; - }, [videoDTO]); - return ( - {videoUrl && ( + {videoDTO?.video_url && ( <> { )} - {!videoUrl && No video generated} + {!videoDTO?.video_url && No video generated} ); }); From 1b95021c45844df975eac58fa492b763e5c64ce3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Tue, 19 Aug 2025 23:06:00 +1000 Subject: [PATCH 17/55] feat(ui): more video stuff --- invokeai/frontend/web/src/app/store/store.ts | 2 +- .../controlLayers/store/paramsSlice.ts | 2 - .../dnd/DndDragPreviewMultipleVideo.tsx | 63 ++++++++ .../dnd/DndDragPreviewSingleVideo.tsx | 63 ++++++++ invokeai/frontend/web/src/features/dnd/dnd.ts | 32 +++- .../ImageMenuItemSendToVideo.tsx | 2 +- .../components/ImageGrid/GalleryImage.tsx | 2 +- .../components/ImageGrid/GalleryVideo.tsx | 144 +++++++++--------- .../gallery/components/NewGallery.tsx | 38 ++++- .../gallery/components/VideoGallery.tsx | 17 +-- .../components/use-gallery-video-ids.ts | 2 +- .../hooks/useRangeBasedVideoFetching.ts | 2 +- .../graph/generation/buildRunwayVideoGraph.ts | 3 +- .../Dimensions/DimensionsHeight.tsx | 2 +- .../components/Dimensions/DimensionsWidth.tsx | 2 +- .../components/Video/ParamDuration.tsx | 6 +- .../VideoSettingsAccordion.tsx | 14 +- .../features/ui/layouts/VideoPlayerPanel.tsx | 26 ++-- .../features/ui/layouts/VideoTabLeftPanel.tsx | 2 +- .../web/src/services/api/endpoints/videos.ts | 2 +- 20 files changed, 298 insertions(+), 128 deletions(-) create mode 100644 invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx create mode 100644 invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx diff --git a/invokeai/frontend/web/src/app/store/store.ts b/invokeai/frontend/web/src/app/store/store.ts index 40d0bfe2705..40cd78a3e45 100644 --- a/invokeai/frontend/web/src/app/store/store.ts +++ b/invokeai/frontend/web/src/app/store/store.ts @@ -33,6 +33,7 @@ import { nodesSliceConfig } from 'features/nodes/store/nodesSlice'; import { workflowLibrarySliceConfig } from 'features/nodes/store/workflowLibrarySlice'; import { workflowSettingsSliceConfig } from 'features/nodes/store/workflowSettingsSlice'; import { upscaleSliceConfig } from 'features/parameters/store/upscaleSlice'; +import { videoSliceConfig } from 'features/parameters/store/videoSlice'; import { queueSliceConfig } from 'features/queue/store/queueSlice'; import { stylePresetSliceConfig } from 'features/stylePresets/store/stylePresetSlice'; import { configSliceConfig } from 'features/system/store/configSlice'; @@ -54,7 +55,6 @@ import { actionsDenylist } from './middleware/devtools/actionsDenylist'; import { stateSanitizer } from './middleware/devtools/stateSanitizer'; import { addArchivedOrDeletedBoardListener } from './middleware/listenerMiddleware/listeners/addArchivedOrDeletedBoardListener'; import { addImageUploadedFulfilledListener } from './middleware/listenerMiddleware/listeners/imageUploaded'; -import { videoSliceConfig } from 'features/parameters/store/videoSlice'; export const listenerMiddleware = createListenerMiddleware(); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index cc124605bc7..047bef8d564 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -16,8 +16,6 @@ import { isChatGPT4oAspectRatioID, isFluxKontextAspectRatioID, isImagenAspectRatioID, - isRunwayAspectRatioID, - RUNWAY_ASPECT_RATIOS, zParamsState, } from 'features/controlLayers/store/types'; import { calculateNewSize } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx new file mode 100644 index 00000000000..c40d7a88061 --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx @@ -0,0 +1,63 @@ +import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { Flex, Heading } from '@invoke-ai/ui-library'; +import type { MultipleVideoDndSourceData } from 'features/dnd/dnd'; +import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; +import { memo } from 'react'; +import { createPortal } from 'react-dom'; +import { useTranslation } from 'react-i18next'; +import type { Param0 } from 'tsafe'; + +const DndDragPreviewMultipleVideo = memo(({ ids}: { ids: string[] }) => { + const { t } = useTranslation(); + return ( + + {ids.length} + {t('parameters.videos')} + + ); +}); + +DndDragPreviewMultipleVideo.displayName = 'DndDragPreviewMultipleVideo'; + +export type DndDragPreviewMultipleVideoState = { + type: 'multiple-video'; + container: HTMLElement; + ids: string[]; +}; + +export const createMultipleVideoDragPreview = (arg: DndDragPreviewMultipleVideoState) => + createPortal(, arg.container); + +type SetMultipleDragPreviewArg = { + multipleVideoDndData: MultipleVideoDndSourceData; + setDragPreviewState: (dragPreviewState: DndDragPreviewMultipleVideoState | null) => void; + onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; +}; + +export const setMultipleVideoDragPreview = ({ + multipleVideoDndData, + onGenerateDragPreviewArgs, + setDragPreviewState, +}: SetMultipleDragPreviewArg) => { + const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs; + setCustomNativeDragPreview({ + render({ container }) { + setDragPreviewState({ type: 'multiple-video', container, ids: multipleVideoDndData.payload.ids}); + return () => setDragPreviewState(null); + }, + nativeSetDragImage, + getOffset: preserveOffsetOnSourceFallbackCentered({ + element: source.element, + input: location.current.input, + }), + }); +}; diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx new file mode 100644 index 00000000000..6af0046f09d --- /dev/null +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx @@ -0,0 +1,63 @@ +import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; +import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; +import { chakra, Flex, Text } from '@invoke-ai/ui-library'; +import type { SingleVideoDndSourceData } from 'features/dnd/dnd'; +import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; +import { memo } from 'react'; +import { createPortal } from 'react-dom'; +import type { VideoDTO } from 'services/api/types'; +import type { Param0 } from 'tsafe'; + +const ChakraImg = chakra('img'); + +const DndDragPreviewSingleVideo = memo(({ videoDTO }: { videoDTO: VideoDTO}) => { + return ( + + I AM A VIDEO + + + ); +}); + +DndDragPreviewSingleVideo.displayName = 'DndDragPreviewSingleVideo'; + +export type DndDragPreviewSingleVideoState = { + type: 'single-video'; + container: HTMLElement; + videoDTO: VideoDTO; +}; + +export const createSingleVideoDragPreview = (arg: DndDragPreviewSingleVideoState) => + createPortal(, arg.container); + +type SetSingleDragPreviewArg = { + singleVideoDndData: SingleVideoDndSourceData; + setDragPreviewState: (dragPreviewState: DndDragPreviewSingleVideoState | null) => void; + onGenerateDragPreviewArgs: Param0['onGenerateDragPreview']>; +}; + +export const setSingleVideoDragPreview = ({ + singleVideoDndData, + onGenerateDragPreviewArgs, + setDragPreviewState, +}: SetSingleDragPreviewArg) => { + const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs; + setCustomNativeDragPreview({ + render({ container }) { + setDragPreviewState({ type: 'single-video', container, videoDTO: singleVideoDndData.payload.videoDTO}); + return () => setDragPreviewState(null); + }, + nativeSetDragImage, + getOffset: preserveOffsetOnSourceFallbackCentered({ + element: source.element, + input: location.current.input, + }), + }); +}; diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index a66410c2613..f5e155e5df4 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -25,7 +25,7 @@ import { type FieldIdentifier, isImageFieldCollectionInputInstance } from 'featu import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; import { expandPrompt } from 'features/prompt/PromptExpansion/expand'; import { promptExpansionApi } from 'features/prompt/PromptExpansion/state'; -import type { ImageDTO } from 'services/api/types'; +import type { ImageDTO, VideoDTO } from 'services/api/types'; import type { JsonObject } from 'type-fest'; const log = logger('dnd'); @@ -71,6 +71,36 @@ type DndSource = { typeGuard: ReturnType>; getData: ReturnType>; }; + +//#region Single Video +const _singleVideo = buildTypeAndKey('single-video'); +export type SingleVideoDndSourceData = DndData< + typeof _singleVideo.type, + typeof _singleVideo.key, + { videoDTO: VideoDTO } +>; +export const singleVideoDndSource: DndSource = { + ..._singleVideo, + typeGuard: buildTypeGuard(_singleVideo.key), + getData: buildGetData(_singleVideo.key, _singleVideo.type), +}; +//#endregion + +//#region Multiple Image +const _multipleVideo = buildTypeAndKey('multiple-video'); +export type MultipleVideoDndSourceData = DndData< + typeof _multipleVideo.type, + typeof _multipleVideo.key, + { ids: string[]; board_id: BoardId } +>; +export const multipleVideoDndSource: DndSource = { + ..._multipleVideo, + typeGuard: buildTypeGuard(_multipleVideo.key), + getData: buildGetData(_multipleVideo.key, _multipleVideo.type), +}; +//#endregion + + //#region Single Image const _singleImage = buildTypeAndKey('single-image'); export type SingleImageDndSourceData = DndData< diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx index 0f00c93a0c4..4e7e39f9d78 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx @@ -21,7 +21,7 @@ export const ImageMenuItemSendToVideo = memo(() => { } onClickCapture={onClick} - aria-label={"Send to Video"} + aria-label="Send to Video" > Send to Video diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 739ee9c2edf..53e2dec7f1e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -243,7 +243,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { ref={ref} sx={galleryImageContainerSX} data-is-dragging={isDragging} - data-image-name={imageDTO.image_name} + data-item-id={imageDTO.image_name} role="button" onMouseOver={onMouseOver} onMouseOut={onMouseOut} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx index 7a2c899e0e5..2c05069986d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -6,17 +6,14 @@ import { createSelector } from '@reduxjs/toolkit'; import type { AppDispatch, AppGetState } from 'app/store/store'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { uniq } from 'es-toolkit'; -import { multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; -import type { DndDragPreviewMultipleImageState } from 'features/dnd/DndDragPreviewMultipleImage'; -import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'features/dnd/DndDragPreviewMultipleImage'; -import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; -import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; +import { multipleVideoDndSource, singleVideoDndSource } from 'features/dnd/dnd'; +import type { DndDragPreviewMultipleVideoState} from 'features/dnd/DndDragPreviewMultipleVideo'; +import { createMultipleVideoDragPreview, setMultipleVideoDragPreview } from 'features/dnd/DndDragPreviewMultipleVideo'; +import type { DndDragPreviewSingleVideoState} from 'features/dnd/DndDragPreviewSingleVideo'; +import { createSingleVideoDragPreview, setSingleVideoDragPreview } from 'features/dnd/DndDragPreviewSingleVideo'; import { firefoxDndFix } from 'features/dnd/util'; -import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; -import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; import { selectGetImageNamesQueryArgs, - selectGetVideoIdsQueryArgs, selectSelectedBoardId, selectSelection, } from 'features/gallery/store/gallerySelectors'; @@ -27,8 +24,7 @@ import type { MouseEvent, MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiImageBold } from 'react-icons/pi'; import { imagesApi } from 'services/api/endpoints/images'; -import { videosApi } from 'services/api/endpoints/videos'; -import type { ImageDTO, VideoDTO } from 'services/api/types'; +import type { VideoDTO } from 'services/api/types'; const galleryImageContainerSX = { containerType: 'inline-size', @@ -86,55 +82,60 @@ interface Props { } const buildOnClick = - (videoId: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => { + (imageName: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => { const { shiftKey, ctrlKey, metaKey, altKey } = e; const state = getState(); - const queryArgs = selectGetVideoIdsQueryArgs(state); - const videoIds = videosApi.endpoints.getVideoIds.select(queryArgs)(state).data?.video_ids ?? []; + const queryArgs = selectGetImageNamesQueryArgs(state); + const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(state).data?.image_names ?? []; // If we don't have the image names cached, we can't perform selection operations // This can happen if the user clicks on an image before the names are loaded - if (videoIds.length === 0) { + if (imageNames.length === 0) { // For basic click without modifiers, we can still set selection if (!shiftKey && !ctrlKey && !metaKey && !altKey) { - dispatch(selectionChanged([videoId])); + dispatch(selectionChanged([imageName])); } return; } const selection = state.gallery.selection; - if (shiftKey) { - const rangeEndVideoId = videoId; - const lastSelectedVideoId = selection.at(-1); - const lastClickedIndex = videoIds.findIndex((id) => id === lastSelectedVideoId); - const currentClickedIndex = videoIds.findIndex((id) => id === rangeEndVideoId); + if (altKey) { + if (state.gallery.imageToCompare === imageName) { + dispatch(imageToCompareChanged(null)); + } else { + dispatch(imageToCompareChanged(imageName)); + } + } else if (shiftKey) { + const rangeEndImageName = imageName; + const lastSelectedImage = selection.at(-1); + const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage); + const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName); if (lastClickedIndex > -1 && currentClickedIndex > -1) { // We have a valid range! const start = Math.min(lastClickedIndex, currentClickedIndex); const end = Math.max(lastClickedIndex, currentClickedIndex); - const videosToSelect = videoIds.slice(start, end + 1); - dispatch(selectionChanged(uniq(selection.concat(videosToSelect)))); + const imagesToSelect = imageNames.slice(start, end + 1); + dispatch(selectionChanged(uniq(selection.concat(imagesToSelect)))); } } else if (ctrlKey || metaKey) { - if (selection.some((n) => n === videoId) && selection.length > 1) { - dispatch(selectionChanged(uniq(selection.filter((n) => n !== videoId)))); + if (selection.some((n) => n === imageName) && selection.length > 1) { + dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName)))); } else { - dispatch(selectionChanged(uniq(selection.concat(videoId)))); + dispatch(selectionChanged(uniq(selection.concat(imageName)))); } } else { - dispatch(selectionChanged([videoId])); + dispatch(selectionChanged([imageName])); } }; -export const GalleryVideo = memo(({ videoDTO }: Props) => { +export const GalleryVideo = memo(({ videoDTO }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [dragPreviewState, setDragPreviewState] = useState< - DndDragPreviewSingleImageState | DndDragPreviewMultipleImageState | null + DndDragPreviewSingleVideoState | DndDragPreviewMultipleVideoState | null >(null); const ref = useRef(null); - const selectIsSelected = useMemo( () => createSelector(selectGallerySlice, (gallery) => gallery.selection.includes(videoDTO.video_id)), [videoDTO.video_id] @@ -150,63 +151,55 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { firefoxDndFix(element), draggable({ element, - // getInitialData: () => { - // const selection = selectSelection(store.getState()); - // const boardId = selectSelectedBoardId(store.getState()); - // // When we have multiple images selected, and the dragged image is part of the selection, initiate a - // // multi-image drag. - // if (selection.length > 1 && selection.includes(videoDTO.video_id)) { - // return multipleImageDndSource.getData({ - // image_names: selection, - // board_id: boardId, - // }); - // } + getInitialData: () => { + const selection = selectSelection(store.getState()); + const boardId = selectSelectedBoardId(store.getState()); - // // Otherwise, initiate a single-image drag - // return singleImageDndSource.getData({ videoDTO }, videoDTO.video_id); - // }, + // Otherwise, initiate a single-image drag + return singleVideoDndSource.getData({ videoDTO }, videoDTO.video_id); + }, // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. onDragStart: ({ source }) => { // When we start dragging a single image, set the dragging state to true. This is only called when this // specific image is dragged. - if (singleImageDndSource.typeGuard(source.data)) { + if (singleVideoDndSource.typeGuard(source.data)) { setIsDragging(true); return; } }, onGenerateDragPreview: (args) => { - if (multipleImageDndSource.typeGuard(args.source.data)) { - setMultipleImageDragPreview({ - multipleImageDndData: args.source.data, + if (multipleVideoDndSource.typeGuard(args.source.data)) { + setMultipleVideoDragPreview({ + multipleVideoDndData: args.source.data, onGenerateDragPreviewArgs: args, setDragPreviewState, }); - } else if (singleImageDndSource.typeGuard(args.source.data)) { - setSingleImageDragPreview({ - singleImageDndData: args.source.data, + } else if (singleVideoDndSource.typeGuard(args.source.data)) { + setSingleVideoDragPreview({ + singleVideoDndData: args.source.data, onGenerateDragPreviewArgs: args, setDragPreviewState, }); } }, }), - // monitorForElements({ - // // This is a "global" drag start event, meaning that it is called for all drag events. - // onDragStart: ({ source }) => { - // // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the - // // selection. This is called for all drag events. - // if ( - // multipleImageDndSource.typeGuard(source.data) && - // source.data.payload.video_ids.includes(videoDTO.video_id) - // ) { - // setIsDragging(true); - // } - // }, - // onDrop: () => { - // // Always set the dragging state to false when a drop event occurs. - // setIsDragging(false); - // }, - // }) + monitorForElements({ + // This is a "global" drag start event, meaning that it is called for all drag events. + onDragStart: ({ source }) => { + // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the + // selection. This is called for all drag events. + if ( + multipleVideoDndSource.typeGuard(source.data) && + source.data.payload.ids.includes(videoDTO.video_id) + ) { + setIsDragging(true); + } + }, + onDrop: () => { + // Always set the dragging state to false when a drop event occurs. + setIsDragging(false); + }, + }) ); }, [videoDTO, store]); @@ -235,38 +228,39 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { ref={ref} sx={galleryImageContainerSX} data-is-dragging={isDragging} - data-video-id={videoDTO.video_id} + data-item-id={videoDTO.video_id} role="button" onMouseOver={onMouseOver} onMouseOut={onMouseOut} onClick={onClick} onDoubleClick={onDoubleClick} data-selected={isSelected} + data-selected-for-compare={false} > } + fallback={} objectFit="contain" maxW="full" maxH="full" borderRadius="base" /> - {/* */} + {/* */} - {dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null} - {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} + {dragPreviewState?.type === 'multiple-video' ? createMultipleVideoDragPreview(dragPreviewState) : null} + {dragPreviewState?.type === 'single-video' ? createSingleVideoDragPreview(dragPreviewState) : null} ); }); -GalleryVideo.displayName = 'GalleryVideo'; +GalleryVideo.displayName = 'GalleryImage'; -export const GalleryVideoPlaceholder = memo((props: FlexProps) => ( +export const GalleryImagePlaceholder = memo((props: FlexProps) => ( )); -GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder'; +GalleryImagePlaceholder.displayName = 'GalleryImagePlaceholder'; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 9f5aeddd632..01ef4205b8d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -28,6 +28,7 @@ import type { } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; import { imagesApi, useImageDTO, useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; +import { videosApi } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; @@ -62,13 +63,38 @@ const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string imagesApi.endpoints.getImageDTO.useQuerySubscription(imageName, { skip: isUninitialized }); if (!imageDTO) { - return ; + return ; } return ; }); ImageAtPosition.displayName = 'ImageAtPosition'; +const VideoAtPosition = memo(({ itemId }: { index: number; itemId: string }) => { + /* + * We rely on the useRangeBasedImageFetching to fetch all image DTOs, caching them with RTK Query. + * + * In this component, we just want to consume that cache. Unforutnately, RTK Query does not provide a way to + * subscribe to a query without triggering a new fetch. + * + * There is a hack, though: + * - https://github.com/reduxjs/redux-toolkit/discussions/4213 + * + * This essentially means "subscribe to the query once it has some data". + */ + + // Use `currentData` instead of `data` to prevent a flash of previous image rendered at this index + const { currentData: item, isUninitialized } = videosApi.endpoints.getVideoDTO.useQueryState(itemId); + videosApi.endpoints.getVideoDTO.useQuerySubscription(itemId, { skip: isUninitialized }); + + if (!item) { + return ; + } + + return ; +}); +VideoAtPosition.displayName = 'VideoAtPosition'; + const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; }; @@ -133,8 +159,8 @@ const getImagesPerRow = (rootEl: HTMLDivElement): number => { * Scroll the item at the given index into view if it is not currently visible. */ const scrollIntoView = ( - targetImageName: string, - imageNames: string[], + targetItemId: string, + itemIds: string[], rootEl: HTMLDivElement, virtuosoGridHandle: VirtuosoGridHandle, range: ListRange @@ -145,7 +171,7 @@ const scrollIntoView = ( return; } - const targetIndex = imageNames.findIndex((name) => name === targetImageName); + const targetIndex = itemIds.findIndex((name) => name === targetItemId); if (targetIndex === -1) { // The image isn't in the currently rendered list. @@ -154,7 +180,7 @@ const scrollIntoView = ( } const targetItem = rootEl.querySelector( - `.virtuoso-grid-item:has([data-image-name="${targetImageName}"])` + `.virtuoso-grid-item:has([data-item-id="${targetItemId}"])` ) as HTMLElement; if (!targetItem) { @@ -188,7 +214,7 @@ const scrollIntoView = ( }); } else { log.debug( - `Unable to find image ${targetImageName} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` + `Unable to find image ${targetItemId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` ); } return; diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index 2cfbe3579d4..ce1efe658b7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -2,17 +2,16 @@ import { Box, Flex, forwardRef, Grid, GridItem, Spinner, Text } from '@invoke-ai import { createSelector } from '@reduxjs/toolkit'; import { logger } from 'app/logging/logger'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; -import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; -import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; -import type { selectGetImageNamesQueryArgs, selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { getFocusedRegion } from 'common/hooks/focus'; +import { useRangeBasedVideoFetching } from 'features/gallery/hooks/useRangeBasedVideoFetching'; +import type { selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, selectGalleryView, selectImageToCompare, selectLastSelectedImage, - selectSelectionCount, } from 'features/gallery/store/gallerySelectors'; -import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; +import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { useOverlayScrollbars } from 'overlayscrollbars-react'; import type { MutableRefObject, RefObject } from 'react'; @@ -26,15 +25,13 @@ import type { VirtuosoGridHandle, } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; +import { videosApi } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; -import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; -import { useGalleryImageNames } from './use-gallery-image-names'; -import { useGalleryVideoIds } from './use-gallery-video-ids'; -import { videosApi } from 'services/api/endpoints/videos'; import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; -import { useRangeBasedVideoFetching } from '../hooks/useRangeBasedVideoFetching'; +import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; import { GalleryVideo } from './ImageGrid/GalleryVideo'; +import { useGalleryVideoIds } from './use-gallery-video-ids'; const log = logger('gallery'); diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts index 8b224e2b53d..5bb9c5c0b72 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-video-ids.ts @@ -1,6 +1,6 @@ import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectGetImageNamesQueryArgs, selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; import { useGetVideoIdsQuery } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts index 7caf341b47c..5d2466db288 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts @@ -1,7 +1,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { useCallback, useEffect, useState } from 'react'; import type { ListRange } from 'react-virtuoso'; -import { videosApi, useGetVideoDTOsByNamesMutation } from 'services/api/endpoints/videos'; +import { useGetVideoDTOsByNamesMutation,videosApi } from 'services/api/endpoints/videos'; import { useThrottledCallback } from 'use-debounce'; interface UseRangeBasedVideoFetchingArgs { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts index 7e68048e52f..f239693b3b5 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts @@ -1,13 +1,12 @@ import { logger } from 'app/logging/logger'; -import type { RootState } from 'app/store/store'; import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; -import { selectStartingFrameImage } from 'features/parameters/store/videoSlice'; import { zImageField } from 'features/nodes/types/common'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderArg, GraphBuilderReturn } from 'features/nodes/util/graph/types'; import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; +import { selectStartingFrameImage } from 'features/parameters/store/videoSlice'; import { t } from 'i18next'; import { assert } from 'tsafe'; diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx index c7d752102e1..14f04f68391 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsHeight.tsx @@ -5,9 +5,9 @@ import { heightChanged, selectHeight } from 'features/controlLayers/store/params import { selectGridSize, selectOptimalDimension } from 'features/controlLayers/store/selectors'; import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel'; import { selectHeightConfig } from 'features/system/store/configSlice'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; export const DimensionsHeight = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx index 63d71570268..2a2ebfde744 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsWidth.tsx @@ -5,9 +5,9 @@ import { selectWidth, widthChanged } from 'features/controlLayers/store/paramsSl import { selectGridSize, selectOptimalDimension } from 'features/controlLayers/store/selectors'; import { useIsApiModel } from 'features/parameters/hooks/useIsApiModel'; import { selectWidthConfig } from 'features/system/store/configSlice'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; export const DimensionsWidth = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx index 12530c9f7b0..963725b6682 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx @@ -1,8 +1,10 @@ import { FormControl, FormLabel, Select } from "@invoke-ai/ui-library"; import { useAppDispatch, useAppSelector } from "app/store/storeHooks"; import { selectVideoDuration, setVideoDuration } from "features/controlLayers/store/paramsSlice"; -import { isParameterDuration, ParameterDuration } from "features/parameters/types/parameterSchemas"; -import { ChangeEventHandler, useCallback, useMemo } from "react"; +import type { ParameterDuration } from "features/parameters/types/parameterSchemas"; +import { isParameterDuration } from "features/parameters/types/parameterSchemas"; +import type { ChangeEventHandler} from "react"; +import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { PiCaretDownBold } from "react-icons/pi"; diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx index 12b31fcf91c..64301e4d60b 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx @@ -1,14 +1,14 @@ import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { aspectRatioIdChanged, aspectRatioLockToggled,heightChanged, widthChanged } from 'features/controlLayers/store/paramsSlice'; +import { RUNWAY_ASPECT_RATIOS } from 'features/controlLayers/store/types'; +import { Dimensions } from 'features/parameters/components/Dimensions/Dimensions'; +import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed'; +import { ParamDuration } from 'features/parameters/components/Video/ParamDuration'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; import { memo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; -import { ParamDuration } from 'features/parameters/components/Video/ParamDuration'; -import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed'; -import { Dimensions } from 'features/parameters/components/Dimensions/Dimensions'; -import { RUNWAY_ASPECT_RATIOS } from 'features/controlLayers/store/types'; -import { useAppDispatch } from 'app/store/storeHooks'; -import { widthChanged, heightChanged, aspectRatioIdChanged, aspectRatioLockToggled } from 'features/controlLayers/store/paramsSlice'; import { StartingFrameImage } from './StartingFrameImage'; @@ -31,7 +31,7 @@ export const VideoSettingsAccordion = memo(() => { return ( { const { t } = useTranslation(); const ref = useRef(null); - const generatedVideo = useAppSelector(selectGeneratedVideo); const lastSelectedVideoId = useAppSelector(selectLastSelectedImage); const { data: videoDTO } = useGetVideoDTOQuery(lastSelectedVideoId ?? skipToken); @@ -23,23 +23,21 @@ export const VideoPlayerPanel = memo(() => { {videoDTO?.video_url && ( <> - )} - {!videoDTO?.video_url && No video generated} + {!videoDTO?.video_url && No video generated} ); }); diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx index e15ac786474..0d99be4f963 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoTabLeftPanel.tsx @@ -1,7 +1,7 @@ import { Box, Flex } from '@invoke-ai/ui-library'; import QueueControls from 'features/queue/components/QueueControls'; +import { ParametersPanelVideo } from 'features/ui/components/ParametersPanels/ParametersPanelVideo'; import { memo } from 'react'; -import { ParametersPanelVideo } from '../components/ParametersPanels/ParametersPanelVideo'; export const VideoTabLeftPanel = memo(() => { return ( diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 1de126457d4..58eda65915a 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -7,7 +7,7 @@ import type { import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; -import { api, buildV1Url, LIST_TAG } from '..'; +import { api, buildV1Url } from '..'; /** * Builds an endpoint URL for the videos router From a7fafeef1b97a220b5efb9b6705343972d831dad Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Tue, 19 Aug 2025 14:18:51 -0400 Subject: [PATCH 18/55] add readiness logic to video tab --- .../graph/generation/buildRunwayVideoGraph.ts | 2 + .../InvokeButtonTooltip.tsx | 25 +++++++++ .../web/src/features/queue/store/readiness.ts | 52 +++++++++++++++++-- 3 files changed, 76 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts index f239693b3b5..87e8e02e67d 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts @@ -24,6 +24,8 @@ export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn const params = selectParamsSlice(state); const prompts = selectPresetModifiedPrompts(state); + assert(prompts.positive.length > 0, 'Runway video requires positive prompt to have at least one character'); + const startingFrameImage = selectStartingFrameImage(state); assert(startingFrameImage, 'Video starting frame is required for runway video generation'); diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx index 60a5c2d026c..1b372767227 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx @@ -47,10 +47,35 @@ const TooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => { return ; } + if (activeTab === 'video') { + return ; + } + return null; }); TooltipContent.displayName = 'TooltipContent'; +const VideoTabTooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => { + const isReady = useStore($isReadyToEnqueue); + const reasons = useStore($reasonsWhyCannotEnqueue); + + return ( + + + + {reasons.length > 0 && ( + <> + + + + )} + + + + ) +}); +VideoTabTooltipContent.displayName = 'VideoTabTooltipContent'; + const CanvasTabTooltipContent = memo(({ prepend = false }: { prepend?: boolean }) => { const isReady = useStore($isReadyToEnqueue); const reasons = useStore($reasonsWhyCannotEnqueue); diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index c0c7f39da8e..bc6ffc51db1 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -34,6 +34,7 @@ import { resolveBatchValue } from 'features/nodes/util/node/resolveBatchValue'; import { useIsModelDisabled } from 'features/parameters/hooks/useIsModelDisabled'; import type { UpscaleState } from 'features/parameters/store/upscaleSlice'; import { selectUpscaleSlice } from 'features/parameters/store/upscaleSlice'; +import { selectVideoSlice, type VideoState } from 'features/parameters/store/videoSlice'; import type { ParameterModel } from 'features/parameters/types/parameterSchemas'; import { getGridSize } from 'features/parameters/util/optimalDimension'; import { promptExpansionApi, type PromptExpansionRequestState } from 'features/prompt/PromptExpansion/state'; @@ -91,7 +92,8 @@ const debouncedUpdateReasons = debounce( store: AppStore, isInPublishFlow: boolean, isChatGPT4oHighModelDisabled: (model: ParameterModel) => boolean, - promptExpansionRequest: PromptExpansionRequestState + promptExpansionRequest: PromptExpansionRequestState, + video: VideoState, ) => { if (tab === 'generate') { const model = selectMainModelConfig(store.getState()); @@ -121,6 +123,7 @@ const debouncedUpdateReasons = debounce( canvasIsSelectingObject, isChatGPT4oHighModelDisabled, promptExpansionRequest, + }); $reasonsWhyCannotEnqueue.set(reasons); } else if (tab === 'workflows') { @@ -142,6 +145,15 @@ const debouncedUpdateReasons = debounce( promptExpansionRequest, }); $reasonsWhyCannotEnqueue.set(reasons); + } else if (tab === 'video') { + const reasons = getReasonsWhyCannotEnqueueVideoTab({ + isConnected, + video, + params, + promptExpansionRequest, + dynamicPrompts, + }); + $reasonsWhyCannotEnqueue.set(reasons); } else { $reasonsWhyCannotEnqueue.set(EMPTY_ARRAY); } @@ -172,7 +184,7 @@ export const useReadinessWatcher = () => { const isInPublishFlow = useStore($isInPublishFlow); const { isChatGPT4oHighModelDisabled } = useIsModelDisabled(); const promptExpansionRequest = useStore(promptExpansionApi.$state); - + const video = useAppSelector(selectVideoSlice); useEffect(() => { debouncedUpdateReasons( tab, @@ -194,7 +206,8 @@ export const useReadinessWatcher = () => { store, isInPublishFlow, isChatGPT4oHighModelDisabled, - promptExpansionRequest + promptExpansionRequest, + video, ); }, [ store, @@ -217,11 +230,44 @@ export const useReadinessWatcher = () => { isInPublishFlow, isChatGPT4oHighModelDisabled, promptExpansionRequest, + video, ]); }; const disconnectedReason = (t: typeof i18n.t) => ({ content: t('parameters.invoke.systemDisconnected') }); +const getReasonsWhyCannotEnqueueVideoTab = (arg: { + isConnected: boolean; + video: VideoState; + params: ParamsState; + dynamicPrompts: DynamicPromptsState; + promptExpansionRequest: PromptExpansionRequestState; +}) => { + const { isConnected, video, params, dynamicPrompts, promptExpansionRequest } = arg; + const { positivePrompt } = params; + const reasons: Reason[] = []; + + if (!isConnected) { + reasons.push(disconnectedReason(i18n.t)); + } + + if (dynamicPrompts.prompts.length === 0 && getShouldProcessPrompt(positivePrompt)) { + reasons.push({ content: i18n.t('parameters.invoke.noPrompts') }); + } + + if (promptExpansionRequest.isPending) { + reasons.push({ content: i18n.t('parameters.invoke.promptExpansionPending') }); + } else if (promptExpansionRequest.isSuccess) { + reasons.push({ content: i18n.t('parameters.invoke.promptExpansionResultPending') }); + } + + if (!video.startingFrameImage?.image_name) { + reasons.push({ content: i18n.t('parameters.invoke.noStartingFrameImage') }); + } + + return reasons; +}; + const getReasonsWhyCannotEnqueueGenerateTab = (arg: { isConnected: boolean; model: MainModelConfig | null | undefined; From d405c94059d931a178141dd0b9f064905957b2fc Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Tue, 19 Aug 2025 16:05:41 -0400 Subject: [PATCH 19/55] hook up starring, unstarring, and deleting single videos (no multiselect yet), adapt context menus to work for both images and videos and start on video context menu --- .../app/components/GlobalModalIsolator.tsx | 4 +- .../web/src/features/dnd/DndImage.tsx | 2 +- .../ImageContextMenu.tsx | 4 +- .../MenuItems/ContextMenuItemChangeBoard.tsx} | 17 +- .../MenuItems/ContextMenuItemCopy.tsx} | 17 +- .../MenuItems/ContextMenuItemDelete.tsx} | 17 +- .../MenuItems/ContextMenuItemDownload.tsx} | 17 +- .../MenuItems/ContextMenuItemLoadWorkflow.tsx | 39 +++ .../ContextMenuItemLocateInGalery.tsx} | 29 +- ...tadataRecallActionsCanvasGenerateTabs.tsx} | 24 +- ...nuItemMetadataRecallActionsUpscaleTab.tsx} | 14 +- ...textMenuItemNewCanvasFromImageSubMenu.tsx} | 17 +- ...ntextMenuItemNewLayerFromImageSubMenu.tsx} | 71 ++--- .../ContextMenuItemOpenInNewTab.tsx} | 17 +- .../ContextMenuItemOpenInViewer.tsx} | 17 +- .../ContextMenuItemSelectForCompare.tsx} | 26 +- .../ContextMenuItemSendToUpscale.tsx} | 9 +- .../MenuItems/ContextMenuItemSendToVideo.tsx} | 11 +- .../MenuItems/ContextMenuItemStarUnstar.tsx} | 30 +- .../ContextMenuItemUseAsPromptTemplate.tsx} | 10 +- .../ContextMenuItemUseAsRefImage.tsx} | 9 +- ...ContextMenuItemUseForPromptGeneration.tsx} | 8 +- .../MultipleSelectionMenuItems.tsx | 0 .../MultipleSelectionVideoMenuItems.tsx | 63 ++++ .../ContextMenu/SingleSelectionMenuItems.tsx | 69 +++++ .../SingleSelectionVideoMenuItems.tsx | 36 +++ .../ContextMenu/VideoContextMenu.tsx | 279 ++++++++++++++++++ .../ImageMenuItemLoadWorkflow.tsx | 27 -- .../SingleSelectionMenuItems.tsx | 69 ----- .../components/ImageGrid/GalleryImage.tsx | 6 +- .../ImageGrid/GalleryImageHoverIcons.tsx | 28 -- .../ImageGrid/GalleryImageStarIconButton.tsx | 51 ---- ...on.tsx => GalleryItemDeleteIconButton.tsx} | 21 +- .../ImageGrid/GalleryItemHoverIcons.tsx | 28 ++ ... => GalleryItemOpenInViewerIconButton.tsx} | 19 +- ...SizeBadge.tsx => GalleryItemSizeBadge.tsx} | 10 +- .../ImageGrid/GalleryItemStarIconButton.tsx | 62 ++++ .../components/ImageGrid/GalleryVideo.tsx | 8 +- .../ImageViewer/CurrentImageButtons.tsx | 2 +- .../gallery/components/NewGallery.tsx | 3 +- .../gallery/contexts/ImageDTOContext.ts | 13 - .../gallery/contexts/ItemDTOContext.ts | 27 ++ .../components/Video/ParamDuration.tsx | 5 +- .../web/src/services/api/endpoints/images.ts | 55 +--- .../web/src/services/api/endpoints/videos.ts | 79 ++++- .../frontend/web/src/services/api/index.ts | 3 + .../frontend/web/src/services/api/types.ts | 6 + .../src/services/api/util/tagInvalidation.ts | 82 +++++ 48 files changed, 1036 insertions(+), 424 deletions(-) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu => ContextMenu}/ImageContextMenu.tsx (98%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemChangeBoard.tsx => ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx} (56%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemCopy.tsx => ContextMenu/MenuItems/ContextMenuItemCopy.tsx} (56%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemDelete.tsx => ContextMenu/MenuItems/ContextMenuItemDelete.tsx} (59%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemDownload.tsx => ContextMenu/MenuItems/ContextMenuItemDownload.tsx} (55%) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemLocateInGalery.tsx => ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx} (58%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx => ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx} (74%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemMetadataRecallActionsUpscaleTab.tsx => ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsUpscaleTab.tsx} (70%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx => ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx} (90%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx => ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx} (75%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemOpenInNewTab.tsx => ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx} (59%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemOpenInViewer.tsx => ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx} (65%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemSelectForCompare.tsx => ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx} (56%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemSendToUpscale.tsx => ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx} (76%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemSendToVideo.tsx => ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx} (67%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemStarUnstar.tsx => ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx} (54%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemUseAsPromptTemplate.tsx => ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx} (63%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemUseAsRefImage.tsx => ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx} (77%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu/ImageMenuItemUseForPromptGeneration.tsx => ContextMenu/MenuItems/ContextMenuItemUseForPromptGeneration.tsx} (82%) rename invokeai/frontend/web/src/features/gallery/components/{ImageContextMenu => ContextMenu}/MultipleSelectionMenuItems.tsx (100%) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/ContextMenu/VideoContextMenu.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx rename invokeai/frontend/web/src/features/gallery/components/ImageGrid/{GalleryImageDeleteIconButton.tsx => GalleryItemDeleteIconButton.tsx} (57%) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemHoverIcons.tsx rename invokeai/frontend/web/src/features/gallery/components/ImageGrid/{GalleryImageOpenInViewerIconButton.tsx => GalleryItemOpenInViewerIconButton.tsx} (61%) rename invokeai/frontend/web/src/features/gallery/components/ImageGrid/{GalleryImageSizeBadge.tsx => GalleryItemSizeBadge.tsx} (61%) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx delete mode 100644 invokeai/frontend/web/src/features/gallery/contexts/ImageDTOContext.ts create mode 100644 invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts create mode 100644 invokeai/frontend/web/src/services/api/util/tagInvalidation.ts diff --git a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx index 59f73f109b3..61a9aebf28a 100644 --- a/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalModalIsolator.tsx @@ -6,7 +6,8 @@ import { DeleteImageModal } from 'features/deleteImageModal/components/DeleteIma import { FullscreenDropzone } from 'features/dnd/FullscreenDropzone'; import { DynamicPromptsModal } from 'features/dynamicPrompts/components/DynamicPromptsPreviewModal'; import DeleteBoardModal from 'features/gallery/components/Boards/DeleteBoardModal'; -import { ImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { ImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu'; +import { VideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu'; import { ShareWorkflowModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/ShareWorkflowModal'; import { WorkflowLibraryModal } from 'features/nodes/components/sidePanel/workflow/WorkflowLibrary/WorkflowLibraryModal'; import { CancelAllExceptCurrentQueueItemConfirmationAlertDialog } from 'features/queue/components/CancelAllExceptCurrentQueueItemConfirmationAlertDialog'; @@ -47,6 +48,7 @@ export const GlobalModalIsolator = memo(() => { + diff --git a/invokeai/frontend/web/src/features/dnd/DndImage.tsx b/invokeai/frontend/web/src/features/dnd/DndImage.tsx index 0debc621ecf..e67c9013a45 100644 --- a/invokeai/frontend/web/src/features/dnd/DndImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndImage.tsx @@ -7,7 +7,7 @@ import { singleImageDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { firefoxDndFix } from 'features/dnd/util'; -import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; +import { useImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu'; import { forwardRef, memo, useEffect, useImperativeHandle, useRef, useState } from 'react'; import type { ImageDTO } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/ImageContextMenu.tsx similarity index 98% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/ImageContextMenu.tsx index 2b6c51e63ec..79e75af7e32 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageContextMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/ImageContextMenu.tsx @@ -3,8 +3,8 @@ import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke- import { useStore } from '@nanostores/react'; import { useAppSelector } from 'app/store/storeHooks'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; -import MultipleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems'; -import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems'; +import MultipleSelectionMenuItems from 'features/gallery/components/ContextMenu/MultipleSelectionMenuItems'; +import SingleSelectionMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionMenuItems'; import { selectSelectionCount } from 'features/gallery/store/gallerySelectors'; import { map } from 'nanostores'; import type { RefObject } from 'react'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx similarity index 56% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx index 331ccb5538f..a14f50f850c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx @@ -1,20 +1,25 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFoldersBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemChangeBoard = memo(() => { +export const ContextMenuItemChangeBoard = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const onClick = useCallback(() => { - dispatch(imagesToChangeSelected([imageDTO.image_name])); + if (isImageDTO(itemDTO)) { + dispatch(imagesToChangeSelected([itemDTO.image_name])); + } else { + // dispatch(videosToChangeSelected([itemDTO.video_id])); + } dispatch(isModalOpenChanged(true)); - }, [dispatch, imageDTO]); + }, [dispatch, itemDTO]); return ( } onClickCapture={onClick}> @@ -23,4 +28,4 @@ export const ImageMenuItemChangeBoard = memo(() => { ); }); -ImageMenuItemChangeBoard.displayName = 'ImageMenuItemChangeBoard'; +ContextMenuItemChangeBoard.displayName = 'ContextMenuItemChangeBoard'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemCopy.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy.tsx similarity index 56% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemCopy.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy.tsx index 06c24b3db45..35608d6ddee 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemCopy.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy.tsx @@ -1,18 +1,23 @@ import { IconMenuItem } from 'common/components/IconMenuItem'; import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCopyBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemCopy = memo(() => { +export const ContextMenuItemCopy = memo(() => { const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const copyImageToClipboard = useCopyImageToClipboard(); const onClick = useCallback(() => { - copyImageToClipboard(imageDTO.image_url); - }, [copyImageToClipboard, imageDTO.image_url]); + if (isImageDTO(itemDTO)) { + copyImageToClipboard(itemDTO.image_url); + } else { + // copyVideoToClipboard(itemDTO.video_url); + } + }, [copyImageToClipboard, itemDTO]); return ( { ); }); -ImageMenuItemCopy.displayName = 'ImageMenuItemCopy'; +ContextMenuItemCopy.displayName = 'ContextMenuItemCopy'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete.tsx similarity index 59% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete.tsx index 2708381b190..ac6504d496e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDelete.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete.tsx @@ -1,22 +1,27 @@ import { IconMenuItem } from 'common/components/IconMenuItem'; import { useDeleteImageModalApi } from 'features/deleteImageModal/store/state'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemDelete = memo(() => { +export const ContextMenuItemDelete = memo(() => { const { t } = useTranslation(); const deleteImageModal = useDeleteImageModalApi(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const onClick = useCallback(async () => { try { - await deleteImageModal.delete([imageDTO.image_name]); + if (isImageDTO(itemDTO)) { + await deleteImageModal.delete([itemDTO.image_name]); + } else { + // await deleteVideoModal.delete([itemDTO.video_id]); + } } catch { // noop; } - }, [deleteImageModal, imageDTO]); + }, [deleteImageModal, itemDTO]); return ( { ); }); -ImageMenuItemDelete.displayName = 'ImageMenuItemDelete'; +ContextMenuItemDelete.displayName = 'ContextMenuItemDelete'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDownload.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload.tsx similarity index 55% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDownload.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload.tsx index 03a2b2eccdc..b7ff4d7e940 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemDownload.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload.tsx @@ -1,18 +1,23 @@ import { IconMenuItem } from 'common/components/IconMenuItem'; import { useDownloadImage } from 'common/hooks/useDownloadImage'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiDownloadSimpleBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemDownload = memo(() => { +export const ContextMenuItemDownload = memo(() => { const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const { downloadImage } = useDownloadImage(); const onClick = useCallback(() => { - downloadImage(imageDTO.image_url, imageDTO.image_name); - }, [downloadImage, imageDTO.image_name, imageDTO.image_url]); + if (isImageDTO(itemDTO)) { + downloadImage(itemDTO.image_url, itemDTO.image_name); + } else { + // downloadVideo(itemDTO.video_url, itemDTO.video_id); + } + }, [downloadImage, itemDTO]); return ( { ); }); -ImageMenuItemDownload.displayName = 'ImageMenuItemDownload'; +ContextMenuItemDownload.displayName = 'ContextMenuItemDownload'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx new file mode 100644 index 00000000000..9aaf5dca4a9 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx @@ -0,0 +1,39 @@ +import { MenuItem } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; +import { $hasTemplates } from 'features/nodes/store/nodesSlice'; +import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFlowArrowBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; + +export const ContextMenuItemLoadWorkflow = memo(() => { + const { t } = useTranslation(); + const itemDTO = useItemDTOContext(); + const loadWorkflowWithDialog = useLoadWorkflowWithDialog(); + const hasTemplates = useStore($hasTemplates); + + const onClick = useCallback(() => { + if (isImageDTO(itemDTO)) { + loadWorkflowWithDialog({ type: 'image', data: itemDTO.image_name }); + } else { + // loadWorkflowWithDialog({ type: 'video', data: itemDTO.video_id }); + } + }, [loadWorkflowWithDialog, itemDTO]); + + const isDisabled = useMemo(() => { + if (isImageDTO(itemDTO)) { + return !itemDTO.has_workflow || !hasTemplates; + } + return false + }, [itemDTO, hasTemplates]); + + return ( + } onClickCapture={onClick} isDisabled={isDisabled}> + {t('nodes.loadWorkflow')} + + ); +}); + +ContextMenuItemLoadWorkflow.displayName = 'ContextMenuItemLoadWorkflow'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLocateInGalery.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx similarity index 58% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLocateInGalery.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx index 6074750597b..d9157604d2b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLocateInGalery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx @@ -1,6 +1,6 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel'; @@ -9,25 +9,30 @@ import { memo, useCallback, useMemo } from 'react'; import { flushSync } from 'react-dom'; import { useTranslation } from 'react-i18next'; import { PiCrosshairBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemLocateInGalery = memo(() => { +export const ContextMenuItemLocateInGalery = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const activeTab = useAppSelector(selectActiveTab); const galleryPanel = useGalleryPanel(activeTab); const isGalleryImage = useMemo(() => { - return !imageDTO.is_intermediate; - }, [imageDTO]); + return !itemDTO.is_intermediate; + }, [itemDTO]); const onClick = useCallback(() => { - navigationApi.expandRightPanel(); - galleryPanel.expand(); - flushSync(() => { - dispatch(boardIdSelected({ boardId: imageDTO.board_id ?? 'none', selectedImageName: imageDTO.image_name })); - }); - }, [dispatch, galleryPanel, imageDTO]); + if (isImageDTO(itemDTO)) { + navigationApi.expandRightPanel(); + galleryPanel.expand(); + flushSync(() => { + dispatch(boardIdSelected({ boardId: itemDTO.board_id ?? 'none', selectedImageName: itemDTO.image_name })); + }); + } else { + // TODO: Implement video locate in gallery + } + }, [dispatch, galleryPanel, itemDTO]); return ( } onClickCapture={onClick} isDisabled={!isGalleryImage}> @@ -36,4 +41,4 @@ export const ImageMenuItemLocateInGalery = memo(() => { ); }); -ImageMenuItemLocateInGalery.displayName = 'ImageMenuItemLocateInGalery'; +ContextMenuItemLocateInGalery.displayName = 'ContextMenuItemLocateInGalery'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx similarity index 74% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx index 9f1cd668d59..4ff5f0cccd7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsCanvasGenerateTabs.tsx @@ -1,6 +1,6 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { useRecallAll } from 'features/gallery/hooks/useRecallAll'; import { useRecallCLIPSkip } from 'features/gallery/hooks/useRecallCLIPSkip'; import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions'; @@ -17,19 +17,21 @@ import { PiQuotesBold, PiRulerBold, } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; -export const ImageMenuItemMetadataRecallActionsCanvasGenerateTabs = memo(() => { +export const ContextMenuItemMetadataRecallActionsCanvasGenerateTabs = memo(() => { const { t } = useTranslation(); const subMenu = useSubMenu(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); - const recallAll = useRecallAll(imageDTO); - const recallRemix = useRecallRemix(imageDTO); - const recallPrompts = useRecallPrompts(imageDTO); - const recallSeed = useRecallSeed(imageDTO); - const recallDimensions = useRecallDimensions(imageDTO); - const recallCLIPSkip = useRecallCLIPSkip(imageDTO); + // TODO: Implement video recall metadata actions + const recallAll = useRecallAll(itemDTO as ImageDTO); + const recallRemix = useRecallRemix(itemDTO as ImageDTO); + const recallPrompts = useRecallPrompts(itemDTO as ImageDTO); + const recallSeed = useRecallSeed(itemDTO as ImageDTO); + const recallDimensions = useRecallDimensions(itemDTO as ImageDTO); + const recallCLIPSkip = useRecallCLIPSkip(itemDTO as ImageDTO); return ( }> @@ -66,5 +68,5 @@ export const ImageMenuItemMetadataRecallActionsCanvasGenerateTabs = memo(() => { ); }); -ImageMenuItemMetadataRecallActionsCanvasGenerateTabs.displayName = - 'ImageMenuItemMetadataRecallActionsCanvasGenerateTabs'; +ContextMenuItemMetadataRecallActionsCanvasGenerateTabs.displayName = + 'ContextMenuItemMetadataRecallActionsCanvasGenerateTabs'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsUpscaleTab.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsUpscaleTab.tsx similarity index 70% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsUpscaleTab.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsUpscaleTab.tsx index 42fe1b62ee1..d3511e29e0b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsUpscaleTab.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsUpscaleTab.tsx @@ -1,20 +1,22 @@ import { Menu, MenuButton, MenuItem, MenuList } from '@invoke-ai/ui-library'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts'; import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowBendUpLeftBold, PiPlantBold, PiQuotesBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; -export const ImageMenuItemMetadataRecallActionsUpscaleTab = memo(() => { +export const ContextMenuItemMetadataRecallActionsUpscaleTab = memo(() => { const { t } = useTranslation(); const subMenu = useSubMenu(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); - const recallPrompts = useRecallPrompts(imageDTO); - const recallSeed = useRecallSeed(imageDTO); + // TODO: Implement video recall metadata actions + const recallPrompts = useRecallPrompts(itemDTO as ImageDTO); + const recallSeed = useRecallSeed(itemDTO as ImageDTO); return ( }> @@ -35,4 +37,4 @@ export const ImageMenuItemMetadataRecallActionsUpscaleTab = memo(() => { ); }); -ImageMenuItemMetadataRecallActionsUpscaleTab.displayName = 'ImageMenuItemMetadataRecallActionsUpscaleTab'; +ContextMenuItemMetadataRecallActionsUpscaleTab.displayName = 'ContextMenuItemMetadataRecallActionsUpscaleTab'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx similarity index 90% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx index ef234999369..d7007156a05 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx @@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { newCanvasFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -11,12 +11,13 @@ import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFileBold, PiPlusBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => { +export const ContextMenuItemNewCanvasFromImageSubMenu = memo(() => { const { t } = useTranslation(); const subMenu = useSubMenu(); const store = useAppStore(); - const imageDTO = useImageDTOContext(); + const imageDTO = useItemDTOContextImageOnly(); const isBusy = useCanvasIsBusySafe(); const isStaging = useCanvasIsStaging(); @@ -70,8 +71,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => { toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), - status: 'success', - }); + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => { @@ -88,8 +89,8 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => { toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), - status: 'success', - }); + status: 'success', + }); }, [imageDTO, store, t]); return ( @@ -133,4 +134,4 @@ export const ImageMenuItemNewCanvasFromImageSubMenu = memo(() => { ); }); -ImageMenuItemNewCanvasFromImageSubMenu.displayName = 'ImageMenuItemNewCanvasFromImageSubMenu'; +ContextMenuItemNewCanvasFromImageSubMenu.displayName = 'ContextMenuItemNewCanvasFromImageSubMenu'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx similarity index 75% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx index 23ee544173c..62ba54c90b8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx @@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { sentImageToCanvas } from 'features/gallery/store/actions'; import { createNewCanvasEntityFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; @@ -12,72 +12,73 @@ import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { +export const ContextMenuItemNewLayerFromImageSubMenu = memo(() => { const { t } = useTranslation(); const subMenu = useSubMenu(); const store = useAppStore(); - const imageDTO = useImageDTOContext(); + const imageDTO = useItemDTOContextImageOnly(); const isBusy = useCanvasIsBusySafe(); const onClickNewRasterLayerFromImage = useCallback(async () => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewControlLayerFromImage = useCallback(async () => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewInpaintMaskFromImage = useCallback(async () => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewRegionalGuidanceFromImage = useCallback(async () => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewRegionalReferenceImageFromImage = useCallback(async () => { const { dispatch, getState } = store; - await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); + await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); return ( @@ -112,4 +113,4 @@ export const ImageMenuItemNewLayerFromImageSubMenu = memo(() => { ); }); -ImageMenuItemNewLayerFromImageSubMenu.displayName = 'ImageMenuItemNewLayerFromImageSubMenu'; +ContextMenuItemNewLayerFromImageSubMenu.displayName = 'ContextMenuItemNewLayerFromImageSubMenu'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx similarity index 59% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx index f447460342e..26ec519d1d8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx @@ -1,19 +1,24 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { IconMenuItem } from 'common/components/IconMenuItem'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { imageOpenedInNewTab } from 'features/gallery/store/actions'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowSquareOutBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemOpenInNewTab = memo(() => { +export const ContextMenuItemOpenInNewTab = memo(() => { const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const dispatch = useAppDispatch(); const onClick = useCallback(() => { - window.open(imageDTO.image_url, '_blank'); + if (isImageDTO(itemDTO)) { + window.open(itemDTO.image_url, '_blank'); dispatch(imageOpenedInNewTab()); - }, [imageDTO.image_url, dispatch]); + } else { + // TODO: Implement video open in new tab + } + }, [itemDTO, dispatch]); return ( { ); }); -ImageMenuItemOpenInNewTab.displayName = 'ImageMenuItemOpenInNewTab'; +ContextMenuItemOpenInNewTab.displayName = 'ContextMenuItemOpenInNewTab'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx similarity index 65% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx index bd016b9c6dd..e70bda9dc4b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx @@ -1,22 +1,27 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { IconMenuItem } from 'common/components/IconMenuItem'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemOpenInViewer = memo(() => { +export const ContextMenuItemOpenInViewer = memo(() => { const dispatch = useAppDispatch(); const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const onClick = useCallback(() => { + if (isImageDTO(itemDTO)) { dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(imageDTO.image_name)); + dispatch(imageSelected(itemDTO.image_name)); navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID); - }, [dispatch, imageDTO]); + } else { + // TODO: Implement video open in viewer + } + }, [dispatch, itemDTO]); return ( { ); }); -ImageMenuItemOpenInViewer.displayName = 'ImageMenuItemOpenInViewer'; +ContextMenuItemOpenInViewer.displayName = 'ContextMenuItemOpenInViewer'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx similarity index 56% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx index 129671819fe..6bbe072e96f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx @@ -1,25 +1,35 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { IconMenuItem } from 'common/components/IconMenuItem'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { imageToCompareChanged, selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiImagesBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemSelectForCompare = memo(() => { +export const ContextMenuItemSelectForCompare = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const selectMaySelectForCompare = useMemo( - () => createSelector(selectGallerySlice, (gallery) => gallery.imageToCompare !== imageDTO.image_name), - [imageDTO.image_name] + () => createSelector(selectGallerySlice, (gallery) => { + if (isImageDTO(itemDTO)) { + return gallery.imageToCompare !== itemDTO.image_name; + } + return false; + }), + [itemDTO] ); const maySelectForCompare = useAppSelector(selectMaySelectForCompare); const onClick = useCallback(() => { - dispatch(imageToCompareChanged(imageDTO.image_name)); - }, [dispatch, imageDTO]); + if (isImageDTO(itemDTO)) { + dispatch(imageToCompareChanged(itemDTO.image_name)); + } else { + // TODO: Implement video select for compare + } + }, [dispatch, itemDTO]); return ( { ); }); -ImageMenuItemSelectForCompare.displayName = 'ImageMenuItemSelectForCompare'; +ContextMenuItemSelectForCompare.displayName = 'ContextMenuItemSelectForCompare'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx similarity index 76% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx index 14b561c836e..34e9eedd0c6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx @@ -1,18 +1,19 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { toast } from 'features/toast/toast'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiShareFatBold } from 'react-icons/pi'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemSendToUpscale = memo(() => { +export const ContextMenuItemSendToUpscale = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const imageDTO = useImageDTOContext(); + const imageDTO = useItemDTOContextImageOnly(); const handleSendToCanvas = useCallback(() => { dispatch(upscaleInitialImageChanged(imageDTOToImageWithDims(imageDTO))); @@ -31,4 +32,4 @@ export const ImageMenuItemSendToUpscale = memo(() => { ); }); -ImageMenuItemSendToUpscale.displayName = 'ImageMenuItemSendToUpscale'; +ContextMenuItemSendToUpscale.displayName = 'ContextMenuItemSendToUpscale'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx similarity index 67% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx index 4e7e39f9d78..c488d6b236e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx @@ -1,21 +1,22 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiVideoBold } from 'react-icons/pi'; import { useDispatch } from 'react-redux'; +import { isImageDTO } from 'services/api/types'; -export const ImageMenuItemSendToVideo = memo(() => { +export const ContextMenuItemSendToVideo = memo(() => { const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); + const imageDTO = useItemDTOContextImageOnly(); const dispatch = useDispatch(); const onClick = useCallback(() => { dispatch(startingFrameImageChanged(imageDTO)); navigationApi.switchToTab('video'); - }, [imageDTO]); + }, [imageDTO, dispatch]); return ( { ); }); -ImageMenuItemSendToVideo.displayName = 'ImageMenuItemSendToVideo'; \ No newline at end of file +ContextMenuItemSendToVideo.displayName = 'ContextMenuItemSendToVideo'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx similarity index 54% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx index fd89a328d49..214b586b4ec 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx @@ -1,32 +1,40 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $customStarUI } from 'app/store/nanostores/customStarUI'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiStarBold, PiStarFill } from 'react-icons/pi'; import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; +import { useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos'; +import { isImageDTO, isVideoDTO } from 'services/api/types'; -export const ImageMenuItemStarUnstar = memo(() => { +export const ContextMenuItemStarUnstar = memo(() => { const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); + const itemDTO = useItemDTOContext(); const customStarUi = useStore($customStarUI); const [starImages] = useStarImagesMutation(); const [unstarImages] = useUnstarImagesMutation(); + const [starVideos] = useStarVideosMutation(); + const [unstarVideos] = useUnstarVideosMutation(); const starImage = useCallback(() => { - if (imageDTO) { - starImages({ image_names: [imageDTO.image_name] }); + if (isImageDTO(itemDTO)) { + starImages({ image_names: [itemDTO.image_name] }); + } else if (isVideoDTO(itemDTO)) { + starVideos({ video_ids: [itemDTO.video_id] }); } - }, [starImages, imageDTO]); + }, [starImages, itemDTO]); const unstarImage = useCallback(() => { - if (imageDTO) { - unstarImages({ image_names: [imageDTO.image_name] }); + if (isImageDTO(itemDTO)) { + unstarImages({ image_names: [itemDTO.image_name] }); + } else if (isVideoDTO(itemDTO)) { + unstarVideos({ video_ids: [itemDTO.video_id] }); } - }, [unstarImages, imageDTO]); + }, [unstarImages, itemDTO]); - if (imageDTO.starred) { + if (itemDTO.starred) { return ( } onClickCapture={unstarImage}> {customStarUi ? customStarUi.off.text : t('gallery.unstarImage')} @@ -41,4 +49,4 @@ export const ImageMenuItemStarUnstar = memo(() => { ); }); -ImageMenuItemStarUnstar.displayName = 'ImageMenuItemStarUnstar'; +ContextMenuItemStarUnstar.displayName = 'ContextMenuItemStarUnstar'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsPromptTemplate.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx similarity index 63% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsPromptTemplate.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx index 33206fe7bf6..cd7f99b2ec7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsPromptTemplate.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx @@ -1,13 +1,15 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { useCreateStylePresetFromMetadata } from 'features/gallery/hooks/useCreateStylePresetFromMetadata'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPaintBrushBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; -export const ImageMenuItemUseAsPromptTemplate = memo(() => { +export const ContextMenuItemUseAsPromptTemplate = memo(() => { const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); + const imageDTO = useItemDTOContextImageOnly(); + const stylePreset = useCreateStylePresetFromMetadata(imageDTO); return ( @@ -17,4 +19,4 @@ export const ImageMenuItemUseAsPromptTemplate = memo(() => { ); }); -ImageMenuItemUseAsPromptTemplate.displayName = 'ImageMenuItemUseAsPromptTemplate'; +ContextMenuItemUseAsPromptTemplate.displayName = 'ContextMenuItemUseAsPromptTemplate'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx similarity index 77% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx index ea1e06342c9..4a183040672 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx @@ -3,16 +3,17 @@ import { useAppStore } from 'app/store/storeHooks'; import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiImageBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; -export const ImageMenuItemUseAsRefImage = memo(() => { +export const ContextMenuItemUseAsRefImage = memo(() => { const { t } = useTranslation(); const store = useAppStore(); - const imageDTO = useImageDTOContext(); + const imageDTO = useItemDTOContextImageOnly(); const onClickNewGlobalReferenceImageFromImage = useCallback(() => { const { dispatch, getState } = store; @@ -33,4 +34,4 @@ export const ImageMenuItemUseAsRefImage = memo(() => { ); }); -ImageMenuItemUseAsRefImage.displayName = 'ImageMenuItemUseAsRefImage'; + ContextMenuItemUseAsRefImage.displayName = 'ContextMenuItemUseAsRefImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseForPromptGeneration.tsx similarity index 82% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseForPromptGeneration.tsx index f4e0b64b5f8..12cbb22f9c9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseForPromptGeneration.tsx @@ -1,7 +1,7 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { expandPrompt } from 'features/prompt/PromptExpansion/expand'; import { promptExpansionApi } from 'features/prompt/PromptExpansion/state'; import { selectAllowPromptExpansion } from 'features/system/store/configSlice'; @@ -10,10 +10,10 @@ import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTextTBold } from 'react-icons/pi'; -export const ImageMenuItemUseForPromptGeneration = memo(() => { +export const ContextMenuItemUseForPromptGeneration = memo(() => { const { t } = useTranslation(); const { dispatch, getState } = useAppStore(); - const imageDTO = useImageDTOContext(); + const imageDTO = useItemDTOContextImageOnly(); const { isPending } = useStore(promptExpansionApi.$state); const isPromptExpansionEnabled = useAppSelector(selectAllowPromptExpansion); @@ -43,4 +43,4 @@ export const ImageMenuItemUseForPromptGeneration = memo(() => { ); }); -ImageMenuItemUseForPromptGeneration.displayName = 'ImageMenuItemUseForPromptGeneration'; +ContextMenuItemUseForPromptGeneration.displayName = 'ContextMenuItemUseForPromptGeneration'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx similarity index 100% rename from invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/MultipleSelectionMenuItems.tsx rename to invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx new file mode 100644 index 00000000000..3ff0a1e60c6 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx @@ -0,0 +1,63 @@ +import { MenuDivider, MenuItem } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { $customStarUI } from 'app/store/nanostores/customStarUI'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi'; +import { + useDeleteVideosMutation, + useStarVideosMutation, + useUnstarVideosMutation, +} from 'services/api/endpoints/videos'; + +const MultipleSelectionMenuItems = () => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const selection = useAppSelector((s) => s.gallery.selection); + const customStarUi = useStore($customStarUI); + + + const [starVideos] = useStarVideosMutation(); + const [unstarVideos] = useUnstarVideosMutation(); + const [deleteVideos] = useDeleteVideosMutation(); + + const handleChangeBoard = useCallback(() => { + dispatch(imagesToChangeSelected(selection)); + dispatch(isModalOpenChanged(true)); + }, [dispatch, selection]); + + const handleDeleteSelection = useCallback(() => { + // TODO: Add confirm on delete and video usage functionality + deleteVideos({ video_ids: selection }); + }, [deleteVideos, selection]); + + const handleStarSelection = useCallback(() => { + starVideos({ video_ids: selection }); + }, [starVideos, selection]); + + const handleUnstarSelection = useCallback(() => { + unstarVideos({ video_ids: selection }); + }, [unstarVideos, selection]); + + return ( + <> + } onClickCapture={handleUnstarSelection}> + {customStarUi ? customStarUi.off.text : `Unstar All`} + + } onClickCapture={handleStarSelection}> + {customStarUi ? customStarUi.on.text : `Star All`} + + } onClickCapture={handleChangeBoard}> + {t('boards.changeBoard')} + + + } onClickCapture={handleDeleteSelection}> + {t('gallery.deleteSelection')} + + + ); +}; + +export default memo(MultipleSelectionMenuItems); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx new file mode 100644 index 00000000000..e21fcd8ccd3 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx @@ -0,0 +1,69 @@ +import { MenuDivider } from '@invoke-ai/ui-library'; +import { useAppSelector } from 'app/store/storeHooks'; +import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard'; +import { ContextMenuItemCopy } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy'; +import { ContextMenuItemDelete } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete'; +import { ContextMenuItemDownload } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload'; +import { ContextMenuItemLoadWorkflow } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow'; +import { ContextMenuItemLocateInGalery } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery'; +import { ContextMenuItemMetadataRecallActionsCanvasGenerateTabs } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemMetadataRecallActionsCanvasGenerateTabs'; +import { ContextMenuItemNewCanvasFromImageSubMenu } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu'; +import { ContextMenuItemNewLayerFromImageSubMenu } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu'; +import { ContextMenuItemOpenInNewTab } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab'; +import { ContextMenuItemOpenInViewer } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer'; +import { ContextMenuItemSelectForCompare } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare'; +import { ContextMenuItemSendToUpscale } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale'; +import { ContextMenuItemSendToVideo } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo'; +import { ContextMenuItemStarUnstar } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar'; +import { ContextMenuItemUseAsPromptTemplate } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate'; +import { ContextMenuItemUseAsRefImage } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage'; +import { ContextMenuItemUseForPromptGeneration } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseForPromptGeneration'; +import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; +import type { ImageDTO } from 'services/api/types'; + +import { ContextMenuItemMetadataRecallActionsUpscaleTab } from './MenuItems/ContextMenuItemMetadataRecallActionsUpscaleTab'; + +type SingleSelectionMenuItemsProps = { + imageDTO: ImageDTO; +}; + +const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) => { + const tab = useAppSelector(selectActiveTab); + + return ( + + + + + + + + + + + + {(tab === 'canvas' || tab === 'generate') && } + {tab === 'upscaling' && } + + + + + {(tab === 'canvas' || tab === 'generate') && } + + + {tab === 'canvas' && } + + + + {(tab === 'canvas' || tab === 'generate' || tab === 'workflows' || tab === 'upscaling') && + !imageDTO.is_intermediate && ( + // Only render this button on tabs with a gallery. + + )} + + ); +}; + +export default SingleSelectionMenuItems; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx new file mode 100644 index 00000000000..2c2ebd8fbe6 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx @@ -0,0 +1,36 @@ +import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { ContextMenuItemCopy } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy'; +import { ContextMenuItemDelete } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete'; +import { ContextMenuItemDownload } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload'; +import { ContextMenuItemOpenInNewTab } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab'; +import { ContextMenuItemOpenInViewer } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer'; +import { ContextMenuItemSelectForCompare } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare'; +import type { VideoDTO } from 'services/api/types'; + +import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext'; +import { ContextMenuItemStarUnstar } from './MenuItems/ContextMenuItemStarUnstar'; +import { MenuDivider } from '@invoke-ai/ui-library'; + +type SingleSelectionVideoMenuItemsProps = { + videoDTO: VideoDTO; +}; + +const SingleSelectionVideoMenuItems = ({ videoDTO }: SingleSelectionVideoMenuItemsProps) => { + + return ( + + + + + + + + + + + + + ); +}; + +export default SingleSelectionVideoMenuItems; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/VideoContextMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/VideoContextMenu.tsx new file mode 100644 index 00000000000..533a3d38b9a --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/VideoContextMenu.tsx @@ -0,0 +1,279 @@ +import type { ChakraProps } from '@invoke-ai/ui-library'; +import { Menu, MenuButton, MenuList, Portal, useGlobalMenuClose } from '@invoke-ai/ui-library'; +import { useStore } from '@nanostores/react'; +import { useAppSelector } from 'app/store/storeHooks'; +import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; +import MultipleSelectionVideoMenuItems from 'features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems'; +import SingleSelectionVideoMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems'; +import { selectSelectionCount } from 'features/gallery/store/gallerySelectors'; +import { map } from 'nanostores'; +import type { RefObject } from 'react'; +import { memo, useCallback, useEffect, useRef } from 'react'; +import type { VideoDTO } from 'services/api/types'; + +/** + * The delay in milliseconds before the context menu opens on long press. + */ +const LONGPRESS_DELAY_MS = 500; +/** + * The threshold in pixels that the pointer must move before the long press is cancelled. + */ +const LONGPRESS_MOVE_THRESHOLD_PX = 10; + +/** + * The singleton state of the context menu. + */ +const $videoContextMenuState = map<{ + isOpen: boolean; + videoDTO: VideoDTO | null; + position: { x: number; y: number }; +}>({ + isOpen: false, + videoDTO: null, + position: { x: -1, y: -1 }, +}); + +/** + * Convenience function to close the context menu. + */ +const onClose = () => { + $videoContextMenuState.setKey('isOpen', false); +}; + +/** + * Map of elements to image DTOs. This is used to determine which image DTO to show the context menu for, depending on + * the target of the context menu or long press event. + */ +const elToVideoMap = new Map(); + +/** + * Given a target node, find the first registered parent element that contains the target node and return the imageDTO + * associated with it. + */ +const getVideoDTOFromMap = (target: Node): VideoDTO | undefined => { + const entry = Array.from(elToVideoMap.entries()).find((entry) => entry[0].contains(target)); + return entry?.[1]; +}; + +/** + * Register a context menu for an image DTO on a target element. + * @param imageDTO The image DTO to register the context menu for. + * @param targetRef The ref of the target element that should trigger the context menu. + */ +export const useVideoContextMenu = (videoDTO: VideoDTO, ref: RefObject | (HTMLElement | null)) => { + useEffect(() => { + if (ref === null) { + return; + } + const el = ref instanceof HTMLElement ? ref : ref.current; + if (!el) { + return; + } + elToVideoMap.set(el, videoDTO); + return () => { + elToVideoMap.delete(el); + }; + }, [videoDTO, ref]); +}; + +/** + * Singleton component that renders the context menu for images. + */ +export const VideoContextMenu = memo(() => { + useAssertSingleton('VideoContextMenu'); + const state = useStore($videoContextMenuState); + useGlobalMenuClose(onClose); + + return ( + + + + + + + + ); +}); + +VideoContextMenu.displayName = 'VideoContextMenu'; + +const _hover: ChakraProps['_hover'] = { bg: 'transparent' }; + +/** + * A logical component that listens for context menu events and opens the context menu. It's separate from + * ImageContextMenu component to avoid re-rendering the whole context menu on every context menu event. + */ +const VideoContextMenuEventLogical = memo(() => { + const lastPositionRef = useRef<{ x: number; y: number }>({ x: -1, y: -1 }); + const longPressTimeoutRef = useRef(0); + const animationTimeoutRef = useRef(0); + + const onContextMenu = useCallback((e: MouseEvent | PointerEvent) => { + if (e.shiftKey) { + // This is a shift + right click event, which should open the native context menu + onClose(); + return; + } + + const videoDTO = getVideoDTOFromMap(e.target as Node); + + if (!videoDTO) { + // Can't find the image DTO, close the context menu + onClose(); + return; + } + + // clear pending delayed open + window.clearTimeout(animationTimeoutRef.current); + e.preventDefault(); + + if (lastPositionRef.current.x !== e.pageX || lastPositionRef.current.y !== e.pageY) { + // if the mouse moved, we need to close, wait for animation and reopen the menu at the new position + if ($videoContextMenuState.get().isOpen) { + onClose(); + } + animationTimeoutRef.current = window.setTimeout(() => { + // Open the menu after the animation with the new state + $videoContextMenuState.set({ + isOpen: true, + position: { x: e.pageX, y: e.pageY }, + videoDTO, + }); + }, 100); + } else { + // else we can just open the menu at the current position w/ new state + $videoContextMenuState.set({ + isOpen: true, + position: { x: e.pageX, y: e.pageY }, + videoDTO, + }); + } + + // Always sync the last position + lastPositionRef.current = { x: e.pageX, y: e.pageY }; + }, []); + + // Use a long press to open the context menu on touch devices + const onPointerDown = useCallback( + (e: PointerEvent) => { + if (e.pointerType === 'mouse') { + // Bail out if it's a mouse event - this is for touch/pen only + return; + } + + longPressTimeoutRef.current = window.setTimeout(() => { + onContextMenu(e); + }, LONGPRESS_DELAY_MS); + + lastPositionRef.current = { x: e.pageX, y: e.pageY }; + }, + [onContextMenu] + ); + + const onPointerMove = useCallback((e: PointerEvent) => { + if (e.pointerType === 'mouse') { + // Bail out if it's a mouse event - this is for touch/pen only + return; + } + if (longPressTimeoutRef.current === null) { + return; + } + + // If the pointer has moved more than the threshold, cancel the long press + const lastPosition = lastPositionRef.current; + + const distanceFromLastPosition = Math.hypot(e.pageX - lastPosition.x, e.pageY - lastPosition.y); + + if (distanceFromLastPosition > LONGPRESS_MOVE_THRESHOLD_PX) { + clearTimeout(longPressTimeoutRef.current); + } + }, []); + + const onPointerUp = useCallback((e: PointerEvent) => { + if (e.pointerType === 'mouse') { + // Bail out if it's a mouse event - this is for touch/pen only + return; + } + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } + }, []); + + const onPointerCancel = useCallback((e: PointerEvent) => { + if (e.pointerType === 'mouse') { + // Bail out if it's a mouse event - this is for touch/pen only + return; + } + if (longPressTimeoutRef.current) { + clearTimeout(longPressTimeoutRef.current); + } + }, []); + + useEffect(() => { + const controller = new AbortController(); + + // Context menu events + window.addEventListener('contextmenu', onContextMenu, { signal: controller.signal }); + + // Long press events + window.addEventListener('pointerdown', onPointerDown, { signal: controller.signal }); + window.addEventListener('pointerup', onPointerUp, { signal: controller.signal }); + window.addEventListener('pointercancel', onPointerCancel, { signal: controller.signal }); + window.addEventListener('pointermove', onPointerMove, { signal: controller.signal }); + + return () => { + controller.abort(); + }; + }, [onContextMenu, onPointerCancel, onPointerDown, onPointerMove, onPointerUp]); + + useEffect( + () => () => { + // Clean up any timeouts when we unmount + window.clearTimeout(animationTimeoutRef.current); + window.clearTimeout(longPressTimeoutRef.current); + }, + [] + ); + + return null; +}); + +VideoContextMenuEventLogical.displayName = 'VideoContextMenuEventLogical'; + +// The content of the context menu, which changes based on the selection count. Split out and memoized to avoid +// re-rendering the whole context menu too often. +const MenuContent = memo(() => { + const selectionCount = useAppSelector(selectSelectionCount); + const state = useStore($videoContextMenuState); + + if (!state.videoDTO) { + return null; + } + + if (selectionCount > 1) { + return ( + + + + ); + } + + return ( + + + + ); +}); + +MenuContent.displayName = 'MenuContent'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx deleted file mode 100644 index 86bf6426bae..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow.tsx +++ /dev/null @@ -1,27 +0,0 @@ -import { MenuItem } from '@invoke-ai/ui-library'; -import { useStore } from '@nanostores/react'; -import { useImageDTOContext } from 'features/gallery/contexts/ImageDTOContext'; -import { $hasTemplates } from 'features/nodes/store/nodesSlice'; -import { useLoadWorkflowWithDialog } from 'features/workflowLibrary/components/LoadWorkflowConfirmationAlertDialog'; -import { memo, useCallback } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiFlowArrowBold } from 'react-icons/pi'; - -export const ImageMenuItemLoadWorkflow = memo(() => { - const { t } = useTranslation(); - const imageDTO = useImageDTOContext(); - const loadWorkflowWithDialog = useLoadWorkflowWithDialog(); - const hasTemplates = useStore($hasTemplates); - - const onClick = useCallback(() => { - loadWorkflowWithDialog({ type: 'image', data: imageDTO.image_name }); - }, [loadWorkflowWithDialog, imageDTO.image_name]); - - return ( - } onClickCapture={onClick} isDisabled={!imageDTO.has_workflow || !hasTemplates}> - {t('nodes.loadWorkflow')} - - ); -}); - -ImageMenuItemLoadWorkflow.displayName = 'ImageMenuItemLoadWorkflow'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx deleted file mode 100644 index dae7fc3bbc7..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ /dev/null @@ -1,69 +0,0 @@ -import { MenuDivider } from '@invoke-ai/ui-library'; -import { useAppSelector } from 'app/store/storeHooks'; -import { IconMenuItemGroup } from 'common/components/IconMenuItem'; -import { ImageMenuItemChangeBoard } from 'features/gallery/components/ImageContextMenu/ImageMenuItemChangeBoard'; -import { ImageMenuItemCopy } from 'features/gallery/components/ImageContextMenu/ImageMenuItemCopy'; -import { ImageMenuItemDelete } from 'features/gallery/components/ImageContextMenu/ImageMenuItemDelete'; -import { ImageMenuItemDownload } from 'features/gallery/components/ImageContextMenu/ImageMenuItemDownload'; -import { ImageMenuItemLoadWorkflow } from 'features/gallery/components/ImageContextMenu/ImageMenuItemLoadWorkflow'; -import { ImageMenuItemLocateInGalery } from 'features/gallery/components/ImageContextMenu/ImageMenuItemLocateInGalery'; -import { ImageMenuItemMetadataRecallActionsCanvasGenerateTabs } from 'features/gallery/components/ImageContextMenu/ImageMenuItemMetadataRecallActionsCanvasGenerateTabs'; -import { ImageMenuItemNewCanvasFromImageSubMenu } from 'features/gallery/components/ImageContextMenu/ImageMenuItemNewCanvasFromImageSubMenu'; -import { ImageMenuItemNewLayerFromImageSubMenu } from 'features/gallery/components/ImageContextMenu/ImageMenuItemNewLayerFromImageSubMenu'; -import { ImageMenuItemOpenInNewTab } from 'features/gallery/components/ImageContextMenu/ImageMenuItemOpenInNewTab'; -import { ImageMenuItemOpenInViewer } from 'features/gallery/components/ImageContextMenu/ImageMenuItemOpenInViewer'; -import { ImageMenuItemSelectForCompare } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSelectForCompare'; -import { ImageMenuItemSendToUpscale } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToUpscale'; -import { ImageMenuItemSendToVideo } from 'features/gallery/components/ImageContextMenu/ImageMenuItemSendToVideo'; -import { ImageMenuItemStarUnstar } from 'features/gallery/components/ImageContextMenu/ImageMenuItemStarUnstar'; -import { ImageMenuItemUseAsPromptTemplate } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsPromptTemplate'; -import { ImageMenuItemUseAsRefImage } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseAsRefImage'; -import { ImageMenuItemUseForPromptGeneration } from 'features/gallery/components/ImageContextMenu/ImageMenuItemUseForPromptGeneration'; -import { ImageDTOContextProvider } from 'features/gallery/contexts/ImageDTOContext'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; -import type { ImageDTO } from 'services/api/types'; - -import { ImageMenuItemMetadataRecallActionsUpscaleTab } from './ImageMenuItemMetadataRecallActionsUpscaleTab'; - -type SingleSelectionMenuItemsProps = { - imageDTO: ImageDTO; -}; - -const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) => { - const tab = useAppSelector(selectActiveTab); - - return ( - - - - - - - - - - - - {(tab === 'canvas' || tab === 'generate') && } - {tab === 'upscaling' && } - - - - - {(tab === 'canvas' || tab === 'generate') && } - - - {tab === 'canvas' && } - - - - {(tab === 'canvas' || tab === 'generate' || tab === 'workflows' || tab === 'upscaling') && - !imageDTO.is_intermediate && ( - // Only render this button on tabs with a gallery. - - )} - - ); -}; - -export default SingleSelectionMenuItems; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 53e2dec7f1e..5a1df27ff77 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -12,8 +12,8 @@ import { createMultipleImageDragPreview, setMultipleImageDragPreview } from 'fea import type { DndDragPreviewSingleImageState } from 'features/dnd/DndDragPreviewSingleImage'; import { createSingleImageDragPreview, setSingleImageDragPreview } from 'features/dnd/DndDragPreviewSingleImage'; import { firefoxDndFix } from 'features/dnd/util'; -import { useImageContextMenu } from 'features/gallery/components/ImageContextMenu/ImageContextMenu'; -import { GalleryImageHoverIcons } from 'features/gallery/components/ImageGrid/GalleryImageHoverIcons'; +import { useImageContextMenu } from 'features/gallery/components/ContextMenu/ImageContextMenu'; +import { GalleryItemHoverIcons } from 'features/gallery/components/ImageGrid/GalleryItemHoverIcons'; import { selectGetImageNamesQueryArgs, selectSelectedBoardId, @@ -262,7 +262,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { maxH="full" borderRadius="base" /> - + {dragPreviewState?.type === 'multiple-image' ? createMultipleImageDragPreview(dragPreviewState) : null} {dragPreviewState?.type === 'single-image' ? createSingleImageDragPreview(dragPreviewState) : null} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx deleted file mode 100644 index dcaa5729d13..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageHoverIcons.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { useAppSelector } from 'app/store/storeHooks'; -import { GalleryImageDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryImageDeleteIconButton'; -import { GalleryImageOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton'; -import { GalleryImageSizeBadge } from 'features/gallery/components/ImageGrid/GalleryImageSizeBadge'; -import { GalleryImageStarIconButton } from 'features/gallery/components/ImageGrid/GalleryImageStarIconButton'; -import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors'; -import { memo } from 'react'; -import type { ImageDTO } from 'services/api/types'; - -type Props = { - imageDTO: ImageDTO; - isHovered: boolean; -}; - -export const GalleryImageHoverIcons = memo(({ imageDTO, isHovered }: Props) => { - const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge); - - return ( - <> - {(isHovered || alwaysShowImageSizeBadge) && } - {(isHovered || imageDTO.starred) && } - {isHovered && } - {isHovered && } - - ); -}); - -GalleryImageHoverIcons.displayName = 'GalleryImageHoverIcons'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx deleted file mode 100644 index 0306c2095d9..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageStarIconButton.tsx +++ /dev/null @@ -1,51 +0,0 @@ -import { useStore } from '@nanostores/react'; -import { $customStarUI } from 'app/store/nanostores/customStarUI'; -import { DndImageIcon } from 'features/dnd/DndImageIcon'; -import { memo, useCallback } from 'react'; -import { PiStarBold, PiStarFill } from 'react-icons/pi'; -import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; -import type { ImageDTO } from 'services/api/types'; - -type Props = { - imageDTO: ImageDTO; -}; - -export const GalleryImageStarIconButton = memo(({ imageDTO }: Props) => { - const customStarUi = useStore($customStarUI); - const [starImages] = useStarImagesMutation(); - const [unstarImages] = useUnstarImagesMutation(); - - const toggleStarredState = useCallback(() => { - if (imageDTO.starred) { - unstarImages({ image_names: [imageDTO.image_name] }); - } else { - starImages({ image_names: [imageDTO.image_name] }); - } - }, [starImages, unstarImages, imageDTO]); - - if (customStarUi) { - return ( - - ); - } - - return ( - : } - tooltip={imageDTO.starred ? 'Unstar' : 'Star'} - position="absolute" - top={2} - insetInlineEnd={2} - /> - ); -}); - -GalleryImageStarIconButton.displayName = 'GalleryImageStarIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx similarity index 57% rename from invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx rename to invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx index 137bb3e6fde..278debfca93 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageDeleteIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx @@ -5,26 +5,33 @@ import type { MouseEvent } from 'react'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleFill } from 'react-icons/pi'; -import type { ImageDTO } from 'services/api/types'; +import { useDeleteVideosMutation } from 'services/api/endpoints/videos'; +import { isImageDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; type Props = { - imageDTO: ImageDTO; + itemDTO: ImageDTO | VideoDTO; }; -export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => { +export const GalleryItemDeleteIconButton = memo(({ itemDTO }: Props) => { const shift = useShiftModifier(); const { t } = useTranslation(); const deleteImageModal = useDeleteImageModalApi(); + const [deleteVideos] = useDeleteVideosMutation(); const onClick = useCallback( (e: MouseEvent) => { e.stopPropagation(); - if (!imageDTO) { + if (!itemDTO) { return; } - deleteImageModal.delete([imageDTO.image_name]); + if (isImageDTO(itemDTO)) { + deleteImageModal.delete([itemDTO.image_name]); + } else { + // TODO: Add confirm on delete and video usage functionality + deleteVideos({ video_ids: [itemDTO.video_id] }); + } }, - [deleteImageModal, imageDTO] + [deleteImageModal, deleteVideos, itemDTO] ); if (!shift) { @@ -43,4 +50,4 @@ export const GalleryImageDeleteIconButton = memo(({ imageDTO }: Props) => { ); }); -GalleryImageDeleteIconButton.displayName = 'GalleryImageDeleteIconButton'; +GalleryItemDeleteIconButton.displayName = 'GalleryItemDeleteIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemHoverIcons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemHoverIcons.tsx new file mode 100644 index 00000000000..03e69780dc4 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemHoverIcons.tsx @@ -0,0 +1,28 @@ +import { useAppSelector } from 'app/store/storeHooks'; +import { GalleryItemDeleteIconButton } from 'features/gallery/components/ImageGrid/GalleryItemDeleteIconButton'; +import { GalleryItemOpenInViewerIconButton } from 'features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton'; +import { GalleryItemSizeBadge } from 'features/gallery/components/ImageGrid/GalleryItemSizeBadge'; +import { GalleryItemStarIconButton } from 'features/gallery/components/ImageGrid/GalleryItemStarIconButton'; +import { selectAlwaysShouldImageSizeBadge } from 'features/gallery/store/gallerySelectors'; +import { memo } from 'react'; +import type { ImageDTO, VideoDTO } from 'services/api/types'; + +type Props = { + itemDTO: ImageDTO | VideoDTO; + isHovered: boolean; +}; + +export const GalleryItemHoverIcons = memo(({ itemDTO, isHovered }: Props) => { + const alwaysShowImageSizeBadge = useAppSelector(selectAlwaysShouldImageSizeBadge); + + return ( + <> + {(isHovered || alwaysShowImageSizeBadge) && } + {(isHovered || itemDTO.starred) && } + {isHovered && } + {isHovered && } + + ); +}); + +GalleryItemHoverIcons.displayName = 'GalleryItemHoverIcons'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx similarity index 61% rename from invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx rename to invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx index aba327e8670..fa5c8781c76 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx @@ -6,21 +6,26 @@ import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; -import type { ImageDTO } from 'services/api/types'; +import { isImageDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; type Props = { - imageDTO: ImageDTO; + itemDTO: ImageDTO | VideoDTO; }; -export const GalleryImageOpenInViewerIconButton = memo(({ imageDTO }: Props) => { +export const GalleryItemOpenInViewerIconButton = memo(({ itemDTO }: Props) => { const dispatch = useAppDispatch(); const { t } = useTranslation(); const onClick = useCallback(() => { - dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(imageDTO.image_name)); + if (isImageDTO(itemDTO)) { + dispatch(imageToCompareChanged(null)); + dispatch(imageSelected(itemDTO.image_name)); + } else { + // dispatch(videoToCompareChanged(null)); + // dispatch(videoSelected(itemDTO.video_id)); + } navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID); - }, [dispatch, imageDTO]); + }, [dispatch, itemDTO]); return ( ); }); -GalleryImageOpenInViewerIconButton.displayName = 'GalleryImageOpenInViewerIconButton'; +GalleryItemOpenInViewerIconButton.displayName = 'GalleryItemOpenInViewerIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemSizeBadge.tsx similarity index 61% rename from invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx rename to invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemSizeBadge.tsx index e7e473d86a6..9ed52ef1554 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImageSizeBadge.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemSizeBadge.tsx @@ -1,12 +1,12 @@ import { Text } from '@invoke-ai/ui-library'; import { memo } from 'react'; -import type { ImageDTO } from 'services/api/types'; +import type { ImageDTO, VideoDTO } from 'services/api/types'; type Props = { - imageDTO: ImageDTO; + itemDTO: ImageDTO | VideoDTO; }; -export const GalleryImageSizeBadge = memo(({ imageDTO }: Props) => { +export const GalleryItemSizeBadge = memo(({ itemDTO }: Props) => { return ( { lineHeight={1.25} borderTopEndRadius="base" pointerEvents="none" - >{`${imageDTO.width}x${imageDTO.height}`} + >{`${itemDTO.width}x${itemDTO.height}`} ); }); -GalleryImageSizeBadge.displayName = 'GalleryImageSizeBadge'; +GalleryItemSizeBadge.displayName = 'GalleryItemSizeBadge'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx new file mode 100644 index 00000000000..fb1530c4c4b --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx @@ -0,0 +1,62 @@ +import { useStore } from '@nanostores/react'; +import { $customStarUI } from 'app/store/nanostores/customStarUI'; +import { DndImageIcon } from 'features/dnd/DndImageIcon'; +import { memo, useCallback } from 'react'; +import { PiStarBold, PiStarFill } from 'react-icons/pi'; +import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; +import { useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos'; +import { isImageDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; + +type Props = { + itemDTO: ImageDTO | VideoDTO; +}; + +export const GalleryItemStarIconButton = memo(({ itemDTO }: Props) => { + const customStarUi = useStore($customStarUI); + const [starImages] = useStarImagesMutation(); + const [unstarImages] = useUnstarImagesMutation(); + const [starVideos] = useStarVideosMutation(); + const [unstarVideos] = useUnstarVideosMutation(); + + const toggleStarredState = useCallback(() => { + if (itemDTO.starred) { + if (isImageDTO(itemDTO)) { + unstarImages({ image_names: [itemDTO.image_name] }); + } else { + unstarVideos({ video_ids: [itemDTO.video_id] }); + } + } else { + if (isImageDTO(itemDTO)) { + starImages({ image_names: [itemDTO.image_name] }); + } else { + starVideos({ video_ids: [itemDTO.video_id] }); + } + } + }, [starImages, unstarImages, starVideos, unstarVideos, itemDTO]); + + if (customStarUi) { + return ( + + ); + } + + return ( + : } + tooltip={itemDTO.starred ? 'Unstar' : 'Star'} + position="absolute" + top={2} + insetInlineEnd={2} + /> + ); +}); + +GalleryItemStarIconButton.displayName = 'GalleryItemStarIconButton'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx index 2c05069986d..a73243fea11 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -25,6 +25,8 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiImageBold } from 'react-icons/pi'; import { imagesApi } from 'services/api/endpoints/images'; import type { VideoDTO } from 'services/api/types'; +import { GalleryItemHoverIcons } from './GalleryItemHoverIcons'; +import { useVideoContextMenu } from '../ContextMenu/VideoContextMenu'; const galleryImageContainerSX = { containerType: 'inline-size', @@ -220,7 +222,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID); }, [store]); - // useImageContextMenu(videoDTO, ref); + useVideoContextMenu(videoDTO, ref); return ( <> @@ -247,7 +249,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { maxH="full" borderRadius="base" /> - {/* */} + {dragPreviewState?.type === 'multiple-video' ? createMultipleVideoDragPreview(dragPreviewState) : null} {dragPreviewState?.type === 'single-video' ? createSingleVideoDragPreview(dragPreviewState) : null} @@ -255,7 +257,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { ); }); -GalleryVideo.displayName = 'GalleryImage'; +GalleryVideo.displayName = 'GalleryVideo'; export const GalleryImagePlaceholder = memo((props: FlexProps) => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index e64e5fa74bf..19eb7dd7cbb 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -1,7 +1,7 @@ import { Button, Divider, IconButton, Menu, MenuButton, MenuList } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton'; -import SingleSelectionMenuItems from 'features/gallery/components/ImageContextMenu/SingleSelectionMenuItems'; +import SingleSelectionMenuItems from 'features/gallery/components/ContextMenu/SingleSelectionMenuItems'; import { useDeleteImage } from 'features/gallery/hooks/useDeleteImage'; import { useEditImage } from 'features/gallery/hooks/useEditImage'; import { useLoadWorkflow } from 'features/gallery/hooks/useLoadWorkflow'; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 01ef4205b8d..8c358653ed1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -35,6 +35,7 @@ import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage' import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; import { useGalleryImageNames } from './use-gallery-image-names'; import { useGalleryVideoIds } from './use-gallery-video-ids'; +import { GalleryVideo } from './ImageGrid/GalleryVideo'; const log = logger('gallery'); @@ -91,7 +92,7 @@ const VideoAtPosition = memo(({ itemId }: { index: number; itemId: string }) => return ; } - return ; + return ; }); VideoAtPosition.displayName = 'VideoAtPosition'; diff --git a/invokeai/frontend/web/src/features/gallery/contexts/ImageDTOContext.ts b/invokeai/frontend/web/src/features/gallery/contexts/ImageDTOContext.ts deleted file mode 100644 index dcb01f5ba61..00000000000 --- a/invokeai/frontend/web/src/features/gallery/contexts/ImageDTOContext.ts +++ /dev/null @@ -1,13 +0,0 @@ -import { createContext, useContext } from 'react'; -import type { ImageDTO } from 'services/api/types'; -import { assert } from 'tsafe'; - -const ImageDTOContext = createContext(null); - -export const ImageDTOContextProvider = ImageDTOContext.Provider; - -export const useImageDTOContext = () => { - const imageDTO = useContext(ImageDTOContext); - assert(imageDTO !== null, 'useImageDTOContext must be used within ImageDTOContextProvider'); - return imageDTO; -}; diff --git a/invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts b/invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts new file mode 100644 index 00000000000..da95d013542 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts @@ -0,0 +1,27 @@ +import { createContext, useContext } from 'react'; +import { isImageDTO, isVideoDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; +import { assert } from 'tsafe'; + +const ItemDTOContext = createContext(null); + +export const ItemDTOContextProvider = ItemDTOContext.Provider; + +export const useItemDTOContext = () => { + const itemDTO = useContext(ItemDTOContext); + assert(itemDTO !== null, 'useItemDTOContext must be used within ItemDTOContextProvider'); + return itemDTO; +}; + +export const useItemDTOContextImageOnly = (): ImageDTO => { + const itemDTO = useContext(ItemDTOContext); + assert(itemDTO !== null, 'useItemDTOContext must be used within ItemDTOContextProvider'); + assert(isImageDTO(itemDTO), 'ItemDTO is not an image'); + return itemDTO as ImageDTO; +}; + +export const useItemDTOContextVideoOnly = (): VideoDTO => { + const itemDTO = useContext(ItemDTOContext); + assert(itemDTO !== null, 'useItemDTOContext must be used within ItemDTOContextProvider'); + assert(isVideoDTO(itemDTO), 'ItemDTO is not a video'); + return itemDTO as VideoDTO; +}; diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx index 963725b6682..86608614895 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx @@ -20,11 +20,12 @@ export const ParamDuration = () => { const onChange = useCallback>( (e) => { - if (!isParameterDuration(e.target.value)) { + const duration = parseInt(e.target.value); + if (!isParameterDuration(duration)) { return; } - dispatch(setVideoDuration(e.target.value)); + dispatch(setVideoDuration(duration)); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 878973c34c9..114fdf8fdc7 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -19,9 +19,9 @@ import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import type { JsonObject } from 'type-fest'; -import type { ApiTagDescription } from '..'; import { api, buildV1Url, LIST_TAG } from '..'; import { buildBoardsUrl } from './boards'; +import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForImageMutation } from '../util/tagInvalidation'; /** * Builds an endpoint URL for the images router @@ -591,57 +591,4 @@ export const useImageDTO = (imageName: string | null | undefined) => { return imageDTO ?? null; }; -const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => { - const tags: ApiTagDescription[] = []; - for (const image_name of image_names) { - tags.push({ - type: 'Image', - id: image_name, - }); - tags.push({ - type: 'ImageMetadata', - id: image_name, - }); - tags.push({ - type: 'ImageWorkflow', - id: image_name, - }); - } - - return tags; -}; - -const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => { - const tags: ApiTagDescription[] = ['ImageNameList']; - - for (const board_id of affected_boards) { - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id, - categories: IMAGE_CATEGORIES, - }), - }); - - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id, - categories: ASSETS_CATEGORIES, - }), - }); - - tags.push({ - type: 'Board', - id: board_id, - }); - - tags.push({ - type: 'BoardImagesTotal', - id: board_id, - }); - } - - return tags; -}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 58eda65915a..2afee589d7f 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -7,7 +7,8 @@ import type { import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; -import { api, buildV1Url } from '..'; +import { api, buildV1Url, LIST_TAG } from '..'; +import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForVideoMutation } from '../util/tagInvalidation'; /** * Builds an endpoint URL for the videos router @@ -76,6 +77,79 @@ export const videosApi = api.injectEndpoints({ } }, }), + /** + * Star a list of videos. + */ + starVideos: build.mutation< + paths['/api/v1/videos/star']['post']['responses']['200']['content']['application/json'], + paths['/api/v1/videos/star']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => ({ + url: buildVideosUrl('star'), + method: 'POST', + body, + }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + return [ + ...getTagsToInvalidateForVideoMutation(result.starred_videos), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'VideoCollectionCounts', + { type: 'VideoCollection', id: 'starred' }, + { type: 'VideoCollection', id: 'unstarred' }, + ]; + }, + }), + /** + * Unstar a list of videos. + */ + unstarVideos: build.mutation< + paths['/api/v1/videos/unstar']['post']['responses']['200']['content']['application/json'], + paths['/api/v1/videos/unstar']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => ({ + url: buildVideosUrl('unstar'), + method: 'POST', + body, + }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + return [ + ...getTagsToInvalidateForVideoMutation(result.unstarred_videos), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'VideoCollectionCounts', + { type: 'VideoCollection', id: 'starred' }, + { type: 'VideoCollection', id: 'unstarred' }, + ]; + }, + }), + deleteVideos: build.mutation< + paths['/api/v1/videos/delete']['post']['responses']['200']['content']['application/json'], + paths['/api/v1/videos/delete']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => ({ + url: buildVideosUrl('delete'), + method: 'POST', + body, + }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + // We ignore the deleted images when getting tags to invalidate. If we did not, we will invalidate the queries + // that fetch image DTOs, metadata, and workflows. But we have just deleted those images! Invalidating the tags + // will force those queries to re-fetch, and the requests will of course 404. + return [ + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + 'VideoCollectionCounts', + { type: 'VideoCollection', id: LIST_TAG }, + ]; + }, + }), }), }); @@ -83,6 +157,9 @@ export const { useGetVideoDTOQuery, useGetVideoIdsQuery, useGetVideoDTOsByNamesMutation, + useStarVideosMutation, + useUnstarVideosMutation, + useDeleteVideosMutation, } = videosApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index 5029a40fbcb..2b72394feed 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -55,7 +55,10 @@ const tagTypes = [ 'Schema', 'QueueCountsByDestination', 'Video', + 'VideoList', 'VideoIdList', + 'VideoCollectionCounts', + 'VideoCollection', // This is invalidated on reconnect. It should be used for queries that have changing data, // especially related to the queue and generation. 'FetchOnReconnect', diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 6406a46a70c..7b8475c19a2 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -69,6 +69,9 @@ const _zImageDTO = z.object({ }); export type ImageDTO = z.infer; assert>(); +export const isImageDTO = (dto: ImageDTO | VideoDTO): dto is ImageDTO => { + return 'image_name' in dto; +}; export type BoardDTO = S['BoardDTO']; export type OffsetPaginatedResults_ImageDTO_ = S['OffsetPaginatedResults_ImageDTO_']; @@ -91,6 +94,9 @@ const _zVideoDTO = z.object({ }); export type VideoDTO = z.infer; assert>(); +export const isVideoDTO = (dto: ImageDTO | VideoDTO): dto is VideoDTO => { + return 'video_id' in dto; +}; export type OffsetPaginatedResults_VideoDTO_ = S['OffsetPaginatedResults_VideoDTO_']; // Models diff --git a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts new file mode 100644 index 00000000000..003bf8805af --- /dev/null +++ b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts @@ -0,0 +1,82 @@ +import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from "features/gallery/store/types"; +import { ApiTagDescription } from ".."; +import { getListImagesUrl } from "../util"; + +export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => { + const tags: ApiTagDescription[] = ['ImageNameList']; + + for (const board_id of affected_boards) { + tags.push({ + type: 'ImageList', + id: getListImagesUrl({ + board_id, + categories: IMAGE_CATEGORIES, + }), + }); + + tags.push({ + type: 'ImageList', + id: getListImagesUrl({ + board_id, + categories: ASSETS_CATEGORIES, + }), + }); + + tags.push({ + type: 'VideoIdList', + + }); + + tags.push({ + type: 'Board', + id: board_id, + }); + + tags.push({ + type: 'BoardImagesTotal', + id: board_id, + }); + } + + return tags; + }; + + +export const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => { + const tags: ApiTagDescription[] = []; + + for (const image_name of image_names) { + tags.push({ + type: 'Image', + id: image_name, + }); + tags.push({ + type: 'ImageMetadata', + id: image_name, + }); + tags.push({ + type: 'ImageWorkflow', + id: image_name, + }); + } + + return tags; + }; + +export const getTagsToInvalidateForVideoMutation = (video_ids: string[]): ApiTagDescription[] => { + const tags: ApiTagDescription[] = []; + + for (const video_id of video_ids) { + tags.push({ + type: 'Video', + id: video_id, + }); + // tags.push({ + // type: 'VideoMetadata', + // id: video_id, + // }); + + } + + return tags; + }; \ No newline at end of file From 217ed444265a6f115af6d5e7063999810b9520ca Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Tue, 19 Aug 2025 16:28:05 -0400 Subject: [PATCH 20/55] stubbing out change board functionality --- invokeai/app/api/routers/board_videos.py | 38 +++++ invokeai/app/api_app.py | 2 + .../web/src/common/hooks/useDownloadImage.ts | 13 +- .../MenuItems/ContextMenuItemDelete.tsx | 7 +- .../MenuItems/ContextMenuItemDownload.tsx | 10 +- .../MenuItems/ContextMenuItemOpenInNewTab.tsx | 2 +- .../web/src/services/api/endpoints/videos.ts | 44 ++++- .../frontend/web/src/services/api/schema.ts | 153 ++++++++++++++++++ 8 files changed, 254 insertions(+), 15 deletions(-) create mode 100644 invokeai/app/api/routers/board_videos.py diff --git a/invokeai/app/api/routers/board_videos.py b/invokeai/app/api/routers/board_videos.py new file mode 100644 index 00000000000..29ee640d650 --- /dev/null +++ b/invokeai/app/api/routers/board_videos.py @@ -0,0 +1,38 @@ +from fastapi import Body, HTTPException +from fastapi.routing import APIRouter + +from invokeai.app.services.videos_common import AddVideosToBoardResult, RemoveVideosFromBoardResult + +board_videos_router = APIRouter(prefix="/v1/board_videos", tags=["boards"]) + +@board_videos_router.post( + "/batch", + operation_id="add_videos_to_board", + responses={ + 201: {"description": "Videos were added to board successfully"}, + }, + status_code=201, + response_model=AddVideosToBoardResult, +) +async def add_videos_to_board( + board_id: str = Body(description="The id of the board to add to"), + video_ids: list[str] = Body(description="The ids of the videos to add", embed=True), +) -> AddVideosToBoardResult: + """Adds a list of videos to a board""" + raise HTTPException(status_code=501, detail="Not implemented") + + +@board_videos_router.post( + "/batch/delete", + operation_id="remove_videos_from_board", + responses={ + 201: {"description": "Videos were removed from board successfully"}, + }, + status_code=201, + response_model=RemoveVideosFromBoardResult, +) +async def remove_videos_from_board( + video_ids: list[str] = Body(description="The ids of the videos to remove", embed=True), +) -> RemoveVideosFromBoardResult: + """Removes a list of videos from their board, if they had one""" + raise HTTPException(status_code=501, detail="Not implemented") diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 2ff66c6982d..07ff4ae8c43 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -20,6 +20,7 @@ board_images, boards, client_state, + board_videos, download_queue, images, model_manager, @@ -129,6 +130,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(videos.videos_router, prefix="/api") app.include_router(boards.boards_router, prefix="/api") app.include_router(board_images.board_images_router, prefix="/api") +app.include_router(board_videos.board_videos_router, prefix="/api") app.include_router(model_relationships.model_relationships_router, prefix="/api") app.include_router(app_info.app_router, prefix="/api") app.include_router(session_queue.session_queue_router, prefix="/api") diff --git a/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts index ede247b9fbe..900c67fa39c 100644 --- a/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts +++ b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts @@ -5,14 +5,15 @@ import { imageDownloaded } from 'features/gallery/store/actions'; import { toast } from 'features/toast/toast'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; +import { ImageDTO, VideoDTO } from 'services/api/types'; -export const useDownloadImage = () => { +export const useDownloadItem = (itemDTO: ImageDTO | VideoDTO) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const authToken = useStore($authToken); - const downloadImage = useCallback( - async (image_url: string, image_name: string) => { + const downloadItem = useCallback( + async (item_url: string, item_id: string) => { try { const requestOpts = authToken ? { @@ -21,7 +22,7 @@ export const useDownloadImage = () => { }, } : {}; - const blob = await fetch(image_url, requestOpts).then((resp) => resp.blob()); + const blob = await fetch(item_url, requestOpts).then((resp) => resp.blob()); if (!blob) { throw new Error('Unable to create Blob'); } @@ -30,7 +31,7 @@ export const useDownloadImage = () => { const a = document.createElement('a'); a.style.display = 'none'; a.href = url; - a.download = image_name; + a.download = item_id; document.body.appendChild(a); a.click(); window.URL.revokeObjectURL(url); @@ -47,5 +48,5 @@ export const useDownloadImage = () => { [t, dispatch, authToken] ); - return { downloadImage }; + return { downloadItem }; }; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete.tsx index ac6504d496e..ac42c65124d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete.tsx @@ -4,11 +4,13 @@ import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleBold } from 'react-icons/pi'; +import { useDeleteVideosMutation } from 'services/api/endpoints/videos'; import { isImageDTO } from 'services/api/types'; export const ContextMenuItemDelete = memo(() => { const { t } = useTranslation(); const deleteImageModal = useDeleteImageModalApi(); + const [deleteVideos] = useDeleteVideosMutation(); const itemDTO = useItemDTOContext(); const onClick = useCallback(async () => { @@ -16,12 +18,13 @@ export const ContextMenuItemDelete = memo(() => { if (isImageDTO(itemDTO)) { await deleteImageModal.delete([itemDTO.image_name]); } else { - // await deleteVideoModal.delete([itemDTO.video_id]); + // TODO: Add confirm on delete and video usage functionality + await deleteVideos({ video_ids: [itemDTO.video_id] }); } } catch { // noop; } - }, [deleteImageModal, itemDTO]); + }, [deleteImageModal, deleteVideos, itemDTO]); return ( { const { t } = useTranslation(); const itemDTO = useItemDTOContext(); - const { downloadImage } = useDownloadImage(); + const { downloadItem } = useDownloadItem(itemDTO); const onClick = useCallback(() => { if (isImageDTO(itemDTO)) { - downloadImage(itemDTO.image_url, itemDTO.image_name); + downloadItem(itemDTO.image_url, itemDTO.image_name); } else { - // downloadVideo(itemDTO.video_url, itemDTO.video_id); + downloadItem(itemDTO.video_url, itemDTO.video_id); } - }, [downloadImage, itemDTO]); + }, [downloadItem, itemDTO]); return ( { window.open(itemDTO.image_url, '_blank'); dispatch(imageOpenedInNewTab()); } else { - // TODO: Implement video open in new tab + window.open(itemDTO.video_url, '_blank'); } }, [itemDTO, dispatch]); diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 2afee589d7f..6366e70c37f 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -8,7 +8,7 @@ import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import { api, buildV1Url, LIST_TAG } from '..'; -import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForVideoMutation } from '../util/tagInvalidation'; +import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForImageMutation, getTagsToInvalidateForVideoMutation } from '../util/tagInvalidation'; /** * Builds an endpoint URL for the videos router @@ -19,6 +19,8 @@ import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForVid const buildVideosUrl = (path: string = '', query?: Parameters[1]) => buildV1Url(`videos/${path}`, query); +const buildBoardVideosUrl = (path: string = '') => buildV1Url(`board_videos/${path}`); + export const videosApi = api.injectEndpoints({ endpoints: (build) => ({ /** @@ -150,6 +152,44 @@ export const videosApi = api.injectEndpoints({ ]; }, }), + addVideosToBoard: build.mutation< + paths['/api/v1/board_videos/batch']['post']['responses']['201']['content']['application/json'], + paths['/api/v1/board_videos/batch']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => ({ + url: buildBoardVideosUrl('batch'), + method: 'POST', + body, + }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + return [ + ...getTagsToInvalidateForVideoMutation(result.added_videos), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + ]; + }, + }), + removeVideosFromBoard: build.mutation< + paths['/api/v1/board_videos/batch/delete']['post']['responses']['201']['content']['application/json'], + paths['/api/v1/board_videos/batch/delete']['post']['requestBody']['content']['application/json'] + >({ + query: (body) => ({ + url: buildBoardVideosUrl('batch/delete'), + method: 'POST', + body, + }), + invalidatesTags: (result) => { + if (!result) { + return []; + } + return [ + ...getTagsToInvalidateForVideoMutation(result.removed_videos), + ...getTagsToInvalidateForBoardAffectingMutation(result.affected_boards), + ]; + }, + }), }), }); @@ -160,6 +200,8 @@ export const { useStarVideosMutation, useUnstarVideosMutation, useDeleteVideosMutation, + useAddVideosToBoardMutation, + useRemoveVideosFromBoardMutation, } = videosApi; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 6a4c91e847c..4ee8bf43839 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1083,6 +1083,46 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/board_videos/batch": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Add Videos To Board + * @description Adds a list of videos to a board + */ + post: operations["add_videos_to_board"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/board_videos/batch/delete": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** + * Remove Videos From Board + * @description Removes a list of videos from their board, if they had one + */ + post: operations["remove_videos_from_board"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/model_relationships/i/{model_key}": { parameters: { query?: never; @@ -2087,6 +2127,19 @@ export type components = { */ type: "add"; }; + /** AddVideosToBoardResult */ + AddVideosToBoardResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Added Videos + * @description The video ids that were added to the board + */ + added_videos: string[]; + }; /** * Alpha Mask to Tensor * @description Convert a mask image to a tensor. Opaque regions are 1 and transparent regions are 0. @@ -2772,6 +2825,19 @@ export type components = { */ image_names: string[]; }; + /** Body_add_videos_to_board */ + Body_add_videos_to_board: { + /** + * Board Id + * @description The id of the board to add to + */ + board_id: string; + /** + * Video Ids + * @description The ids of the videos to add + */ + video_ids: string[]; + }; /** Body_cancel_by_batch_ids */ Body_cancel_by_batch_ids: { /** @@ -2957,6 +3023,14 @@ export type components = { */ image_names: string[]; }; + /** Body_remove_videos_from_board */ + Body_remove_videos_from_board: { + /** + * Video Ids + * @description The ids of the videos to remove + */ + video_ids: string[]; + }; /** Body_set_workflow_thumbnail */ Body_set_workflow_thumbnail: { /** @@ -18385,6 +18459,19 @@ export type components = { */ removed_images: string[]; }; + /** RemoveVideosFromBoardResult */ + RemoveVideosFromBoardResult: { + /** + * Affected Boards + * @description The ids of boards affected by the delete operation + */ + affected_boards: string[]; + /** + * Removed Videos + * @description The video ids that were removed from their board + */ + removed_videos: string[]; + }; /** * Resize Latents * @description Resizes latents to explicit width/height (in pixels). Provided dimensions are floor-divided by 8. @@ -25148,6 +25235,72 @@ export interface operations { }; }; }; + add_videos_to_board: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_add_videos_to_board"]; + }; + }; + responses: { + /** @description Videos were added to board successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["AddVideosToBoardResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; + remove_videos_from_board: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody: { + content: { + "application/json": components["schemas"]["Body_remove_videos_from_board"]; + }; + }; + responses: { + /** @description Videos were removed from board successfully */ + 201: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["RemoveVideosFromBoardResult"]; + }; + }; + /** @description Validation Error */ + 422: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["HTTPValidationError"]; + }; + }; + }; + }; get_related_models: { parameters: { query?: never; From 45984890eb4364a330c0db74f10706e37c490a89 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:10:35 +1000 Subject: [PATCH 21/55] fix(ui): panel names on video tab --- .../features/ui/layouts/video-tab-auto-layout.tsx | 15 +++++++++------ 1 file changed, 9 insertions(+), 6 deletions(-) diff --git a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx index b4ec7339b61..c15e683d0ca 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx @@ -7,7 +7,8 @@ import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightP import type { AutoLayoutDockviewComponents, AutoLayoutGridviewComponents, - PanelParameters, + DockviewPanelParameters, + GridviewPanelParameters, RootLayoutGridviewComponents, } from 'features/ui/layouts/auto-layout-context'; import { AutoLayoutProvider, useAutoLayoutContext, withPanelContainer } from 'features/ui/layouts/auto-layout-context'; @@ -57,7 +58,7 @@ const mainPanelComponents: AutoLayoutDockviewComponents = { const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { navigationApi.registerContainer(tab, 'main', api, () => { - const launchpad = api.addPanel({ + const launchpad = api.addPanel({ id: LAUNCHPAD_PANEL_ID, component: LAUNCHPAD_PANEL_ID, title: t('ui.panels.launchpad'), @@ -65,10 +66,11 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { params: { tab, focusRegion: 'launchpad', + i18nKey: 'ui.panels.launchpad' }, }); - api.addPanel({ + api.addPanel({ id: VIEWER_PANEL_ID, component: VIEWER_PANEL_ID, title: t('ui.panels.imageViewer'), @@ -76,6 +78,7 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { params: { tab, focusRegion: 'viewer', + i18nKey: 'ui.panels.imageViewer' }, position: { direction: 'within', @@ -123,7 +126,7 @@ const rightPanelComponents: AutoLayoutGridviewComponents = { const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { navigationApi.registerContainer(tab, 'right', api, () => { - const gallery = api.addPanel({ + const gallery = api.addPanel({ id: GALLERY_PANEL_ID, component: GALLERY_PANEL_ID, minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, @@ -134,7 +137,7 @@ const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { }, }); - const boards = api.addPanel({ + const boards = api.addPanel({ id: BOARDS_PANEL_ID, component: BOARDS_PANEL_ID, minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, @@ -179,7 +182,7 @@ const leftPanelComponents: AutoLayoutGridviewComponents = { const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => { navigationApi.registerContainer(tab, 'left', api, () => { - api.addPanel({ + api.addPanel({ id: SETTINGS_PANEL_ID, component: SETTINGS_PANEL_ID, params: { From 990fec4992d84c3fb768eb7c3edde0e589fdceec Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 20 Aug 2025 15:11:17 +1000 Subject: [PATCH 22/55] feat(ui): gallery optimistic updates for video --- .../web/src/services/api/endpoints/videos.ts | 39 +++- .../services/api/util/optimisticUpdates.ts | 59 +++++- .../services/events/onInvocationComplete.tsx | 171 ++++++++++++++++-- 3 files changed, 247 insertions(+), 22 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 6366e70c37f..88036168a9d 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -1,14 +1,14 @@ +import { getStore } from 'app/store/nanostores/store'; import type { paths } from 'services/api/schema'; -import type { - GetVideoIdsArgs, - GetVideoIdsResult, - VideoDTO, -} from 'services/api/types'; +import type { GetVideoIdsArgs, GetVideoIdsResult, VideoDTO } from 'services/api/types'; +import { + getTagsToInvalidateForBoardAffectingMutation, + getTagsToInvalidateForVideoMutation, +} from 'services/api/util/tagInvalidation'; import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import { api, buildV1Url, LIST_TAG } from '..'; -import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForImageMutation, getTagsToInvalidateForVideoMutation } from '../util/tagInvalidation'; /** * Builds an endpoint URL for the videos router @@ -16,10 +16,10 @@ import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForIma * buildVideosUrl('some-path') * // '/api/v1/videos/some-path' */ -const buildVideosUrl = (path: string = '', query?: Parameters[1]) => +const buildVideosUrl = (path: string = '', query?: Parameters[1]) => buildV1Url(`videos/${path}`, query); -const buildBoardVideosUrl = (path: string = '') => buildV1Url(`board_videos/${path}`); +const buildBoardVideosUrl = (path: string = '') => buildV1Url(`board_videos/${path}`); export const videosApi = api.injectEndpoints({ endpoints: (build) => ({ @@ -31,7 +31,6 @@ export const videosApi = api.injectEndpoints({ query: (video_id) => ({ url: buildVideosUrl(`i/${video_id}`) }), providesTags: (result, error, video_id) => [{ type: 'Video', id: video_id }], }), - /** * Get ordered list of image names for selection operations @@ -204,4 +203,24 @@ export const { useRemoveVideosFromBoardMutation, } = videosApi; - +/** + * Imperative RTKQ helper to fetch an VideoDTO. + * @param id The id of the video to fetch + * @param options The options for the query. By default, the query will not subscribe to the store. + * @returns The ImageDTO if found, otherwise null + */ +export const getVideoDTOSafe = async ( + id: string, + options?: Parameters[1] +): Promise => { + const _options = { + subscribe: false, + ...options, + }; + const req = getStore().dispatch(videosApi.endpoints.getVideoDTOsByNames.initiate({ video_ids: [id] }, _options)); + try { + return (await req.unwrap())[0] ?? null; + } catch { + return null; + } +}; diff --git a/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts b/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts index 772dc077fb5..ca79a99ca49 100644 --- a/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts +++ b/invokeai/frontend/web/src/services/api/util/optimisticUpdates.ts @@ -1,5 +1,5 @@ import type { OrderDir } from 'features/gallery/store/types'; -import type { GetImageNamesResult, ImageDTO } from 'services/api/types'; +import type { GetImageNamesResult, GetVideoIdsResult, ImageDTO, VideoDTO } from 'services/api/types'; /** * Calculates the optimal insertion position for a new image in the names list. @@ -57,3 +57,60 @@ export function insertImageIntoNamesResult( total_count: currentResult.total_count + 1, }; } + +/** + * Calculates the optimal insertion position for a new image in the names list. + * For starred_first=true: starred images go to position 0, unstarred go after all starred images + * For starred_first=false: all new images go to position 0 (newest first) + */ +function calculateVideoInsertionPosition( + videoDTO: VideoDTO, + starredFirst: boolean, + starredCount: number, + orderDir: OrderDir = 'DESC' +): number { + if (!starredFirst) { + // When starred_first is false, insertion depends on order direction + return orderDir === 'DESC' ? 0 : Number.MAX_SAFE_INTEGER; + } + + // When starred_first is true + if (videoDTO.starred) { + // Starred images: beginning for desc, after existing starred for asc + return orderDir === 'DESC' ? 0 : starredCount; + } + + // Unstarred images go after all starred images + return orderDir === 'DESC' ? starredCount : Number.MAX_SAFE_INTEGER; +} + +/** + * Optimistically inserts a new image into the ImageNamesResult at the correct position + */ +export function insertVideoIntoGetVideoIdsResult( + currentResult: GetVideoIdsResult, + videoDTO: VideoDTO, + starredFirst: boolean, + orderDir: OrderDir = 'DESC' +): GetVideoIdsResult { + // Don't insert if the image is already in the list + if (currentResult.video_ids.includes(videoDTO.video_id)) { + return currentResult; + } + + const insertPosition = calculateVideoInsertionPosition(videoDTO, starredFirst, currentResult.starred_count, orderDir); + + const newVideoIds = [...currentResult.video_ids]; + // Handle MAX_SAFE_INTEGER by pushing to end + if (insertPosition >= newVideoIds.length) { + newVideoIds.push(videoDTO.video_id); + } else { + newVideoIds.splice(insertPosition, 0, videoDTO.video_id); + } + + return { + video_ids: newVideoIds, + starred_count: starredFirst && videoDTO.starred ? currentResult.starred_count + 1 : currentResult.starred_count, + total_count: currentResult.total_count + 1, + }; +} diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index bdf02cc26d2..82664852120 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -5,6 +5,7 @@ import { selectAutoSwitch, selectGalleryView, selectGetImageNamesQueryArgs, + selectGetVideoIdsQueryArgs, selectListBoardsQueryArgs, selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; @@ -17,9 +18,10 @@ import { generatedVideoChanged } from 'features/parameters/store/videoSlice'; import type { LRUCache } from 'lru-cache'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; -import type { ImageDTO, S } from 'services/api/types'; +import { getVideoDTOSafe, videosApi } from 'services/api/endpoints/videos'; +import type { ImageDTO, S, VideoDTO } from 'services/api/types'; import { getCategories } from 'services/api/util'; -import { insertImageIntoNamesResult } from 'services/api/util/optimisticUpdates'; +import { insertImageIntoNamesResult, insertVideoIntoGetVideoIdsResult } from 'services/api/util/optimisticUpdates'; import { $lastProgressEvent } from 'services/events/stores'; import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; @@ -185,6 +187,154 @@ export const buildOnInvocationComplete = ( } }; + const addVideosToGallery = async (data: S['InvocationCompleteEvent']) => { + if (nodeTypeDenylist.includes(data.invocation.type)) { + log.trace(`Skipping denylisted node type (${data.invocation.type})`); + return; + } + + const videoDTOs = await getResultVideoDTOs(data); + if (videoDTOs.length === 0) { + return; + } + + // For efficiency's sake, we want to minimize the number of dispatches and invalidations we do. + // We'll keep track of each change we need to make and do them all at once. + const boardTotalAdditions: Record = {}; + const getVideoIdsArg = selectGetVideoIdsQueryArgs(getState()); + + for (const videoDTO of videoDTOs) { + if (videoDTO.is_intermediate) { + return; + } + + const board_id = videoDTO.board_id ?? 'none'; + + boardTotalAdditions[board_id] = (boardTotalAdditions[board_id] || 0) + 1; + } + + // Update all the board image totals at once + const entries: Param0 = []; + for (const [boardId, amountToAdd] of objectEntries(boardTotalAdditions)) { + // upsertQueryEntries doesn't provide a "recipe" function for the update - we must provide the new value + // directly. So we need to select the board totals first. + const total = boardsApi.endpoints.getBoardImagesTotal.select(boardId)(getState()).data?.total; + if (total === undefined) { + // No cache exists for this board, so we can't update it. + continue; + } + entries.push({ + endpointName: 'getBoardImagesTotal', + arg: boardId, + value: { total: total + amountToAdd }, + }); + } + dispatch(boardsApi.util.upsertQueryEntries(entries)); + + dispatch( + boardsApi.util.updateQueryData('listAllBoards', selectListBoardsQueryArgs(getState()), (draft) => { + for (const board of draft) { + board.image_count = board.image_count + (boardTotalAdditions[board.board_id] ?? 0); + } + }) + ); + + /** + * Optimistic update and cache invalidation for image names queries that match this image's board and categories. + * - Optimistic update for the cache that does not have a search term (we cannot derive the correct insertion + * position when a search term is present). + * - Cache invalidation for the query that has a search term, so it will be refetched. + * + * Note: The image DTO objects are already implicitly cached by the getResultImageDTOs function. We do not need + * to explicitly cache them again here. + */ + for (const videoDTO of videoDTOs) { + // Override board_id and categories for this specific image to build the "expected" args for the query. + const videoSpecificArgs = { + board_id: videoDTO.board_id ?? 'none', + }; + + const expectedQueryArgs = { + ...getVideoIdsArg, + ...videoSpecificArgs, + search_term: '', + }; + + // If the cache for the query args provided here does not exist, RTK Query will ignore the update. + dispatch( + videosApi.util.updateQueryData( + 'getVideoIds', + { + ...getVideoIdsArg, + ...videoSpecificArgs, + search_term: '', + }, + (draft) => { + const updatedResult = insertVideoIntoGetVideoIdsResult( + draft, + videoDTO, + expectedQueryArgs.starred_first ?? true, + expectedQueryArgs.order_dir + ); + + draft.video_ids = updatedResult.video_ids; + draft.starred_count = updatedResult.starred_count; + draft.total_count = updatedResult.total_count; + } + ) + ); + + // If there is a search term present, we need to invalidate that query to ensure the search results are updated. + if (getVideoIdsArg.search_term) { + const expectedQueryArgs = { + ...getVideoIdsArg, + ...videoSpecificArgs, + }; + dispatch(imagesApi.util.invalidateTags([{ type: 'ImageNameList', id: stableHash(expectedQueryArgs) }])); + } + } + + // No need to invalidate tags since we're doing optimistic updates + // Board totals are already updated above via upsertQueryEntries + + const autoSwitch = selectAutoSwitch(getState()); + + if (!autoSwitch) { + return; + } + + // Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list. + const lastVideoDTO = videoDTOs.at(-1); + + if (!lastVideoDTO) { + return; + } + + const { video_id } = lastVideoDTO; + const board_id = lastVideoDTO.board_id ?? 'none'; + + // With optimistic updates, we can immediately switch to the new image + const selectedBoardId = selectSelectedBoardId(getState()); + + // If the image is from a different board, switch to that board & select the image - otherwise just select the + // image. This implicitly changes the view to 'images' if it was not already. + if (board_id !== selectedBoardId) { + dispatch( + boardIdSelected({ + boardId: board_id, + selectedImageName: video_id, + }) + ); + } else { + // Ensure we are on the 'images' gallery view - that's where this image will be displayed + const galleryView = selectGalleryView(getState()); + if (galleryView !== 'videos') { + dispatch(galleryViewChanged('videos')); + } + // Select the image immediately since we've optimistically updated the cache + dispatch(imageSelected(lastVideoDTO.video_id)); + } + }; const getResultImageDTOs = async (data: S['InvocationCompleteEvent']): Promise => { const { result } = data; const imageDTOs: ImageDTO[] = []; @@ -206,17 +356,20 @@ export const buildOnInvocationComplete = ( return imageDTOs; }; - const getResultVideoFields = (data: S['InvocationCompleteEvent']): VideoField[] => { + const getResultVideoDTOs = async (data: S['InvocationCompleteEvent']): Promise => { const { result } = data; - const videoFields: VideoField[] = []; + const videoDTOs: VideoDTO[] = []; for (const [_name, value] of objectEntries(result)) { if (isVideoField(value)) { - videoFields.push(value); + const videoDTO = await getVideoDTOSafe(value.video_id); + if (videoDTO) { + videoDTOs.push(videoDTO); + } } } - return videoFields; + return videoDTOs; }; return async (data: S['InvocationCompleteEvent']) => { @@ -239,11 +392,7 @@ export const buildOnInvocationComplete = ( } await addImagesToGallery(data); - - const videoField = getResultVideoFields(data)[0]; - if (videoField) { - dispatch(generatedVideoChanged({ videoField })); - } + await addVideosToGallery(data); $lastProgressEvent.set(null); }; From 12b70bca678c58a8917ec29cc04d229d4b664385 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Wed, 20 Aug 2025 18:42:14 +1000 Subject: [PATCH 23/55] feat(ui): consolidated gallery (wip) --- .../features/gallery/components/Gallery.tsx | 2 +- .../gallery/components/NewGallery.tsx | 159 +++++++++++------- .../components/use-gallery-image-names.ts | 35 +++- .../web/src/services/api/endpoints/videos.ts | 6 + 4 files changed, 134 insertions(+), 68 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 5033e32c330..038b12d6e08 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -122,7 +122,7 @@ export const GalleryPanel = memo(() => { - {galleryView === 'images' ? : galleryView === 'videos' ? : } + ); diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 8c358653ed1..fb2ad8e8634 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -4,7 +4,7 @@ import { logger } from 'app/logging/logger'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; -import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import type { selectGetImageNamesQueryArgs, selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, selectGalleryView, @@ -28,23 +28,30 @@ import type { } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; import { imagesApi, useImageDTO, useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; -import { videosApi } from 'services/api/endpoints/videos'; +import { useStarVideosMutation, useUnstarVideosMutation, useVideoDTO, videosApi } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; -import { useGalleryImageNames } from './use-gallery-image-names'; -import { useGalleryVideoIds } from './use-gallery-video-ids'; import { GalleryVideo } from './ImageGrid/GalleryVideo'; +import { useGalleryImageNames, useGalleryVideoIds } from './use-gallery-image-names'; const log = logger('gallery'); type ListImageNamesQueryArgs = ReturnType; +type ListVideoIdsQueryArgs = ReturnType; -type GridContext = { - queryArgs: ListImageNamesQueryArgs; - imageNames: string[]; -}; +type GridContext = + | { + queryArgs: ListImageNamesQueryArgs; + galleryView: 'images' | 'assets'; + itemIds: string[]; + } + | { + queryArgs: ListVideoIdsQueryArgs; + galleryView: 'videos'; + itemIds: string[]; + }; const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string }) => { /* @@ -96,8 +103,8 @@ const VideoAtPosition = memo(({ itemId }: { index: number; itemId: string }) => }); VideoAtPosition.displayName = 'VideoAtPosition'; -const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { - return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; +const computeItemKey: GridComputeItemKey = (index, id, { queryArgs }) => { + return `${JSON.stringify(queryArgs)}-${id ?? index}`; }; /** @@ -106,7 +113,7 @@ const computeItemKey: GridComputeItemKey = (index, imageNam * TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value * changes. Cache this calculation. */ -const getImagesPerRow = (rootEl: HTMLDivElement): number => { +const getItemsPerRow = (rootEl: HTMLDivElement): number => { // Start from root and find virtuoso grid elements const gridElement = rootEl.querySelector('.virtuoso-grid-list'); @@ -140,20 +147,20 @@ const getImagesPerRow = (rootEl: HTMLDivElement): number => { * * Instead, we use a more robust approach that iteratively calculates how many images fit in the row. */ - let imagesPerRow = 0; + let itemsPerRow = 0; let spaceUsed = 0; // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes // this, without the possibility of accidentally adding an extra column. while (spaceUsed + itemRect.width <= containerRect.width + 1) { - imagesPerRow++; // Increment the number of images + itemsPerRow++; // Increment the number of images spaceUsed += itemRect.width; // Add image size to the used space if (spaceUsed + gap <= containerRect.width) { spaceUsed += gap; // Add gap size to the used space after each image except after the last image } } - return Math.max(1, imagesPerRow); + return Math.max(1, itemsPerRow); }; /** @@ -180,9 +187,7 @@ const scrollIntoView = ( return; } - const targetItem = rootEl.querySelector( - `.virtuoso-grid-item:has([data-item-id="${targetItemId}"])` - ) as HTMLElement; + const targetItem = rootEl.querySelector(`.virtuoso-grid-item:has([data-item-id="${targetItemId}"])`) as HTMLElement; if (!targetItem) { if (targetIndex > range.endIndex) { @@ -268,11 +273,11 @@ const scrollIntoView = ( * If the image name is not found, return 0. * If no image name is provided, return 0. */ -const getImageIndex = (imageName: string | undefined | null, imageNames: string[]) => { - if (!imageName || imageNames.length === 0) { +const getItemIndex = (targetItemId: string | undefined | null, itemIds: string[]) => { + if (!targetItemId || itemIds.length === 0) { return 0; } - const index = imageNames.findIndex((n) => n === imageName); + const index = itemIds.findIndex((n) => n === targetItemId); return index >= 0 ? index : 0; }; @@ -280,7 +285,7 @@ const getImageIndex = (imageName: string | undefined | null, imageNames: string[ * Handles keyboard navigation for the gallery. */ const useKeyboardNavigation = ( - imageNames: string[], + itemIds: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject ) => { @@ -308,13 +313,13 @@ const useKeyboardNavigation = ( return; } - if (imageNames.length === 0) { + if (itemIds.length === 0) { return; } - const imagesPerRow = getImagesPerRow(rootEl); + const itemsPerRow = getItemsPerRow(rootEl); - if (imagesPerRow === 0) { + if (itemsPerRow === 0) { // This can happen if the grid is not yet rendered or has no items return; } @@ -322,13 +327,14 @@ const useKeyboardNavigation = ( event.preventDefault(); const state = getState(); - const imageName = event.altKey - ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, - // we start from the last selected image - (selectImageToCompare(state) ?? selectLastSelectedImage(state)) - : selectLastSelectedImage(state); + const imageName = + event.altKey && selectGalleryView(state) !== 'videos' + ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, + // we start from the last selected image + (selectImageToCompare(state) ?? selectLastSelectedImage(state)) + : selectLastSelectedImage(state); - const currentIndex = getImageIndex(imageName, imageNames); + const currentIndex = getItemIndex(imageName, itemIds); let newIndex = currentIndex; @@ -342,7 +348,7 @@ const useKeyboardNavigation = ( } break; case 'ArrowRight': - if (currentIndex < imageNames.length - 1) { + if (currentIndex < itemIds.length - 1) { newIndex = currentIndex + 1; // } else { // // Wrap to first image @@ -351,26 +357,26 @@ const useKeyboardNavigation = ( break; case 'ArrowUp': // If on first row, stay on current image - if (currentIndex < imagesPerRow) { + if (currentIndex < itemsPerRow) { newIndex = currentIndex; } else { - newIndex = Math.max(0, currentIndex - imagesPerRow); + newIndex = Math.max(0, currentIndex - itemsPerRow); } break; case 'ArrowDown': // If no images below, stay on current image - if (currentIndex >= imageNames.length - imagesPerRow) { + if (currentIndex >= itemIds.length - itemsPerRow) { newIndex = currentIndex; } else { - newIndex = Math.min(imageNames.length - 1, currentIndex + imagesPerRow); + newIndex = Math.min(itemIds.length - 1, currentIndex + itemsPerRow); } break; } - if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) { - const newImageName = imageNames[newIndex]; + if (newIndex !== currentIndex && newIndex >= 0 && newIndex < itemIds.length) { + const newImageName = itemIds[newIndex]; if (newImageName) { - if (event.altKey) { + if (selectGalleryView(state) !== 'videos' && event.altKey) { dispatch(imageToCompareChanged(newImageName)); } else { dispatch(selectionChanged([newImageName])); @@ -378,7 +384,7 @@ const useKeyboardNavigation = ( } } }, - [rootRef, virtuosoRef, imageNames, getState, dispatch] + [rootRef, virtuosoRef, itemIds, getState, dispatch] ); useRegisteredHotkeys({ @@ -451,8 +457,8 @@ const useKeyboardNavigation = ( * This is useful for keyboard navigation and ensuring the user can see their selection. * It only tracks the last selected image, not the image to compare. */ -const useKeepSelectedImageInView = ( - imageNames: string[], +const useKeepSelectedItemInView = ( + itemIds: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject, rangeRef: MutableRefObject @@ -460,19 +466,19 @@ const useKeepSelectedImageInView = ( const selection = useAppSelector(selectSelection); useEffect(() => { - const targetImageName = selection.at(-1); + const targetItemId = selection.at(-1); const virtuosoGridHandle = virtuosoRef.current; const rootEl = rootRef.current; const range = rangeRef.current; - if (!virtuosoGridHandle || !rootEl || !targetImageName || !imageNames || imageNames.length === 0) { + if (!virtuosoGridHandle || !rootEl || !targetItemId || !itemIds || itemIds.length === 0) { return; } setTimeout(() => { - scrollIntoView(targetImageName, imageNames, rootEl, virtuosoGridHandle, range); + scrollIntoView(targetItemId, itemIds, rootEl, virtuosoGridHandle, range); }, 0); - }, [imageNames, rangeRef, rootRef, virtuosoRef, selection]); + }, [itemIds, rangeRef, rootRef, virtuosoRef, selection]); }; /** @@ -523,22 +529,32 @@ const useScrollableGallery = (rootRef: RefObject) => { const useStarImageHotkey = () => { const lastSelectedImage = useAppSelector(selectLastSelectedImage); const selectionCount = useAppSelector(selectSelectionCount); + const galleryView = useAppSelector(selectGalleryView); const isGalleryFocused = useIsRegionFocused('gallery'); - const imageDTO = useImageDTO(lastSelectedImage); + const imageDTO = useImageDTO(galleryView !== 'videos' ? lastSelectedImage : null); + const videoDTO = useVideoDTO(galleryView === 'videos' ? lastSelectedImage : null); const [starImages] = useStarImagesMutation(); const [unstarImages] = useUnstarImagesMutation(); + const [starVideos] = useStarVideosMutation(); + const [unstarVideos] = useUnstarVideosMutation(); + const handleStarHotkey = useCallback(() => { - if (!imageDTO) { - return; - } if (!isGalleryFocused) { return; } - if (imageDTO.starred) { - unstarImages({ image_names: [imageDTO.image_name] }); - } else { - starImages({ image_names: [imageDTO.image_name] }); + if (galleryView === 'videos' && videoDTO) { + if (videoDTO.starred) { + unstarVideos({ video_ids: [videoDTO.video_id] }); + } else { + starVideos({ video_ids: [videoDTO.video_id] }); + } + } else if (galleryView !== 'videos' && imageDTO) { + if (imageDTO.starred) { + unstarImages({ image_names: [imageDTO.image_name] }); + } else { + starImages({ image_names: [imageDTO.image_name] }); + } } }, [imageDTO, isGalleryFocused, starImages, unstarImages]); @@ -546,7 +562,12 @@ const useStarImageHotkey = () => { id: 'starImage', category: 'gallery', callback: handleStarHotkey, - options: { enabled: !!imageDTO && selectionCount === 1 && isGalleryFocused }, + options: { + enabled: + ((galleryView === 'videos' && !!videoDTO) || (galleryView !== 'videos' && !!imageDTO)) && + selectionCount === 1 && + isGalleryFocused, + }, dependencies: [imageDTO, selectionCount, isGalleryFocused, handleStarHotkey], }); }; @@ -558,18 +579,22 @@ export const NewGallery = memo(() => { const galleryView = useAppSelector(selectGalleryView); // Get the ordered list of image names - this is our primary data source for virtualization - const { queryArgs, imageNames, isLoading } = useGalleryImageNames(); - const { queryArgs: videoQueryArgs, videoIds, isLoading: isLoadingVideos } = useGalleryVideoIds(); + const galleryImageNamesQuery = useGalleryImageNames(); + const galleryVideoIdsQuery = useGalleryVideoIds(); // Use range-based fetching for bulk loading image DTOs into cache based on the visible range const { onRangeChanged } = useRangeBasedImageFetching({ - imageNames, - enabled: !isLoading, + imageNames: galleryImageNamesQuery.imageNames, + enabled: !galleryImageNamesQuery.isLoading, }); + const itemIds = galleryView === 'videos' ? galleryVideoIdsQuery.video_ids : galleryImageNamesQuery.imageNames; + const queryArgs = galleryView === 'videos' ? galleryVideoIdsQuery.queryArgs : galleryImageNamesQuery.queryArgs; + const isLoading = galleryView === 'videos' ? galleryVideoIdsQuery.isLoading : galleryImageNamesQuery.isLoading; useStarImageHotkey(); - useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); - useKeyboardNavigation(imageNames, virtuosoRef, rootRef); + + useKeepSelectedItemInView(itemIds, virtuosoRef, rootRef, rangeRef); + useKeyboardNavigation(itemIds, virtuosoRef, rootRef); const scrollerRef = useScrollableGallery(rootRef); /* @@ -584,7 +609,7 @@ export const NewGallery = memo(() => { [onRangeChanged] ); - const context = useMemo(() => ({ imageNames, queryArgs, videoIds, videoQueryArgs }), [imageNames, queryArgs, videoIds, videoQueryArgs]); + const context = useMemo(() => ({ itemIds, galleryView, queryArgs }), [itemIds, queryArgs, galleryView]); if (isLoading) { return ( @@ -595,7 +620,7 @@ export const NewGallery = memo(() => { ); } - if (imageNames.length === 0) { + if (itemIds.length === 0) { return ( No images found @@ -609,7 +634,7 @@ export const NewGallery = memo(() => { ref={virtuosoRef} context={context} - data={galleryView === 'images' ? imageNames : videoIds} + data={itemIds} increaseViewportBy={4096} itemContent={itemContent} computeItemKey={computeItemKey} @@ -652,8 +677,12 @@ const ListComponent: GridComponents['List'] = forwardRef(({ context }); ListComponent.displayName = 'ListComponent'; -const itemContent: GridItemContent = (index, imageName) => { - return ; +const itemContent: GridItemContent = (index, itemId, { galleryView }) => { + if (galleryView === 'videos') { + return ; + } else { + return ; + } }; const ItemComponent: GridComponents['Item'] = forwardRef(({ context: _, ...rest }, ref) => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts index c81728a1b21..62a1d3448cb 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts @@ -1,7 +1,14 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { skipToken } from '@reduxjs/toolkit/query'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; +import { + selectGalleryView, + selectGetImageNamesQueryArgs, + selectGetVideoIdsQueryArgs, +} from 'features/gallery/store/gallerySelectors'; import { useGetImageNamesQuery } from 'services/api/endpoints/images'; +import { useGetVideoIdsQuery } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; const getImageNamesQueryOptions = { @@ -13,9 +20,33 @@ const getImageNamesQueryOptions = { }), } satisfies Parameters[1]; +const getVideoIdsQueryOptions = { + refetchOnReconnect: true, + selectFromResult: ({ currentData, isLoading, isFetching }) => ({ + video_ids: currentData?.video_ids ?? EMPTY_ARRAY, + isLoading, + isFetching, + }), +} satisfies Parameters[1]; + export const useGalleryImageNames = () => { + const galleryView = useAppSelector(selectGalleryView); const _queryArgs = useAppSelector(selectGetImageNamesQueryArgs); const [queryArgs] = useDebounce(_queryArgs, 300); - const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); + const { imageNames, isLoading, isFetching } = useGetImageNamesQuery( + galleryView !== 'videos' ? queryArgs : skipToken, + getImageNamesQueryOptions + ); return { imageNames, isLoading, isFetching, queryArgs }; }; + +export const useGalleryVideoIds = () => { + const galleryView = useAppSelector(selectGalleryView); + const _queryArgs = useAppSelector(selectGetVideoIdsQueryArgs); + const [queryArgs] = useDebounce(_queryArgs, 300); + const { video_ids, isLoading, isFetching } = useGetVideoIdsQuery( + galleryView === 'videos' ? queryArgs : skipToken, + getVideoIdsQueryOptions + ); + return { video_ids, isLoading, isFetching, queryArgs }; +}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 88036168a9d..525d09b10c0 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -9,6 +9,7 @@ import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import { api, buildV1Url, LIST_TAG } from '..'; +import { skipToken } from '@reduxjs/toolkit/query'; /** * Builds an endpoint URL for the videos router @@ -224,3 +225,8 @@ export const getVideoDTOSafe = async ( return null; } }; + +export const useVideoDTO = (id: string | null | undefined) => { + const { currentData: videoDTO } = useGetVideoDTOQuery(id ?? skipToken); + return videoDTO ?? null; +}; From 6972618fded959149f80d45a280118493e7f2df1 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Wed, 20 Aug 2025 11:05:29 -0400 Subject: [PATCH 24/55] add Veo3 model support to backend --- invokeai/app/invocations/fields.py | 1 + invokeai/backend/model_manager/taxonomy.py | 1 + 2 files changed, 2 insertions(+) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index 5f319a9bfbd..693f6b29d7d 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -65,6 +65,7 @@ class UIType(str, Enum, metaclass=MetaEnum): Imagen4Model = "Imagen4ModelField" ChatGPT4oModel = "ChatGPT4oModelField" FluxKontextModel = "FluxKontextModelField" + Veo3Model = "Veo3ModelField" # endregion # region Misc Field Types diff --git a/invokeai/backend/model_manager/taxonomy.py b/invokeai/backend/model_manager/taxonomy.py index a353a44e765..243f247f10e 100644 --- a/invokeai/backend/model_manager/taxonomy.py +++ b/invokeai/backend/model_manager/taxonomy.py @@ -30,6 +30,7 @@ class BaseModelType(str, Enum): Imagen4 = "imagen4" ChatGPT4o = "chatgpt-4o" FluxKontext = "flux-kontext" + Veo3 = "veo3" class ModelType(str, Enum): From 38fa20c5a72d100af06b35a9e661e34b1ea6acdd Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Wed, 20 Aug 2025 14:02:06 -0400 Subject: [PATCH 25/55] replace runway with veo, build out veo3 model support --- invokeai/app/invocations/primitives.py | 16 ------ .../controlLayers/store/paramsSlice.ts | 5 +- .../src/features/controlLayers/store/types.ts | 29 ++++++----- .../Invocation/fields/InputFieldRenderer.tsx | 10 ++++ .../inputs/Veo3ModelFieldInputComponent.tsx | 46 +++++++++++++++++ .../src/features/nodes/store/nodesSlice.ts | 6 +++ .../web/src/features/nodes/types/common.ts | 2 + .../web/src/features/nodes/types/field.ts | 25 ++++++++++ ...ayVideoGraph.ts => buildVeo3VideoGraph.ts} | 49 ++++++++++--------- .../util/schema/buildFieldInputInstance.ts | 1 + .../util/schema/buildFieldInputTemplate.ts | 15 ++++++ .../DimensionsAspectRatioSelect.tsx | 6 +-- .../components/Video/ParamDuration.tsx | 27 +++++----- .../features/parameters/store/videoSlice.ts | 14 +++++- .../parameters/types/parameterSchemas.ts | 5 -- .../features/queue/hooks/useEnqueueVideo.ts | 4 +- .../VideoModelPicker.tsx | 42 ++++++++++++++++ .../VideoSettingsAccordion.tsx | 13 +---- .../src/services/api/hooks/modelsByType.ts | 2 + .../frontend/web/src/services/api/schema.ts | 30 ++---------- .../frontend/web/src/services/api/types.ts | 5 ++ invokeai/invocation_api/__init__.py | 2 - 22 files changed, 236 insertions(+), 118 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/Veo3ModelFieldInputComponent.tsx rename invokeai/frontend/web/src/features/nodes/util/graph/generation/{buildRunwayVideoGraph.ts => buildVeo3VideoGraph.ts} (61%) create mode 100644 invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index 9d07efb03ca..5f00e6db89d 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -292,22 +292,6 @@ def invoke(self, context: InvocationContext) -> ImageCollectionOutput: # region Video - -@invocation_output("runway_video_output") -class RunwayVideoOutput(BaseInvocationOutput): - """Base class for nodes that output a runway result""" - - video_url: str = OutputField(description="The output video url") - runway_task_id: str = OutputField(description="The runway task id") - - @classmethod - def build(cls, video_url: str, runway_task_id: str) -> "RunwayVideoOutput": - return cls( - video_url=video_url, - runway_task_id=runway_task_id, - ) - - @invocation_output("video_output") class VideoOutput(BaseInvocationOutput): """Base class for nodes that output a video""" diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index 047bef8d564..aeb0ec01e79 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -5,7 +5,7 @@ import type { SliceConfig } from 'app/store/types'; import { deepClone } from 'common/util/deepClone'; import { roundDownToMultiple, roundToMultiple } from 'common/util/roundDownToMultiple'; import { clamp } from 'es-toolkit/compat'; -import type { AspectRatioID, ParamsState, RgbaColor } from 'features/controlLayers/store/types'; +import type { AspectRatioID, ParamsState, RgbaColor, Veo3Duration } from 'features/controlLayers/store/types'; import { ASPECT_RATIO_MAP, CHATGPT_ASPECT_RATIOS, @@ -356,7 +356,7 @@ const slice = createSlice({ state.dimensions.rect.height = bboxDims.height; } }, - setVideoDuration: (state, action: PayloadAction) => { + setVideoDuration: (state, action: PayloadAction) => { state.videoDuration = action.payload; }, paramsReset: (state) => resetState(state), @@ -493,7 +493,6 @@ export const selectIsFluxKontext = createParamsSelector((params) => { return false; }); export const selectIsChatGPT4o = createParamsSelector((params) => params.model?.base === 'chatgpt-4o'); - export const selectModel = createParamsSelector((params) => params.model); export const selectModelKey = createParamsSelector((params) => params.model?.key); export const selectVAE = createParamsSelector((params) => params.vae); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 5e5b1ad78c8..699b09cd419 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -9,7 +9,6 @@ import { zParameterCLIPGEmbedModel, zParameterCLIPLEmbedModel, zParameterControlLoRAModel, - zParameterDuration, zParameterGuidance, zParameterImageDimension, zParameterMaskBlurMethod, @@ -462,17 +461,12 @@ export const FLUX_KONTEXT_ASPECT_RATIOS: Record; -export const isRunwayAspectRatioID = (v: unknown): v is RunwayAspectRatio => - zRunwayAspectRatioID.safeParse(v).success; -export const RUNWAY_ASPECT_RATIOS: Record = { +export const zVeo3AspectRatioID = z.enum(['16:9']); +type Veo3AspectRatio = z.infer; +export const isVeo3AspectRatioID = (v: unknown): v is Veo3AspectRatio => + zVeo3AspectRatioID.safeParse(v).success; +export const VEO3_ASPECT_RATIOS: Record = { '16:9': { width: 1280, height: 720 }, - '4:3': { width: 1104, height: 832 }, - '1:1': { width: 960, height: 960 }, - '3:4': { width: 832, height: 1104 }, - '9:16': { width: 720, height: 1280 }, - '21:9': { width: 1584, height: 672 }, }; const zAspectRatioConfig = z.object({ @@ -488,6 +482,15 @@ export const DEFAULT_ASPECT_RATIO_CONFIG: AspectRatioConfig = { isLocked: false, }; +export const zVeo3DurationID = z.enum(['8']); +export type Veo3Duration = z.infer; +export const isVeo3DurationID = (v: unknown): v is Veo3Duration => + zVeo3DurationID.safeParse(v).success; +export const VEO3_DURATIONS: Record = { + '8': '8 seconds', + +}; + const zBboxState = z.object({ rect: z.object({ x: z.number().int(), @@ -559,7 +562,7 @@ export const zParamsState = z.object({ clipGEmbedModel: zParameterCLIPGEmbedModel.nullable(), controlLora: zParameterControlLoRAModel.nullable(), dimensions: zDimensionsState, - videoDuration: zParameterDuration, + videoDuration: zVeo3DurationID, }); export type ParamsState = z.infer; export const getInitialParamsState = (): ParamsState => ({ @@ -610,7 +613,7 @@ export const getInitialParamsState = (): ParamsState => ({ rect: { x: 0, y: 0, width: 512, height: 512 }, aspectRatio: deepClone(DEFAULT_ASPECT_RATIO_CONFIG), }, - videoDuration: 5, + videoDuration: '8', }); const zInpaintMasks = z.object({ diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx index 493960fdba6..cc7451227a8 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx @@ -109,6 +109,8 @@ import { isT5EncoderModelFieldInputTemplate, isVAEModelFieldInputInstance, isVAEModelFieldInputTemplate, + isVeo3ModelFieldInputInstance, + isVeo3ModelFieldInputTemplate, } from 'features/nodes/types/field'; import type { NodeFieldElement } from 'features/nodes/types/workflow'; import { memo } from 'react'; @@ -142,6 +144,7 @@ import SpandrelImageToImageModelFieldInputComponent from './inputs/SpandrelImage import T2IAdapterModelFieldInputComponent from './inputs/T2IAdapterModelFieldInputComponent'; import T5EncoderModelFieldInputComponent from './inputs/T5EncoderModelFieldInputComponent'; import VAEModelFieldInputComponent from './inputs/VAEModelFieldInputComponent'; +import Veo3ModelFieldInputComponent from './inputs/Veo3ModelFieldInputComponent'; type Props = { nodeId: string; @@ -434,6 +437,13 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) return ; } + if (isVeo3ModelFieldInputTemplate(template)) { + if (!isVeo3ModelFieldInputInstance(field)) { + return null; + } + return ; + } + if (isColorFieldInputTemplate(template)) { if (!isColorFieldInputInstance(field)) { return null; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/Veo3ModelFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/Veo3ModelFieldInputComponent.tsx new file mode 100644 index 00000000000..bd6c0ea5f65 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/Veo3ModelFieldInputComponent.tsx @@ -0,0 +1,46 @@ +import { useAppDispatch } from 'app/store/storeHooks'; +import { ModelFieldCombobox } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelFieldCombobox'; +import { fieldVeo3ModelValueChanged } from 'features/nodes/store/nodesSlice'; +import type { Veo3ModelFieldInputInstance, Veo3ModelFieldInputTemplate } from 'features/nodes/types/field'; +import { memo, useCallback } from 'react'; +import { useVeo3Models } from 'services/api/hooks/modelsByType'; +import type { ApiModelConfig } from 'services/api/types'; + +import type { FieldComponentProps } from './types'; + +const Veo3ModelFieldInputComponent = ( + props: FieldComponentProps +) => { + const { nodeId, field } = props; + const dispatch = useAppDispatch(); + + const [modelConfigs, { isLoading }] = useVeo3Models(); + + const onChange = useCallback( + (value: ApiModelConfig | null) => { + if (!value) { + return; + } + dispatch( + fieldVeo3ModelValueChanged({ + nodeId, + fieldName: field.name, + value, + }) + ); + }, + [dispatch, field.name, nodeId] + ); + + return ( + + ); +}; + +export default memo(Veo3ModelFieldInputComponent); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index 592af29d51b..d262b939cb5 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -62,6 +62,7 @@ import type { T2IAdapterModelFieldValue, T5EncoderModelFieldValue, VAEModelFieldValue, + Veo3ModelFieldValue, } from 'features/nodes/types/field'; import { zBoardFieldValue, @@ -104,6 +105,7 @@ import { zT2IAdapterModelFieldValue, zT5EncoderModelFieldValue, zVAEModelFieldValue, + zVeo3ModelFieldValue, } from 'features/nodes/types/field'; import type { AnyEdge, AnyNode } from 'features/nodes/types/invocation'; import { isInvocationNode, isNotesNode } from 'features/nodes/types/invocation'; @@ -552,6 +554,9 @@ const slice = createSlice({ fieldChatGPT4oModelValueChanged: (state, action: FieldValueAction) => { fieldValueReducer(state, action, zChatGPT4oModelFieldValue); }, + fieldVeo3ModelValueChanged: (state, action: FieldValueAction) => { + fieldValueReducer(state, action, zVeo3ModelFieldValue); + }, fieldFluxKontextModelValueChanged: (state, action: FieldValueAction) => { fieldValueReducer(state, action, zFluxKontextModelFieldValue); }, @@ -730,6 +735,7 @@ export const { fieldIntegerGeneratorValueChanged, fieldStringGeneratorValueChanged, fieldImageGeneratorValueChanged, + fieldVeo3ModelValueChanged, fieldDescriptionChanged, nodeEditorReset, nodeIsIntermediateChanged, diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index b11c5a05bbb..24dee5dd7c8 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -86,6 +86,7 @@ const zBaseModel = z.enum([ 'imagen4', 'chatgpt-4o', 'flux-kontext', + 'veo3', ]); export type BaseModelType = z.infer; export const zMainModelBase = z.enum([ @@ -99,6 +100,7 @@ export const zMainModelBase = z.enum([ 'imagen4', 'chatgpt-4o', 'flux-kontext', + 'veo3', ]); type MainModelBase = z.infer; export const isMainModelBase = (base: unknown): base is MainModelBase => zMainModelBase.safeParse(base).success; diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index af5132b394f..54fd5c8897d 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -261,6 +261,10 @@ const zChatGPT4oModelFieldType = zFieldTypeBase.extend({ name: z.literal('ChatGPT4oModelField'), originalType: zStatelessFieldType.optional(), }); +const zVeo3ModelFieldType = zFieldTypeBase.extend({ + name: z.literal('Veo3ModelField'), + originalType: zStatelessFieldType.optional(), +}); const zFluxKontextModelFieldType = zFieldTypeBase.extend({ name: z.literal('FluxKontextModelField'), originalType: zStatelessFieldType.optional(), @@ -319,6 +323,7 @@ const zStatefulFieldType = z.union([ zImagen4ModelFieldType, zChatGPT4oModelFieldType, zFluxKontextModelFieldType, + zVeo3ModelFieldType, zColorFieldType, zSchedulerFieldType, zFloatGeneratorFieldType, @@ -361,6 +366,7 @@ const modelFieldTypeNames = [ zImagen4ModelFieldType.shape.name.value, zChatGPT4oModelFieldType.shape.name.value, zFluxKontextModelFieldType.shape.name.value, + zVeo3ModelFieldType.shape.name.value, // Stateless model fields 'UNetField', 'VAEField', @@ -1274,6 +1280,24 @@ export const isChatGPT4oModelFieldInputTemplate = buildTemplateTypeGuard('ChatGPT4oModelField'); // #endregion +// #region Veo3ModelField +export const zVeo3ModelFieldValue = zModelIdentifierField.optional(); +const zVeo3ModelFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zVeo3ModelFieldValue, +}); +const zVeo3ModelFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zVeo3ModelFieldType, + originalType: zFieldType.optional(), + default: zVeo3ModelFieldValue, +}); +export type Veo3ModelFieldValue = z.infer; +export type Veo3ModelFieldInputInstance = z.infer; +export type Veo3ModelFieldInputTemplate = z.infer; +export const isVeo3ModelFieldInputInstance = buildInstanceTypeGuard(zVeo3ModelFieldInputInstance); +export const isVeo3ModelFieldInputTemplate = + buildTemplateTypeGuard('Veo3ModelField'); +// #endregion + // #region SchedulerField export const zSchedulerFieldValue = zSchedulerField.optional(); const zSchedulerFieldInputInstance = zFieldInputInstanceBase.extend({ @@ -2003,6 +2027,7 @@ const zStatefulFieldInputTemplate = z.union([ zImagen4ModelFieldInputTemplate, zChatGPT4oModelFieldInputTemplate, zFluxKontextModelFieldInputTemplate, + zVeo3ModelFieldInputTemplate, zColorFieldInputTemplate, zSchedulerFieldInputTemplate, zStatelessFieldInputTemplate, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts similarity index 61% rename from invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts rename to invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts index 87e8e02e67d..e40231f4aab 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts @@ -6,36 +6,33 @@ import { Graph } from 'features/nodes/util/graph/generation/Graph'; import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderArg, GraphBuilderReturn } from 'features/nodes/util/graph/types'; import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; -import { selectStartingFrameImage } from 'features/parameters/store/videoSlice'; +import { selectStartingFrameImage, selectVideoSlice } from 'features/parameters/store/videoSlice'; import { t } from 'i18next'; import { assert } from 'tsafe'; const log = logger('system'); -export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => { +export const buildVeo3VideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => { const { generationMode, state, manager } = arg; - log.debug({ generationMode, manager: manager?.id }, 'Building Runway video graph'); + log.debug({ generationMode, manager: manager?.id }, 'Building Veo3 video graph'); const supportedModes = ['txt2img'] as const; if (!supportedModes.includes(generationMode as any)) { - throw new UnsupportedGenerationModeError(t('toast.runwayIncompatibleGenerationMode')); + throw new UnsupportedGenerationModeError(t('toast.veo3IncompatibleGenerationMode')); } const params = selectParamsSlice(state); + const videoParams = selectVideoSlice(state); const prompts = selectPresetModifiedPrompts(state); - assert(prompts.positive.length > 0, 'Runway video requires positive prompt to have at least one character'); + assert(prompts.positive.length > 0, 'Veo3 video requires positive prompt to have at least one character'); - const startingFrameImage = selectStartingFrameImage(state); - - assert(startingFrameImage, 'Video starting frame is required for runway video generation'); - const firstFrameImageField = zImageField.parse(startingFrameImage); - - // Get seed from params + const { seed, shouldRandomizeSeed } = params; + const { videoModel } = videoParams; const finalSeed = shouldRandomizeSeed ? undefined : seed; - const g = new Graph(getPrefixedId('runway_video_graph')); + const g = new Graph(getPrefixedId('veo3_video_graph')); const positivePrompt = g.addNode({ id: getPrefixedId('positive_prompt'), @@ -43,19 +40,26 @@ export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn value: prompts.positive, }); - // Create the runway video generation node - const runwayVideoNode = g.addNode({ - id: getPrefixedId('runway_generate_video'), + // Create the veo3 video generation node + const veo3VideoNode = g.addNode({ + id: getPrefixedId('google_veo_3_generate_video'), // @ts-expect-error: This node is not available in the OSS application - type: 'runway_generate_video', - duration: params.videoDuration, + type: 'google_veo_3_generate_video', + model: videoParams.videoModel, aspect_ratio: params.dimensions.aspectRatio.id, seed: finalSeed, - first_frame_image: firstFrameImageField, }); + const startingFrameImage = selectStartingFrameImage(state); + + if (startingFrameImage) { + const startingFrameImageField = zImageField.parse(startingFrameImage); + // @ts-expect-error: This node is not available in the OSS application + veo3VideoNode.starting_image = startingFrameImageField; + } + // @ts-expect-error: This node is not available in the OSS application - g.addEdge(positivePrompt, 'value', runwayVideoNode, 'prompt'); + g.addEdge(positivePrompt, 'value', veo3VideoNode, 'prompt'); // Set up metadata g.upsertMetadata({ @@ -65,12 +69,11 @@ export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn video_aspect_ratio: params.dimensions.aspectRatio.id, seed: finalSeed, generation_type: 'image-to-video', - first_frame_image: startingFrameImage, + starting_image: startingFrameImage, + video_model: videoParams.videoModel, }); - - - g.setMetadataReceivingNode(runwayVideoNode); + g.setMetadataReceivingNode(veo3VideoNode); return { g, diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts index 20ba67b24b3..39c91c60297 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts @@ -37,6 +37,7 @@ const FIELD_VALUE_FALLBACK_MAP: Record = Imagen4ModelField: undefined, ChatGPT4oModelField: undefined, FluxKontextModelField: undefined, + Veo3ModelField: undefined, FloatGeneratorField: undefined, IntegerGeneratorField: undefined, StringGeneratorField: undefined, diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts index 4e3284f92a6..a9f15ecb214 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts @@ -48,6 +48,7 @@ import type { T2IAdapterModelFieldInputTemplate, T5EncoderModelFieldInputTemplate, VAEModelFieldInputTemplate, + Veo3ModelFieldInputTemplate, } from 'features/nodes/types/field'; import { getFloatGeneratorArithmeticSequenceDefaults, @@ -628,6 +629,19 @@ const buildFluxKontextModelFieldInputTemplate: FieldInputTemplateBuilder = ({ + schemaObject, + baseField, + fieldType, +}) => { + const template: Veo3ModelFieldInputTemplate = { + ...baseField, + type: fieldType, + default: schemaObject.default ?? undefined, + }; + return template; +}; + const buildChatGPT4oModelFieldInputTemplate: FieldInputTemplateBuilder = ({ schemaObject, baseField, @@ -851,6 +865,7 @@ export const TEMPLATE_BUILDER_MAP: Record { const { t } = useTranslation(); @@ -44,7 +44,7 @@ export const DimensionsAspectRatioSelect = memo(() => { return zFluxKontextAspectRatioID.options; } if (activeTab === 'video') { - return zRunwayAspectRatioID.options; + return zVeo3AspectRatioID.options; } // All other models return zAspectRatioID.options; diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx index 86608614895..5e4a999ad03 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx @@ -1,27 +1,30 @@ import { FormControl, FormLabel, Select } from "@invoke-ai/ui-library"; import { useAppDispatch, useAppSelector } from "app/store/storeHooks"; -import { selectVideoDuration, setVideoDuration } from "features/controlLayers/store/paramsSlice"; -import type { ParameterDuration } from "features/parameters/types/parameterSchemas"; -import { isParameterDuration } from "features/parameters/types/parameterSchemas"; +import { selectVideoDuration, setVideoDuration } from "features/controlLayers/store/paramsSlice"; +import { isVeo3DurationID, VEO3_DURATIONS, zVeo3DurationID } from "features/controlLayers/store/types"; import type { ChangeEventHandler} from "react"; import { useCallback, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { PiCaretDownBold } from "react-icons/pi"; -const options: { label: string; value: ParameterDuration }[] = [ - { label: '5 seconds', value: 5 }, - { label: '10 seconds', value: 10 }, - ]; - export const ParamDuration = () => { const videoDuration = useAppSelector(selectVideoDuration); const { t } = useTranslation(); const dispatch = useAppDispatch(); + const options = useMemo(() => { + + return Object.entries(VEO3_DURATIONS).map(([key, value]) => ({ + label: value, + value: key, + })); + + }, []); + const onChange = useCallback>( (e) => { - const duration = parseInt(e.target.value); - if (!isParameterDuration(duration)) { + const duration = e.target.value; + if (!isVeo3DurationID(duration)) { return; } @@ -30,11 +33,11 @@ export const ParamDuration = () => { [dispatch] ); - const value = useMemo(() => options.find((o) => o.value === videoDuration), [videoDuration]); + const value = useMemo(() => options.find((o) => o.value === videoDuration)?.value, [videoDuration]); return {t('parameters.duration')} - }> {options.map((duration) => ( From 0c9202232a7c9c06a2e175562101c9f4696e1a5a Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Wed, 20 Aug 2025 15:44:54 -0400 Subject: [PATCH 27/55] add videos to change board modal --- invokeai/frontend/web/public/locales/en.json | 2 + .../components/ChangeBoardModal.tsx | 44 ++++++++++++++----- .../features/changeBoardModal/store/slice.ts | 6 ++- .../MenuItems/ContextMenuItemChangeBoard.tsx | 4 +- .../MultipleSelectionVideoMenuItems.tsx | 4 +- .../SingleSelectionVideoMenuItems.tsx | 2 + 6 files changed, 47 insertions(+), 15 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index a2d20f62fe3..6b537daeaa8 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -43,6 +43,8 @@ "move": "Move", "movingImagesToBoard_one": "Moving {{count}} image to board:", "movingImagesToBoard_other": "Moving {{count}} images to board:", + "movingVideosToBoard_one": "Moving {{count}} video to board:", + "movingVideosToBoard_other": "Moving {{count}} videos to board:", "myBoard": "My Board", "noBoards": "No {{boardType}} Boards", "noMatching": "No matching Boards", diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index feaa7e54d2a..0fd5fdd3bcd 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -12,12 +12,18 @@ import { memo, useCallback, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { useListAllBoardsQuery } from 'services/api/endpoints/boards'; import { useAddImagesToBoardMutation, useRemoveImagesFromBoardMutation } from 'services/api/endpoints/images'; +import { useAddVideosToBoardMutation, useRemoveVideosFromBoardMutation } from 'services/api/endpoints/videos'; const selectImagesToChange = createSelector( selectChangeBoardModalSlice, (changeBoardModal) => changeBoardModal.image_names ); +const selectVideosToChange = createSelector( + selectChangeBoardModalSlice, + (changeBoardModal) => changeBoardModal.video_ids +); + const selectIsModalOpen = createSelector( selectChangeBoardModalSlice, (changeBoardModal) => changeBoardModal.isModalOpen @@ -30,8 +36,11 @@ const ChangeBoardModal = () => { const { data: boards, isFetching } = useListAllBoardsQuery({ include_archived: true }); const isModalOpen = useAppSelector(selectIsModalOpen); const imagesToChange = useAppSelector(selectImagesToChange); + const videosToChange = useAppSelector(selectVideosToChange); const [addImagesToBoard] = useAddImagesToBoardMutation(); const [removeImagesFromBoard] = useRemoveImagesFromBoardMutation(); + const [addVideosToBoard] = useAddVideosToBoardMutation(); + const [removeVideosFromBoard] = useRemoveVideosFromBoardMutation(); const { t } = useTranslation(); const options = useMemo(() => { @@ -51,21 +60,33 @@ const ChangeBoardModal = () => { }, [dispatch]); const handleChangeBoard = useCallback(() => { - if (!imagesToChange.length || !selectedBoard) { + if (!selectedBoard || (imagesToChange.length === 0 && videosToChange.length === 0)) { return; } - if (selectedBoard === 'none') { - removeImagesFromBoard({ image_names: imagesToChange }); - } else { - addImagesToBoard({ - image_names: imagesToChange, - board_id: selectedBoard, - }); + if (imagesToChange.length) { + if (selectedBoard === 'none') { + removeImagesFromBoard({ image_names: imagesToChange }); + } else { + addImagesToBoard({ + image_names: imagesToChange, + board_id: selectedBoard, + }); + } + } + if (videosToChange.length) { + if (selectedBoard === 'none') { + removeVideosFromBoard({ video_ids: videosToChange }); + } else { + addVideosToBoard({ + video_ids: videosToChange, + board_id: selectedBoard, + }); + } } setSelectedBoard(null); dispatch(changeBoardReset()); - }, [addImagesToBoard, dispatch, imagesToChange, removeImagesFromBoard, selectedBoard]); + }, [addImagesToBoard, dispatch, imagesToChange, videosToChange, removeImagesFromBoard, selectedBoard]); const onChange = useCallback((v) => { if (!v) { @@ -86,9 +107,12 @@ const ChangeBoardModal = () => { > - {t('boards.movingImagesToBoard', { + {imagesToChange.length > 0 && t('boards.movingImagesToBoard', { count: imagesToChange.length, })} + {videosToChange.length > 0 && t('boards.movingVideosToBoard', { + count: videosToChange.length, + })} : diff --git a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts index 3f72720a420..3e60fc3b612 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts +++ b/invokeai/frontend/web/src/features/changeBoardModal/store/slice.ts @@ -7,6 +7,7 @@ import z from 'zod'; const zChangeBoardModalState = z.object({ isModalOpen: z.boolean().default(false), image_names: z.array(z.string()).default(() => []), + video_ids: z.array(z.string()).default(() => []), }); type ChangeBoardModalState = z.infer; @@ -22,6 +23,9 @@ const slice = createSlice({ imagesToChangeSelected: (state, action: PayloadAction) => { state.image_names = action.payload; }, + videosToChangeSelected: (state, action: PayloadAction) => { + state.video_ids = action.payload; + }, changeBoardReset: (state) => { state.image_names = []; state.isModalOpen = false; @@ -29,7 +33,7 @@ const slice = createSlice({ }, }); -export const { isModalOpenChanged, imagesToChangeSelected, changeBoardReset } = slice.actions; +export const { isModalOpenChanged, imagesToChangeSelected, videosToChangeSelected, changeBoardReset } = slice.actions; export const selectChangeBoardModalSlice = (state: RootState) => state.changeBoardModal; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx index a14f50f850c..3f85665ce22 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx @@ -1,6 +1,6 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; +import { imagesToChangeSelected, isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; @@ -16,7 +16,7 @@ export const ContextMenuItemChangeBoard = memo(() => { if (isImageDTO(itemDTO)) { dispatch(imagesToChangeSelected([itemDTO.image_name])); } else { - // dispatch(videosToChangeSelected([itemDTO.video_id])); + dispatch(videosToChangeSelected([itemDTO.video_id])); } dispatch(isModalOpenChanged(true)); }, [dispatch, itemDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx index 3ff0a1e60c6..2f6292844ba 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx @@ -2,7 +2,7 @@ import { MenuDivider, MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $customStarUI } from 'app/store/nanostores/customStarUI'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { imagesToChangeSelected, isModalOpenChanged } from 'features/changeBoardModal/store/slice'; +import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi'; @@ -24,7 +24,7 @@ const MultipleSelectionMenuItems = () => { const [deleteVideos] = useDeleteVideosMutation(); const handleChangeBoard = useCallback(() => { - dispatch(imagesToChangeSelected(selection)); + dispatch(videosToChangeSelected(selection)); dispatch(isModalOpenChanged(true)); }, [dispatch, selection]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx index 2c2ebd8fbe6..8828df04800 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx @@ -10,6 +10,7 @@ import type { VideoDTO } from 'services/api/types'; import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext'; import { ContextMenuItemStarUnstar } from './MenuItems/ContextMenuItemStarUnstar'; import { MenuDivider } from '@invoke-ai/ui-library'; +import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard'; type SingleSelectionVideoMenuItemsProps = { videoDTO: VideoDTO; @@ -29,6 +30,7 @@ const SingleSelectionVideoMenuItems = ({ videoDTO }: SingleSelectionVideoMenuIte + ); }; From dced66535a72f56d91fc10fab0646f4192deaa62 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 21 Aug 2025 21:47:27 +1000 Subject: [PATCH 28/55] Revert "feat(ui): consolidated gallery (wip)" This reverts commit 12b70bca678c58a8917ec29cc04d229d4b664385. --- .../features/gallery/components/Gallery.tsx | 2 +- .../gallery/components/NewGallery.tsx | 159 +++++++----------- .../components/use-gallery-image-names.ts | 35 +--- .../web/src/services/api/endpoints/videos.ts | 6 - 4 files changed, 68 insertions(+), 134 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 038b12d6e08..5033e32c330 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -122,7 +122,7 @@ export const GalleryPanel = memo(() => { - + {galleryView === 'images' ? : galleryView === 'videos' ? : } ); diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index fb2ad8e8634..8c358653ed1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -4,7 +4,7 @@ import { logger } from 'app/logging/logger'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; -import type { selectGetImageNamesQueryArgs, selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; +import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, selectGalleryView, @@ -28,30 +28,23 @@ import type { } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; import { imagesApi, useImageDTO, useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; -import { useStarVideosMutation, useUnstarVideosMutation, useVideoDTO, videosApi } from 'services/api/endpoints/videos'; +import { videosApi } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; +import { useGalleryImageNames } from './use-gallery-image-names'; +import { useGalleryVideoIds } from './use-gallery-video-ids'; import { GalleryVideo } from './ImageGrid/GalleryVideo'; -import { useGalleryImageNames, useGalleryVideoIds } from './use-gallery-image-names'; const log = logger('gallery'); type ListImageNamesQueryArgs = ReturnType; -type ListVideoIdsQueryArgs = ReturnType; -type GridContext = - | { - queryArgs: ListImageNamesQueryArgs; - galleryView: 'images' | 'assets'; - itemIds: string[]; - } - | { - queryArgs: ListVideoIdsQueryArgs; - galleryView: 'videos'; - itemIds: string[]; - }; +type GridContext = { + queryArgs: ListImageNamesQueryArgs; + imageNames: string[]; +}; const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string }) => { /* @@ -103,8 +96,8 @@ const VideoAtPosition = memo(({ itemId }: { index: number; itemId: string }) => }); VideoAtPosition.displayName = 'VideoAtPosition'; -const computeItemKey: GridComputeItemKey = (index, id, { queryArgs }) => { - return `${JSON.stringify(queryArgs)}-${id ?? index}`; +const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { + return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; }; /** @@ -113,7 +106,7 @@ const computeItemKey: GridComputeItemKey = (index, id, { qu * TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value * changes. Cache this calculation. */ -const getItemsPerRow = (rootEl: HTMLDivElement): number => { +const getImagesPerRow = (rootEl: HTMLDivElement): number => { // Start from root and find virtuoso grid elements const gridElement = rootEl.querySelector('.virtuoso-grid-list'); @@ -147,20 +140,20 @@ const getItemsPerRow = (rootEl: HTMLDivElement): number => { * * Instead, we use a more robust approach that iteratively calculates how many images fit in the row. */ - let itemsPerRow = 0; + let imagesPerRow = 0; let spaceUsed = 0; // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes // this, without the possibility of accidentally adding an extra column. while (spaceUsed + itemRect.width <= containerRect.width + 1) { - itemsPerRow++; // Increment the number of images + imagesPerRow++; // Increment the number of images spaceUsed += itemRect.width; // Add image size to the used space if (spaceUsed + gap <= containerRect.width) { spaceUsed += gap; // Add gap size to the used space after each image except after the last image } } - return Math.max(1, itemsPerRow); + return Math.max(1, imagesPerRow); }; /** @@ -187,7 +180,9 @@ const scrollIntoView = ( return; } - const targetItem = rootEl.querySelector(`.virtuoso-grid-item:has([data-item-id="${targetItemId}"])`) as HTMLElement; + const targetItem = rootEl.querySelector( + `.virtuoso-grid-item:has([data-item-id="${targetItemId}"])` + ) as HTMLElement; if (!targetItem) { if (targetIndex > range.endIndex) { @@ -273,11 +268,11 @@ const scrollIntoView = ( * If the image name is not found, return 0. * If no image name is provided, return 0. */ -const getItemIndex = (targetItemId: string | undefined | null, itemIds: string[]) => { - if (!targetItemId || itemIds.length === 0) { +const getImageIndex = (imageName: string | undefined | null, imageNames: string[]) => { + if (!imageName || imageNames.length === 0) { return 0; } - const index = itemIds.findIndex((n) => n === targetItemId); + const index = imageNames.findIndex((n) => n === imageName); return index >= 0 ? index : 0; }; @@ -285,7 +280,7 @@ const getItemIndex = (targetItemId: string | undefined | null, itemIds: string[] * Handles keyboard navigation for the gallery. */ const useKeyboardNavigation = ( - itemIds: string[], + imageNames: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject ) => { @@ -313,13 +308,13 @@ const useKeyboardNavigation = ( return; } - if (itemIds.length === 0) { + if (imageNames.length === 0) { return; } - const itemsPerRow = getItemsPerRow(rootEl); + const imagesPerRow = getImagesPerRow(rootEl); - if (itemsPerRow === 0) { + if (imagesPerRow === 0) { // This can happen if the grid is not yet rendered or has no items return; } @@ -327,14 +322,13 @@ const useKeyboardNavigation = ( event.preventDefault(); const state = getState(); - const imageName = - event.altKey && selectGalleryView(state) !== 'videos' - ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, - // we start from the last selected image - (selectImageToCompare(state) ?? selectLastSelectedImage(state)) - : selectLastSelectedImage(state); + const imageName = event.altKey + ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, + // we start from the last selected image + (selectImageToCompare(state) ?? selectLastSelectedImage(state)) + : selectLastSelectedImage(state); - const currentIndex = getItemIndex(imageName, itemIds); + const currentIndex = getImageIndex(imageName, imageNames); let newIndex = currentIndex; @@ -348,7 +342,7 @@ const useKeyboardNavigation = ( } break; case 'ArrowRight': - if (currentIndex < itemIds.length - 1) { + if (currentIndex < imageNames.length - 1) { newIndex = currentIndex + 1; // } else { // // Wrap to first image @@ -357,26 +351,26 @@ const useKeyboardNavigation = ( break; case 'ArrowUp': // If on first row, stay on current image - if (currentIndex < itemsPerRow) { + if (currentIndex < imagesPerRow) { newIndex = currentIndex; } else { - newIndex = Math.max(0, currentIndex - itemsPerRow); + newIndex = Math.max(0, currentIndex - imagesPerRow); } break; case 'ArrowDown': // If no images below, stay on current image - if (currentIndex >= itemIds.length - itemsPerRow) { + if (currentIndex >= imageNames.length - imagesPerRow) { newIndex = currentIndex; } else { - newIndex = Math.min(itemIds.length - 1, currentIndex + itemsPerRow); + newIndex = Math.min(imageNames.length - 1, currentIndex + imagesPerRow); } break; } - if (newIndex !== currentIndex && newIndex >= 0 && newIndex < itemIds.length) { - const newImageName = itemIds[newIndex]; + if (newIndex !== currentIndex && newIndex >= 0 && newIndex < imageNames.length) { + const newImageName = imageNames[newIndex]; if (newImageName) { - if (selectGalleryView(state) !== 'videos' && event.altKey) { + if (event.altKey) { dispatch(imageToCompareChanged(newImageName)); } else { dispatch(selectionChanged([newImageName])); @@ -384,7 +378,7 @@ const useKeyboardNavigation = ( } } }, - [rootRef, virtuosoRef, itemIds, getState, dispatch] + [rootRef, virtuosoRef, imageNames, getState, dispatch] ); useRegisteredHotkeys({ @@ -457,8 +451,8 @@ const useKeyboardNavigation = ( * This is useful for keyboard navigation and ensuring the user can see their selection. * It only tracks the last selected image, not the image to compare. */ -const useKeepSelectedItemInView = ( - itemIds: string[], +const useKeepSelectedImageInView = ( + imageNames: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject, rangeRef: MutableRefObject @@ -466,19 +460,19 @@ const useKeepSelectedItemInView = ( const selection = useAppSelector(selectSelection); useEffect(() => { - const targetItemId = selection.at(-1); + const targetImageName = selection.at(-1); const virtuosoGridHandle = virtuosoRef.current; const rootEl = rootRef.current; const range = rangeRef.current; - if (!virtuosoGridHandle || !rootEl || !targetItemId || !itemIds || itemIds.length === 0) { + if (!virtuosoGridHandle || !rootEl || !targetImageName || !imageNames || imageNames.length === 0) { return; } setTimeout(() => { - scrollIntoView(targetItemId, itemIds, rootEl, virtuosoGridHandle, range); + scrollIntoView(targetImageName, imageNames, rootEl, virtuosoGridHandle, range); }, 0); - }, [itemIds, rangeRef, rootRef, virtuosoRef, selection]); + }, [imageNames, rangeRef, rootRef, virtuosoRef, selection]); }; /** @@ -529,32 +523,22 @@ const useScrollableGallery = (rootRef: RefObject) => { const useStarImageHotkey = () => { const lastSelectedImage = useAppSelector(selectLastSelectedImage); const selectionCount = useAppSelector(selectSelectionCount); - const galleryView = useAppSelector(selectGalleryView); const isGalleryFocused = useIsRegionFocused('gallery'); - const imageDTO = useImageDTO(galleryView !== 'videos' ? lastSelectedImage : null); - const videoDTO = useVideoDTO(galleryView === 'videos' ? lastSelectedImage : null); + const imageDTO = useImageDTO(lastSelectedImage); const [starImages] = useStarImagesMutation(); const [unstarImages] = useUnstarImagesMutation(); - const [starVideos] = useStarVideosMutation(); - const [unstarVideos] = useUnstarVideosMutation(); - const handleStarHotkey = useCallback(() => { + if (!imageDTO) { + return; + } if (!isGalleryFocused) { return; } - if (galleryView === 'videos' && videoDTO) { - if (videoDTO.starred) { - unstarVideos({ video_ids: [videoDTO.video_id] }); - } else { - starVideos({ video_ids: [videoDTO.video_id] }); - } - } else if (galleryView !== 'videos' && imageDTO) { - if (imageDTO.starred) { - unstarImages({ image_names: [imageDTO.image_name] }); - } else { - starImages({ image_names: [imageDTO.image_name] }); - } + if (imageDTO.starred) { + unstarImages({ image_names: [imageDTO.image_name] }); + } else { + starImages({ image_names: [imageDTO.image_name] }); } }, [imageDTO, isGalleryFocused, starImages, unstarImages]); @@ -562,12 +546,7 @@ const useStarImageHotkey = () => { id: 'starImage', category: 'gallery', callback: handleStarHotkey, - options: { - enabled: - ((galleryView === 'videos' && !!videoDTO) || (galleryView !== 'videos' && !!imageDTO)) && - selectionCount === 1 && - isGalleryFocused, - }, + options: { enabled: !!imageDTO && selectionCount === 1 && isGalleryFocused }, dependencies: [imageDTO, selectionCount, isGalleryFocused, handleStarHotkey], }); }; @@ -579,22 +558,18 @@ export const NewGallery = memo(() => { const galleryView = useAppSelector(selectGalleryView); // Get the ordered list of image names - this is our primary data source for virtualization - const galleryImageNamesQuery = useGalleryImageNames(); - const galleryVideoIdsQuery = useGalleryVideoIds(); + const { queryArgs, imageNames, isLoading } = useGalleryImageNames(); + const { queryArgs: videoQueryArgs, videoIds, isLoading: isLoadingVideos } = useGalleryVideoIds(); // Use range-based fetching for bulk loading image DTOs into cache based on the visible range const { onRangeChanged } = useRangeBasedImageFetching({ - imageNames: galleryImageNamesQuery.imageNames, - enabled: !galleryImageNamesQuery.isLoading, + imageNames, + enabled: !isLoading, }); - const itemIds = galleryView === 'videos' ? galleryVideoIdsQuery.video_ids : galleryImageNamesQuery.imageNames; - const queryArgs = galleryView === 'videos' ? galleryVideoIdsQuery.queryArgs : galleryImageNamesQuery.queryArgs; - const isLoading = galleryView === 'videos' ? galleryVideoIdsQuery.isLoading : galleryImageNamesQuery.isLoading; useStarImageHotkey(); - - useKeepSelectedItemInView(itemIds, virtuosoRef, rootRef, rangeRef); - useKeyboardNavigation(itemIds, virtuosoRef, rootRef); + useKeepSelectedImageInView(imageNames, virtuosoRef, rootRef, rangeRef); + useKeyboardNavigation(imageNames, virtuosoRef, rootRef); const scrollerRef = useScrollableGallery(rootRef); /* @@ -609,7 +584,7 @@ export const NewGallery = memo(() => { [onRangeChanged] ); - const context = useMemo(() => ({ itemIds, galleryView, queryArgs }), [itemIds, queryArgs, galleryView]); + const context = useMemo(() => ({ imageNames, queryArgs, videoIds, videoQueryArgs }), [imageNames, queryArgs, videoIds, videoQueryArgs]); if (isLoading) { return ( @@ -620,7 +595,7 @@ export const NewGallery = memo(() => { ); } - if (itemIds.length === 0) { + if (imageNames.length === 0) { return ( No images found @@ -634,7 +609,7 @@ export const NewGallery = memo(() => { ref={virtuosoRef} context={context} - data={itemIds} + data={galleryView === 'images' ? imageNames : videoIds} increaseViewportBy={4096} itemContent={itemContent} computeItemKey={computeItemKey} @@ -677,12 +652,8 @@ const ListComponent: GridComponents['List'] = forwardRef(({ context }); ListComponent.displayName = 'ListComponent'; -const itemContent: GridItemContent = (index, itemId, { galleryView }) => { - if (galleryView === 'videos') { - return ; - } else { - return ; - } +const itemContent: GridItemContent = (index, imageName) => { + return ; }; const ItemComponent: GridComponents['Item'] = forwardRef(({ context: _, ...rest }, ref) => ( diff --git a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts index 62a1d3448cb..c81728a1b21 100644 --- a/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts +++ b/invokeai/frontend/web/src/features/gallery/components/use-gallery-image-names.ts @@ -1,14 +1,7 @@ -import { createSelector } from '@reduxjs/toolkit'; -import { skipToken } from '@reduxjs/toolkit/query'; import { EMPTY_ARRAY } from 'app/store/constants'; import { useAppSelector } from 'app/store/storeHooks'; -import { - selectGalleryView, - selectGetImageNamesQueryArgs, - selectGetVideoIdsQueryArgs, -} from 'features/gallery/store/gallerySelectors'; +import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { useGetImageNamesQuery } from 'services/api/endpoints/images'; -import { useGetVideoIdsQuery } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; const getImageNamesQueryOptions = { @@ -20,33 +13,9 @@ const getImageNamesQueryOptions = { }), } satisfies Parameters[1]; -const getVideoIdsQueryOptions = { - refetchOnReconnect: true, - selectFromResult: ({ currentData, isLoading, isFetching }) => ({ - video_ids: currentData?.video_ids ?? EMPTY_ARRAY, - isLoading, - isFetching, - }), -} satisfies Parameters[1]; - export const useGalleryImageNames = () => { - const galleryView = useAppSelector(selectGalleryView); const _queryArgs = useAppSelector(selectGetImageNamesQueryArgs); const [queryArgs] = useDebounce(_queryArgs, 300); - const { imageNames, isLoading, isFetching } = useGetImageNamesQuery( - galleryView !== 'videos' ? queryArgs : skipToken, - getImageNamesQueryOptions - ); + const { imageNames, isLoading, isFetching } = useGetImageNamesQuery(queryArgs, getImageNamesQueryOptions); return { imageNames, isLoading, isFetching, queryArgs }; }; - -export const useGalleryVideoIds = () => { - const galleryView = useAppSelector(selectGalleryView); - const _queryArgs = useAppSelector(selectGetVideoIdsQueryArgs); - const [queryArgs] = useDebounce(_queryArgs, 300); - const { video_ids, isLoading, isFetching } = useGetVideoIdsQuery( - galleryView === 'videos' ? queryArgs : skipToken, - getVideoIdsQueryOptions - ); - return { video_ids, isLoading, isFetching, queryArgs }; -}; diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 525d09b10c0..88036168a9d 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -9,7 +9,6 @@ import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import { api, buildV1Url, LIST_TAG } from '..'; -import { skipToken } from '@reduxjs/toolkit/query'; /** * Builds an endpoint URL for the videos router @@ -225,8 +224,3 @@ export const getVideoDTOSafe = async ( return null; } }; - -export const useVideoDTO = (id: string | null | undefined) => { - const { currentData: videoDTO } = useGetVideoDTOQuery(id ?? skipToken); - return videoDTO ?? null; -}; From 360c81739b2b8e3a23521c6ff7b275fd9c48149f Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Thu, 21 Aug 2025 23:00:12 +1000 Subject: [PATCH 29/55] gallery --- getItemsPerRow.ts | 55 ++++ .../features/gallery/components/Gallery.tsx | 4 +- .../components/ImageGrid/GalleryImage.tsx | 55 +--- .../components/ImageGrid/GalleryVideo.tsx | 91 ++----- .../ImageGrid/galleryItemContainerSX.tsx | 52 ++++ .../gallery/components/NewGallery.tsx | 243 +---------------- .../gallery/components/VideoGallery.tsx | 257 ++---------------- .../gallery/components/getItemIndex.tsx | 12 + .../gallery/components/scrollIntoView.tsx | 107 ++++++++ .../components/useScrollableGallery.tsx | 47 ++++ 10 files changed, 343 insertions(+), 580 deletions(-) create mode 100644 getItemsPerRow.ts create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/galleryItemContainerSX.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/getItemIndex.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx diff --git a/getItemsPerRow.ts b/getItemsPerRow.ts new file mode 100644 index 00000000000..d29272e1088 --- /dev/null +++ b/getItemsPerRow.ts @@ -0,0 +1,55 @@ +/** + * Calculate how many images fit in a row based on the current grid layout. + * + * TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value + * changes. Cache this calculation. + */ +export const getItemsPerRow = (rootEl: HTMLDivElement): number => { + // Start from root and find virtuoso grid elements + const gridElement = rootEl.querySelector('.virtuoso-grid-list'); + + if (!gridElement) { + return 0; + } + + const firstGridItem = gridElement.querySelector('.virtuoso-grid-item'); + + if (!firstGridItem) { + return 0; + } + + const itemRect = firstGridItem.getBoundingClientRect(); + const containerRect = gridElement.getBoundingClientRect(); + + // Get the computed gap from CSS + const gridStyle = window.getComputedStyle(gridElement); + const gapValue = gridStyle.gap; + const gap = parseFloat(gapValue); + + if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) { + return 0; + } + + /** + * You might be tempted to just do some simple math like: + * const itemsPerRow = Math.floor(containerRect.width / itemRect.width); + * + * But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases. + * + * Instead, we use a more robust approach that iteratively calculates how many items fit in the row. + */ + let itemsPerRow = 0; + let spaceUsed = 0; + + // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes + // this, without the possibility of accidentally adding an extra column. + while (spaceUsed + itemRect.width <= containerRect.width + 1) { + itemsPerRow++; // Increment the number of items + spaceUsed += itemRect.width; // Add image size to the used space + if (spaceUsed + gap <= containerRect.width) { + spaceUsed += gap; // Add gap size to the used space after each item except after the last item + } + } + + return Math.max(1, itemsPerRow); +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx index 5033e32c330..34c9ffcf4b7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/Gallery.tsx @@ -17,7 +17,7 @@ import { useBoardName } from 'services/api/hooks/useBoardName'; import { GallerySettingsPopover } from './GallerySettingsPopover/GallerySettingsPopover'; import { GalleryUploadButton } from './GalleryUploadButton'; import { GallerySearch } from './ImageGrid/GallerySearch'; -import { NewGallery } from './NewGallery'; +import { ImageGallery } from './NewGallery'; import { VideoGallery } from './VideoGallery'; const COLLAPSE_STYLES: CSSProperties = { flexShrink: 0, minHeight: 0, width: '100%' }; @@ -122,7 +122,7 @@ export const GalleryPanel = memo(() => { - {galleryView === 'images' ? : galleryView === 'videos' ? : } + {galleryView === 'videos' ? : } ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index 5a1df27ff77..be7af178f41 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -27,57 +27,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiImageBold } from 'react-icons/pi'; import { imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; - -const galleryImageContainerSX = { - containerType: 'inline-size', - w: 'full', - h: 'full', - '.gallery-image-size-badge': { - '@container (max-width: 80px)': { - '&': { display: 'none' }, - }, - }, - '&[data-is-dragging=true]': { - opacity: 0.3, - }, - userSelect: 'none', - webkitUserSelect: 'none', - position: 'relative', - justifyContent: 'center', - alignItems: 'center', - aspectRatio: '1/1', - '::before': { - content: '""', - display: 'inline-block', - position: 'absolute', - top: 0, - left: 0, - right: 0, - bottom: 0, - pointerEvents: 'none', - borderRadius: 'base', - }, - '&[data-selected=true]::before': { - boxShadow: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', - }, - '&[data-selected-for-compare=true]::before': { - boxShadow: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', - }, - '&:hover::before': { - boxShadow: - 'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)', - }, - '&:hover[data-selected=true]::before': { - boxShadow: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', - }, - '&:hover[data-selected-for-compare=true]::before': { - boxShadow: - 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', - }, -} satisfies SystemStyleObject; +import { galleryItemContainerSX } from './galleryItemContainerSX'; interface Props { imageDTO: ImageDTO; @@ -161,6 +111,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { getInitialData: () => { const selection = selectSelection(store.getState()); const boardId = selectSelectedBoardId(store.getState()); + // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. if (selection.length > 1 && selection.includes(imageDTO.image_name)) { @@ -241,7 +192,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { <> { +export const GalleryVideo = memo(({ videoDTO }: Props) => { const store = useAppStore(); const [isDragging, setIsDragging] = useState(false); const [dragPreviewState, setDragPreviewState] = useState< @@ -157,8 +107,16 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { const selection = selectSelection(store.getState()); const boardId = selectSelectedBoardId(store.getState()); - // Otherwise, initiate a single-image drag - return singleVideoDndSource.getData({ videoDTO }, videoDTO.video_id); + // When we have multiple images selected, and the dragged image is part of the selection, initiate a + // multi-image drag. + if (selection.length > 1 && selection.includes(videoDTO.video_id)) { + return multipleVideoDndSource.getData({ + ids: selection, + board_id: boardId, + }); + } // Otherwise, initiate a single-image drag + + return singleVideoDndSource.getData({ videoDTO }, videoDTO.video_id); }, // This is a "local" drag start event, meaning that it is only called when this specific image is dragged. onDragStart: ({ source }) => { @@ -190,10 +148,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { onDragStart: ({ source }) => { // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the // selection. This is called for all drag events. - if ( - multipleVideoDndSource.typeGuard(source.data) && - source.data.payload.ids.includes(videoDTO.video_id) - ) { + if (multipleVideoDndSource.typeGuard(source.data) && source.data.payload.ids.includes(videoDTO.video_id)) { setIsDragging(true); } }, @@ -228,7 +183,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { <> { pointerEvents="none" src={videoDTO.thumbnail_url} w={videoDTO.width} - fallback={} + fallback={} objectFit="contain" maxW="full" maxH="full" @@ -259,10 +214,10 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { GalleryVideo.displayName = 'GalleryVideo'; -export const GalleryImagePlaceholder = memo((props: FlexProps) => ( +export const GalleryVideoPlaceholder = memo((props: FlexProps) => ( - + )); -GalleryImagePlaceholder.displayName = 'GalleryImagePlaceholder'; +GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/galleryItemContainerSX.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/galleryItemContainerSX.tsx new file mode 100644 index 00000000000..4781e8af6b9 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/galleryItemContainerSX.tsx @@ -0,0 +1,52 @@ +import type { SystemStyleObject } from '@invoke-ai/ui-library'; + +export const galleryItemContainerSX = { + containerType: 'inline-size', + w: 'full', + h: 'full', + '.gallery-image-size-badge': { + '@container (max-width: 80px)': { + '&': { display: 'none' }, + }, + }, + '&[data-is-dragging=true]': { + opacity: 0.3, + }, + userSelect: 'none', + webkitUserSelect: 'none', + position: 'relative', + justifyContent: 'center', + alignItems: 'center', + aspectRatio: '1/1', + '::before': { + content: '""', + display: 'inline-block', + position: 'absolute', + top: 0, + left: 0, + right: 0, + bottom: 0, + pointerEvents: 'none', + borderRadius: 'base', + }, + '&[data-selected=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-500), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + }, + '&[data-selected-for-compare=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-300), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, + '&:hover::before': { + boxShadow: + 'inset 0px 0px 0px 1px var(--invoke-colors-invokeBlue-300), inset 0px 0px 0px 2px var(--invoke-colors-invokeBlue-800)', + }, + '&:hover[data-selected=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeBlue-400), inset 0px 0px 0px 4px var(--invoke-colors-invokeBlue-800)', + }, + '&:hover[data-selected-for-compare=true]::before': { + boxShadow: + 'inset 0px 0px 0px 3px var(--invoke-colors-invokeGreen-200), inset 0px 0px 0px 4px var(--invoke-colors-invokeGreen-800)', + }, +} satisfies SystemStyleObject; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 8c358653ed1..da2d046df8d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -15,9 +15,8 @@ import { } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import type { MutableRefObject, RefObject } from 'react'; -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { MutableRefObject } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import type { GridComponents, GridComputeItemKey, @@ -36,6 +35,10 @@ import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; import { useGalleryImageNames } from './use-gallery-image-names'; import { useGalleryVideoIds } from './use-gallery-video-ids'; import { GalleryVideo } from './ImageGrid/GalleryVideo'; +import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; +import { scrollIntoView } from './scrollIntoView'; +import { useScrollableGallery } from './useScrollableGallery'; +import { getItemIndex } from './getItemIndex'; const log = logger('gallery'); @@ -100,182 +103,6 @@ const computeItemKey: GridComputeItemKey = (index, imageNam return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; }; -/** - * Calculate how many images fit in a row based on the current grid layout. - * - * TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value - * changes. Cache this calculation. - */ -const getImagesPerRow = (rootEl: HTMLDivElement): number => { - // Start from root and find virtuoso grid elements - const gridElement = rootEl.querySelector('.virtuoso-grid-list'); - - if (!gridElement) { - return 0; - } - - const firstGridItem = gridElement.querySelector('.virtuoso-grid-item'); - - if (!firstGridItem) { - return 0; - } - - const itemRect = firstGridItem.getBoundingClientRect(); - const containerRect = gridElement.getBoundingClientRect(); - - // Get the computed gap from CSS - const gridStyle = window.getComputedStyle(gridElement); - const gapValue = gridStyle.gap; - const gap = parseFloat(gapValue); - - if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) { - return 0; - } - - /** - * You might be tempted to just do some simple math like: - * const imagesPerRow = Math.floor(containerRect.width / itemRect.width); - * - * But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases. - * - * Instead, we use a more robust approach that iteratively calculates how many images fit in the row. - */ - let imagesPerRow = 0; - let spaceUsed = 0; - - // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes - // this, without the possibility of accidentally adding an extra column. - while (spaceUsed + itemRect.width <= containerRect.width + 1) { - imagesPerRow++; // Increment the number of images - spaceUsed += itemRect.width; // Add image size to the used space - if (spaceUsed + gap <= containerRect.width) { - spaceUsed += gap; // Add gap size to the used space after each image except after the last image - } - } - - return Math.max(1, imagesPerRow); -}; - -/** - * Scroll the item at the given index into view if it is not currently visible. - */ -const scrollIntoView = ( - targetItemId: string, - itemIds: string[], - rootEl: HTMLDivElement, - virtuosoGridHandle: VirtuosoGridHandle, - range: ListRange -) => { - if (range.endIndex === 0) { - // No range is rendered; no need to scroll to anything. - log.trace('Not scrolling into view: Range endIdex is 0'); - return; - } - - const targetIndex = itemIds.findIndex((name) => name === targetItemId); - - if (targetIndex === -1) { - // The image isn't in the currently rendered list. - log.trace('Not scrolling into view: targetIndex is -1'); - return; - } - - const targetItem = rootEl.querySelector( - `.virtuoso-grid-item:has([data-item-id="${targetItemId}"])` - ) as HTMLElement; - - if (!targetItem) { - if (targetIndex > range.endIndex) { - log.trace( - { - index: targetIndex, - behavior: 'auto', - align: 'start', - }, - 'Scrolling into view: not in DOM' - ); - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'start', - }); - } else if (targetIndex < range.startIndex) { - log.trace( - { - index: targetIndex, - behavior: 'auto', - align: 'end', - }, - 'Scrolling into view: not in DOM' - ); - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'end', - }); - } else { - log.debug( - `Unable to find image ${targetItemId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` - ); - } - return; - } - - // We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport. - // Check if it is in the viewport and scroll if necessary. - - const itemRect = targetItem.getBoundingClientRect(); - const rootRect = rootEl.getBoundingClientRect(); - - if (itemRect.top < rootRect.top) { - log.trace( - { - index: targetIndex, - behavior: 'auto', - align: 'start', - }, - 'Scrolling into view: in overscan' - ); - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'start', - }); - } else if (itemRect.bottom > rootRect.bottom) { - log.trace( - { - index: targetIndex, - behavior: 'auto', - align: 'end', - }, - 'Scrolling into view: in overscan' - ); - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'end', - }); - } else { - // Image is already in view - log.debug('Not scrolling into view: Image is already in view'); - } - - return; -}; - -/** - * Get the index of the image in the list of image names. - * If the image name is not found, return 0. - * If no image name is provided, return 0. - */ -const getImageIndex = (imageName: string | undefined | null, imageNames: string[]) => { - if (!imageName || imageNames.length === 0) { - return 0; - } - const index = imageNames.findIndex((n) => n === imageName); - return index >= 0 ? index : 0; -}; - /** * Handles keyboard navigation for the gallery. */ @@ -312,7 +139,7 @@ const useKeyboardNavigation = ( return; } - const imagesPerRow = getImagesPerRow(rootEl); + const imagesPerRow = getItemsPerRow(rootEl); if (imagesPerRow === 0) { // This can happen if the grid is not yet rendered or has no items @@ -328,7 +155,7 @@ const useKeyboardNavigation = ( (selectImageToCompare(state) ?? selectLastSelectedImage(state)) : selectLastSelectedImage(state); - const currentIndex = getImageIndex(imageName, imageNames); + const currentIndex = getItemIndex(imageName, imageNames); let newIndex = currentIndex; @@ -475,51 +302,6 @@ const useKeepSelectedImageInView = ( }, [imageNames, rangeRef, rootRef, virtuosoRef, selection]); }; -/** - * Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element. - */ -const useScrollableGallery = (rootRef: RefObject) => { - const [scroller, scrollerRef] = useState(null); - const [initialize, osInstance] = useOverlayScrollbars({ - defer: true, - events: { - initialized(osInstance) { - // force overflow styles - const { viewport } = osInstance.elements(); - viewport.style.overflowX = `var(--os-viewport-overflow-x)`; - viewport.style.overflowY = `var(--os-viewport-overflow-y)`; - }, - }, - options: { - scrollbars: { - visibility: 'auto', - autoHide: 'scroll', - autoHideDelay: 1300, - theme: 'os-theme-dark', - }, - }, - }); - - useEffect(() => { - const { current: root } = rootRef; - - if (scroller && root) { - initialize({ - target: root, - elements: { - viewport: scroller, - }, - }); - } - - return () => { - osInstance()?.destroy(); - }; - }, [scroller, initialize, osInstance, rootRef]); - - return scrollerRef; -}; - const useStarImageHotkey = () => { const lastSelectedImage = useAppSelector(selectLastSelectedImage); const selectionCount = useAppSelector(selectSelectionCount); @@ -551,7 +333,7 @@ const useStarImageHotkey = () => { }); }; -export const NewGallery = memo(() => { +export const ImageGallery = memo(() => { const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const rootRef = useRef(null); @@ -559,7 +341,6 @@ export const NewGallery = memo(() => { // Get the ordered list of image names - this is our primary data source for virtualization const { queryArgs, imageNames, isLoading } = useGalleryImageNames(); - const { queryArgs: videoQueryArgs, videoIds, isLoading: isLoadingVideos } = useGalleryVideoIds(); // Use range-based fetching for bulk loading image DTOs into cache based on the visible range const { onRangeChanged } = useRangeBasedImageFetching({ @@ -584,7 +365,7 @@ export const NewGallery = memo(() => { [onRangeChanged] ); - const context = useMemo(() => ({ imageNames, queryArgs, videoIds, videoQueryArgs }), [imageNames, queryArgs, videoIds, videoQueryArgs]); + const context = useMemo(() => ({ imageNames, queryArgs }), [imageNames, queryArgs]); if (isLoading) { return ( @@ -609,7 +390,7 @@ export const NewGallery = memo(() => { ref={virtuosoRef} context={context} - data={galleryView === 'images' ? imageNames : videoIds} + data={imageNames} increaseViewportBy={4096} itemContent={itemContent} computeItemKey={computeItemKey} @@ -624,7 +405,7 @@ export const NewGallery = memo(() => { ); }); -NewGallery.displayName = 'NewGallery'; +ImageGallery.displayName = 'NewGallery'; const scrollSeekConfiguration: ScrollSeekConfiguration = { enter: (velocity) => { diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index ce1efe658b7..c8340630025 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -30,10 +30,14 @@ import { useDebounce } from 'use-debounce'; import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; -import { GalleryVideo } from './ImageGrid/GalleryVideo'; +import { GalleryVideo, GalleryVideoPlaceholder } from './ImageGrid/GalleryVideo'; import { useGalleryVideoIds } from './use-gallery-video-ids'; +import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; +import { scrollIntoView } from './scrollIntoView'; +import { getItemIndex } from './getItemIndex'; +import { useScrollableGallery } from './useScrollableGallery'; -const log = logger('gallery'); +export const log = logger('gallery'); type ListVideoIdsQueryArgs = ReturnType; @@ -67,156 +71,15 @@ const VideoAtPosition = memo(({ videoId }: { index: number; videoId: string }) = }); VideoAtPosition.displayName = 'VideoAtPosition'; -const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { - return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; -}; - -/** - * Calculate how many images fit in a row based on the current grid layout. - * - * TODO(psyche): We only need to do this when the gallery width changes, or when the galleryImageMinimumWidth value - * changes. Cache this calculation. - */ -const getVideosPerRow = (rootEl: HTMLDivElement): number => { - // Start from root and find virtuoso grid elements - const gridElement = rootEl.querySelector('.virtuoso-grid-list'); - - if (!gridElement) { - return 0; - } - - const firstGridItem = gridElement.querySelector('.virtuoso-grid-item'); - - if (!firstGridItem) { - return 0; - } - - const itemRect = firstGridItem.getBoundingClientRect(); - const containerRect = gridElement.getBoundingClientRect(); - - // Get the computed gap from CSS - const gridStyle = window.getComputedStyle(gridElement); - const gapValue = gridStyle.gap; - const gap = parseFloat(gapValue); - - if (isNaN(gap) || !itemRect.width || !itemRect.height || !containerRect.width || !containerRect.height) { - return 0; - } - - /** - * You might be tempted to just do some simple math like: - * const imagesPerRow = Math.floor(containerRect.width / itemRect.width); - * - * But floating point precision can cause issues with this approach, causing it to be off by 1 in some cases. - * - * Instead, we use a more robust approach that iteratively calculates how many images fit in the row. - */ - let videosPerRow = 0; - let spaceUsed = 0; - - // Floating point precision can cause imagesPerRow to be 1 too small. Adding 1px to the container size fixes - // this, without the possibility of accidentally adding an extra column. - while (spaceUsed + itemRect.width <= containerRect.width + 1) { - videosPerRow++; // Increment the number of images - spaceUsed += itemRect.width; // Add image size to the used space - if (spaceUsed + gap <= containerRect.width) { - spaceUsed += gap; // Add gap size to the used space after each image except after the last image - } - } - - return Math.max(1, videosPerRow); -}; - -/** - * Scroll the item at the given index into view if it is not currently visible. - */ -const scrollIntoView = ( - targetVideoId: string, - videoIds: string[], - rootEl: HTMLDivElement, - virtuosoGridHandle: VirtuosoGridHandle, - range: ListRange -) => { - if (range.endIndex === 0) { - // No range is rendered; no need to scroll to anything. - return; - } - - const targetIndex = videoIds.findIndex((id) => id === targetVideoId); - - if (targetIndex === -1) { - // The image isn't in the currently rendered list. - return; - } - - const targetItem = rootEl.querySelector( - `.virtuoso-grid-item:has([data-video-id="${targetVideoId}"])` - ) as HTMLElement; - - if (!targetItem) { - if (targetIndex > range.endIndex) { - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'start', - }); - } else if (targetIndex < range.startIndex) { - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'end', - }); - } else { - log.debug( - `Unable to find video ${targetVideoId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` - ); - } - return; - } - - // We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport. - // Check if it is in the viewport and scroll if necessary. - - const itemRect = targetItem.getBoundingClientRect(); - const rootRect = rootEl.getBoundingClientRect(); - - if (itemRect.top < rootRect.top) { - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'start', - }); - } else if (itemRect.bottom > rootRect.bottom) { - virtuosoGridHandle.scrollToIndex({ - index: targetIndex, - behavior: 'auto', - align: 'end', - }); - } else { - // Image is already in view - } - - return; -}; - -/** - * Get the index of the image in the list of image names. - * If the image name is not found, return 0. - * If no image name is provided, return 0. - */ -const getVideoIndex = (videoId: string | undefined | null, videoIds: string[]) => { - if (!videoId || videoIds.length === 0) { - return 0; - } - const index = videoIds.findIndex((n) => n === videoId); - return index >= 0 ? index : 0; +const computeItemKey: GridComputeItemKey = (index, itemId, { queryArgs }) => { + return `${JSON.stringify(queryArgs)}-${itemId ?? index}`; }; /** * Handles keyboard navigation for the gallery. */ const useKeyboardNavigation = ( - videoIds: string[], + itemIds: string[], virtuosoRef: React.RefObject, rootRef: React.RefObject ) => { @@ -244,13 +107,13 @@ const useKeyboardNavigation = ( return; } - if (videoIds.length === 0) { + if (itemIds.length === 0) { return; } - const videosPerRow = getVideosPerRow(rootEl); + const itemsPerRow = getItemsPerRow(rootEl); - if (videosPerRow === 0) { + if (itemsPerRow === 0) { // This can happen if the grid is not yet rendered or has no items return; } @@ -258,13 +121,9 @@ const useKeyboardNavigation = ( event.preventDefault(); const state = getState(); - const videoId = event.altKey - ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, - // we start from the last selected image - (selectImageToCompare(state) ?? selectLastSelectedImage(state)) - : selectLastSelectedImage(state); + const itemId = selectLastSelectedImage(state); - const currentIndex = getVideoIndex(videoId, videoIds); + const currentIndex = getItemIndex(itemId, itemIds); let newIndex = currentIndex; @@ -272,47 +131,39 @@ const useKeyboardNavigation = ( case 'ArrowLeft': if (currentIndex > 0) { newIndex = currentIndex - 1; - // } else { - // // Wrap to last image - // newIndex = imageNames.length - 1; } break; case 'ArrowRight': - if (currentIndex < videoIds.length - 1) { + if (currentIndex < itemIds.length - 1) { newIndex = currentIndex + 1; - // } else { - // // Wrap to first image - // newIndex = 0; } break; case 'ArrowUp': - // If on first row, stay on current image - if (currentIndex < videosPerRow) { + // If on first row, stay on current item + if (currentIndex < itemsPerRow) { newIndex = currentIndex; } else { - newIndex = Math.max(0, currentIndex - videosPerRow); + newIndex = Math.max(0, currentIndex - itemsPerRow); } break; case 'ArrowDown': - // If no images below, stay on current image - if (currentIndex >= videoIds.length - videosPerRow) { + // If no items below, stay on current item + if (currentIndex >= itemIds.length - itemsPerRow) { newIndex = currentIndex; } else { - newIndex = Math.min(videoIds.length - 1, currentIndex + videosPerRow); + newIndex = Math.min(itemIds.length - 1, currentIndex + itemsPerRow); } break; } - if (newIndex !== currentIndex && newIndex >= 0 && newIndex < videoIds.length) { - const newVideoId = videoIds[newIndex]; - if (newVideoId) { - - dispatch(selectionChanged([newVideoId])); - + if (newIndex !== currentIndex && newIndex >= 0 && newIndex < itemIds.length) { + const nextItemId = itemIds[newIndex]; + if (nextItemId) { + dispatch(selectionChanged([nextItemId])); } } }, - [rootRef, virtuosoRef, videoIds, getState, dispatch] + [rootRef, virtuosoRef, itemIds, getState, dispatch] ); useRegisteredHotkeys({ @@ -405,58 +256,10 @@ const useKeepSelectedVideoInView = ( }, [targetVideoId, videoIds, rangeRef, rootRef, virtuosoRef]); }; -/** - * Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element. - */ -const useScrollableGallery = (rootRef: RefObject) => { - const [scroller, scrollerRef] = useState(null); - const [initialize, osInstance] = useOverlayScrollbars({ - defer: true, - events: { - initialized(osInstance) { - // force overflow styles - const { viewport } = osInstance.elements(); - viewport.style.overflowX = `var(--os-viewport-overflow-x)`; - viewport.style.overflowY = `var(--os-viewport-overflow-y)`; - }, - }, - options: { - scrollbars: { - visibility: 'auto', - autoHide: 'scroll', - autoHideDelay: 1300, - theme: 'os-theme-dark', - }, - }, - }); - - useEffect(() => { - const { current: root } = rootRef; - - if (scroller && root) { - initialize({ - target: root, - elements: { - viewport: scroller, - }, - }); - } - - return () => { - osInstance()?.destroy(); - }; - }, [scroller, initialize, osInstance, rootRef]); - - return scrollerRef; -}; - - - export const VideoGallery = memo(() => { const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const rootRef = useRef(null); - const galleryView = useAppSelector(selectGalleryView); // Get the ordered list of image names - this is our primary data source for virtualization const { queryArgs, videoIds, isLoading } = useGalleryVideoIds(); @@ -492,7 +295,7 @@ export const VideoGallery = memo(() => { Loading gallery... ); - } + } if (videoIds.length === 0) { return ( @@ -513,7 +316,7 @@ export const VideoGallery = memo(() => { itemContent={itemContent} computeItemKey={computeItemKey} components={components} - style={style} + style={virtuosoGridStyle} scrollerRef={scrollerRef} scrollSeekConfiguration={scrollSeekConfiguration} rangeChanged={handleRangeChanged} @@ -535,7 +338,7 @@ const scrollSeekConfiguration: ScrollSeekConfiguration = { }; // Styles -const style = { height: '100%', width: '100%' }; +const virtuosoGridStyle = { height: '100%', width: '100%' }; const selectGridTemplateColumns = createSelector( selectGalleryImageMinimumWidth, @@ -562,7 +365,7 @@ ItemComponent.displayName = 'ItemComponent'; const ScrollSeekPlaceholderComponent: GridComponents['ScrollSeekPlaceholder'] = (props) => ( - + ); diff --git a/invokeai/frontend/web/src/features/gallery/components/getItemIndex.tsx b/invokeai/frontend/web/src/features/gallery/components/getItemIndex.tsx new file mode 100644 index 00000000000..f3e4f9bb783 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/getItemIndex.tsx @@ -0,0 +1,12 @@ +/** + * Get the index of the item in the list of item names. + * If the item name is not found, return 0. + * If no item name is provided, return 0. + */ +export const getItemIndex = (targetItemId: string | undefined | null, itemIds: string[]) => { + if (!targetItemId || itemIds.length === 0) { + return 0; + } + const index = itemIds.findIndex((n) => n === targetItemId); + return index >= 0 ? index : 0; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx new file mode 100644 index 00000000000..be1cb57e892 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx @@ -0,0 +1,107 @@ +import type { VirtuosoGridHandle, ListRange } from 'react-virtuoso'; +import { log } from './VideoGallery'; + +/** + * Scroll the item at the given index into view if it is not currently visible. + */ +export const scrollIntoView = ( + targetItemId: string, + itemIds: string[], + rootEl: HTMLDivElement, + virtuosoGridHandle: VirtuosoGridHandle, + range: ListRange +) => { + if (range.endIndex === 0) { + // No range is rendered; no need to scroll to anything. + log.trace('Not scrolling into view: Range endIdex is 0'); + return; + } + + const targetIndex = itemIds.findIndex((name) => name === targetItemId); + + if (targetIndex === -1) { + // The image isn't in the currently rendered list. + log.trace('Not scrolling into view: targetIndex is -1'); + return; + } + + const targetItem = rootEl.querySelector(`.virtuoso-grid-item:has([data-item-id="${targetItemId}"])`) as HTMLElement; + + if (!targetItem) { + if (targetIndex > range.endIndex) { + log.trace( + { + index: targetIndex, + behavior: 'auto', + align: 'start', + }, + 'Scrolling into view: not in DOM' + ); + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'start', + }); + } else if (targetIndex < range.startIndex) { + log.trace( + { + index: targetIndex, + behavior: 'auto', + align: 'end', + }, + 'Scrolling into view: not in DOM' + ); + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'end', + }); + } else { + log.debug( + `Unable to find image ${targetItemId} at index ${targetIndex} but it is in the rendered range ${range.startIndex}-${range.endIndex}` + ); + } + return; + } + + // We found the image in the DOM, but it might be in the overscan range - rendered but not in the visible viewport. + // Check if it is in the viewport and scroll if necessary. + + const itemRect = targetItem.getBoundingClientRect(); + const rootRect = rootEl.getBoundingClientRect(); + + if (itemRect.top < rootRect.top) { + log.trace( + { + index: targetIndex, + behavior: 'auto', + align: 'start', + }, + 'Scrolling into view: in overscan' + ); + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'start', + }); + } else if (itemRect.bottom > rootRect.bottom) { + log.trace( + { + index: targetIndex, + behavior: 'auto', + align: 'end', + }, + 'Scrolling into view: in overscan' + ); + virtuosoGridHandle.scrollToIndex({ + index: targetIndex, + behavior: 'auto', + align: 'end', + }); + } else { + // Image is already in view + log.debug('Not scrolling into view: Image is already in view'); + } + + return; +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx new file mode 100644 index 00000000000..0355ff3280e --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx @@ -0,0 +1,47 @@ +import { useOverlayScrollbars } from 'overlayscrollbars-react'; +import { type RefObject, useState, useEffect } from 'react'; + +/** + * Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element. + */ +export const useScrollableGallery = (rootRef: RefObject) => { + const [scroller, scrollerRef] = useState(null); + const [initialize, osInstance] = useOverlayScrollbars({ + defer: true, + events: { + initialized(osInstance) { + // force overflow styles + const { viewport } = osInstance.elements(); + viewport.style.overflowX = `var(--os-viewport-overflow-x)`; + viewport.style.overflowY = `var(--os-viewport-overflow-y)`; + }, + }, + options: { + scrollbars: { + visibility: 'auto', + autoHide: 'scroll', + autoHideDelay: 1300, + theme: 'os-theme-dark', + }, + }, + }); + + useEffect(() => { + const { current: root } = rootRef; + + if (scroller && root) { + initialize({ + target: root, + elements: { + viewport: scroller, + }, + }); + } + + return () => { + osInstance()?.destroy(); + }; + }, [scroller, initialize, osInstance, rootRef]); + + return scrollerRef; +}; From 888df4961f53a0bd93a3529ffbac80a3afb932be Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 09:04:03 -0400 Subject: [PATCH 30/55] lint the dang thing --- .../web/src/common/hooks/useDownloadImage.ts | 3 +- .../web/src/common/hooks/useGlobalHotkeys.ts | 1 - .../components/ChangeBoardModal.tsx | 25 ++- .../src/features/controlLayers/store/types.ts | 10 +- .../dnd/DndDragPreviewMultipleVideo.tsx | 4 +- .../dnd/DndDragPreviewSingleVideo.tsx | 8 +- invokeai/frontend/web/src/features/dnd/dnd.ts | 1 - .../MenuItems/ContextMenuItemChangeBoard.tsx | 6 +- .../MenuItems/ContextMenuItemLoadWorkflow.tsx | 2 +- ...ntextMenuItemNewCanvasFromImageSubMenu.tsx | 11 +- ...ontextMenuItemNewLayerFromImageSubMenu.tsx | 65 ++++---- .../MenuItems/ContextMenuItemOpenInNewTab.tsx | 4 +- .../MenuItems/ContextMenuItemOpenInViewer.tsx | 6 +- .../ContextMenuItemSelectForCompare.tsx | 15 +- .../ContextMenuItemSendToUpscale.tsx | 3 +- .../MenuItems/ContextMenuItemSendToVideo.tsx | 15 +- .../MenuItems/ContextMenuItemStarUnstar.tsx | 4 +- .../ContextMenuItemUseAsPromptTemplate.tsx | 3 +- .../ContextMenuItemUseAsRefImage.tsx | 5 +- .../MultipleSelectionVideoMenuItems.tsx | 9 +- .../ContextMenu/SingleSelectionMenuItems.tsx | 2 +- .../SingleSelectionVideoMenuItems.tsx | 7 +- .../ImageGrid/GalleryItemDeleteIconButton.tsx | 2 +- .../GalleryItemOpenInViewerIconButton.tsx | 2 +- .../ImageGrid/GalleryItemStarIconButton.tsx | 2 +- .../components/ImageGrid/GalleryVideo.tsx | 13 +- .../gallery/components/NewGallery.tsx | 2 +- .../gallery/components/VideoGallery.tsx | 7 +- .../gallery/contexts/ItemDTOContext.ts | 2 +- .../hooks/useRangeBasedVideoFetching.ts | 4 +- .../gallery/store/gallerySelectors.ts | 7 +- .../web/src/features/nodes/types/field.ts | 3 +- .../graph/generation/buildVeo3VideoGraph.ts | 9 +- .../DimensionsAspectRatioSelect.tsx | 4 +- .../components/Video/ParamDuration.tsx | 47 +++--- .../components/Video/ParamResolution.tsx | 50 +++--- .../features/parameters/store/videoSlice.ts | 15 +- .../InvokeButtonTooltip.tsx | 2 +- .../features/queue/hooks/useEnqueueVideo.ts | 14 +- .../web/src/features/queue/store/readiness.ts | 5 +- .../VideoModelPicker.tsx | 12 +- .../VideoSettingsAccordion.tsx | 62 ++++---- .../ParametersPanels/ParametersPanelVideo.tsx | 1 - .../features/ui/layouts/VideoPlayerPanel.tsx | 32 ++-- .../src/features/ui/layouts/navigation-api.ts | 4 - .../ui/layouts/video-tab-auto-layout.tsx | 4 +- .../web/src/services/api/endpoints/images.ts | 7 +- .../web/src/services/api/endpoints/videos.ts | 1 + .../src/services/api/hooks/modelsByType.ts | 2 +- .../frontend/web/src/services/api/types.ts | 4 +- .../src/services/api/util/tagInvalidation.ts | 146 +++++++++--------- .../services/events/onInvocationComplete.tsx | 2 - 52 files changed, 327 insertions(+), 349 deletions(-) diff --git a/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts index 900c67fa39c..1309afdbf56 100644 --- a/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts +++ b/invokeai/frontend/web/src/common/hooks/useDownloadImage.ts @@ -5,9 +5,8 @@ import { imageDownloaded } from 'features/gallery/store/actions'; import { toast } from 'features/toast/toast'; import { useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { ImageDTO, VideoDTO } from 'services/api/types'; -export const useDownloadItem = (itemDTO: ImageDTO | VideoDTO) => { +export const useDownloadItem = () => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const authToken = useStore($authToken); diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index bbc08e69ed6..4ffc9a36307 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -122,7 +122,6 @@ export const useGlobalHotkeys = () => { dependencies: [dispatch, isModelManagerEnabled], }); - const deleteImageModalApi = useDeleteImageModalApi(); useRegisteredHotkeys({ id: 'deleteSelection', diff --git a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx index 0fd5fdd3bcd..76d9b336d2e 100644 --- a/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx +++ b/invokeai/frontend/web/src/features/changeBoardModal/components/ChangeBoardModal.tsx @@ -86,7 +86,16 @@ const ChangeBoardModal = () => { } setSelectedBoard(null); dispatch(changeBoardReset()); - }, [addImagesToBoard, dispatch, imagesToChange, videosToChange, removeImagesFromBoard, selectedBoard]); + }, [ + addImagesToBoard, + dispatch, + imagesToChange, + videosToChange, + removeImagesFromBoard, + selectedBoard, + addVideosToBoard, + removeVideosFromBoard, + ]); const onChange = useCallback((v) => { if (!v) { @@ -107,12 +116,14 @@ const ChangeBoardModal = () => { > - {imagesToChange.length > 0 && t('boards.movingImagesToBoard', { - count: imagesToChange.length, - })} - {videosToChange.length > 0 && t('boards.movingVideosToBoard', { - count: videosToChange.length, - })} + {imagesToChange.length > 0 && + t('boards.movingImagesToBoard', { + count: imagesToChange.length, + })} + {videosToChange.length > 0 && + t('boards.movingVideosToBoard', { + count: videosToChange.length, + })} : diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index 2488d524952..a84ed67602b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -463,16 +463,14 @@ export const FLUX_KONTEXT_ASPECT_RATIOS: Record; -export const isVeo3AspectRatioID = (v: unknown): v is Veo3AspectRatio => - zVeo3AspectRatioID.safeParse(v).success; +export const isVeo3AspectRatioID = (v: unknown): v is Veo3AspectRatio => zVeo3AspectRatioID.safeParse(v).success; export const VEO3_ASPECT_RATIOS: Record = { '16:9': { width: 1280, height: 720 }, }; export const zVeo3Resolution = z.enum(['720p', '1080p']); export type Veo3Resolution = z.infer; -export const isVeo3Resolution = (v: unknown): v is Veo3Resolution => - zVeo3Resolution.safeParse(v).success; +export const isVeo3Resolution = (v: unknown): v is Veo3Resolution => zVeo3Resolution.safeParse(v).success; export const VEO3_RESOLUTIONS: Record = { '720p': { width: 1280, height: 720 }, '1080p': { width: 1920, height: 1080 }, @@ -493,11 +491,9 @@ export const DEFAULT_ASPECT_RATIO_CONFIG: AspectRatioConfig = { export const zVeo3DurationID = z.enum(['8']); export type Veo3Duration = z.infer; -export const isVeo3DurationID = (v: unknown): v is Veo3Duration => - zVeo3DurationID.safeParse(v).success; +export const isVeo3DurationID = (v: unknown): v is Veo3Duration => zVeo3DurationID.safeParse(v).success; export const VEO3_DURATIONS: Record = { '8': '8 seconds', - }; const zBboxState = z.object({ diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx index c40d7a88061..4c600e88f7a 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx @@ -8,7 +8,7 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import type { Param0 } from 'tsafe'; -const DndDragPreviewMultipleVideo = memo(({ ids}: { ids: string[] }) => { +const DndDragPreviewMultipleVideo = memo(({ ids }: { ids: string[] }) => { const { t } = useTranslation(); return ( setDragPreviewState(null); }, nativeSetDragImage, diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx index 6af0046f09d..4bd8c60f06c 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx @@ -10,10 +10,10 @@ import type { Param0 } from 'tsafe'; const ChakraImg = chakra('img'); -const DndDragPreviewSingleVideo = memo(({ videoDTO }: { videoDTO: VideoDTO}) => { +const DndDragPreviewSingleVideo = memo(({ videoDTO }: { videoDTO: VideoDTO }) => { return ( - - I AM A VIDEO + + I AM A VIDEO setDragPreviewState(null); }, nativeSetDragImage, diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index f5e155e5df4..bd855429851 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -100,7 +100,6 @@ export const multipleVideoDndSource: DndSource = { }; //#endregion - //#region Single Image const _singleImage = buildTypeAndKey('single-image'); export type SingleImageDndSourceData = DndData< diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx index 3f85665ce22..5091193375f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard.tsx @@ -1,6 +1,10 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; -import { imagesToChangeSelected, isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; +import { + imagesToChangeSelected, + isModalOpenChanged, + videosToChangeSelected, +} from 'features/changeBoardModal/store/slice'; import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx index 9aaf5dca4a9..9b77378c01e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLoadWorkflow.tsx @@ -26,7 +26,7 @@ export const ContextMenuItemLoadWorkflow = memo(() => { if (isImageDTO(itemDTO)) { return !itemDTO.has_workflow || !hasTemplates; } - return false + return false; }, [itemDTO, hasTemplates]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx index d7007156a05..b40525ae474 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewCanvasFromImageSubMenu.tsx @@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; import { useCanvasIsStaging } from 'features/controlLayers/store/canvasStagingAreaSlice'; -import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { newCanvasFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -11,7 +11,6 @@ import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFileBold, PiPlusBold } from 'react-icons/pi'; -import { isImageDTO } from 'services/api/types'; export const ContextMenuItemNewCanvasFromImageSubMenu = memo(() => { const { t } = useTranslation(); @@ -71,8 +70,8 @@ export const ContextMenuItemNewCanvasFromImageSubMenu = memo(() => { toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), - status: 'success', - }); + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewCanvasWithControlLayerFromImageWithResize = useCallback(async () => { @@ -89,8 +88,8 @@ export const ContextMenuItemNewCanvasFromImageSubMenu = memo(() => { toast({ id: 'SENT_TO_CANVAS', title: t('toast.sentToCanvas'), - status: 'success', - }); + status: 'success', + }); }, [imageDTO, store, t]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx index 62ba54c90b8..710a381d937 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemNewLayerFromImageSubMenu.tsx @@ -3,7 +3,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { SubMenuButtonContent, useSubMenu } from 'common/hooks/useSubMenu'; import { NewLayerIcon } from 'features/controlLayers/components/common/icons'; import { useCanvasIsBusySafe } from 'features/controlLayers/hooks/useCanvasIsBusy'; -import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { sentImageToCanvas } from 'features/gallery/store/actions'; import { createNewCanvasEntityFromImage } from 'features/imageActions/actions'; import { toast } from 'features/toast/toast'; @@ -12,7 +12,6 @@ import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPlusBold } from 'react-icons/pi'; -import { isImageDTO } from 'services/api/types'; export const ContextMenuItemNewLayerFromImageSubMenu = memo(() => { const { t } = useTranslation(); @@ -25,60 +24,60 @@ export const ContextMenuItemNewLayerFromImageSubMenu = memo(() => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'raster_layer', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewControlLayerFromImage = useCallback(async () => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'control_layer', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewInpaintMaskFromImage = useCallback(async () => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'inpaint_mask', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewRegionalGuidanceFromImage = useCallback(async () => { const { dispatch, getState } = store; await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); const onClickNewRegionalReferenceImageFromImage = useCallback(async () => { const { dispatch, getState } = store; - await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); + await navigationApi.focusPanel('canvas', WORKSPACE_PANEL_ID); createNewCanvasEntityFromImage({ imageDTO, type: 'regional_guidance_with_reference_image', dispatch, getState }); - dispatch(sentImageToCanvas()); - toast({ - id: 'SENT_TO_CANVAS', - title: t('toast.sentToCanvas'), - status: 'success', - }); + dispatch(sentImageToCanvas()); + toast({ + id: 'SENT_TO_CANVAS', + title: t('toast.sentToCanvas'), + status: 'success', + }); }, [imageDTO, store, t]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx index 2308ddb371f..e2b9f42363b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab.tsx @@ -13,8 +13,8 @@ export const ContextMenuItemOpenInNewTab = memo(() => { const dispatch = useAppDispatch(); const onClick = useCallback(() => { if (isImageDTO(itemDTO)) { - window.open(itemDTO.image_url, '_blank'); - dispatch(imageOpenedInNewTab()); + window.open(itemDTO.image_url, '_blank'); + dispatch(imageOpenedInNewTab()); } else { window.open(itemDTO.video_url, '_blank'); } diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx index e70bda9dc4b..58c76e3c3b5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx @@ -15,9 +15,9 @@ export const ContextMenuItemOpenInViewer = memo(() => { const itemDTO = useItemDTOContext(); const onClick = useCallback(() => { if (isImageDTO(itemDTO)) { - dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(itemDTO.image_name)); - navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID); + dispatch(imageToCompareChanged(null)); + dispatch(imageSelected(itemDTO.image_name)); + navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID); } else { // TODO: Implement video open in viewer } diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx index 6bbe072e96f..e626de2f485 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare.tsx @@ -13,19 +13,20 @@ export const ContextMenuItemSelectForCompare = memo(() => { const dispatch = useAppDispatch(); const itemDTO = useItemDTOContext(); const selectMaySelectForCompare = useMemo( - () => createSelector(selectGallerySlice, (gallery) => { - if (isImageDTO(itemDTO)) { - return gallery.imageToCompare !== itemDTO.image_name; - } - return false; - }), + () => + createSelector(selectGallerySlice, (gallery) => { + if (isImageDTO(itemDTO)) { + return gallery.imageToCompare !== itemDTO.image_name; + } + return false; + }), [itemDTO] ); const maySelectForCompare = useAppSelector(selectMaySelectForCompare); const onClick = useCallback(() => { if (isImageDTO(itemDTO)) { - dispatch(imageToCompareChanged(itemDTO.image_name)); + dispatch(imageToCompareChanged(itemDTO.image_name)); } else { // TODO: Implement video select for compare } diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx index 34e9eedd0c6..42bf10efa26 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToUpscale.tsx @@ -1,14 +1,13 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch } from 'app/store/storeHooks'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { upscaleInitialImageChanged } from 'features/parameters/store/upscaleSlice'; import { toast } from 'features/toast/toast'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiShareFatBold } from 'react-icons/pi'; -import { isImageDTO } from 'services/api/types'; export const ContextMenuItemSendToUpscale = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx index c488d6b236e..430b51f2ace 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSendToVideo.tsx @@ -1,12 +1,11 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; -import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; +import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiVideoBold } from 'react-icons/pi'; import { useDispatch } from 'react-redux'; -import { isImageDTO } from 'services/api/types'; export const ContextMenuItemSendToVideo = memo(() => { const { t } = useTranslation(); @@ -19,14 +18,10 @@ export const ContextMenuItemSendToVideo = memo(() => { }, [imageDTO, dispatch]); return ( - } - onClickCapture={onClick} - aria-label="Send to Video" - > - Send to Video + } onClickCapture={onClick} aria-label={t('parameters.sendToVideo')}> + {t('parameters.sendToVideo')} ); }); -ContextMenuItemSendToVideo.displayName = 'ContextMenuItemSendToVideo'; \ No newline at end of file +ContextMenuItemSendToVideo.displayName = 'ContextMenuItemSendToVideo'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx index 214b586b4ec..4828fb5e9ce 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemStarUnstar.tsx @@ -24,7 +24,7 @@ export const ContextMenuItemStarUnstar = memo(() => { } else if (isVideoDTO(itemDTO)) { starVideos({ video_ids: [itemDTO.video_id] }); } - }, [starImages, itemDTO]); + }, [starImages, itemDTO, starVideos]); const unstarImage = useCallback(() => { if (isImageDTO(itemDTO)) { @@ -32,7 +32,7 @@ export const ContextMenuItemStarUnstar = memo(() => { } else if (isVideoDTO(itemDTO)) { unstarVideos({ video_ids: [itemDTO.video_id] }); } - }, [unstarImages, itemDTO]); + }, [unstarImages, itemDTO, unstarVideos]); if (itemDTO.starred) { return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx index cd7f99b2ec7..ae526243fe5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsPromptTemplate.tsx @@ -1,10 +1,9 @@ import { MenuItem } from '@invoke-ai/ui-library'; -import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { useCreateStylePresetFromMetadata } from 'features/gallery/hooks/useCreateStylePresetFromMetadata'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiPaintBrushBold } from 'react-icons/pi'; -import type { ImageDTO } from 'services/api/types'; export const ContextMenuItemUseAsPromptTemplate = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx index 4a183040672..ea789356c39 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemUseAsRefImage.tsx @@ -3,12 +3,11 @@ import { useAppStore } from 'app/store/storeHooks'; import { getDefaultRefImageConfig } from 'features/controlLayers/hooks/addLayerHooks'; import { refImageAdded } from 'features/controlLayers/store/refImagesSlice'; import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; -import { useItemDTOContext, useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; +import { useItemDTOContextImageOnly } from 'features/gallery/contexts/ItemDTOContext'; import { toast } from 'features/toast/toast'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiImageBold } from 'react-icons/pi'; -import type { ImageDTO } from 'services/api/types'; export const ContextMenuItemUseAsRefImage = memo(() => { const { t } = useTranslation(); @@ -34,4 +33,4 @@ export const ContextMenuItemUseAsRefImage = memo(() => { ); }); - ContextMenuItemUseAsRefImage.displayName = 'ContextMenuItemUseAsRefImage'; +ContextMenuItemUseAsRefImage.displayName = 'ContextMenuItemUseAsRefImage'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx index 2f6292844ba..3297ff987f2 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx @@ -2,15 +2,11 @@ import { MenuDivider, MenuItem } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { $customStarUI } from 'app/store/nanostores/customStarUI'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; +import { isModalOpenChanged, videosToChangeSelected } from 'features/changeBoardModal/store/slice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiFoldersBold, PiStarBold, PiStarFill, PiTrashSimpleBold } from 'react-icons/pi'; -import { - useDeleteVideosMutation, - useStarVideosMutation, - useUnstarVideosMutation, -} from 'services/api/endpoints/videos'; +import { useDeleteVideosMutation, useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos'; const MultipleSelectionMenuItems = () => { const { t } = useTranslation(); @@ -18,7 +14,6 @@ const MultipleSelectionMenuItems = () => { const selection = useAppSelector((s) => s.gallery.selection); const customStarUi = useStore($customStarUI); - const [starVideos] = useStarVideosMutation(); const [unstarVideos] = useUnstarVideosMutation(); const [deleteVideos] = useDeleteVideosMutation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx index e21fcd8ccd3..5209e992fb9 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionMenuItems.tsx @@ -35,7 +35,7 @@ const SingleSelectionMenuItems = ({ imageDTO }: SingleSelectionMenuItemsProps) = return ( - + diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx index 8828df04800..cd9b2421ee7 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/SingleSelectionVideoMenuItems.tsx @@ -1,23 +1,22 @@ +import { MenuDivider } from '@invoke-ai/ui-library'; import { IconMenuItemGroup } from 'common/components/IconMenuItem'; +import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard'; import { ContextMenuItemCopy } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemCopy'; import { ContextMenuItemDelete } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDelete'; import { ContextMenuItemDownload } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload'; import { ContextMenuItemOpenInNewTab } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInNewTab'; import { ContextMenuItemOpenInViewer } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer'; import { ContextMenuItemSelectForCompare } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemSelectForCompare'; +import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext'; import type { VideoDTO } from 'services/api/types'; -import { ItemDTOContextProvider } from 'features/gallery/contexts/ItemDTOContext'; import { ContextMenuItemStarUnstar } from './MenuItems/ContextMenuItemStarUnstar'; -import { MenuDivider } from '@invoke-ai/ui-library'; -import { ContextMenuItemChangeBoard } from 'features/gallery/components/ContextMenu/MenuItems/ContextMenuItemChangeBoard'; type SingleSelectionVideoMenuItemsProps = { videoDTO: VideoDTO; }; const SingleSelectionVideoMenuItems = ({ videoDTO }: SingleSelectionVideoMenuItemsProps) => { - return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx index 278debfca93..2b5185b11f6 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemDeleteIconButton.tsx @@ -6,7 +6,7 @@ import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiTrashSimpleFill } from 'react-icons/pi'; import { useDeleteVideosMutation } from 'services/api/endpoints/videos'; -import { isImageDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; +import { type ImageDTO, isImageDTO, type VideoDTO } from 'services/api/types'; type Props = { itemDTO: ImageDTO | VideoDTO; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx index fa5c8781c76..4876a4558fd 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx @@ -6,7 +6,7 @@ import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { PiArrowsOutBold } from 'react-icons/pi'; -import { isImageDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; +import { type ImageDTO, isImageDTO, type VideoDTO } from 'services/api/types'; type Props = { itemDTO: ImageDTO | VideoDTO; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx index fb1530c4c4b..798eba6834e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemStarIconButton.tsx @@ -5,7 +5,7 @@ import { memo, useCallback } from 'react'; import { PiStarBold, PiStarFill } from 'react-icons/pi'; import { useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; import { useStarVideosMutation, useUnstarVideosMutation } from 'services/api/endpoints/videos'; -import { isImageDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; +import { type ImageDTO, isImageDTO, type VideoDTO } from 'services/api/types'; type Props = { itemDTO: ImageDTO | VideoDTO; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx index 283fa97ac55..638fe4d6936 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -6,27 +6,24 @@ import { createSelector } from '@reduxjs/toolkit'; import type { AppDispatch, AppGetState } from 'app/store/store'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { uniq } from 'es-toolkit'; -import { multipleImageDndSource, multipleVideoDndSource, singleVideoDndSource } from 'features/dnd/dnd'; +import { multipleVideoDndSource, singleVideoDndSource } from 'features/dnd/dnd'; import type { DndDragPreviewMultipleVideoState } from 'features/dnd/DndDragPreviewMultipleVideo'; import { createMultipleVideoDragPreview, setMultipleVideoDragPreview } from 'features/dnd/DndDragPreviewMultipleVideo'; import type { DndDragPreviewSingleVideoState } from 'features/dnd/DndDragPreviewSingleVideo'; import { createSingleVideoDragPreview, setSingleVideoDragPreview } from 'features/dnd/DndDragPreviewSingleVideo'; import { firefoxDndFix } from 'features/dnd/util'; -import { - selectGetImageNamesQueryArgs, - selectSelectedBoardId, - selectSelection, -} from 'features/gallery/store/gallerySelectors'; +import { useVideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu'; +import { selectGetImageNamesQueryArgs, selectSelectedBoardId, selectSelection } from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import type { MouseEvent, MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { PiImageBold, PiVideoBold } from 'react-icons/pi'; +import { PiVideoBold } from 'react-icons/pi'; import { imagesApi } from 'services/api/endpoints/images'; import type { VideoDTO } from 'services/api/types'; + import { GalleryItemHoverIcons } from './GalleryItemHoverIcons'; -import { useVideoContextMenu } from '../ContextMenu/VideoContextMenu'; import { galleryItemContainerSX } from './galleryItemContainerSX'; interface Props { diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index da2d046df8d..e1cb2f1fa40 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -322,7 +322,7 @@ const useStarImageHotkey = () => { } else { starImages({ image_names: [imageDTO.image_name] }); } - }, [imageDTO, isGalleryFocused, starImages, unstarImages]); + }, [imageDTO, isGalleryFocused, starImages, unstarImages, starVideos, unstarVideos, galleryView, videoDTO]); useRegisteredHotkeys({ id: 'starImage', diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index c8340630025..2f6bc0d8b33 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -7,15 +7,12 @@ import { useRangeBasedVideoFetching } from 'features/gallery/hooks/useRangeBased import type { selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, - selectGalleryView, - selectImageToCompare, selectLastSelectedImage, } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; -import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import type { MutableRefObject, RefObject } from 'react'; -import React, { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; +import type { MutableRefObject } from 'react'; +import React, { memo, useCallback, useEffect, useMemo, useRef } from 'react'; import type { GridComponents, GridComputeItemKey, diff --git a/invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts b/invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts index da95d013542..ebabbbe540a 100644 --- a/invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts +++ b/invokeai/frontend/web/src/features/gallery/contexts/ItemDTOContext.ts @@ -1,5 +1,5 @@ import { createContext, useContext } from 'react'; -import { isImageDTO, isVideoDTO, type ImageDTO, type VideoDTO } from 'services/api/types'; +import { type ImageDTO, isImageDTO, isVideoDTO, type VideoDTO } from 'services/api/types'; import { assert } from 'tsafe'; const ItemDTOContext = createContext(null); diff --git a/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts index 5d2466db288..4808ea6e623 100644 --- a/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts +++ b/invokeai/frontend/web/src/features/gallery/hooks/useRangeBasedVideoFetching.ts @@ -1,7 +1,7 @@ import { useAppStore } from 'app/store/storeHooks'; import { useCallback, useEffect, useState } from 'react'; import type { ListRange } from 'react-virtuoso'; -import { useGetVideoDTOsByNamesMutation,videosApi } from 'services/api/endpoints/videos'; +import { useGetVideoDTOsByNamesMutation, videosApi } from 'services/api/endpoints/videos'; import { useThrottledCallback } from 'use-debounce'; interface UseRangeBasedVideoFetchingArgs { @@ -50,7 +50,7 @@ export const useRangeBasedVideoFetching = ({ } const cachedVideoIds = videosApi.util.selectCachedArgsForQuery(store.getState(), 'getVideoDTO'); const uncachedIds = getUncachedIds(videoIds, cachedVideoIds, ranges); - console.log('uncachedIds', uncachedIds); + // console.log('uncachedIds', uncachedIds); if (uncachedIds.length === 0) { return; } diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index cca1ee4938f..48de554d13a 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -52,12 +52,7 @@ export const selectGetImageNamesQueryArgs = createMemoizedSelector( ); export const selectGetVideoIdsQueryArgs = createMemoizedSelector( - [ - selectSelectedBoardId, - selectGallerySearchTerm, - selectGalleryOrderDir, - selectGalleryStarredFirst, - ], + [selectSelectedBoardId, selectGallerySearchTerm, selectGalleryOrderDir, selectGalleryStarredFirst], (board_id, search_term, order_dir, starred_first): GetVideoIdsArgs => ({ board_id, search_term, diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index 54fd5c8897d..6aefed2ea64 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -1294,8 +1294,7 @@ export type Veo3ModelFieldValue = z.infer; export type Veo3ModelFieldInputInstance = z.infer; export type Veo3ModelFieldInputTemplate = z.infer; export const isVeo3ModelFieldInputInstance = buildInstanceTypeGuard(zVeo3ModelFieldInputInstance); -export const isVeo3ModelFieldInputTemplate = - buildTemplateTypeGuard('Veo3ModelField'); +export const isVeo3ModelFieldInputTemplate = buildTemplateTypeGuard('Veo3ModelField'); // #endregion // #region SchedulerField diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts index 76d356c8bf7..36ace7484ee 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildVeo3VideoGraph.ts @@ -3,7 +3,7 @@ import { getPrefixedId } from 'features/controlLayers/konva/util'; import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; import { zImageField } from 'features/nodes/types/common'; import { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; +import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderArg, GraphBuilderReturn } from 'features/nodes/util/graph/types'; import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { selectStartingFrameImage, selectVideoSlice } from 'features/parameters/store/videoSlice'; @@ -17,8 +17,8 @@ export const buildVeo3VideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => log.debug({ generationMode, manager: manager?.id }, 'Building Veo3 video graph'); - const supportedModes = ['txt2img'] as const; - if (!supportedModes.includes(generationMode as any)) { + const supportedModes = ['txt2img']; + if (!supportedModes.includes(generationMode)) { throw new UnsupportedGenerationModeError(t('toast.veo3IncompatibleGenerationMode')); } @@ -27,7 +27,6 @@ export const buildVeo3VideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => const prompts = selectPresetModifiedPrompts(state); assert(prompts.positive.length > 0, 'Veo3 video requires positive prompt to have at least one character'); - const { seed, shouldRandomizeSeed } = params; const { videoModel, videoResolution, videoDuration } = videoParams; const finalSeed = shouldRandomizeSeed ? undefined : seed; @@ -46,7 +45,7 @@ export const buildVeo3VideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => // @ts-expect-error: This node is not available in the OSS application type: 'google_veo_3_generate_video', model: videoModel, - aspect_ratio: "16:9", + aspect_ratio: '16:9', resolution: videoResolution, seed: finalSeed, }); diff --git a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx index 1848f68adee..19f6c9ef0ab 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Dimensions/DimensionsAspectRatioSelect.tsx @@ -16,12 +16,12 @@ import { zFluxKontextAspectRatioID, zImagen3AspectRatioID, zVeo3AspectRatioID, - } from 'features/controlLayers/store/types'; +} from 'features/controlLayers/store/types'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import type { ChangeEventHandler } from 'react'; import { memo, useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; -import { selectActiveTab } from 'features/ui/store/uiSelectors'; export const DimensionsAspectRatioSelect = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx index aac53c3747f..cdff52d56c7 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx @@ -1,11 +1,11 @@ -import { FormControl, FormLabel, Select } from "@invoke-ai/ui-library"; -import { useAppDispatch, useAppSelector } from "app/store/storeHooks"; -import { selectVideoDuration, videoDurationChanged } from "features/parameters/store/videoSlice"; -import { isVeo3DurationID, VEO3_DURATIONS, zVeo3DurationID } from "features/controlLayers/store/types"; -import type { ChangeEventHandler} from "react"; -import { useCallback, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { PiCaretDownBold } from "react-icons/pi"; +import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { isVeo3DurationID, VEO3_DURATIONS } from 'features/controlLayers/store/types'; +import { selectVideoDuration, videoDurationChanged } from 'features/parameters/store/videoSlice'; +import type { ChangeEventHandler } from 'react'; +import { useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretDownBold } from 'react-icons/pi'; export const ParamDuration = () => { const videoDuration = useAppSelector(selectVideoDuration); @@ -13,12 +13,10 @@ export const ParamDuration = () => { const dispatch = useAppDispatch(); const options = useMemo(() => { - - return Object.entries(VEO3_DURATIONS).map(([key, value]) => ({ - label: value, - value: key, - })); - + return Object.entries(VEO3_DURATIONS).map(([key, value]) => ({ + label: value, + value: key, + })); }, []); const onChange = useCallback>( @@ -33,16 +31,25 @@ export const ParamDuration = () => { [dispatch] ); - const value = useMemo(() => options.find((o) => o.value === videoDuration)?.value, [videoDuration]); + const value = useMemo(() => options.find((o) => o.value === videoDuration)?.value, [videoDuration, options]); - return - {t('parameters.duration')} - } + > {options.map((duration) => ( ))} - ; -}; \ No newline at end of file + + ); +}; diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx index ebbb39e65d6..c12a2d3324a 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx @@ -1,11 +1,12 @@ -import { FormControl, FormLabel, Select } from "@invoke-ai/ui-library"; -import { useAppDispatch, useAppSelector } from "app/store/storeHooks"; -import { aspectRatioIdChanged, heightChanged, widthChanged } from "features/controlLayers/store/paramsSlice"; -import { isVeo3Resolution, VEO3_RESOLUTIONS, zVeo3Resolution } from "features/controlLayers/store/types"; -import { selectVideoResolution, videoResolutionChanged } from "features/parameters/store/videoSlice"; -import { ChangeEventHandler, useCallback, useEffect, useMemo } from "react"; -import { useTranslation } from "react-i18next"; -import { PiCaretDownBold } from "react-icons/pi"; +import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { aspectRatioIdChanged, heightChanged, widthChanged } from 'features/controlLayers/store/paramsSlice'; +import { isVeo3Resolution, VEO3_RESOLUTIONS, zVeo3Resolution } from 'features/controlLayers/store/types'; +import { selectVideoResolution, videoResolutionChanged } from 'features/parameters/store/videoSlice'; +import type { ChangeEventHandler } from 'react'; +import { useCallback, useEffect, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretDownBold } from 'react-icons/pi'; export const ParamResolution = () => { const videoResolution = useAppSelector(selectVideoResolution); @@ -35,16 +36,25 @@ export const ParamResolution = () => { [dispatch] ); - const value = useMemo(() => options.find((o) => o === videoResolution), [videoResolution]); + const value = useMemo(() => options.find((o) => o === videoResolution), [videoResolution, options]); - return - {t('parameters.resolution')} - - ; -}; \ No newline at end of file + return ( + + {t('parameters.resolution')} + + + ); +}; diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 6347f425218..2fb21e706ab 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -7,8 +7,7 @@ import type { ImageWithDims, Veo3Duration, Veo3Resolution } from 'features/contr import { zImageWithDims, zVeo3DurationID, zVeo3Resolution } from 'features/controlLayers/store/types'; import type { VideoField } from 'features/nodes/types/common'; import { zModelIdentifierField, zVideoField } from 'features/nodes/types/common'; -import { ModelIdentifier } from 'features/nodes/types/v2/common'; -import { Veo3ModelConfig } from 'services/api/types'; +import type { Veo3ModelConfig } from 'services/api/types'; import { assert } from 'tsafe'; import z from 'zod'; @@ -16,7 +15,7 @@ const zVideoState = z.object({ _version: z.literal(1), startingFrameImage: zImageWithDims.nullable(), generatedVideo: zVideoField.nullable(), - videoModel: zModelIdentifierField.nullable(), + videoModel: zModelIdentifierField.nullable(), videoResolution: zVeo3Resolution.nullable(), videoDuration: zVeo3DurationID.nullable(), }); @@ -60,7 +59,13 @@ const slice = createSlice({ }, }); -export const { startingFrameImageChanged, generatedVideoChanged, videoModelChanged, videoResolutionChanged, videoDurationChanged } = slice.actions; +export const { + startingFrameImageChanged, + generatedVideoChanged, + videoModelChanged, + videoResolutionChanged, + videoDurationChanged, +} = slice.actions; export const videoSliceConfig: SliceConfig = { slice, @@ -84,4 +89,4 @@ export const selectStartingFrameImage = createVideoSelector((video) => video.sta export const selectGeneratedVideo = createVideoSelector((video) => video.generatedVideo); export const selectVideoModel = createVideoSelector((video) => video.videoModel); export const selectVideoResolution = createVideoSelector((video) => video.videoResolution); -export const selectVideoDuration = createVideoSelector((video) => video.videoDuration); \ No newline at end of file +export const selectVideoDuration = createVideoSelector((video) => video.videoDuration); diff --git a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx index 1b372767227..fdc18cde45a 100644 --- a/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx +++ b/invokeai/frontend/web/src/features/queue/components/InvokeButtonTooltip/InvokeButtonTooltip.tsx @@ -72,7 +72,7 @@ const VideoTabTooltipContent = memo(({ prepend = false }: { prepend?: boolean }) - ) + ); }); VideoTabTooltipContent.displayName = 'VideoTabTooltipContent'; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts index 9cad2a0b306..21d4945852d 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts @@ -54,7 +54,7 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { return; } - const { g, seed, positivePrompt } = buildGraphResult.value; + const { g } = buildGraphResult.value; // const prepareBatchResult = withResult(() => // prepareLinearUIBatch({ @@ -75,7 +75,6 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { // const batchConfig = prepareBatchResult.value; - const batchConfig = { prepend, batch: { @@ -87,13 +86,10 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { }; const req = dispatch( - queueApi.endpoints.enqueueBatch.initiate( - batchConfig, - { - ...enqueueMutationFixedCacheKeyOptions, - track: false, - } - ) + queueApi.endpoints.enqueueBatch.initiate(batchConfig, { + ...enqueueMutationFixedCacheKeyOptions, + track: false, + }) ); const enqueueResult = await req.unwrap(); diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index bc6ffc51db1..f66a43253b6 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -93,7 +93,7 @@ const debouncedUpdateReasons = debounce( isInPublishFlow: boolean, isChatGPT4oHighModelDisabled: (model: ParameterModel) => boolean, promptExpansionRequest: PromptExpansionRequestState, - video: VideoState, + video: VideoState ) => { if (tab === 'generate') { const model = selectMainModelConfig(store.getState()); @@ -123,7 +123,6 @@ const debouncedUpdateReasons = debounce( canvasIsSelectingObject, isChatGPT4oHighModelDisabled, promptExpansionRequest, - }); $reasonsWhyCannotEnqueue.set(reasons); } else if (tab === 'workflows') { @@ -207,7 +206,7 @@ export const useReadinessWatcher = () => { isInPublishFlow, isChatGPT4oHighModelDisabled, promptExpansionRequest, - video, + video ); }, [ store, diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx index 6c3e1c5bdc5..41545cc4a30 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx @@ -1,16 +1,12 @@ -import { Flex, FormLabel, Icon } from '@invoke-ai/ui-library'; +import { Flex, FormLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; -import { UseDefaultSettingsButton } from 'features/parameters/components/MainModel/UseDefaultSettingsButton'; import { ModelPicker } from 'features/parameters/components/ModelPicker'; -import { modelSelected } from 'features/parameters/store/actions'; import { selectVideoModel, videoModelChanged } from 'features/parameters/store/videoSlice'; -import { memo, useCallback, useMemo } from 'react'; +import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { MdMoneyOff } from 'react-icons/md'; -import { useMainModels, useVeo3Models } from 'services/api/hooks/modelsByType'; -import { useSelectedModelConfig } from 'services/api/hooks/useSelectedModelConfig'; -import { type AnyModelConfig, isCheckpointMainModelConfig, Veo3ModelConfig } from 'services/api/types'; +import { useVeo3Models } from 'services/api/hooks/modelsByType'; +import type { Veo3ModelConfig } from 'services/api/types'; export const VideoModelPicker = memo(() => { const { t } = useTranslation(); diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx index a2bbec7a448..a787474b604 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx @@ -1,48 +1,44 @@ import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; import { Dimensions } from 'features/parameters/components/Dimensions/Dimensions'; import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed'; import { ParamDuration } from 'features/parameters/components/Video/ParamDuration'; +import { ParamResolution } from 'features/parameters/components/Video/ParamResolution'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; -import { memo, useEffect } from 'react'; +import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { StartingFrameImage } from './StartingFrameImage'; import { VideoModelPicker } from './VideoModelPicker'; -import { ParamResolution } from 'features/parameters/components/Video/ParamResolution'; - export const VideoSettingsAccordion = memo(() => { - const { t } = useTranslation(); - const { isOpen: isOpenAccordion, onToggle: onToggleAccordion } = useStandaloneAccordionToggle({ - id: 'video-settings', - defaultIsOpen: true, - }); - const dispatch = useAppDispatch(); - - return ( - - - - - - - - - - - - - - + const { t } = useTranslation(); + const { isOpen: isOpenAccordion, onToggle: onToggleAccordion } = useStandaloneAccordionToggle({ + id: 'video-settings', + defaultIsOpen: true, + }); + return ( + + + + + + + + + - - ); + + + + + + + ); }); VideoSettingsAccordion.displayName = 'VideoSettingsAccordion'; diff --git a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx index 5604c8d132b..7319956c542 100644 --- a/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx +++ b/invokeai/frontend/web/src/features/ui/components/ParametersPanels/ParametersPanelVideo.tsx @@ -18,7 +18,6 @@ const overlayScrollbarsStyles: CSSProperties = { export const ParametersPanelVideo = memo(() => { const isStylePresetsMenuOpen = useStore($isStylePresetsMenuOpen); - return ( diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx index 25cec25561e..7295f4f5f51 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx @@ -8,7 +8,7 @@ import { useTranslation } from 'react-i18next'; import ReactPlayer from 'react-player'; import { useGetVideoDTOQuery } from 'services/api/endpoints/videos'; -const ChakraReactPlayer = chakra(ReactPlayer) +const ChakraReactPlayer = chakra(ReactPlayer); export const VideoPlayerPanel = memo(() => { const { t } = useTranslation(); @@ -21,23 +21,21 @@ export const VideoPlayerPanel = memo(() => { return ( {videoDTO?.video_url && ( - <> - - - - + + + )} - {!videoDTO?.video_url && No video generated} + {!videoDTO?.video_url && {t('gallery.noVideoSelected')}} ); }); diff --git a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts index 10afb4eb95c..daa191acd79 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts +++ b/invokeai/frontend/web/src/features/ui/layouts/navigation-api.ts @@ -238,8 +238,6 @@ export class NavigationApi { this._app.storage.set(key, api.toJSON()); } - console.log('api.panels', api.panels); - for (const panel of api.panels) { this._registerPanel(tab, panel.id, panel); } @@ -447,7 +445,6 @@ export class NavigationApi { */ getPanel = (tab: TabName, panelId: string): PanelType | undefined => { const key = this._getPanelKey(tab, panelId); - console.log('key', key); return this.panels.get(key); }; @@ -489,7 +486,6 @@ export class NavigationApi { */ toggleLeftPanel = (): boolean => { const activeTab = this._app?.activeTab.get() ?? null; - console.log('activeTab', activeTab); if (!activeTab) { log.warn('No active tab found to toggle left panel'); return false; diff --git a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx index c15e683d0ca..0857ce38f1a 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx @@ -66,7 +66,7 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { params: { tab, focusRegion: 'launchpad', - i18nKey: 'ui.panels.launchpad' + i18nKey: 'ui.panels.launchpad', }, }); @@ -78,7 +78,7 @@ const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { params: { tab, focusRegion: 'viewer', - i18nKey: 'ui.panels.imageViewer' + i18nKey: 'ui.panels.imageViewer', }, position: { direction: 'within', diff --git a/invokeai/frontend/web/src/services/api/endpoints/images.ts b/invokeai/frontend/web/src/services/api/endpoints/images.ts index 114fdf8fdc7..8d1f1783d3e 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/images.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/images.ts @@ -15,13 +15,16 @@ import type { UploadImageArg, } from 'services/api/types'; import { getListImagesUrl } from 'services/api/util'; +import { + getTagsToInvalidateForBoardAffectingMutation, + getTagsToInvalidateForImageMutation, +} from 'services/api/util/tagInvalidation'; import stableHash from 'stable-hash'; import type { Param0 } from 'tsafe'; import type { JsonObject } from 'type-fest'; import { api, buildV1Url, LIST_TAG } from '..'; import { buildBoardsUrl } from './boards'; -import { getTagsToInvalidateForBoardAffectingMutation, getTagsToInvalidateForImageMutation } from '../util/tagInvalidation'; /** * Builds an endpoint URL for the images router @@ -590,5 +593,3 @@ export const useImageDTO = (imageName: string | null | undefined) => { const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken); return imageDTO ?? null; }; - - diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 88036168a9d..211d9969ce4 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -1,3 +1,4 @@ +import { skipToken } from '@reduxjs/toolkit/query'; import { getStore } from 'app/store/nanostores/store'; import type { paths } from 'services/api/schema'; import type { GetVideoIdsArgs, GetVideoIdsResult, VideoDTO } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts index 08c684ac08a..428f482120a 100644 --- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts +++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts @@ -10,7 +10,6 @@ import { import type { AnyModelConfig } from 'services/api/types'; import { isChatGPT4oModelConfig, - isVeo3ModelConfig, isCLIPEmbedModelConfig, isCLIPVisionModelConfig, isCogView4MainModelModelConfig, @@ -38,6 +37,7 @@ import { isT5EncoderModelConfig, isTIModelConfig, isVAEModelConfig, + isVeo3ModelConfig, } from 'services/api/types'; type ModelHookArgs = { excludeSubmodels?: boolean }; diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 7abe6cddbbe..6e33a2a5e10 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -14,11 +14,9 @@ export type GetImageNamesResult = paths['/api/v1/images/names']['get']['responses']['200']['content']['application/json']; export type GetImageNamesArgs = NonNullable; -export type GetVideoIdsResult = - paths['/api/v1/videos/ids']['get']['responses']['200']['content']['application/json']; +export type GetVideoIdsResult = paths['/api/v1/videos/ids']['get']['responses']['200']['content']['application/json']; export type GetVideoIdsArgs = NonNullable; - export type ListBoardsArgs = NonNullable; export type CreateBoardArg = paths['/api/v1/boards/']['post']['parameters']['query']; diff --git a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts index 003bf8805af..6876da6e2d2 100644 --- a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts +++ b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts @@ -1,82 +1,80 @@ -import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from "features/gallery/store/types"; -import { ApiTagDescription } from ".."; -import { getListImagesUrl } from "../util"; +import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; +import { getListImagesUrl } from 'services/api/util'; + +import type { ApiTagDescription } from '..'; export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => { - const tags: ApiTagDescription[] = ['ImageNameList']; - - for (const board_id of affected_boards) { - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id, - categories: IMAGE_CATEGORIES, - }), - }); - - tags.push({ - type: 'ImageList', - id: getListImagesUrl({ - board_id, - categories: ASSETS_CATEGORIES, - }), - }); + const tags: ApiTagDescription[] = ['ImageNameList']; + + for (const board_id of affected_boards) { + tags.push({ + type: 'ImageList', + id: getListImagesUrl({ + board_id, + categories: IMAGE_CATEGORIES, + }), + }); + + tags.push({ + type: 'ImageList', + id: getListImagesUrl({ + board_id, + categories: ASSETS_CATEGORIES, + }), + }); + + tags.push({ + type: 'VideoIdList', + }); + + tags.push({ + type: 'Board', + id: board_id, + }); - tags.push({ - type: 'VideoIdList', - - }); - - tags.push({ - type: 'Board', - id: board_id, - }); - - tags.push({ - type: 'BoardImagesTotal', - id: board_id, - }); - } - - return tags; - }; - + tags.push({ + type: 'BoardImagesTotal', + id: board_id, + }); + } + + return tags; +}; export const getTagsToInvalidateForImageMutation = (image_names: string[]): ApiTagDescription[] => { - const tags: ApiTagDescription[] = []; - - for (const image_name of image_names) { - tags.push({ - type: 'Image', - id: image_name, - }); - tags.push({ - type: 'ImageMetadata', - id: image_name, - }); - tags.push({ - type: 'ImageWorkflow', - id: image_name, - }); - } - - return tags; - }; + const tags: ApiTagDescription[] = []; + + for (const image_name of image_names) { + tags.push({ + type: 'Image', + id: image_name, + }); + tags.push({ + type: 'ImageMetadata', + id: image_name, + }); + tags.push({ + type: 'ImageWorkflow', + id: image_name, + }); + } + + return tags; +}; export const getTagsToInvalidateForVideoMutation = (video_ids: string[]): ApiTagDescription[] => { - const tags: ApiTagDescription[] = []; - - for (const video_id of video_ids) { - tags.push({ - type: 'Video', - id: video_id, - }); - // tags.push({ - // type: 'VideoMetadata', - // id: video_id, - // }); + const tags: ApiTagDescription[] = []; + + for (const video_id of video_ids) { + tags.push({ + type: 'Video', + id: video_id, + }); + // tags.push({ + // type: 'VideoMetadata', + // id: video_id, + // }); + } - } - - return tags; - }; \ No newline at end of file + return tags; +}; diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index 82664852120..ffeed705431 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -11,10 +11,8 @@ import { } from 'features/gallery/store/gallerySelectors'; import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; -import type { VideoField } from 'features/nodes/types/common'; import { isImageField, isImageFieldCollection, isVideoField } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; -import { generatedVideoChanged } from 'features/parameters/store/videoSlice'; import type { LRUCache } from 'lru-cache'; import { boardsApi } from 'services/api/endpoints/boards'; import { getImageDTOSafe, imagesApi } from 'services/api/endpoints/images'; From d703d40a15569e51fd7024dc06deabe92328de4a Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 09:12:44 -0400 Subject: [PATCH 31/55] tsc --- invokeai/frontend/web/public/locales/en.json | 2 ++ .../src/features/controlLayers/store/paramsSlice.ts | 6 +++--- .../MenuItems/ContextMenuItemDownload.tsx | 2 +- .../subpanels/ModelManagerPanel/ModelBaseBadge.tsx | 1 + .../parameters/components/Advanced/ParamClipSkip.tsx | 12 ++++++------ .../web/src/features/parameters/types/constants.ts | 4 +++- 6 files changed, 16 insertions(+), 11 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 6b537daeaa8..0a9fc4a2ee5 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -382,6 +382,7 @@ "sortDirection": "Sort Direction", "showStarredImagesFirst": "Show Starred Images First", "noImageSelected": "No Image Selected", + "noVideoSelected": "No Video Selected", "noImagesInGallery": "No Images to Display", "starImage": "Star Image", "unstarImage": "Unstar Image", @@ -1294,6 +1295,7 @@ "postProcessing": "Post-Processing (Shift + U)", "processImage": "Process Image", "upscaling": "Upscaling", + "video": "Video", "useAll": "Use All", "useSize": "Use Size", "useCpuNoise": "Use CPU Noise", diff --git a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts index e40661c3f70..c8ec0bf13e7 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/paramsSlice.ts @@ -366,7 +366,7 @@ const applyClipSkip = (state: { clipSkip: number }, model: ParameterModel | null const maxClip = getModelMaxClipSkip(model); - state.clipSkip = clamp(clipSkip, 0, maxClip); + state.clipSkip = clamp(clipSkip, 0, maxClip ?? 0); }; const hasModelClipSkip = (model: ParameterModel | null) => { @@ -374,7 +374,7 @@ const hasModelClipSkip = (model: ParameterModel | null) => { return false; } - return getModelMaxClipSkip(model) > 0; + return getModelMaxClipSkip(model) ?? 0 > 0; }; const getModelMaxClipSkip = (model: ParameterModel) => { @@ -383,7 +383,7 @@ const getModelMaxClipSkip = (model: ParameterModel) => { return 0; } - return CLIP_SKIP_MAP[model.base].maxClip; + return CLIP_SKIP_MAP[model.base]?.maxClip; }; const resetState = (state: ParamsState): ParamsState => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload.tsx index e39e54dc17e..13e4dcdb61d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemDownload.tsx @@ -9,7 +9,7 @@ import { isImageDTO } from 'services/api/types'; export const ContextMenuItemDownload = memo(() => { const { t } = useTranslation(); const itemDTO = useItemDTOContext(); - const { downloadItem } = useDownloadItem(itemDTO); + const { downloadItem } = useDownloadItem(); const onClick = useCallback(() => { if (isImageDTO(itemDTO)) { diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx index 59b7f022e22..52a038ea3d2 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx @@ -20,6 +20,7 @@ export const BASE_COLOR_MAP: Record = { imagen4: 'pink', 'chatgpt-4o': 'pink', 'flux-kontext': 'pink', + veo3: 'white', }; const ModelBaseBadge = ({ base }: Props) => { diff --git a/invokeai/frontend/web/src/features/parameters/components/Advanced/ParamClipSkip.tsx b/invokeai/frontend/web/src/features/parameters/components/Advanced/ParamClipSkip.tsx index f7abd6f86ed..1d5eacc669c 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Advanced/ParamClipSkip.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Advanced/ParamClipSkip.tsx @@ -24,16 +24,16 @@ const ParamClipSkip = () => { const max = useMemo(() => { if (!model) { - return CLIP_SKIP_MAP['sd-1'].maxClip; + return CLIP_SKIP_MAP['sd-1']?.maxClip; } - return CLIP_SKIP_MAP[model.base].maxClip; + return CLIP_SKIP_MAP[model.base]?.maxClip; }, [model]); const sliderMarks = useMemo(() => { if (!model) { - return CLIP_SKIP_MAP['sd-1'].markers; + return CLIP_SKIP_MAP['sd-1']?.markers; } - return CLIP_SKIP_MAP[model.base].markers; + return CLIP_SKIP_MAP[model.base]?.markers; }, [model]); if (model?.base === 'sdxl') { @@ -49,7 +49,7 @@ const ParamClipSkip = () => { value={clipSkip} defaultValue={config.initial} min={config.sliderMin} - max={max} + max={max ?? 0} step={config.coarseStep} fineStep={config.fineStep} onChange={handleClipSkipChange} @@ -59,7 +59,7 @@ const ParamClipSkip = () => { value={clipSkip} defaultValue={config.initial} min={config.numberInputMin} - max={max} + max={max ?? 0} step={config.coarseStep} fineStep={config.fineStep} onChange={handleClipSkipChange} diff --git a/invokeai/frontend/web/src/features/parameters/types/constants.ts b/invokeai/frontend/web/src/features/parameters/types/constants.ts index d00ff1b1fa8..96a04cd065a 100644 --- a/invokeai/frontend/web/src/features/parameters/types/constants.ts +++ b/invokeai/frontend/web/src/features/parameters/types/constants.ts @@ -17,6 +17,7 @@ export const MODEL_TYPE_MAP: Record = { imagen4: 'Imagen4', 'chatgpt-4o': 'ChatGPT 4o', 'flux-kontext': 'Flux Kontext', + veo3: 'Veo3', }; /** @@ -35,12 +36,13 @@ export const MODEL_TYPE_SHORT_MAP: Record = { imagen4: 'Imagen4', 'chatgpt-4o': 'ChatGPT 4o', 'flux-kontext': 'Flux Kontext', + veo3: 'Veo3', }; /** * Mapping of base model to CLIP skip parameter constraints */ -export const CLIP_SKIP_MAP: Record = { +export const CLIP_SKIP_MAP: { [key in BaseModelType]?: { maxClip: number; markers: number[] } } = { any: { maxClip: 0, markers: [], From 4b0d52a1a52f5c99cd14f2f036556e59b65eb987 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 09:45:56 -0400 Subject: [PATCH 32/55] lint --- .../gallery/components/ImageGrid/GalleryImage.tsx | 3 ++- .../gallery/components/ImageGrid/GalleryVideo.tsx | 8 ++++++-- .../src/features/gallery/components/NewGallery.tsx | 14 ++++---------- .../features/gallery/components/VideoGallery.tsx | 11 ++++------- .../features/gallery/components/scrollIntoView.tsx | 3 ++- .../gallery/components/useScrollableGallery.tsx | 2 +- .../web/src/services/api/endpoints/videos.ts | 1 - 7 files changed, 19 insertions(+), 23 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index be7af178f41..ab1623182bf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -1,6 +1,6 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import type { FlexProps, SystemStyleObject } from '@invoke-ai/ui-library'; +import type { FlexProps } from '@invoke-ai/ui-library'; import { Flex, Icon, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import type { AppDispatch, AppGetState } from 'app/store/store'; @@ -27,6 +27,7 @@ import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiImageBold } from 'react-icons/pi'; import { imagesApi } from 'services/api/endpoints/images'; import type { ImageDTO } from 'services/api/types'; + import { galleryItemContainerSX } from './galleryItemContainerSX'; interface Props { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx index 638fe4d6936..c1a2bf99460 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -13,7 +13,11 @@ import type { DndDragPreviewSingleVideoState } from 'features/dnd/DndDragPreview import { createSingleVideoDragPreview, setSingleVideoDragPreview } from 'features/dnd/DndDragPreviewSingleVideo'; import { firefoxDndFix } from 'features/dnd/util'; import { useVideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu'; -import { selectGetImageNamesQueryArgs, selectSelectedBoardId, selectSelection } from 'features/gallery/store/gallerySelectors'; +import { + selectGetImageNamesQueryArgs, + selectSelectedBoardId, + selectSelection, +} from 'features/gallery/store/gallerySelectors'; import { imageToCompareChanged, selectGallerySlice, selectionChanged } from 'features/gallery/store/gallerySlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; @@ -23,8 +27,8 @@ import { PiVideoBold } from 'react-icons/pi'; import { imagesApi } from 'services/api/endpoints/images'; import type { VideoDTO } from 'services/api/types'; -import { GalleryItemHoverIcons } from './GalleryItemHoverIcons'; import { galleryItemContainerSX } from './galleryItemContainerSX'; +import { GalleryItemHoverIcons } from './GalleryItemHoverIcons'; interface Props { videoDTO: VideoDTO; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index e1cb2f1fa40..48f3ac4f6da 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -1,13 +1,11 @@ import { Box, Flex, forwardRef, Grid, GridItem, Spinner, Text } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; -import { logger } from 'app/logging/logger'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion, useIsRegionFocused } from 'common/hooks/focus'; import { useRangeBasedImageFetching } from 'features/gallery/hooks/useRangeBasedImageFetching'; import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; import { selectGalleryImageMinimumWidth, - selectGalleryView, selectImageToCompare, selectLastSelectedImage, selectSelection, @@ -30,17 +28,14 @@ import { imagesApi, useImageDTO, useStarImagesMutation, useUnstarImagesMutation import { videosApi } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; +import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; +import { getItemIndex } from './getItemIndex'; import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; -import { useGalleryImageNames } from './use-gallery-image-names'; -import { useGalleryVideoIds } from './use-gallery-video-ids'; import { GalleryVideo } from './ImageGrid/GalleryVideo'; -import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; import { scrollIntoView } from './scrollIntoView'; +import { useGalleryImageNames } from './use-gallery-image-names'; import { useScrollableGallery } from './useScrollableGallery'; -import { getItemIndex } from './getItemIndex'; - -const log = logger('gallery'); type ListImageNamesQueryArgs = ReturnType; @@ -322,7 +317,7 @@ const useStarImageHotkey = () => { } else { starImages({ image_names: [imageDTO.image_name] }); } - }, [imageDTO, isGalleryFocused, starImages, unstarImages, starVideos, unstarVideos, galleryView, videoDTO]); + }, [imageDTO, isGalleryFocused, starImages, unstarImages]); useRegisteredHotkeys({ id: 'starImage', @@ -337,7 +332,6 @@ export const ImageGallery = memo(() => { const virtuosoRef = useRef(null); const rangeRef = useRef({ startIndex: 0, endIndex: 0 }); const rootRef = useRef(null); - const galleryView = useAppSelector(selectGalleryView); // Get the ordered list of image names - this is our primary data source for virtualization const { queryArgs, imageNames, isLoading } = useGalleryImageNames(); diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index 2f6bc0d8b33..2db5294ff76 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -5,10 +5,7 @@ import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion } from 'common/hooks/focus'; import { useRangeBasedVideoFetching } from 'features/gallery/hooks/useRangeBasedVideoFetching'; import type { selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { - selectGalleryImageMinimumWidth, - selectLastSelectedImage, -} from 'features/gallery/store/gallerySelectors'; +import { selectGalleryImageMinimumWidth, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import type { MutableRefObject } from 'react'; @@ -25,13 +22,13 @@ import { VirtuosoGrid } from 'react-virtuoso'; import { videosApi } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; +import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; +import { getItemIndex } from './getItemIndex'; import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; import { GalleryVideo, GalleryVideoPlaceholder } from './ImageGrid/GalleryVideo'; -import { useGalleryVideoIds } from './use-gallery-video-ids'; -import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; import { scrollIntoView } from './scrollIntoView'; -import { getItemIndex } from './getItemIndex'; +import { useGalleryVideoIds } from './use-gallery-video-ids'; import { useScrollableGallery } from './useScrollableGallery'; export const log = logger('gallery'); diff --git a/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx index be1cb57e892..7448c3703ec 100644 --- a/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx @@ -1,4 +1,5 @@ -import type { VirtuosoGridHandle, ListRange } from 'react-virtuoso'; +import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso'; + import { log } from './VideoGallery'; /** diff --git a/invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx index 0355ff3280e..6e8c44c5954 100644 --- a/invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/useScrollableGallery.tsx @@ -1,5 +1,5 @@ import { useOverlayScrollbars } from 'overlayscrollbars-react'; -import { type RefObject, useState, useEffect } from 'react'; +import { type RefObject, useEffect, useState } from 'react'; /** * Handles the initialization of the overlay scrollbars for the gallery, returning the ref to the scroller element. diff --git a/invokeai/frontend/web/src/services/api/endpoints/videos.ts b/invokeai/frontend/web/src/services/api/endpoints/videos.ts index 211d9969ce4..88036168a9d 100644 --- a/invokeai/frontend/web/src/services/api/endpoints/videos.ts +++ b/invokeai/frontend/web/src/services/api/endpoints/videos.ts @@ -1,4 +1,3 @@ -import { skipToken } from '@reduxjs/toolkit/query'; import { getStore } from 'app/store/nanostores/store'; import type { paths } from 'services/api/schema'; import type { GetVideoIdsArgs, GetVideoIdsResult, VideoDTO } from 'services/api/types'; From 604105de7eb11f522414a2447e265adfe12aed3e Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 13:36:06 -0400 Subject: [PATCH 33/55] update redux selection to have a list of images and/or videos, update image viewer to show either image or video depending on what is selected --- .../src/app/components/GlobalImageHotkeys.tsx | 6 +- .../listeners/appStarted.ts | 10 ++- .../listeners/boardIdSelected.ts | 66 ++++++++++---- .../web/src/common/hooks/useGlobalHotkeys.ts | 2 +- .../features/deleteImageModal/store/state.ts | 12 ++- .../MenuItems/ContextMenuItemOpenInViewer.tsx | 4 +- .../MultipleSelectionMenuItems.tsx | 10 +-- .../MultipleSelectionVideoMenuItems.tsx | 8 +- .../components/ImageGrid/GalleryImage.tsx | 20 ++--- .../GalleryItemOpenInViewerIconButton.tsx | 4 +- .../ImageGrid/GallerySelectionCountTag.tsx | 13 +-- .../components/ImageGrid/GalleryVideo.tsx | 48 +++++----- .../ImageViewer/CurrentImagePreview.tsx | 4 +- .../ImageViewer/ImageComparison.tsx | 9 +- .../components/ImageViewer/ImageViewer.tsx | 6 +- .../ImageViewer/ImageViewerPanel.tsx | 9 +- .../ToggleMetadataViewerButton.tsx | 4 +- .../components/ImageViewer/ViewerToolbar.tsx | 6 +- .../gallery/components/ImageViewer/common.ts | 2 +- .../gallery/components/NewGallery.tsx | 16 ++-- .../components/NextPrevImageButtons.tsx | 84 ------------------ .../components/NextPrevItemButtons.tsx | 88 +++++++++++++++++++ .../gallery/components/VideoGallery.tsx | 8 +- .../gallery/store/gallerySelectors.ts | 4 +- .../features/gallery/store/gallerySlice.ts | 27 +++--- .../web/src/features/gallery/store/types.ts | 2 +- .../nodes/CurrentImage/CurrentImageNode.tsx | 10 +-- .../ui/layouts/video-tab-auto-layout.tsx | 4 +- .../components/VideoPlayer.tsx} | 10 +-- .../src/services/api/util/tagInvalidation.ts | 6 +- .../services/events/onInvocationComplete.tsx | 18 ++-- 31 files changed, 284 insertions(+), 236 deletions(-) delete mode 100644 invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx create mode 100644 invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx rename invokeai/frontend/web/src/features/{ui/layouts/VideoPlayerPanel.tsx => video/components/VideoPlayer.tsx} (77%) diff --git a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx index 9304b62b2ed..490f7ff2642 100644 --- a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx @@ -7,7 +7,7 @@ import { useRecallDimensions } from 'features/gallery/hooks/useRecallDimensions' import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts'; import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix'; import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo } from 'react'; import { useImageDTO } from 'services/api/endpoints/images'; @@ -15,8 +15,8 @@ import type { ImageDTO } from 'services/api/types'; export const GlobalImageHotkeys = memo(() => { useAssertSingleton('GlobalImageHotkeys'); - const imageName = useAppSelector(selectLastSelectedImage); - const imageDTO = useImageDTO(imageName); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const imageDTO = useImageDTO(lastSelectedItem?.id ?? null); if (!imageDTO) { return null; diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts index 70fcc0611a3..5ed60447aae 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/appStarted.ts @@ -1,7 +1,7 @@ import { createAction } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/store'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; +import { itemSelected } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images'; export const appStarted = createAction('app/appStarted'); @@ -18,11 +18,13 @@ export const addAppStartedListener = (startAppListening: AppStartListening) => { const firstImageLoad = await take(imagesApi.endpoints.getImageNames.matchFulfilled); if (firstImageLoad !== null) { const [{ payload }] = firstImageLoad; - const selectedImage = selectLastSelectedImage(getState()); + const selectedImage = selectLastSelectedItem(getState()); if (selectedImage) { return; } - dispatch(imageSelected(payload.image_names.at(0) ?? null)); + if (payload.image_names[0]) { + dispatch(itemSelected({ type: 'image', id: payload.image_names[0] })); + } } }, }); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index c5340ec7bd7..7dc33be0ae3 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -1,8 +1,14 @@ import { isAnyOf } from '@reduxjs/toolkit'; import type { AppStartListening } from 'app/store/store'; -import { selectGetImageNamesQueryArgs, selectSelectedBoardId } from 'features/gallery/store/gallerySelectors'; -import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; +import { + selectGalleryView, + selectGetImageNamesQueryArgs, + selectGetVideoIdsQueryArgs, + selectSelectedBoardId, +} from 'features/gallery/store/gallerySelectors'; +import { boardIdSelected, galleryViewChanged, itemSelected } from 'features/gallery/store/gallerySlice'; import { imagesApi } from 'services/api/endpoints/images'; +import { videosApi } from 'services/api/endpoints/videos'; export const addBoardIdSelectedListener = (startAppListening: AppStartListening) => { startAppListening({ @@ -19,27 +25,53 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) const state = getState(); const board_id = selectSelectedBoardId(state); + const view = selectGalleryView(state); - const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id }; + if (view === 'images' || view === 'assets') { + const queryArgs = { ...selectGetImageNamesQueryArgs(state), board_id }; + // wait until the board has some images - maybe it already has some from a previous fetch + // must use getState() to ensure we do not have stale state + const isSuccess = await condition( + () => imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).isSuccess, + 5000 + ); - // wait until the board has some images - maybe it already has some from a previous fetch - // must use getState() to ensure we do not have stale state - const isSuccess = await condition( - () => imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).isSuccess, - 5000 - ); + if (!isSuccess) { + dispatch(itemSelected(null)); + return; + } - if (!isSuccess) { - dispatch(imageSelected(null)); - return; - } + // the board was just changed - we can select the first image + const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).data?.image_names; + + const imageToSelect = imageNames && imageNames.length > 0 ? imageNames[0] : null; - // the board was just changed - we can select the first image - const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(getState()).data?.image_names; + if (imageToSelect) { + dispatch(itemSelected({ type: 'image', id: imageToSelect })); + } + } else { + const queryArgs = { ...selectGetVideoIdsQueryArgs(state), board_id }; + // wait until the board has some images - maybe it already has some from a previous fetch + // must use getState() to ensure we do not have stale state + const isSuccess = await condition( + () => videosApi.endpoints.getVideoIds.select(queryArgs)(getState()).isSuccess, + 5000 + ); - const imageToSelect = imageNames?.at(0) ?? null; + if (!isSuccess) { + dispatch(itemSelected(null)); + return; + } - dispatch(imageSelected(imageToSelect)); + // the board was just changed - we can select the first image + const videoIds = videosApi.endpoints.getVideoIds.select(queryArgs)(getState()).data?.video_ids; + + const videoToSelect = videoIds && videoIds.length > 0 ? videoIds[0] : null; + + if (videoToSelect) { + dispatch(itemSelected({ type: 'video', id: videoToSelect })); + } + } }, }); }; diff --git a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts index 4ffc9a36307..7bdd062625f 100644 --- a/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts +++ b/invokeai/frontend/web/src/common/hooks/useGlobalHotkeys.ts @@ -135,7 +135,7 @@ export const useGlobalHotkeys = () => { if (!selection.length) { return; } - deleteImageModalApi.delete(selection); + deleteImageModalApi.delete(selection.map((s) => s.id)); }, dependencies: [getState, deleteImageModalApi], }); diff --git a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts index 0c19eda02e3..421238df1ef 100644 --- a/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts +++ b/invokeai/frontend/web/src/features/deleteImageModal/store/state.ts @@ -12,7 +12,7 @@ import { selectCanvasSlice } from 'features/controlLayers/store/selectors'; import type { CanvasState, RefImagesState } from 'features/controlLayers/store/types'; import type { ImageUsage } from 'features/deleteImageModal/store/types'; import { selectGetImageNamesQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; +import { itemSelected } from 'features/gallery/store/gallerySlice'; import { fieldImageCollectionValueChanged, fieldImageValueChanged } from 'features/nodes/store/nodesSlice'; import { selectNodesSlice } from 'features/nodes/store/selectors'; import type { NodesState } from 'features/nodes/store/types'; @@ -89,9 +89,15 @@ const handleDeletions = async (image_names: string[], store: AppStore) => { const newImageNames = data?.image_names.filter((name) => !deleted_images.includes(name)) || []; const newSelectedImage = newImageNames[index ?? 0] || null; - if (intersection(state.gallery.selection, image_names).length > 0) { + if ( + intersection( + state.gallery.selection.map((s) => s.id), + image_names + ).length > 0 && + newSelectedImage + ) { // Some selected images were deleted, clear selection - dispatch(imageSelected(newSelectedImage)); + dispatch(itemSelected({ type: 'image', id: newSelectedImage })); } // We need to reset the features where the image is in use - none of these work if their image(s) don't exist diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx index 58c76e3c3b5..0dcb28f985c 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemOpenInViewer.tsx @@ -1,7 +1,7 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { IconMenuItem } from 'common/components/IconMenuItem'; import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; -import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; +import { imageToCompareChanged, itemSelected } from 'features/gallery/store/gallerySlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; @@ -16,7 +16,7 @@ export const ContextMenuItemOpenInViewer = memo(() => { const onClick = useCallback(() => { if (isImageDTO(itemDTO)) { dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(itemDTO.image_name)); + dispatch(itemSelected({ type: 'image', id: itemDTO.image_name })); navigationApi.focusPanelInActiveTab(VIEWER_PANEL_ID); } else { // TODO: Implement video open in viewer diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx index c4c232dc136..ee21261cb31 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionMenuItems.tsx @@ -28,24 +28,24 @@ const MultipleSelectionMenuItems = () => { const [bulkDownload] = useBulkDownloadImagesMutation(); const handleChangeBoard = useCallback(() => { - dispatch(imagesToChangeSelected(selection)); + dispatch(imagesToChangeSelected(selection.map((s) => s.id))); dispatch(isModalOpenChanged(true)); }, [dispatch, selection]); const handleDeleteSelection = useCallback(() => { - deleteImageModal.delete(selection); + deleteImageModal.delete(selection.map((s) => s.id)); }, [deleteImageModal, selection]); const handleStarSelection = useCallback(() => { - starImages({ image_names: selection }); + starImages({ image_names: selection.map((s) => s.id) }); }, [starImages, selection]); const handleUnstarSelection = useCallback(() => { - unstarImages({ image_names: selection }); + unstarImages({ image_names: selection.map((s) => s.id) }); }, [unstarImages, selection]); const handleBulkDownload = useCallback(() => { - bulkDownload({ image_names: selection }); + bulkDownload({ image_names: selection.map((s) => s.id) }); }, [selection, bulkDownload]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx index 3297ff987f2..47edf37d3ff 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MultipleSelectionVideoMenuItems.tsx @@ -19,21 +19,21 @@ const MultipleSelectionMenuItems = () => { const [deleteVideos] = useDeleteVideosMutation(); const handleChangeBoard = useCallback(() => { - dispatch(videosToChangeSelected(selection)); + dispatch(videosToChangeSelected(selection.map((s) => s.id))); dispatch(isModalOpenChanged(true)); }, [dispatch, selection]); const handleDeleteSelection = useCallback(() => { // TODO: Add confirm on delete and video usage functionality - deleteVideos({ video_ids: selection }); + deleteVideos({ video_ids: selection.map((s) => s.id) }); }, [deleteVideos, selection]); const handleStarSelection = useCallback(() => { - starVideos({ video_ids: selection }); + starVideos({ video_ids: selection.map((s) => s.id) }); }, [starVideos, selection]); const handleUnstarSelection = useCallback(() => { - unstarVideos({ video_ids: selection }); + unstarVideos({ video_ids: selection.map((s) => s.id) }); }, [unstarVideos, selection]); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx index ab1623182bf..260aabd0cc1 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryImage.tsx @@ -46,7 +46,7 @@ const buildOnClick = if (imageNames.length === 0) { // For basic click without modifiers, we can still set selection if (!shiftKey && !ctrlKey && !metaKey && !altKey) { - dispatch(selectionChanged([imageName])); + dispatch(selectionChanged([{ type: 'image', id: imageName }])); } return; } @@ -61,7 +61,7 @@ const buildOnClick = } } else if (shiftKey) { const rangeEndImageName = imageName; - const lastSelectedImage = selection.at(-1); + const lastSelectedImage = selection.at(-1)?.id; const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage); const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName); if (lastClickedIndex > -1 && currentClickedIndex > -1) { @@ -69,16 +69,16 @@ const buildOnClick = const start = Math.min(lastClickedIndex, currentClickedIndex); const end = Math.max(lastClickedIndex, currentClickedIndex); const imagesToSelect = imageNames.slice(start, end + 1); - dispatch(selectionChanged(uniq(selection.concat(imagesToSelect)))); + dispatch(selectionChanged(uniq(selection.concat(imagesToSelect.map((name) => ({ type: 'image', id: name })))))); } } else if (ctrlKey || metaKey) { - if (selection.some((n) => n === imageName) && selection.length > 1) { - dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName)))); + if (selection.some((n) => n.id === imageName) && selection.length > 1) { + dispatch(selectionChanged(uniq(selection.filter((n) => n.id !== imageName)))); } else { - dispatch(selectionChanged(uniq(selection.concat(imageName)))); + dispatch(selectionChanged(uniq(selection.concat({ type: 'image', id: imageName })))); } } else { - dispatch(selectionChanged([imageName])); + dispatch(selectionChanged([{ type: 'image', id: imageName }])); } }; @@ -95,7 +95,7 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { ); const isSelectedForCompare = useAppSelector(selectIsSelectedForCompare); const selectIsSelected = useMemo( - () => createSelector(selectGallerySlice, (gallery) => gallery.selection.includes(imageDTO.image_name)), + () => createSelector(selectGallerySlice, (gallery) => gallery.selection.some((s) => s.id === imageDTO.image_name)), [imageDTO.image_name] ); const isSelected = useAppSelector(selectIsSelected); @@ -115,9 +115,9 @@ export const GalleryImage = memo(({ imageDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. - if (selection.length > 1 && selection.includes(imageDTO.image_name)) { + if (selection.length > 1 && selection.some((s) => s.id === imageDTO.image_name)) { return multipleImageDndSource.getData({ - image_names: selection, + image_names: selection.map((s) => s.id), board_id: boardId, }); } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx index 4876a4558fd..14a2f6f0874 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryItemOpenInViewerIconButton.tsx @@ -1,6 +1,6 @@ import { useAppDispatch } from 'app/store/storeHooks'; import { DndImageIcon } from 'features/dnd/DndImageIcon'; -import { imageSelected, imageToCompareChanged } from 'features/gallery/store/gallerySlice'; +import { imageToCompareChanged, itemSelected } from 'features/gallery/store/gallerySlice'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import { memo, useCallback } from 'react'; @@ -19,7 +19,7 @@ export const GalleryItemOpenInViewerIconButton = memo(({ itemDTO }: Props) => { const onClick = useCallback(() => { if (isImageDTO(itemDTO)) { dispatch(imageToCompareChanged(null)); - dispatch(imageSelected(itemDTO.image_name)); + dispatch(itemSelected({ type: 'image', id: itemDTO.image_name })); } else { // dispatch(videoToCompareChanged(null)); // dispatch(videoSelected(itemDTO.video_id)); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx index e299cd69bf2..0939e168e51 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GallerySelectionCountTag.tsx @@ -2,7 +2,7 @@ import { Tag, TagCloseButton, TagLabel } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector, useAppStore } from 'app/store/storeHooks'; import { useIsRegionFocused } from 'common/hooks/focus'; import { useGalleryImageNames } from 'features/gallery/components/use-gallery-image-names'; -import { selectFirstSelectedImage, selectSelectionCount } from 'features/gallery/store/gallerySelectors'; +import { selectFirstSelectedItem, selectSelectionCount } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { memo, useCallback } from 'react'; @@ -15,7 +15,8 @@ export const GallerySelectionCountTag = memo(() => { const isGalleryFocused = useIsRegionFocused('gallery'); const onSelectPage = useCallback(() => { - dispatch(selectionChanged([...imageNames])); + const selection = imageNames.map((name) => ({ type: 'image' as const, id: name })); + dispatch(selectionChanged(selection)); }, [dispatch, imageNames]); useRegisteredHotkeys({ @@ -39,13 +40,13 @@ const GallerySelectionCountTagContent = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const isGalleryFocused = useIsRegionFocused('gallery'); - const firstImage = useAppSelector(selectFirstSelectedImage); + const firstItem = useAppSelector(selectFirstSelectedItem); const selectionCount = useAppSelector(selectSelectionCount); const onClearSelection = useCallback(() => { - if (firstImage) { - dispatch(selectionChanged([firstImage])); + if (firstItem) { + dispatch(selectionChanged([firstItem])); } - }, [dispatch, firstImage]); + }, [dispatch, firstItem]); useRegisteredHotkeys({ id: 'clearSelection', diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx index c1a2bf99460..c4ba374ad1b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -14,7 +14,7 @@ import { createSingleVideoDragPreview, setSingleVideoDragPreview } from 'feature import { firefoxDndFix } from 'features/dnd/util'; import { useVideoContextMenu } from 'features/gallery/components/ContextMenu/VideoContextMenu'; import { - selectGetImageNamesQueryArgs, + selectGetVideoIdsQueryArgs, selectSelectedBoardId, selectSelection, } from 'features/gallery/store/gallerySelectors'; @@ -24,7 +24,7 @@ import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import type { MouseEvent, MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; import { PiVideoBold } from 'react-icons/pi'; -import { imagesApi } from 'services/api/endpoints/images'; +import { videosApi } from 'services/api/endpoints/videos'; import type { VideoDTO } from 'services/api/types'; import { galleryItemContainerSX } from './galleryItemContainerSX'; @@ -35,18 +35,18 @@ interface Props { } const buildOnClick = - (imageName: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => { + (videoId: string, dispatch: AppDispatch, getState: AppGetState) => (e: MouseEvent) => { const { shiftKey, ctrlKey, metaKey, altKey } = e; const state = getState(); - const queryArgs = selectGetImageNamesQueryArgs(state); - const imageNames = imagesApi.endpoints.getImageNames.select(queryArgs)(state).data?.image_names ?? []; + const queryArgs = selectGetVideoIdsQueryArgs(state); + const videoIds = videosApi.endpoints.getVideoIds.select(queryArgs)(state).data?.video_ids ?? []; - // If we don't have the image names cached, we can't perform selection operations - // This can happen if the user clicks on an image before the names are loaded - if (imageNames.length === 0) { + // If we don't have the video ids cached, we can't perform selection operations + // This can happen if the user clicks on a video before the ids are loaded + if (videoIds.length === 0) { // For basic click without modifiers, we can still set selection if (!shiftKey && !ctrlKey && !metaKey && !altKey) { - dispatch(selectionChanged([imageName])); + dispatch(selectionChanged([{ type: 'video', id: videoId }])); } return; } @@ -54,31 +54,31 @@ const buildOnClick = const selection = state.gallery.selection; if (altKey) { - if (state.gallery.imageToCompare === imageName) { + if (state.gallery.imageToCompare === videoId) { dispatch(imageToCompareChanged(null)); } else { - dispatch(imageToCompareChanged(imageName)); + dispatch(imageToCompareChanged(videoId)); } } else if (shiftKey) { - const rangeEndImageName = imageName; - const lastSelectedImage = selection.at(-1); - const lastClickedIndex = imageNames.findIndex((name) => name === lastSelectedImage); - const currentClickedIndex = imageNames.findIndex((name) => name === rangeEndImageName); + const rangeEndVideoId = videoId; + const lastSelectedVideo = selection.at(-1)?.id; + const lastClickedIndex = videoIds.findIndex((id) => id === lastSelectedVideo); + const currentClickedIndex = videoIds.findIndex((id) => id === rangeEndVideoId); if (lastClickedIndex > -1 && currentClickedIndex > -1) { // We have a valid range! const start = Math.min(lastClickedIndex, currentClickedIndex); const end = Math.max(lastClickedIndex, currentClickedIndex); - const imagesToSelect = imageNames.slice(start, end + 1); - dispatch(selectionChanged(uniq(selection.concat(imagesToSelect)))); + const videosToSelect = videoIds.slice(start, end + 1); + dispatch(selectionChanged(uniq(selection.concat(videosToSelect.map((id) => ({ type: 'video', id })))))); } } else if (ctrlKey || metaKey) { - if (selection.some((n) => n === imageName) && selection.length > 1) { - dispatch(selectionChanged(uniq(selection.filter((n) => n !== imageName)))); + if (selection.some((n) => n.id === videoId) && selection.length > 1) { + dispatch(selectionChanged(uniq(selection.filter((n) => n.id !== videoId)))); } else { - dispatch(selectionChanged(uniq(selection.concat(imageName)))); + dispatch(selectionChanged(uniq(selection.concat({ type: 'video', id: videoId })))); } } else { - dispatch(selectionChanged([imageName])); + dispatch(selectionChanged([{ type: 'video', id: videoId }])); } }; @@ -90,7 +90,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { >(null); const ref = useRef(null); const selectIsSelected = useMemo( - () => createSelector(selectGallerySlice, (gallery) => gallery.selection.includes(videoDTO.video_id)), + () => createSelector(selectGallerySlice, (gallery) => gallery.selection.some((s) => s.id === videoDTO.video_id)), [videoDTO.video_id] ); const isSelected = useAppSelector(selectIsSelected); @@ -110,9 +110,9 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { // When we have multiple images selected, and the dragged image is part of the selection, initiate a // multi-image drag. - if (selection.length > 1 && selection.includes(videoDTO.video_id)) { + if (selection.length > 1 && selection.some((s) => s.id === videoDTO.video_id)) { return multipleVideoDndSource.getData({ - ids: selection, + ids: selection.map((s) => s.id), board_id: boardId, }); } // Otherwise, initiate a single-image drag diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx index c685deea829..c5b3684ad2d 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImagePreview.tsx @@ -4,7 +4,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { CanvasAlertsInvocationProgress } from 'features/controlLayers/components/CanvasAlerts/CanvasAlertsInvocationProgress'; import { DndImage } from 'features/dnd/DndImage'; import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; +import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import type { AnimationProps } from 'framer-motion'; import { AnimatePresence, motion } from 'framer-motion'; @@ -85,7 +85,7 @@ export const CurrentImagePreview = memo(({ imageDTO }: { imageDTO: ImageDTO | nu left={0} pointerEvents="none" > - + )} diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx index 899b082b44c..e3bf51bbdbf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageComparison.tsx @@ -8,7 +8,7 @@ import { ImageComparisonDroppable } from 'features/gallery/components/ImageViewe import { ImageComparisonHover } from 'features/gallery/components/ImageViewer/ImageComparisonHover'; import { ImageComparisonSideBySide } from 'features/gallery/components/ImageViewer/ImageComparisonSideBySide'; import { ImageComparisonSlider } from 'features/gallery/components/ImageViewer/ImageComparisonSlider'; -import { selectComparisonMode, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectComparisonMode, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { memo, useCallback, useLayoutEffect, useRef, useState } from 'react'; import { useImageDTO } from 'services/api/endpoints/images'; import type { Equals } from 'tsafe'; @@ -39,10 +39,9 @@ const ImageComparisonContent = memo(({ firstImage, secondImage, rect }: Comparis ImageComparisonContent.displayName = 'ImageComparisonContent'; export const ImageComparison = memo(() => { - const lastSelectedImageName = useAppSelector(selectLastSelectedImage); - const lastSelectedImageDTO = useImageDTO(lastSelectedImageName); - const comparisonImageName = useAppSelector(selectImageToCompare); - const comparisonImageDTO = useImageDTO(comparisonImageName); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const lastSelectedImageDTO = useImageDTO(lastSelectedItem?.id); + const comparisonImageDTO = useImageDTO(useAppSelector(selectImageToCompare)); const [rect, setRect] = useState(null); const ref = useRef(null); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx index ec6ee151b52..5f709f0aebf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewer.tsx @@ -3,7 +3,7 @@ import { useAppSelector } from 'app/store/storeHooks'; import { setComparisonImageDndTarget } from 'features/dnd/dnd'; import { DndDropTarget } from 'features/dnd/DndDropTarget'; import { CurrentImagePreview } from 'features/gallery/components/ImageViewer/CurrentImagePreview'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; import { useImageDTO } from 'services/api/endpoints/images'; @@ -15,8 +15,8 @@ const dndTargetData = setComparisonImageDndTarget.getData(); export const ImageViewer = memo(() => { const { t } = useTranslation(); - const lastSelectedImageName = useAppSelector(selectLastSelectedImage); - const lastSelectedImageDTO = useImageDTO(lastSelectedImageName); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const lastSelectedImageDTO = useImageDTO(lastSelectedItem?.type === 'image' ? lastSelectedItem.id : null); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx index 6205d7a4fcf..14550fde154 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ImageViewerPanel.tsx @@ -1,6 +1,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { useAppSelector } from 'app/store/storeHooks'; -import { selectImageToCompare, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectImageToCompare, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; +import { VideoPlayer } from 'features/video/components/VideoPlayer'; import { memo } from 'react'; import { ImageViewerContextProvider } from './context'; @@ -8,16 +9,18 @@ import { ImageComparison } from './ImageComparison'; import { ImageViewer } from './ImageViewer'; const selectIsComparing = createSelector( - [selectLastSelectedImage, selectImageToCompare], + [selectLastSelectedItem, selectImageToCompare], (lastSelectedImage, imageToCompare) => !!lastSelectedImage && !!imageToCompare ); export const ImageViewerPanel = memo(() => { const isComparing = useAppSelector(selectIsComparing); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); return ( - {!isComparing && } + {!isComparing && lastSelectedItem?.type === 'image' && } + {!isComparing && lastSelectedItem?.type === 'video' && } {isComparing && } ); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx index 983895e26ee..fa240b249de 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ToggleMetadataViewerButton.tsx @@ -1,7 +1,7 @@ import { IconButton } from '@invoke-ai/ui-library'; import { useStore } from '@nanostores/react'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import { selectShouldShowImageDetails, selectShouldShowProgressInViewer } from 'features/ui/store/uiSelectors'; import { setShouldShowImageDetails } from 'features/ui/store/uiSlice'; @@ -20,7 +20,7 @@ export const ToggleMetadataViewerButton = memo(() => { const isDisabledOverride = hasProgressImage && shouldShowProgressInViewer; const shouldShowImageDetails = useAppSelector(selectShouldShowImageDetails); - const imageDTO = useAppSelector(selectLastSelectedImage); + const imageDTO = useAppSelector(selectLastSelectedItem); const { t } = useTranslation(); const toggleMetadataViewer = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx index 388df59f1b2..d6c17653134 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/ViewerToolbar.tsx @@ -1,7 +1,7 @@ import { Flex, Spacer } from '@invoke-ai/ui-library'; import { useAppSelector } from 'app/store/storeHooks'; import { ToggleMetadataViewerButton } from 'features/gallery/components/ImageViewer/ToggleMetadataViewerButton'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { memo } from 'react'; import { useImageDTO } from 'services/api/endpoints/images'; @@ -9,8 +9,8 @@ import { CurrentImageButtons } from './CurrentImageButtons'; import { ToggleProgressButton } from './ToggleProgressButton'; export const ViewerToolbar = memo(() => { - const imageName = useAppSelector(selectLastSelectedImage); - const imageDTO = useImageDTO(imageName); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const imageDTO = useImageDTO(lastSelectedItem?.id); return ( diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts index dfbf62bbbf2..0953a96156b 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/common.ts @@ -65,7 +65,7 @@ export const getSecondImageDims = ( return { width, height }; }; export const selectComparisonImages = createMemoizedSelector(selectGallerySlice, (gallerySlice) => { - const firstImage = gallerySlice.selection.slice(-1)[0] ?? null; + const firstImage = gallerySlice.selection.slice(-1)[0]?.id ?? null; const secondImage = gallerySlice.imageToCompare; return { firstImage, secondImage }; }); diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index 48f3ac4f6da..fa6b7244fcf 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -7,7 +7,7 @@ import type { selectGetImageNamesQueryArgs } from 'features/gallery/store/galler import { selectGalleryImageMinimumWidth, selectImageToCompare, - selectLastSelectedImage, + selectLastSelectedItem, selectSelection, selectSelectionCount, } from 'features/gallery/store/gallerySelectors'; @@ -147,10 +147,10 @@ const useKeyboardNavigation = ( const imageName = event.altKey ? // When the user holds alt, we are changing the image to compare - if no image to compare is currently selected, // we start from the last selected image - (selectImageToCompare(state) ?? selectLastSelectedImage(state)) - : selectLastSelectedImage(state); + (selectImageToCompare(state) ?? selectLastSelectedItem(state)?.id) + : selectLastSelectedItem(state)?.id; - const currentIndex = getItemIndex(imageName, imageNames); + const currentIndex = getItemIndex(imageName ?? null, imageNames); let newIndex = currentIndex; @@ -195,7 +195,7 @@ const useKeyboardNavigation = ( if (event.altKey) { dispatch(imageToCompareChanged(newImageName)); } else { - dispatch(selectionChanged([newImageName])); + dispatch(selectionChanged([{ type: 'image', id: newImageName }])); } } } @@ -282,7 +282,7 @@ const useKeepSelectedImageInView = ( const selection = useAppSelector(selectSelection); useEffect(() => { - const targetImageName = selection.at(-1); + const targetImageName = selection.at(-1)?.id; const virtuosoGridHandle = virtuosoRef.current; const rootEl = rootRef.current; const range = rangeRef.current; @@ -298,10 +298,10 @@ const useKeepSelectedImageInView = ( }; const useStarImageHotkey = () => { - const lastSelectedImage = useAppSelector(selectLastSelectedImage); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); const selectionCount = useAppSelector(selectSelectionCount); const isGalleryFocused = useIsRegionFocused('gallery'); - const imageDTO = useImageDTO(lastSelectedImage); + const imageDTO = useImageDTO(lastSelectedItem?.id); const [starImages] = useStarImagesMutation(); const [unstarImages] = useUnstarImagesMutation(); diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx deleted file mode 100644 index beda2842bc4..00000000000 --- a/invokeai/frontend/web/src/features/gallery/components/NextPrevImageButtons.tsx +++ /dev/null @@ -1,84 +0,0 @@ -import type { ChakraProps } from '@invoke-ai/ui-library'; -import { Box, IconButton } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { clamp } from 'es-toolkit/compat'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; -import { imageSelected } from 'features/gallery/store/gallerySlice'; -import { memo, useCallback, useMemo } from 'react'; -import { useTranslation } from 'react-i18next'; -import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; - -import { useGalleryImageNames } from './use-gallery-image-names'; - -const NextPrevImageButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { - const { t } = useTranslation(); - const dispatch = useAppDispatch(); - const lastSelectedImage = useAppSelector(selectLastSelectedImage); - const { imageNames, isFetching } = useGalleryImageNames(); - - const isOnFirstImage = useMemo( - () => (lastSelectedImage ? imageNames.at(0) === lastSelectedImage : false), - [imageNames, lastSelectedImage] - ); - const isOnLastImage = useMemo( - () => (lastSelectedImage ? imageNames.at(-1) === lastSelectedImage : false), - [imageNames, lastSelectedImage] - ); - - const onClickLeftArrow = useCallback(() => { - const targetIndex = lastSelectedImage ? imageNames.findIndex((n) => n === lastSelectedImage) - 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const n = imageNames.at(clampedIndex); - if (!n) { - return; - } - dispatch(imageSelected(n)); - }, [dispatch, imageNames, lastSelectedImage]); - - const onClickRightArrow = useCallback(() => { - const targetIndex = lastSelectedImage ? imageNames.findIndex((n) => n === lastSelectedImage) + 1 : 0; - const clampedIndex = clamp(targetIndex, 0, imageNames.length - 1); - const n = imageNames.at(clampedIndex); - if (!n) { - return; - } - dispatch(imageSelected(n)); - }, [dispatch, imageNames, lastSelectedImage]); - - return ( - - {!isOnFirstImage && ( - } - variant="unstyled" - onClick={onClickLeftArrow} - isDisabled={isFetching} - color="base.100" - pointerEvents="auto" - insetInlineStart={inset} - /> - )} - {!isOnLastImage && ( - } - variant="unstyled" - onClick={onClickRightArrow} - isDisabled={isFetching} - color="base.100" - pointerEvents="auto" - insetInlineEnd={inset} - /> - )} - - ); -}; - -export default memo(NextPrevImageButtons); diff --git a/invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx new file mode 100644 index 00000000000..c7a6823f4d5 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/NextPrevItemButtons.tsx @@ -0,0 +1,88 @@ +import type { ChakraProps } from '@invoke-ai/ui-library'; +import { Box, IconButton } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { clamp } from 'es-toolkit/compat'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; +import { itemSelected } from 'features/gallery/store/gallerySlice'; +import { memo, useCallback, useMemo } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiCaretLeftBold, PiCaretRightBold } from 'react-icons/pi'; + +import { useGalleryImageNames } from './use-gallery-image-names'; +import { useGalleryVideoIds } from './use-gallery-video-ids'; + +const NextPrevItemButtons = ({ inset = 8 }: { inset?: ChakraProps['insetInlineStart' | 'insetInlineEnd'] }) => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const { imageNames, isFetching } = useGalleryImageNames(); + const { videoIds, isFetching: isFetchingVideos } = useGalleryVideoIds(); + + const isOnFirstItem = useMemo( + () => (lastSelectedItem ? imageNames.at(0) === lastSelectedItem.id : false), + [imageNames, lastSelectedItem] + ); + const isOnLastItem = useMemo( + () => (lastSelectedItem ? imageNames.at(-1) === lastSelectedItem.id : false), + [imageNames, lastSelectedItem] + ); + + const onClickLeftArrow = useCallback(() => { + const items = lastSelectedItem?.type === 'image' ? imageNames : videoIds; + const targetIndex = lastSelectedItem ? items.findIndex((n) => n === lastSelectedItem.id) - 1 : 0; + const clampedIndex = clamp(targetIndex, 0, items.length - 1); + const n = items.at(clampedIndex); + if (!n) { + return; + } + dispatch(itemSelected({ type: lastSelectedItem?.type ?? 'image', id: n })); + }, [dispatch, imageNames, lastSelectedItem, videoIds]); + + const onClickRightArrow = useCallback(() => { + const items = lastSelectedItem?.type === 'image' ? imageNames : videoIds; + const targetIndex = lastSelectedItem ? items.findIndex((n) => n === lastSelectedItem.id) + 1 : 0; + const clampedIndex = clamp(targetIndex, 0, items.length - 1); + const n = items.at(clampedIndex); + if (!n) { + return; + } + dispatch(itemSelected({ type: lastSelectedItem?.type ?? 'image', id: n })); + }, [dispatch, imageNames, lastSelectedItem, videoIds]); + + return ( + + {!isOnFirstItem && ( + } + variant="unstyled" + onClick={onClickLeftArrow} + isDisabled={isFetching || isFetchingVideos} + color="base.100" + pointerEvents="auto" + insetInlineStart={inset} + /> + )} + {!isOnLastItem && ( + } + variant="unstyled" + onClick={onClickRightArrow} + isDisabled={isFetching || isFetchingVideos} + color="base.100" + pointerEvents="auto" + insetInlineEnd={inset} + /> + )} + + ); +}; + +export default memo(NextPrevItemButtons); diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index 2db5294ff76..a11bb19c712 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -5,7 +5,7 @@ import { useAppSelector, useAppStore } from 'app/store/storeHooks'; import { getFocusedRegion } from 'common/hooks/focus'; import { useRangeBasedVideoFetching } from 'features/gallery/hooks/useRangeBasedVideoFetching'; import type { selectGetVideoIdsQueryArgs } from 'features/gallery/store/gallerySelectors'; -import { selectGalleryImageMinimumWidth, selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectGalleryImageMinimumWidth, selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { selectionChanged } from 'features/gallery/store/gallerySlice'; import { useRegisteredHotkeys } from 'features/system/components/HotkeysModal/useHotkeyData'; import type { MutableRefObject } from 'react'; @@ -115,7 +115,7 @@ const useKeyboardNavigation = ( event.preventDefault(); const state = getState(); - const itemId = selectLastSelectedImage(state); + const itemId = selectLastSelectedItem(state)?.id; const currentIndex = getItemIndex(itemId, itemIds); @@ -153,7 +153,7 @@ const useKeyboardNavigation = ( if (newIndex !== currentIndex && newIndex >= 0 && newIndex < itemIds.length) { const nextItemId = itemIds[newIndex]; if (nextItemId) { - dispatch(selectionChanged([nextItemId])); + dispatch(selectionChanged([{ type: 'video', id: nextItemId }])); } } }, @@ -236,7 +236,7 @@ const useKeepSelectedVideoInView = ( rootRef: React.RefObject, rangeRef: MutableRefObject ) => { - const targetVideoId = useAppSelector(selectLastSelectedImage); + const targetVideoId = useAppSelector(selectLastSelectedItem)?.id; useEffect(() => { const virtuosoGridHandle = virtuosoRef.current; diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts index 48de554d13a..536fbd6d2a7 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySelectors.ts @@ -4,8 +4,8 @@ import { selectGallerySlice } from 'features/gallery/store/gallerySlice'; import { ASSETS_CATEGORIES, IMAGE_CATEGORIES } from 'features/gallery/store/types'; import type { GetImageNamesArgs, GetVideoIdsArgs, ListBoardsArgs } from 'services/api/types'; -export const selectFirstSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); -export const selectLastSelectedImage = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); +export const selectFirstSelectedItem = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(0)); +export const selectLastSelectedItem = createSelector(selectGallerySlice, (gallery) => gallery.selection.at(-1)); export const selectListBoardsQueryArgs = createMemoizedSelector( selectGallerySlice, diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index b99913b2c4b..6d4d67d1cbc 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -3,7 +3,6 @@ import { createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { isPlainObject } from 'es-toolkit'; -import { uniq } from 'es-toolkit/compat'; import type { BoardRecordOrderBy } from 'services/api/types'; import { assert } from 'tsafe'; @@ -41,17 +40,23 @@ const slice = createSlice({ name: 'gallery', initialState: getInitialState(), reducers: { - imageSelected: (state, action: PayloadAction) => { - const selectedImageName = action.payload; + itemSelected: (state, action: PayloadAction<{ type: 'image' | 'video'; id: string } | null>) => { + const selectedItem = action.payload; - if (!selectedImageName) { + if (!selectedItem) { state.selection = []; } else { - state.selection = [selectedImageName]; + state.selection = [selectedItem]; } }, - selectionChanged: (state, action: PayloadAction) => { - state.selection = uniq(action.payload); + selectionChanged: (state, action: PayloadAction<{ type: 'image' | 'video'; id: string }[]>) => { + const uniqueById = new Map(); + for (const item of action.payload) { + if (!uniqueById.has(item.id)) { + uniqueById.set(item.id, item); + } + } + state.selection = Array.from(uniqueById.values()); }, imageToCompareChanged: (state, action: PayloadAction) => { state.imageToCompare = action.payload; @@ -86,7 +91,7 @@ const slice = createSlice({ state.selectedBoardId = boardId; state.galleryView = 'images'; if (selectedImageName) { - state.selection = [selectedImageName]; + state.selection = [{ type: 'image', id: selectedImageName }]; } }, autoAddBoardIdChanged: (state, action: PayloadAction) => { @@ -108,8 +113,8 @@ const slice = createSlice({ comparedImagesSwapped: (state) => { if (state.imageToCompare) { const oldSelection = state.selection; - state.selection = [state.imageToCompare]; - state.imageToCompare = oldSelection[0] ?? null; + state.selection = [{ type: 'image', id: state.imageToCompare }]; + state.imageToCompare = oldSelection[0]?.id ?? null; } }, comparisonFitChanged: (state, action: PayloadAction<'contain' | 'fill'>) => { @@ -137,7 +142,7 @@ const slice = createSlice({ }); export const { - imageSelected, + itemSelected, shouldAutoSwitchChanged, autoAssignBoardOnClickChanged, setGalleryImageMinimumWidth, diff --git a/invokeai/frontend/web/src/features/gallery/store/types.ts b/invokeai/frontend/web/src/features/gallery/store/types.ts index 419306d1ecb..0a03c7d2662 100644 --- a/invokeai/frontend/web/src/features/gallery/store/types.ts +++ b/invokeai/frontend/web/src/features/gallery/store/types.ts @@ -19,7 +19,7 @@ export const IMAGE_CATEGORIES: ImageCategory[] = ['general']; export const ASSETS_CATEGORIES: ImageCategory[] = ['control', 'mask', 'user', 'other']; export const zGalleryState = z.object({ - selection: z.array(z.string()), + selection: z.array(z.object({ type: z.enum(['image', 'video']), id: z.string() })), shouldAutoSwitch: z.boolean(), autoAssignBoardOnClick: z.boolean(), autoAddBoardId: zBoardId, diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx index 414320519b9..2e77415757c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/CurrentImage/CurrentImageNode.tsx @@ -4,8 +4,8 @@ import type { NodeProps } from '@xyflow/react'; import { useAppSelector } from 'app/store/storeHooks'; import { IAINoContentFallback } from 'common/components/IAIImageFallback'; import { DndImage } from 'features/dnd/DndImage'; -import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import NextPrevItemButtons from 'features/gallery/components/NextPrevItemButtons'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import NonInvocationNodeWrapper from 'features/nodes/components/flow/nodes/common/NonInvocationNodeWrapper'; import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants'; import type { AnimationProps } from 'framer-motion'; @@ -17,9 +17,9 @@ import { useImageDTO } from 'services/api/endpoints/images'; import { $lastProgressEvent } from 'services/events/stores'; const CurrentImageNode = (props: NodeProps) => { - const image_name = useAppSelector(selectLastSelectedImage); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); const lastProgressEvent = useStore($lastProgressEvent); - const imageDTO = useImageDTO(image_name); + const imageDTO = useImageDTO(lastSelectedItem?.id); if (lastProgressEvent?.image) { return ( @@ -76,7 +76,7 @@ const Wrapper = (props: PropsWithChildren<{ nodeProps: NodeProps }>) => { {props.children} {isHovering && ( - + )} diff --git a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx index 0857ce38f1a..1b2b76a9dc2 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx @@ -2,6 +2,7 @@ import type { DockviewApi, GridviewApi, IDockviewReactProps, IGridviewReactProps import { DockviewReact, GridviewReact, LayoutPriority, Orientation } from 'dockview'; import { BoardsPanel } from 'features/gallery/components/BoardsListPanelContent'; import { GalleryPanel } from 'features/gallery/components/Gallery'; +import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageViewerPanel'; import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import type { @@ -42,7 +43,6 @@ import { SETTINGS_PANEL_ID, VIEWER_PANEL_ID, } from './shared'; -import { VideoPlayerPanel } from './VideoPlayerPanel'; import { VideoTabLeftPanel } from './VideoTabLeftPanel'; const tabComponents = { @@ -53,7 +53,7 @@ const tabComponents = { const mainPanelComponents: AutoLayoutDockviewComponents = { [LAUNCHPAD_PANEL_ID]: withPanelContainer(GenerateLaunchpadPanel), - [VIEWER_PANEL_ID]: withPanelContainer(VideoPlayerPanel), + [VIEWER_PANEL_ID]: withPanelContainer(ImageViewerPanel), }; const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx similarity index 77% rename from invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx rename to invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx index 7295f4f5f51..82283b30632 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoPlayerPanel.tsx +++ b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx @@ -2,7 +2,7 @@ import { Box, chakra, Flex, Text } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { useFocusRegion } from 'common/hooks/focus'; -import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors'; +import { selectLastSelectedItem } from 'features/gallery/store/gallerySelectors'; import { memo, useRef } from 'react'; import { useTranslation } from 'react-i18next'; import ReactPlayer from 'react-player'; @@ -10,11 +10,11 @@ import { useGetVideoDTOQuery } from 'services/api/endpoints/videos'; const ChakraReactPlayer = chakra(ReactPlayer); -export const VideoPlayerPanel = memo(() => { +export const VideoPlayer = memo(() => { const { t } = useTranslation(); const ref = useRef(null); - const lastSelectedVideoId = useAppSelector(selectLastSelectedImage); - const { data: videoDTO } = useGetVideoDTOQuery(lastSelectedVideoId ?? skipToken); + const lastSelectedItem = useAppSelector(selectLastSelectedItem); + const { data: videoDTO } = useGetVideoDTOQuery(lastSelectedItem?.id ?? skipToken); useFocusRegion('video', ref); @@ -40,4 +40,4 @@ export const VideoPlayerPanel = memo(() => { ); }); -VideoPlayerPanel.displayName = 'VideoPlayerPanel'; +VideoPlayer.displayName = 'VideoPlayer'; diff --git a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts index 6876da6e2d2..b7a0c73df1a 100644 --- a/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts +++ b/invokeai/frontend/web/src/services/api/util/tagInvalidation.ts @@ -4,7 +4,7 @@ import { getListImagesUrl } from 'services/api/util'; import type { ApiTagDescription } from '..'; export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: string[]): ApiTagDescription[] => { - const tags: ApiTagDescription[] = ['ImageNameList']; + const tags: ApiTagDescription[] = ['ImageNameList', 'VideoIdList']; for (const board_id of affected_boards) { tags.push({ @@ -23,10 +23,6 @@ export const getTagsToInvalidateForBoardAffectingMutation = (affected_boards: st }), }); - tags.push({ - type: 'VideoIdList', - }); - tags.push({ type: 'Board', id: board_id, diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index ffeed705431..b63198f1d4a 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -9,7 +9,7 @@ import { selectListBoardsQueryArgs, selectSelectedBoardId, } from 'features/gallery/store/gallerySelectors'; -import { boardIdSelected, galleryViewChanged, imageSelected } from 'features/gallery/store/gallerySlice'; +import { boardIdSelected, galleryViewChanged, itemSelected } from 'features/gallery/store/gallerySlice'; import { $nodeExecutionStates, upsertExecutionState } from 'features/nodes/hooks/useNodeExecutionState'; import { isImageField, isImageFieldCollection, isVideoField } from 'features/nodes/types/common'; import { zNodeStatus } from 'features/nodes/types/invocation'; @@ -181,7 +181,7 @@ export const buildOnInvocationComplete = ( dispatch(galleryViewChanged('images')); } // Select the image immediately since we've optimistically updated the cache - dispatch(imageSelected(lastImageDTO.image_name)); + dispatch(itemSelected({ type: 'image', id: lastImageDTO.image_name })); } }; @@ -288,7 +288,7 @@ export const buildOnInvocationComplete = ( ...getVideoIdsArg, ...videoSpecificArgs, }; - dispatch(imagesApi.util.invalidateTags([{ type: 'ImageNameList', id: stableHash(expectedQueryArgs) }])); + dispatch(videosApi.util.invalidateTags([{ type: 'VideoList', id: stableHash(expectedQueryArgs) }])); } } @@ -301,7 +301,7 @@ export const buildOnInvocationComplete = ( return; } - // Finally, we may need to autoswitch to the new image. We'll only do it for the last image in the list. + // Finally, we may need to autoswitch to the new video. We'll only do it for the last video in the list. const lastVideoDTO = videoDTOs.at(-1); if (!lastVideoDTO) { @@ -314,8 +314,8 @@ export const buildOnInvocationComplete = ( // With optimistic updates, we can immediately switch to the new image const selectedBoardId = selectSelectedBoardId(getState()); - // If the image is from a different board, switch to that board & select the image - otherwise just select the - // image. This implicitly changes the view to 'images' if it was not already. + // If the video is from a different board, switch to that board & select the video - otherwise just select the + // video. This implicitly changes the view to 'videos' if it was not already. if (board_id !== selectedBoardId) { dispatch( boardIdSelected({ @@ -324,13 +324,13 @@ export const buildOnInvocationComplete = ( }) ); } else { - // Ensure we are on the 'images' gallery view - that's where this image will be displayed + // Ensure we are on the 'videos' gallery view - that's where this video will be displayed const galleryView = selectGalleryView(getState()); if (galleryView !== 'videos') { dispatch(galleryViewChanged('videos')); } - // Select the image immediately since we've optimistically updated the cache - dispatch(imageSelected(lastVideoDTO.video_id)); + // Select the video immediately since we've optimistically updated the cache + dispatch(itemSelected({ type: 'video', id: lastVideoDTO.video_id })); } }; const getResultImageDTOs = async (data: S['InvocationCompleteEvent']): Promise => { From 5d33419f94aa6af3d7b3a8911415bc0080cb5692 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 14:18:52 -0400 Subject: [PATCH 34/55] add runway to backend --- invokeai/app/invocations/fields.py | 1 + invokeai/backend/model_manager/taxonomy.py | 1 + 2 files changed, 2 insertions(+) diff --git a/invokeai/app/invocations/fields.py b/invokeai/app/invocations/fields.py index 693f6b29d7d..77bc9c017e0 100644 --- a/invokeai/app/invocations/fields.py +++ b/invokeai/app/invocations/fields.py @@ -66,6 +66,7 @@ class UIType(str, Enum, metaclass=MetaEnum): ChatGPT4oModel = "ChatGPT4oModelField" FluxKontextModel = "FluxKontextModelField" Veo3Model = "Veo3ModelField" + RunwayModel = "RunwayModelField" # endregion # region Misc Field Types diff --git a/invokeai/backend/model_manager/taxonomy.py b/invokeai/backend/model_manager/taxonomy.py index 243f247f10e..920fe3b3930 100644 --- a/invokeai/backend/model_manager/taxonomy.py +++ b/invokeai/backend/model_manager/taxonomy.py @@ -31,6 +31,7 @@ class BaseModelType(str, Enum): ChatGPT4o = "chatgpt-4o" FluxKontext = "flux-kontext" Veo3 = "veo3" + Runway = "runway" class ModelType(str, Enum): From 6dfe3f9396138ad292bc5308f3cd79410e760a55 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 15:17:07 -0400 Subject: [PATCH 35/55] add runway back as a model and allow runway and veo3 to live together in peace and harmony --- .../controlLayers/store/canvasSlice.ts | 16 +++- .../src/features/controlLayers/store/types.ts | 27 +++++++ .../ModelManagerPanel/ModelBaseBadge.tsx | 1 + .../Invocation/fields/InputFieldRenderer.tsx | 10 +++ .../inputs/RunwayModelFieldInputComponent.tsx | 46 +++++++++++ .../src/features/nodes/store/nodesSlice.ts | 6 ++ .../web/src/features/nodes/types/common.ts | 2 + .../web/src/features/nodes/types/field.ts | 25 ++++++ .../graph/generation/buildRunwayVideoGraph.ts | 79 +++++++++++++++++++ .../util/schema/buildFieldInputInstance.ts | 1 + .../util/schema/buildFieldInputTemplate.ts | 15 ++++ .../DimensionsAspectRatioSelect.tsx | 13 ++- .../components/Video/ParamDuration.tsx | 31 ++++++-- .../components/Video/ParamResolution.tsx | 33 +++++--- .../features/parameters/store/videoSlice.ts | 20 +++-- .../features/parameters/types/constants.ts | 4 + .../features/queue/hooks/useEnqueueVideo.ts | 15 +++- .../VideoModelPicker.tsx | 10 +-- .../VideoSettingsAccordion.tsx | 35 +++++++- .../src/services/api/hooks/modelsByType.ts | 3 + .../frontend/web/src/services/api/schema.ts | 4 +- .../frontend/web/src/services/api/types.ts | 5 ++ 22 files changed, 359 insertions(+), 42 deletions(-) create mode 100644 invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/RunwayModelFieldInputComponent.tsx create mode 100644 invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts diff --git a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts index 44472dc9a32..4a9f1bdc000 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/canvasSlice.ts @@ -32,7 +32,8 @@ import { } from 'features/controlLayers/util/getScaledBoundingBoxDimensions'; import { simplifyFlatNumbersArray } from 'features/controlLayers/util/simplify'; import { isMainModelBase, zModelIdentifierField } from 'features/nodes/types/common'; -import { API_BASE_MODELS } from 'features/parameters/types/constants'; +import { videoModelChanged } from 'features/parameters/store/videoSlice'; +import { API_BASE_MODELS, VIDEO_BASE_MODELS } from 'features/parameters/types/constants'; import { getGridSize, getIsSizeOptimal, getOptimalDimension } from 'features/parameters/util/optimalDimension'; import type { IRect } from 'konva/lib/types'; import type { UndoableOptions } from 'redux-undo'; @@ -1573,6 +1574,18 @@ const slice = createSlice({ builder.addCase(canvasReset, (state) => { return resetState(state); }); + builder.addCase(videoModelChanged, (state, action) => { + if (action.payload) { + const { base } = action.payload; + if (VIDEO_BASE_MODELS.includes(base)) { + state.bbox.aspectRatio.isLocked = true; + state.bbox.aspectRatio.value = 1; + state.bbox.aspectRatio.id = '16:9'; + state.bbox.rect.width = 1280; + state.bbox.rect.height = 720; + } + } + }); builder.addCase(modelChanged, (state, action) => { const { model } = action.payload; /** @@ -1603,6 +1616,7 @@ const slice = createSlice({ state.bbox.rect.width = 1024; state.bbox.rect.height = 1024; } + syncScaledSize(state); } }); diff --git a/invokeai/frontend/web/src/features/controlLayers/store/types.ts b/invokeai/frontend/web/src/features/controlLayers/store/types.ts index a84ed67602b..a59bfa78f1b 100644 --- a/invokeai/frontend/web/src/features/controlLayers/store/types.ts +++ b/invokeai/frontend/web/src/features/controlLayers/store/types.ts @@ -468,6 +468,18 @@ export const VEO3_ASPECT_RATIOS: Record = { '16:9': { width: 1280, height: 720 }, }; +export const zRunwayAspectRatioID = z.enum(['16:9', '4:3', '1:1', '3:4', '9:16', '21:9']); +type RunwayAspectRatio = z.infer; +export const isRunwayAspectRatioID = (v: unknown): v is RunwayAspectRatio => zRunwayAspectRatioID.safeParse(v).success; +export const RUNWAY_ASPECT_RATIOS: Record = { + '16:9': { width: 1280, height: 720 }, + '4:3': { width: 1104, height: 832 }, + '1:1': { width: 960, height: 960 }, + '3:4': { width: 832, height: 1104 }, + '9:16': { width: 720, height: 1280 }, + '21:9': { width: 1584, height: 672 }, +}; + export const zVeo3Resolution = z.enum(['720p', '1080p']); export type Veo3Resolution = z.infer; export const isVeo3Resolution = (v: unknown): v is Veo3Resolution => zVeo3Resolution.safeParse(v).success; @@ -476,6 +488,13 @@ export const VEO3_RESOLUTIONS: Record = { '1080p': { width: 1920, height: 1080 }, }; +export const zRunwayResolution = z.enum(['720p']); +export type RunwayResolution = z.infer; +export const isRunwayResolution = (v: unknown): v is RunwayResolution => zRunwayResolution.safeParse(v).success; +export const RUNWAY_RESOLUTIONS: Record = { + '720p': { width: 1280, height: 720 }, +}; + const zAspectRatioConfig = z.object({ id: zAspectRatioID, value: z.number().gt(0), @@ -496,6 +515,14 @@ export const VEO3_DURATIONS: Record = { '8': '8 seconds', }; +export const zRunwayDurationID = z.enum(['5', '10']); +export type RunwayDuration = z.infer; +export const isRunwayDurationID = (v: unknown): v is RunwayDuration => zRunwayDurationID.safeParse(v).success; +export const RUNWAY_DURATIONS: Record = { + '5': '5 seconds', + '10': '10 seconds', +}; + const zBboxState = z.object({ rect: z.object({ x: z.number().int(), diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx index 52a038ea3d2..bcf1c0e51cf 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx @@ -21,6 +21,7 @@ export const BASE_COLOR_MAP: Record = { 'chatgpt-4o': 'pink', 'flux-kontext': 'pink', veo3: 'white', + runway: 'white' }; const ModelBaseBadge = ({ base }: Props) => { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx index cc7451227a8..4d928ea504e 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/InputFieldRenderer.tsx @@ -85,6 +85,8 @@ import { isMainModelFieldInputTemplate, isModelIdentifierFieldInputInstance, isModelIdentifierFieldInputTemplate, + isRunwayModelFieldInputInstance, + isRunwayModelFieldInputTemplate, isSchedulerFieldInputInstance, isSchedulerFieldInputTemplate, isSD3MainModelFieldInputInstance, @@ -136,6 +138,7 @@ import LLaVAModelFieldInputComponent from './inputs/LLaVAModelFieldInputComponen import LoRAModelFieldInputComponent from './inputs/LoRAModelFieldInputComponent'; import MainModelFieldInputComponent from './inputs/MainModelFieldInputComponent'; import RefinerModelFieldInputComponent from './inputs/RefinerModelFieldInputComponent'; +import RunwayModelFieldInputComponent from './inputs/RunwayModelFieldInputComponent'; import SchedulerFieldInputComponent from './inputs/SchedulerFieldInputComponent'; import SD3MainModelFieldInputComponent from './inputs/SD3MainModelFieldInputComponent'; import SDXLMainModelFieldInputComponent from './inputs/SDXLMainModelFieldInputComponent'; @@ -444,6 +447,13 @@ export const InputFieldRenderer = memo(({ nodeId, fieldName, settings }: Props) return ; } + if (isRunwayModelFieldInputTemplate(template)) { + if (!isRunwayModelFieldInputInstance(field)) { + return null; + } + return ; + } + if (isColorFieldInputTemplate(template)) { if (!isColorFieldInputInstance(field)) { return null; diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/RunwayModelFieldInputComponent.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/RunwayModelFieldInputComponent.tsx new file mode 100644 index 00000000000..f89491bffe7 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/fields/inputs/RunwayModelFieldInputComponent.tsx @@ -0,0 +1,46 @@ +import { useAppDispatch } from 'app/store/storeHooks'; +import { ModelFieldCombobox } from 'features/nodes/components/flow/nodes/Invocation/fields/inputs/ModelFieldCombobox'; +import { fieldRunwayModelValueChanged } from 'features/nodes/store/nodesSlice'; +import type { RunwayModelFieldInputInstance, RunwayModelFieldInputTemplate } from 'features/nodes/types/field'; +import { memo, useCallback } from 'react'; +import { useRunwayModels } from 'services/api/hooks/modelsByType'; +import type { ApiModelConfig } from 'services/api/types'; + +import type { FieldComponentProps } from './types'; + +const RunwayModelFieldInputComponent = ( + props: FieldComponentProps +) => { + const { nodeId, field } = props; + const dispatch = useAppDispatch(); + + const [modelConfigs, { isLoading }] = useRunwayModels(); + + const onChange = useCallback( + (value: ApiModelConfig | null) => { + if (!value) { + return; + } + dispatch( + fieldRunwayModelValueChanged({ + nodeId, + fieldName: field.name, + value, + }) + ); + }, + [dispatch, field.name, nodeId] + ); + + return ( + + ); +}; + +export default memo(RunwayModelFieldInputComponent); diff --git a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts index d262b939cb5..e5cbb73b7bb 100644 --- a/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts +++ b/invokeai/frontend/web/src/features/nodes/store/nodesSlice.ts @@ -51,6 +51,7 @@ import type { LoRAModelFieldValue, MainModelFieldValue, ModelIdentifierFieldValue, + RunwayModelFieldValue, SchedulerFieldValue, SDXLRefinerModelFieldValue, SigLipModelFieldValue, @@ -94,6 +95,7 @@ import { zLoRAModelFieldValue, zMainModelFieldValue, zModelIdentifierFieldValue, + zRunwayModelFieldValue, zSchedulerFieldValue, zSDXLRefinerModelFieldValue, zSigLipModelFieldValue, @@ -557,6 +559,9 @@ const slice = createSlice({ fieldVeo3ModelValueChanged: (state, action: FieldValueAction) => { fieldValueReducer(state, action, zVeo3ModelFieldValue); }, + fieldRunwayModelValueChanged: (state, action: FieldValueAction) => { + fieldValueReducer(state, action, zRunwayModelFieldValue); + }, fieldFluxKontextModelValueChanged: (state, action: FieldValueAction) => { fieldValueReducer(state, action, zFluxKontextModelFieldValue); }, @@ -736,6 +741,7 @@ export const { fieldStringGeneratorValueChanged, fieldImageGeneratorValueChanged, fieldVeo3ModelValueChanged, + fieldRunwayModelValueChanged, fieldDescriptionChanged, nodeEditorReset, nodeIsIntermediateChanged, diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index 24dee5dd7c8..231e562239c 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -87,6 +87,7 @@ const zBaseModel = z.enum([ 'chatgpt-4o', 'flux-kontext', 'veo3', + 'runway', ]); export type BaseModelType = z.infer; export const zMainModelBase = z.enum([ @@ -101,6 +102,7 @@ export const zMainModelBase = z.enum([ 'chatgpt-4o', 'flux-kontext', 'veo3', + 'runway', ]); type MainModelBase = z.infer; export const isMainModelBase = (base: unknown): base is MainModelBase => zMainModelBase.safeParse(base).success; diff --git a/invokeai/frontend/web/src/features/nodes/types/field.ts b/invokeai/frontend/web/src/features/nodes/types/field.ts index 6aefed2ea64..ca0d54e6805 100644 --- a/invokeai/frontend/web/src/features/nodes/types/field.ts +++ b/invokeai/frontend/web/src/features/nodes/types/field.ts @@ -265,6 +265,10 @@ const zVeo3ModelFieldType = zFieldTypeBase.extend({ name: z.literal('Veo3ModelField'), originalType: zStatelessFieldType.optional(), }); +const zRunwayModelFieldType = zFieldTypeBase.extend({ + name: z.literal('RunwayModelField'), + originalType: zStatelessFieldType.optional(), +}); const zFluxKontextModelFieldType = zFieldTypeBase.extend({ name: z.literal('FluxKontextModelField'), originalType: zStatelessFieldType.optional(), @@ -324,6 +328,7 @@ const zStatefulFieldType = z.union([ zChatGPT4oModelFieldType, zFluxKontextModelFieldType, zVeo3ModelFieldType, + zRunwayModelFieldType, zColorFieldType, zSchedulerFieldType, zFloatGeneratorFieldType, @@ -367,6 +372,7 @@ const modelFieldTypeNames = [ zChatGPT4oModelFieldType.shape.name.value, zFluxKontextModelFieldType.shape.name.value, zVeo3ModelFieldType.shape.name.value, + zRunwayModelFieldType.shape.name.value, // Stateless model fields 'UNetField', 'VAEField', @@ -1297,6 +1303,24 @@ export const isVeo3ModelFieldInputInstance = buildInstanceTypeGuard(zVeo3ModelFi export const isVeo3ModelFieldInputTemplate = buildTemplateTypeGuard('Veo3ModelField'); // #endregion +// #region RunwayModelField +export const zRunwayModelFieldValue = zModelIdentifierField.optional(); +const zRunwayModelFieldInputInstance = zFieldInputInstanceBase.extend({ + value: zRunwayModelFieldValue, +}); +const zRunwayModelFieldInputTemplate = zFieldInputTemplateBase.extend({ + type: zRunwayModelFieldType, + originalType: zFieldType.optional(), + default: zRunwayModelFieldValue, +}); +export type RunwayModelFieldValue = z.infer; +export type RunwayModelFieldInputInstance = z.infer; +export type RunwayModelFieldInputTemplate = z.infer; +export const isRunwayModelFieldInputInstance = buildInstanceTypeGuard(zRunwayModelFieldInputInstance); +export const isRunwayModelFieldInputTemplate = + buildTemplateTypeGuard('RunwayModelField'); +// #endregion + // #region SchedulerField export const zSchedulerFieldValue = zSchedulerField.optional(); const zSchedulerFieldInputInstance = zFieldInputInstanceBase.extend({ @@ -2027,6 +2051,7 @@ const zStatefulFieldInputTemplate = z.union([ zChatGPT4oModelFieldInputTemplate, zFluxKontextModelFieldInputTemplate, zVeo3ModelFieldInputTemplate, + zRunwayModelFieldInputTemplate, zColorFieldInputTemplate, zSchedulerFieldInputTemplate, zStatelessFieldInputTemplate, diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts new file mode 100644 index 00000000000..2a7dfa17403 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/graph/generation/buildRunwayVideoGraph.ts @@ -0,0 +1,79 @@ +import { logger } from 'app/logging/logger'; +import { getPrefixedId } from 'features/controlLayers/konva/util'; +import { selectParamsSlice } from 'features/controlLayers/store/paramsSlice'; +import { zImageField } from 'features/nodes/types/common'; +import { Graph } from 'features/nodes/util/graph/generation/Graph'; +import { selectPresetModifiedPrompts } from 'features/nodes/util/graph/graphBuilderUtils'; +import type { GraphBuilderArg, GraphBuilderReturn } from 'features/nodes/util/graph/types'; +import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; +import { selectStartingFrameImage, selectVideoSlice } from 'features/parameters/store/videoSlice'; +import { t } from 'i18next'; +import { assert } from 'tsafe'; + +const log = logger('system'); + +export const buildRunwayVideoGraph = (arg: GraphBuilderArg): GraphBuilderReturn => { + const { generationMode, state, manager } = arg; + + log.debug({ generationMode, manager: manager?.id }, 'Building Runway video graph'); + + const supportedModes = ['txt2img']; + if (!supportedModes.includes(generationMode)) { + throw new UnsupportedGenerationModeError(t('toast.runwayIncompatibleGenerationMode')); + } + + const params = selectParamsSlice(state); + const videoParams = selectVideoSlice(state); + const prompts = selectPresetModifiedPrompts(state); + assert(prompts.positive.length > 0, 'Runway video requires positive prompt to have at least one character'); + + const startingFrameImage = selectStartingFrameImage(state); + + assert(startingFrameImage, 'Video starting frame is required for runway video generation'); + const firstFrameImageField = zImageField.parse(startingFrameImage); + + const { seed, shouldRandomizeSeed } = params; + const { videoDuration } = videoParams; + + const finalSeed = shouldRandomizeSeed ? undefined : seed; + + const g = new Graph(getPrefixedId('runway_video_graph')); + + const positivePrompt = g.addNode({ + id: getPrefixedId('positive_prompt'), + type: 'string', + value: prompts.positive, + }); + + // Create the runway video generation node + const runwayVideoNode = g.addNode({ + id: getPrefixedId('runway_generate_video'), + // @ts-expect-error: This node is not available in the OSS application + type: 'runway_generate_video', + duration: parseInt(videoDuration || '0', 10), + aspect_ratio: params.dimensions.aspectRatio.id, + seed: finalSeed, + first_frame_image: firstFrameImageField, + }); + + // @ts-expect-error: This node is not available in the OSS application + g.addEdge(positivePrompt, 'value', runwayVideoNode, 'prompt'); + + // Set up metadata + g.upsertMetadata({ + positive_prompt: prompts.positive, + negative_prompt: prompts.negative || '', + video_duration: videoDuration, + video_aspect_ratio: params.dimensions.aspectRatio.id, + seed: finalSeed, + generation_type: 'image-to-video', + first_frame_image: startingFrameImage, + }); + + g.setMetadataReceivingNode(runwayVideoNode); + + return { + g, + positivePrompt, + }; +}; diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts index 39c91c60297..a62b98023f1 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputInstance.ts @@ -38,6 +38,7 @@ const FIELD_VALUE_FALLBACK_MAP: Record = ChatGPT4oModelField: undefined, FluxKontextModelField: undefined, Veo3ModelField: undefined, + RunwayModelField: undefined, FloatGeneratorField: undefined, IntegerGeneratorField: undefined, StringGeneratorField: undefined, diff --git a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts index a9f15ecb214..917e57cef54 100644 --- a/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts +++ b/invokeai/frontend/web/src/features/nodes/util/schema/buildFieldInputTemplate.ts @@ -34,6 +34,7 @@ import type { LoRAModelFieldInputTemplate, MainModelFieldInputTemplate, ModelIdentifierFieldInputTemplate, + RunwayModelFieldInputTemplate, SchedulerFieldInputTemplate, SD3MainModelFieldInputTemplate, SDXLMainModelFieldInputTemplate, @@ -642,6 +643,19 @@ const buildVeo3ModelFieldInputTemplate: FieldInputTemplateBuilder = ({ + schemaObject, + baseField, + fieldType, +}) => { + const template: RunwayModelFieldInputTemplate = { + ...baseField, + type: fieldType, + default: schemaObject.default ?? undefined, + }; + return template; +}; + const buildChatGPT4oModelFieldInputTemplate: FieldInputTemplateBuilder = ({ schemaObject, baseField, @@ -866,6 +880,7 @@ export const TEMPLATE_BUILDER_MAP: Record { const isChatGPT4o = useAppSelector(selectIsChatGPT4o); const isImagen4 = useAppSelector(selectIsImagen4); const isFluxKontext = useAppSelector(selectIsFluxKontext); - const activeTab = useAppSelector(selectActiveTab); + const isVeo3 = useAppSelector(selectIsVeo3); + const isRunway = useAppSelector(selectIsRunway); const options = useMemo(() => { // Imagen3 and ChatGPT4o have different aspect ratio options, and do not support freeform sizes if (isImagen3 || isImagen4) { @@ -43,12 +45,15 @@ export const DimensionsAspectRatioSelect = memo(() => { if (isFluxKontext) { return zFluxKontextAspectRatioID.options; } - if (activeTab === 'video') { + if (isVeo3) { return zVeo3AspectRatioID.options; } + if (isRunway) { + return zRunwayAspectRatioID.options; + } // All other models return zAspectRatioID.options; - }, [isImagen3, isChatGPT4o, isImagen4, isFluxKontext, activeTab]); + }, [isImagen3, isChatGPT4o, isImagen4, isFluxKontext, isVeo3, isRunway]); const onChange = useCallback>( (e) => { diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx index cdff52d56c7..9f04ed1c65e 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamDuration.tsx @@ -1,7 +1,12 @@ import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { isVeo3DurationID, VEO3_DURATIONS } from 'features/controlLayers/store/types'; -import { selectVideoDuration, videoDurationChanged } from 'features/parameters/store/videoSlice'; +import { + isRunwayDurationID, + isVeo3DurationID, + RUNWAY_DURATIONS, + VEO3_DURATIONS, +} from 'features/controlLayers/store/types'; +import { selectVideoDuration, selectVideoModel, videoDurationChanged } from 'features/parameters/store/videoSlice'; import type { ChangeEventHandler } from 'react'; import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; @@ -11,18 +16,28 @@ export const ParamDuration = () => { const videoDuration = useAppSelector(selectVideoDuration); const { t } = useTranslation(); const dispatch = useAppDispatch(); + const model = useAppSelector(selectVideoModel); const options = useMemo(() => { - return Object.entries(VEO3_DURATIONS).map(([key, value]) => ({ - label: value, - value: key, - })); - }, []); + if (model?.base === 'veo3') { + return Object.entries(VEO3_DURATIONS).map(([key, value]) => ({ + label: value, + value: key, + })); + } else if (model?.base === 'runway') { + return Object.entries(RUNWAY_DURATIONS).map(([key, value]) => ({ + label: value, + value: key, + })); + } else { + return []; + } + }, [model]); const onChange = useCallback>( (e) => { const duration = e.target.value; - if (!isVeo3DurationID(duration)) { + if (!isVeo3DurationID(duration) && !isRunwayDurationID(duration)) { return; } diff --git a/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx b/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx index c12a2d3324a..31b956d05e1 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Video/ParamResolution.tsx @@ -1,10 +1,15 @@ import { FormControl, FormLabel, Select } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; -import { aspectRatioIdChanged, heightChanged, widthChanged } from 'features/controlLayers/store/paramsSlice'; -import { isVeo3Resolution, VEO3_RESOLUTIONS, zVeo3Resolution } from 'features/controlLayers/store/types'; -import { selectVideoResolution, videoResolutionChanged } from 'features/parameters/store/videoSlice'; +import { heightChanged, widthChanged } from 'features/controlLayers/store/paramsSlice'; +import { + isVeo3Resolution, + VEO3_RESOLUTIONS, + zRunwayResolution, + zVeo3Resolution, +} from 'features/controlLayers/store/types'; +import { selectVideoModel, selectVideoResolution, videoResolutionChanged } from 'features/parameters/store/videoSlice'; import type { ChangeEventHandler } from 'react'; -import { useCallback, useEffect, useMemo } from 'react'; +import { useCallback, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; import { PiCaretDownBold } from 'react-icons/pi'; @@ -12,17 +17,17 @@ export const ParamResolution = () => { const videoResolution = useAppSelector(selectVideoResolution); const { t } = useTranslation(); const dispatch = useAppDispatch(); + const model = useAppSelector(selectVideoModel); - const options = useMemo(() => zVeo3Resolution.options, []); - - useEffect(() => { - if (!videoResolution) { - return; + const options = useMemo(() => { + if (model?.base === 'veo3') { + return zVeo3Resolution.options; + } else if (model?.base === 'runway') { + return zRunwayResolution.options; + } else { + return []; } - dispatch(aspectRatioIdChanged({ id: '16:9' })); - dispatch(widthChanged({ width: VEO3_RESOLUTIONS[videoResolution].width, updateAspectRatio: true, clamp: true })); - dispatch(heightChanged({ height: VEO3_RESOLUTIONS[videoResolution].height, updateAspectRatio: true, clamp: true })); - }, [dispatch, videoResolution]); + }, [model]); const onChange = useCallback>( (e) => { @@ -32,6 +37,8 @@ export const ParamResolution = () => { } dispatch(videoResolutionChanged(resolution)); + dispatch(widthChanged({ width: VEO3_RESOLUTIONS[resolution].width, updateAspectRatio: true, clamp: true })); + dispatch(heightChanged({ height: VEO3_RESOLUTIONS[resolution].height, updateAspectRatio: true, clamp: true })); }, [dispatch] ); diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 2fb21e706ab..85fcfed47ab 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -3,11 +3,16 @@ import { createSelector, createSlice } from '@reduxjs/toolkit'; import type { RootState } from 'app/store/store'; import type { SliceConfig } from 'app/store/types'; import { isPlainObject } from 'es-toolkit'; -import type { ImageWithDims, Veo3Duration, Veo3Resolution } from 'features/controlLayers/store/types'; -import { zImageWithDims, zVeo3DurationID, zVeo3Resolution } from 'features/controlLayers/store/types'; +import type { ImageWithDims, RunwayDuration, Veo3Duration, Veo3Resolution } from 'features/controlLayers/store/types'; +import { + zImageWithDims, + zRunwayDurationID, + zVeo3DurationID, + zVeo3Resolution, +} from 'features/controlLayers/store/types'; import type { VideoField } from 'features/nodes/types/common'; import { zModelIdentifierField, zVideoField } from 'features/nodes/types/common'; -import type { Veo3ModelConfig } from 'services/api/types'; +import type { RunwayModelConfig, Veo3ModelConfig } from 'services/api/types'; import { assert } from 'tsafe'; import z from 'zod'; @@ -17,7 +22,7 @@ const zVideoState = z.object({ generatedVideo: zVideoField.nullable(), videoModel: zModelIdentifierField.nullable(), videoResolution: zVeo3Resolution.nullable(), - videoDuration: zVeo3DurationID.nullable(), + videoDuration: zVeo3DurationID.or(zRunwayDurationID).nullable(), }); export type VideoState = z.infer; @@ -44,7 +49,7 @@ const slice = createSlice({ state.generatedVideo = videoField; }, - videoModelChanged: (state, action: PayloadAction) => { + videoModelChanged: (state, action: PayloadAction) => { const parsedModel = zModelIdentifierField.parse(action.payload); state.videoModel = parsedModel; }, @@ -53,7 +58,7 @@ const slice = createSlice({ state.videoResolution = action.payload; }, - videoDurationChanged: (state, action: PayloadAction) => { + videoDurationChanged: (state, action: PayloadAction) => { state.videoDuration = action.payload; }, }, @@ -90,3 +95,6 @@ export const selectGeneratedVideo = createVideoSelector((video) => video.generat export const selectVideoModel = createVideoSelector((video) => video.videoModel); export const selectVideoResolution = createVideoSelector((video) => video.videoResolution); export const selectVideoDuration = createVideoSelector((video) => video.videoDuration); + +export const selectIsVeo3 = createVideoSelector((video) => video.videoModel?.base === 'veo3'); +export const selectIsRunway = createVideoSelector((video) => video.videoModel?.base === 'runway'); diff --git a/invokeai/frontend/web/src/features/parameters/types/constants.ts b/invokeai/frontend/web/src/features/parameters/types/constants.ts index 96a04cd065a..a570a3d27ec 100644 --- a/invokeai/frontend/web/src/features/parameters/types/constants.ts +++ b/invokeai/frontend/web/src/features/parameters/types/constants.ts @@ -18,6 +18,7 @@ export const MODEL_TYPE_MAP: Record = { 'chatgpt-4o': 'ChatGPT 4o', 'flux-kontext': 'Flux Kontext', veo3: 'Veo3', + runway: 'Runway', }; /** @@ -37,6 +38,7 @@ export const MODEL_TYPE_SHORT_MAP: Record = { 'chatgpt-4o': 'ChatGPT 4o', 'flux-kontext': 'Flux Kontext', veo3: 'Veo3', + runway: 'Runway', }; /** @@ -133,3 +135,5 @@ export const SCHEDULER_OPTIONS: ComboboxOption[] = [ * List of base models that make API requests */ export const API_BASE_MODELS = ['imagen3', 'imagen4', 'chatgpt-4o', 'flux-kontext']; + +export const VIDEO_BASE_MODELS = ['veo3', 'runway']; diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts index 21d4945852d..8dee3b81935 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts @@ -5,6 +5,7 @@ import type { AppStore } from 'app/store/store'; import { useAppStore } from 'app/store/storeHooks'; import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; import { withResultAsync } from 'common/util/result'; +import { buildRunwayVideoGraph } from 'features/nodes/util/graph/generation/buildRunwayVideoGraph'; import { buildVeo3VideoGraph } from 'features/nodes/util/graph/generation/buildVeo3VideoGraph'; import { selectCanvasDestination } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderArg } from 'features/nodes/util/graph/types'; @@ -13,7 +14,7 @@ import { toast } from 'features/toast/toast'; import { useCallback } from 'react'; import { serializeError } from 'serialize-error'; import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endpoints/queue'; -import { AssertionError } from 'tsafe'; +import { assert, AssertionError } from 'tsafe'; const log = logger('generation'); export const enqueueRequestedCanvas = createAction('app/enqueueRequestedCanvas'); @@ -28,9 +29,19 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { const destination = selectCanvasDestination(state); const buildGraphResult = await withResultAsync(async () => { + const model = state.video.videoModel; + assert(model, 'No model found in state'); + const base = model.base; const graphBuilderArg: GraphBuilderArg = { generationMode: 'txt2img', state, manager: null }; - return await buildVeo3VideoGraph(graphBuilderArg); + switch (base) { + case 'veo3': + return await buildVeo3VideoGraph(graphBuilderArg); + case 'runway': + return await buildRunwayVideoGraph(graphBuilderArg); + default: + assert(false, `No graph builders for base ${base}`); + } }); if (buildGraphResult.isErr()) { diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx index 41545cc4a30..339a60f5b47 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx @@ -5,16 +5,16 @@ import { ModelPicker } from 'features/parameters/components/ModelPicker'; import { selectVideoModel, videoModelChanged } from 'features/parameters/store/videoSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; -import { useVeo3Models } from 'services/api/hooks/modelsByType'; -import type { Veo3ModelConfig } from 'services/api/types'; +import { useVideoModels } from 'services/api/hooks/modelsByType'; +import type { RunwayModelConfig, Veo3ModelConfig } from 'services/api/types'; export const VideoModelPicker = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); - const [modelConfigs] = useVeo3Models(); + const [modelConfigs] = useVideoModels(); const selectedModelConfig = useAppSelector(selectVideoModel); const onChange = useCallback( - (modelConfig: Veo3ModelConfig) => { + (modelConfig: Veo3ModelConfig | RunwayModelConfig) => { dispatch(videoModelChanged(modelConfig)); }, [dispatch] @@ -28,7 +28,7 @@ export const VideoModelPicker = memo(() => { diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx index a787474b604..bf46d322d12 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoSettingsAccordion.tsx @@ -1,10 +1,19 @@ import { Flex, StandaloneAccordion } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { + aspectRatioIdChanged, + aspectRatioLockToggled, + heightChanged, + widthChanged, +} from 'features/controlLayers/store/paramsSlice'; +import { RUNWAY_ASPECT_RATIOS, VEO3_RESOLUTIONS } from 'features/controlLayers/store/types'; import { Dimensions } from 'features/parameters/components/Dimensions/Dimensions'; import { ParamSeed } from 'features/parameters/components/Seed/ParamSeed'; import { ParamDuration } from 'features/parameters/components/Video/ParamDuration'; import { ParamResolution } from 'features/parameters/components/Video/ParamResolution'; +import { selectVideoModel, videoResolutionChanged } from 'features/parameters/store/videoSlice'; import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle'; -import { memo } from 'react'; +import { memo, useEffect } from 'react'; import { useTranslation } from 'react-i18next'; import { StartingFrameImage } from './StartingFrameImage'; @@ -16,6 +25,30 @@ export const VideoSettingsAccordion = memo(() => { id: 'video-settings', defaultIsOpen: true, }); + const videoModel = useAppSelector(selectVideoModel); + + const dispatch = useAppDispatch(); + + useEffect(() => { + // hack to get the default aspect ratio etc for models outside paramsSlice + if (videoModel?.base === 'runway') { + dispatch(aspectRatioIdChanged({ id: '16:9' })); + const { width, height } = RUNWAY_ASPECT_RATIOS['16:9']; + dispatch(widthChanged({ width, clamp: true })); + dispatch(heightChanged({ height, clamp: true })); + dispatch(aspectRatioLockToggled()); + } + + if (videoModel?.base === 'veo3') { + dispatch(aspectRatioIdChanged({ id: '16:9' })); + dispatch(videoResolutionChanged('720p')); + const { width, height } = VEO3_RESOLUTIONS['720p']; + dispatch(widthChanged({ width, clamp: true })); + dispatch(heightChanged({ height, clamp: true })); + dispatch(aspectRatioLockToggled()); + } + }, [dispatch, videoModel]); + return ( isVeo3ModelConfig(config) || isRunwayModelConfig(config)); const buildModelsSelector = (typeGuard: (config: AnyModelConfig) => config is T): Selector => diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index db802a08e7f..55f05d4ca96 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -2447,7 +2447,7 @@ export type components = { * @description Base model type. * @enum {string} */ - BaseModelType: "any" | "sd-1" | "sd-2" | "sd-3" | "sdxl" | "sdxl-refiner" | "flux" | "cogview4" | "imagen3" | "imagen4" | "chatgpt-4o" | "flux-kontext" | "veo3"; + BaseModelType: "any" | "sd-1" | "sd-2" | "sd-3" | "sdxl" | "sdxl-refiner" | "flux" | "cogview4" | "imagen3" | "imagen4" | "chatgpt-4o" | "flux-kontext" | "veo3" | "runway"; /** Batch */ Batch: { /** @@ -21782,7 +21782,7 @@ export type components = { * used, and the type will be ignored. They are included here for backwards compatibility. * @enum {string} */ - UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "Veo3ModelField" | "SchedulerField" | "AnyField" | "VideoField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; + UIType: "MainModelField" | "CogView4MainModelField" | "FluxMainModelField" | "SD3MainModelField" | "SDXLMainModelField" | "SDXLRefinerModelField" | "ONNXModelField" | "VAEModelField" | "FluxVAEModelField" | "LoRAModelField" | "ControlNetModelField" | "IPAdapterModelField" | "T2IAdapterModelField" | "T5EncoderModelField" | "CLIPEmbedModelField" | "CLIPLEmbedModelField" | "CLIPGEmbedModelField" | "SpandrelImageToImageModelField" | "ControlLoRAModelField" | "SigLipModelField" | "FluxReduxModelField" | "LLaVAModelField" | "Imagen3ModelField" | "Imagen4ModelField" | "ChatGPT4oModelField" | "FluxKontextModelField" | "Veo3ModelField" | "RunwayModelField" | "SchedulerField" | "AnyField" | "VideoField" | "CollectionField" | "CollectionItemField" | "DEPRECATED_Boolean" | "DEPRECATED_Color" | "DEPRECATED_Conditioning" | "DEPRECATED_Control" | "DEPRECATED_Float" | "DEPRECATED_Image" | "DEPRECATED_Integer" | "DEPRECATED_Latents" | "DEPRECATED_String" | "DEPRECATED_BooleanCollection" | "DEPRECATED_ColorCollection" | "DEPRECATED_ConditioningCollection" | "DEPRECATED_ControlCollection" | "DEPRECATED_FloatCollection" | "DEPRECATED_ImageCollection" | "DEPRECATED_IntegerCollection" | "DEPRECATED_LatentsCollection" | "DEPRECATED_StringCollection" | "DEPRECATED_BooleanPolymorphic" | "DEPRECATED_ColorPolymorphic" | "DEPRECATED_ConditioningPolymorphic" | "DEPRECATED_ControlPolymorphic" | "DEPRECATED_FloatPolymorphic" | "DEPRECATED_ImagePolymorphic" | "DEPRECATED_IntegerPolymorphic" | "DEPRECATED_LatentsPolymorphic" | "DEPRECATED_StringPolymorphic" | "DEPRECATED_UNet" | "DEPRECATED_Vae" | "DEPRECATED_CLIP" | "DEPRECATED_Collection" | "DEPRECATED_CollectionItem" | "DEPRECATED_Enum" | "DEPRECATED_WorkflowField" | "DEPRECATED_IsIntermediate" | "DEPRECATED_BoardField" | "DEPRECATED_MetadataItem" | "DEPRECATED_MetadataItemCollection" | "DEPRECATED_MetadataItemPolymorphic" | "DEPRECATED_MetadataDict"; /** UNetField */ UNetField: { /** @description Info to load unet submodel */ diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 6e33a2a5e10..51fe7af7312 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -129,6 +129,7 @@ export type MainModelConfig = DiffusersModelConfig | CheckpointModelConfig | Api export type FLUXKontextModelConfig = MainModelConfig; export type ChatGPT4oModelConfig = ApiModelConfig; export type Veo3ModelConfig = ApiModelConfig; +export type RunwayModelConfig = ApiModelConfig; export type AnyModelConfig = | ControlLoRAModelConfig | LoRAModelConfig @@ -298,6 +299,10 @@ export const isVeo3ModelConfig = (config: AnyModelConfig): config is Veo3ModelCo return config.type === 'main' && config.base === 'veo3'; }; +export const isRunwayModelConfig = (config: AnyModelConfig): config is RunwayModelConfig => { + return config.type === 'main' && config.base === 'runway'; +}; + export const isImagen3ModelConfig = (config: AnyModelConfig): config is ApiModelConfig => { return config.type === 'main' && config.base === 'imagen3'; }; From c6bfe99108ef682d49cdbc347706bb582276b544 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 15:37:59 -0400 Subject: [PATCH 36/55] fix video styling --- .../web/src/features/video/components/VideoPlayer.tsx | 8 +++++--- 1 file changed, 5 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx index 82283b30632..fa749d360b5 100644 --- a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx +++ b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx @@ -24,14 +24,16 @@ export const VideoPlayer = memo(() => { )} From 7a83146e57e79e23fad16e83b436ab7af8ab9776 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Thu, 21 Aug 2025 15:38:08 -0400 Subject: [PATCH 37/55] remove reference images on video tab --- .../src/features/parameters/components/Prompts/Prompts.tsx | 6 ++++-- 1 file changed, 4 insertions(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx index 3f93bec9552..856ce00b0d6 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx @@ -4,17 +4,19 @@ import { RefImageList } from 'features/controlLayers/components/RefImage/RefImag import { selectHasNegativePrompt, selectModelSupportsNegativePrompt } from 'features/controlLayers/store/paramsSlice'; import { ParamNegativePrompt } from 'features/parameters/components/Core/ParamNegativePrompt'; import { ParamPositivePrompt } from 'features/parameters/components/Core/ParamPositivePrompt'; +import { selectActiveTab } from 'features/ui/store/uiSelectors'; import { memo } from 'react'; export const Prompts = memo(() => { const modelSupportsNegativePrompt = useAppSelector(selectModelSupportsNegativePrompt); const hasNegativePrompt = useAppSelector(selectHasNegativePrompt); - + const activeTab = useAppSelector(selectActiveTab); + return ( {modelSupportsNegativePrompt && hasNegativePrompt && } - + {activeTab !== 'video' && } ); }); From 6f6d4b52bc622ef7648c45e847b4cc3d6e2290b5 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:17:31 +1000 Subject: [PATCH 38/55] feat(ui): simpler layout for video player --- .../features/video/components/VideoPlayer.tsx | 20 ++----------------- 1 file changed, 2 insertions(+), 18 deletions(-) diff --git a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx index fa749d360b5..dec8a42f625 100644 --- a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx +++ b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx @@ -8,8 +8,6 @@ import { useTranslation } from 'react-i18next'; import ReactPlayer from 'react-player'; import { useGetVideoDTOQuery } from 'services/api/endpoints/videos'; -const ChakraReactPlayer = chakra(ReactPlayer); - export const VideoPlayer = memo(() => { const { t } = useTranslation(); const ref = useRef(null); @@ -19,23 +17,9 @@ export const VideoPlayer = memo(() => { useFocusRegion('video', ref); return ( - + {videoDTO?.video_url && ( - - - + )} {!videoDTO?.video_url && {t('gallery.noVideoSelected')}} From 734e6e816d31d6ea40f3a20cbb4002179b8a15f6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:17:52 +1000 Subject: [PATCH 39/55] tidy(ui): remove unused VideoAtPosition component --- .../gallery/components/NewGallery.tsx | 25 ------------------- 1 file changed, 25 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index fa6b7244fcf..b19e6b90b40 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -69,31 +69,6 @@ const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string }); ImageAtPosition.displayName = 'ImageAtPosition'; -const VideoAtPosition = memo(({ itemId }: { index: number; itemId: string }) => { - /* - * We rely on the useRangeBasedImageFetching to fetch all image DTOs, caching them with RTK Query. - * - * In this component, we just want to consume that cache. Unforutnately, RTK Query does not provide a way to - * subscribe to a query without triggering a new fetch. - * - * There is a hack, though: - * - https://github.com/reduxjs/redux-toolkit/discussions/4213 - * - * This essentially means "subscribe to the query once it has some data". - */ - - // Use `currentData` instead of `data` to prevent a flash of previous image rendered at this index - const { currentData: item, isUninitialized } = videosApi.endpoints.getVideoDTO.useQueryState(itemId); - videosApi.endpoints.getVideoDTO.useQuerySubscription(itemId, { skip: isUninitialized }); - - if (!item) { - return ; - } - - return ; -}); -VideoAtPosition.displayName = 'VideoAtPosition'; - const computeItemKey: GridComputeItemKey = (index, imageName, { queryArgs }) => { return `${JSON.stringify(queryArgs)}-${imageName ?? index}`; }; From 571aa69d620a1689980477c5ce9c1262eb57e3e3 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 17:18:23 +1000 Subject: [PATCH 40/55] fix(ui): fetching imageDTO for video --- invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx index 490f7ff2642..79c68f5cb7a 100644 --- a/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx +++ b/invokeai/frontend/web/src/app/components/GlobalImageHotkeys.tsx @@ -16,7 +16,7 @@ import type { ImageDTO } from 'services/api/types'; export const GlobalImageHotkeys = memo(() => { useAssertSingleton('GlobalImageHotkeys'); const lastSelectedItem = useAppSelector(selectLastSelectedItem); - const imageDTO = useImageDTO(lastSelectedItem?.id ?? null); + const imageDTO = useImageDTO(lastSelectedItem?.type === 'image' ? lastSelectedItem.id : null); if (!imageDTO) { return null; From 26f22a3105bbcde3827997a74ca5d3dccd7d24a4 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 19:01:15 +1000 Subject: [PATCH 41/55] fix(ui): missing tranlsation --- invokeai/frontend/web/public/locales/en.json | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 0a9fc4a2ee5..86b9fc69ba5 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1241,6 +1241,7 @@ "batchNodeCollectionSizeMismatchNoGroupId": "Batch group collection size mismatch", "batchNodeCollectionSizeMismatch": "Collection size mismatch on Batch {{batchGroupId}}", "noModelSelected": "No model selected", + "noStartingFrameImage": "No starting frame image", "noT5EncoderModelSelected": "No T5 Encoder model selected for FLUX generation", "noFLUXVAEModelSelected": "No VAE model selected for FLUX generation", "noCLIPEmbedModelSelected": "No CLIP Embed model selected for FLUX generation", From 85f6ca3bafe1bc77ee1adcd675634790dcef1ce6 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 19:01:39 +1000 Subject: [PATCH 42/55] fix(ui): iterations works for video models --- .../util/graph/buildLinearBatchConfig.ts | 20 ++--- .../features/queue/hooks/useEnqueueCanvas.ts | 13 +++- .../queue/hooks/useEnqueueGenerate.ts | 11 ++- .../queue/hooks/useEnqueueUpscaling.ts | 11 +++ .../features/queue/hooks/useEnqueueVideo.ts | 74 ++++++++++--------- .../web/src/features/queue/store/readiness.ts | 6 +- 6 files changed, 81 insertions(+), 54 deletions(-) diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts index 029d98cb68a..8662280b3e2 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts @@ -4,22 +4,22 @@ import { range } from 'es-toolkit/compat'; import type { SeedBehaviour } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; import type { ModelIdentifierField } from 'features/nodes/types/common'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; -import { API_BASE_MODELS } from 'features/parameters/types/constants'; +import { API_BASE_MODELS, VIDEO_BASE_MODELS } from 'features/parameters/types/constants'; import type { components } from 'services/api/schema'; -import type { Batch, EnqueueBatchArg, Invocation } from 'services/api/types'; +import type { AnyModelConfig, BaseModelType, Batch, EnqueueBatchArg, Invocation } from 'services/api/types'; import { assert } from 'tsafe'; const getExtendedPrompts = (arg: { seedBehaviour: SeedBehaviour; iterations: number; prompts: string[]; - model: ModelIdentifierField; + base: BaseModelType; }): string[] => { - const { seedBehaviour, iterations, prompts, model } = arg; + const { seedBehaviour, iterations, prompts, base } = arg; // Normally, the seed behaviour implicity determines the batch size. But when we use models without seeds (like // ChatGPT 4o) in conjunction with the per-prompt seed behaviour, we lose out on that implicit batch size. To rectify // this, we need to create a batch of the right size by repeating the prompts. - if (seedBehaviour === 'PER_PROMPT' || API_BASE_MODELS.includes(model.base)) { + if (seedBehaviour === 'PER_PROMPT' || API_BASE_MODELS.includes(base) || VIDEO_BASE_MODELS.includes(base)) { return range(iterations).flatMap(() => prompts); } return prompts; @@ -29,17 +29,16 @@ export const prepareLinearUIBatch = (arg: { state: RootState; g: Graph; prepend: boolean; + base: BaseModelType; positivePromptNode: Invocation<'string'>; seedNode?: Invocation<'integer'>; origin: string; destination: string; }): EnqueueBatchArg => { - const { state, g, prepend, positivePromptNode, seedNode, origin, destination } = arg; - const { iterations, model, shouldRandomizeSeed, seed } = state.params; + const { state, g, base, prepend, positivePromptNode, seedNode, origin, destination } = arg; + const { iterations, shouldRandomizeSeed, seed } = state.params; const { prompts, seedBehaviour } = state.dynamicPrompts; - assert(model, 'No model found in state when preparing batch'); - const data: Batch['data'] = []; const firstBatchDatumList: components['schemas']['BatchDatum'][] = []; const secondBatchDatumList: components['schemas']['BatchDatum'][] = []; @@ -63,6 +62,7 @@ export const prepareLinearUIBatch = (arg: { start: shouldRandomizeSeed ? undefined : seed, }); + console.log(seeds); secondBatchDatumList.push({ node_path: seedNode.id, field_name: 'value', @@ -71,7 +71,7 @@ export const prepareLinearUIBatch = (arg: { data.push(secondBatchDatumList); } - const extendedPrompts = getExtendedPrompts({ seedBehaviour, iterations, prompts, model }); + const extendedPrompts = getExtendedPrompts({ seedBehaviour, iterations, prompts, base }); // zipped batch of prompts firstBatchDatumList.push({ diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts index 99c8ba7bfc2..90cbda06b98 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueCanvas.ts @@ -38,11 +38,15 @@ const enqueueCanvas = async (store: AppStore, canvasManager: CanvasManager, prep const destination = selectCanvasDestination(state); - const buildGraphResult = await withResultAsync(async () => { - const model = state.params.model; - assert(model, 'No model found in state'); - const base = model.base; + const model = state.params.model; + if (!model) { + log.error('No model found in state'); + return; + } + const base = model.base; + + const buildGraphResult = await withResultAsync(async () => { const generationMode = await canvasManager.compositor.getGenerationMode(); const graphBuilderArg: GraphBuilderArg = { generationMode, state, manager: canvasManager }; @@ -98,6 +102,7 @@ const enqueueCanvas = async (store: AppStore, canvasManager: CanvasManager, prep prepareLinearUIBatch({ state, g, + base, prepend, seedNode: seed, positivePromptNode: positivePrompt, diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts index fd5056ed41c..a0025b9ba1d 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts @@ -36,11 +36,14 @@ const enqueueGenerate = async (store: AppStore, prepend: boolean) => { const destination = 'generate'; - const buildGraphResult = await withResultAsync(async () => { - const model = state.params.model; - assert(model, 'No model found in state'); - const base = model.base; + const model = state.params.model; + if (!model) { + log.error('No model found in state'); + return; + } + const base = model.base; + const buildGraphResult = await withResultAsync(async () => { const graphBuilderArg: GraphBuilderArg = { generationMode: 'txt2img', state, manager: null }; switch (base) { diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts index baf5a8c0c81..c92e57d7bc3 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueUpscaling.ts @@ -1,4 +1,5 @@ import { createAction } from '@reduxjs/toolkit'; +import { logger } from 'app/logging/logger'; import type { AppStore } from 'app/store/store'; import { useAppStore } from 'app/store/storeHooks'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; @@ -8,6 +9,8 @@ import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endp export const enqueueRequestedUpscaling = createAction('app/enqueueRequestedUpscaling'); +const log = logger('generation'); + const enqueueUpscaling = async (store: AppStore, prepend: boolean) => { const { dispatch, getState } = store; @@ -15,11 +18,19 @@ const enqueueUpscaling = async (store: AppStore, prepend: boolean) => { const state = getState(); + const model = state.params.model; + if (!model) { + log.error('No model found in state'); + return; + } + const base = model.base; + const { g, seed, positivePrompt } = await buildMultidiffusionUpscaleGraph(state); const batchConfig = prepareLinearUIBatch({ state, g, + base, prepend, seedNode: seed, positivePromptNode: positivePrompt, diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts index 8dee3b81935..a5445bbf6f1 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts @@ -4,7 +4,8 @@ import { logger } from 'app/logging/logger'; import type { AppStore } from 'app/store/store'; import { useAppStore } from 'app/store/storeHooks'; import { extractMessageFromAssertionError } from 'common/util/extractMessageFromAssertionError'; -import { withResultAsync } from 'common/util/result'; +import { withResult, withResultAsync } from 'common/util/result'; +import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildRunwayVideoGraph } from 'features/nodes/util/graph/generation/buildRunwayVideoGraph'; import { buildVeo3VideoGraph } from 'features/nodes/util/graph/generation/buildVeo3VideoGraph'; import { selectCanvasDestination } from 'features/nodes/util/graph/graphBuilderUtils'; @@ -17,21 +18,23 @@ import { enqueueMutationFixedCacheKeyOptions, queueApi } from 'services/api/endp import { assert, AssertionError } from 'tsafe'; const log = logger('generation'); -export const enqueueRequestedCanvas = createAction('app/enqueueRequestedCanvas'); +export const enqueueRequestedVideos = createAction('app/enqueueRequestedVideos'); const enqueueVideo = async (store: AppStore, prepend: boolean) => { const { dispatch, getState } = store; - dispatch(enqueueRequestedCanvas()); + dispatch(enqueueRequestedVideos()); const state = getState(); - const destination = selectCanvasDestination(state); + const model = state.video.videoModel; + if (!model) { + log.error('No model found in state'); + return; + } + const base = model.base; const buildGraphResult = await withResultAsync(async () => { - const model = state.video.videoModel; - assert(model, 'No model found in state'); - const base = model.base; const graphBuilderArg: GraphBuilderArg = { generationMode: 'txt2img', state, manager: null }; switch (base) { @@ -65,36 +68,37 @@ const enqueueVideo = async (store: AppStore, prepend: boolean) => { return; } - const { g } = buildGraphResult.value; + const { g, positivePrompt, seed } = buildGraphResult.value; + + const prepareBatchResult = withResult(() => + prepareLinearUIBatch({ + state, + g, + base, + prepend, + seedNode: seed, + positivePromptNode: positivePrompt, + origin: 'videos', + destination: 'gallery', + }) + ); - // const prepareBatchResult = withResult(() => - // prepareLinearUIBatch({ - // state, - // g, - // prepend, - // seedNode: seed, - // positivePromptNode: positivePrompt, - // origin: 'canvas', + if (prepareBatchResult.isErr()) { + log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch'); + return; + } + + const batchConfig = prepareBatchResult.value; + + // const batchConfig = { + // prepend, + // batch: { + // graph: g.getGraph(), + // runs: 1, + // origin, // destination, - // }) - // ); - - // if (prepareBatchResult.isErr()) { - // log.error({ error: serializeError(prepareBatchResult.error) }, 'Failed to prepare batch'); - // return; - // } - - // const batchConfig = prepareBatchResult.value; - - const batchConfig = { - prepend, - batch: { - graph: g.getGraph(), - runs: 1, - origin, - destination, - }, - }; + // }, + // }; const req = dispatch( queueApi.endpoints.enqueueBatch.initiate(batchConfig, { diff --git a/invokeai/frontend/web/src/features/queue/store/readiness.ts b/invokeai/frontend/web/src/features/queue/store/readiness.ts index f66a43253b6..ae8fa5f3182 100644 --- a/invokeai/frontend/web/src/features/queue/store/readiness.ts +++ b/invokeai/frontend/web/src/features/queue/store/readiness.ts @@ -260,7 +260,11 @@ const getReasonsWhyCannotEnqueueVideoTab = (arg: { reasons.push({ content: i18n.t('parameters.invoke.promptExpansionResultPending') }); } - if (!video.startingFrameImage?.image_name) { + if (!video.videoModel) { + reasons.push({ content: i18n.t('parameters.invoke.noModelSelected') }); + } + + if (video.videoModel?.base === 'runway' && !video.startingFrameImage?.image_name) { reasons.push({ content: i18n.t('parameters.invoke.noStartingFrameImage') }); } From ab3a87705f98491affd8045309cb999098e12f1d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 20:25:14 +1000 Subject: [PATCH 43/55] fix(ui): generate tab graph builder --- .../frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts index a0025b9ba1d..f59d305a044 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueGenerate.ts @@ -98,6 +98,7 @@ const enqueueGenerate = async (store: AppStore, prepend: boolean) => { prepareLinearUIBatch({ state, g, + base, prepend, seedNode: seed, positivePromptNode: positivePrompt, From f9536fb81e714417b5228184d6b7cc38ad5c85be Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 20:26:40 +1000 Subject: [PATCH 44/55] feat(ui): video dnd --- invokeai/frontend/web/public/locales/en.json | 4 ++ .../dnd/DndDragPreviewMultipleImage.tsx | 2 +- .../dnd/DndDragPreviewMultipleVideo.tsx | 12 ++--- .../dnd/DndDragPreviewSingleVideo.tsx | 10 +++- invokeai/frontend/web/src/features/dnd/dnd.ts | 50 +++++++++++++++++-- .../web/src/features/dnd/useDndMonitor.ts | 15 +++++- .../components/ImageGrid/GalleryVideo.tsx | 7 ++- .../web/src/features/imageActions/actions.ts | 13 +++++ 8 files changed, 97 insertions(+), 16 deletions(-) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 86b9fc69ba5..5cad21707ab 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1215,6 +1215,10 @@ "height": "Height", "imageFit": "Fit Initial Image To Output Size", "images": "Images", + "images_withCount_one": "Image", + "images_withCount_other": "Images", + "videos_withCount_one": "Video", + "videos_withCount_other": "Videos", "infillMethod": "Infill Method", "infillColorValue": "Fill Color", "info": "Info", diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx index 2633d9e5083..056ce114112 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleImage.tsx @@ -21,7 +21,7 @@ const DndDragPreviewMultipleImage = memo(({ image_names }: { image_names: string borderRadius="base" > {image_names.length} - {t('parameters.images')} + {t('parameters.images_withCount', { count: image_names.length })} ); }); diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx index 4c600e88f7a..6ccb7e48503 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewMultipleVideo.tsx @@ -8,7 +8,7 @@ import { createPortal } from 'react-dom'; import { useTranslation } from 'react-i18next'; import type { Param0 } from 'tsafe'; -const DndDragPreviewMultipleVideo = memo(({ ids }: { ids: string[] }) => { +const DndDragPreviewMultipleVideo = memo(({ video_ids }: { video_ids: string[] }) => { const { t } = useTranslation(); return ( { bg="base.900" borderRadius="base" > - {ids.length} - {t('parameters.videos')} + {video_ids.length} + {t('parameters.videos_withCount', { count: video_ids.length })} ); }); @@ -31,11 +31,11 @@ DndDragPreviewMultipleVideo.displayName = 'DndDragPreviewMultipleVideo'; export type DndDragPreviewMultipleVideoState = { type: 'multiple-video'; container: HTMLElement; - ids: string[]; + video_ids: string[]; }; export const createMultipleVideoDragPreview = (arg: DndDragPreviewMultipleVideoState) => - createPortal(, arg.container); + createPortal(, arg.container); type SetMultipleDragPreviewArg = { multipleVideoDndData: MultipleVideoDndSourceData; @@ -51,7 +51,7 @@ export const setMultipleVideoDragPreview = ({ const { nativeSetDragImage, source, location } = onGenerateDragPreviewArgs; setCustomNativeDragPreview({ render({ container }) { - setDragPreviewState({ type: 'multiple-video', container, ids: multipleVideoDndData.payload.ids }); + setDragPreviewState({ type: 'multiple-video', container, video_ids: multipleVideoDndData.payload.video_ids }); return () => setDragPreviewState(null); }, nativeSetDragImage, diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx index 4bd8c60f06c..ddf2a909c76 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx @@ -3,6 +3,7 @@ import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/el import { chakra, Flex, Text } from '@invoke-ai/ui-library'; import type { SingleVideoDndSourceData } from 'features/dnd/dnd'; import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; +import { GalleryVideoPlaceholder } from 'features/gallery/components/ImageGrid/GalleryVideo'; import { memo } from 'react'; import { createPortal } from 'react-dom'; import type { VideoDTO } from 'services/api/types'; @@ -12,14 +13,19 @@ const ChakraImg = chakra('img'); const DndDragPreviewSingleVideo = memo(({ videoDTO }: { videoDTO: VideoDTO }) => { return ( - - I AM A VIDEO + + diff --git a/invokeai/frontend/web/src/features/dnd/dnd.ts b/invokeai/frontend/web/src/features/dnd/dnd.ts index bd855429851..e092e4fb80d 100644 --- a/invokeai/frontend/web/src/features/dnd/dnd.ts +++ b/invokeai/frontend/web/src/features/dnd/dnd.ts @@ -9,9 +9,11 @@ import { selectComparisonImages } from 'features/gallery/components/ImageViewer/ import type { BoardId } from 'features/gallery/store/types'; import { addImagesToBoard, + addVideosToBoard, createNewCanvasEntityFromImage, newCanvasFromImage, removeImagesFromBoard, + removeVideosFromBoard, replaceCanvasEntityObjectsWithImage, setComparisonImage, setGlobalReferenceImage, @@ -91,7 +93,7 @@ const _multipleVideo = buildTypeAndKey('multiple-video'); export type MultipleVideoDndSourceData = DndData< typeof _multipleVideo.type, typeof _multipleVideo.key, - { ids: string[]; board_id: BoardId } + { video_ids: string[]; board_id: BoardId } >; export const multipleVideoDndSource: DndSource = { ..._multipleVideo, @@ -473,12 +475,22 @@ export type AddImageToBoardDndTargetData = DndData< >; export const addImageToBoardDndTarget: DndTarget< AddImageToBoardDndTargetData, - SingleImageDndSourceData | MultipleImageDndSourceData + SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | MultipleVideoDndSourceData > = { ..._addToBoard, typeGuard: buildTypeGuard(_addToBoard.key), getData: buildGetData(_addToBoard.key, _addToBoard.type), isValid: ({ sourceData, targetData }) => { + if (singleVideoDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none'; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } + if (multipleVideoDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.board_id; + const destinationBoard = targetData.payload.boardId; + return currentBoard !== destinationBoard; + } if (singleImageDndSource.typeGuard(sourceData)) { const currentBoard = sourceData.payload.imageDTO.board_id ?? 'none'; const destinationBoard = targetData.payload.boardId; @@ -492,6 +504,18 @@ export const addImageToBoardDndTarget: DndTarget< return false; }, handler: ({ sourceData, targetData, dispatch }) => { + if (singleVideoDndSource.typeGuard(sourceData)) { + const { videoDTO } = sourceData.payload; + const { boardId } = targetData.payload; + addVideosToBoard({ video_ids: [videoDTO.video_id], boardId, dispatch }); + } + + if (multipleVideoDndSource.typeGuard(sourceData)) { + const { video_ids } = sourceData.payload; + const { boardId } = targetData.payload; + addVideosToBoard({ video_ids, boardId, dispatch }); + } + if (singleImageDndSource.typeGuard(sourceData)) { const { imageDTO } = sourceData.payload; const { boardId } = targetData.payload; @@ -517,7 +541,7 @@ export type RemoveImageFromBoardDndTargetData = DndData< >; export const removeImageFromBoardDndTarget: DndTarget< RemoveImageFromBoardDndTargetData, - SingleImageDndSourceData | MultipleImageDndSourceData + SingleImageDndSourceData | MultipleImageDndSourceData | SingleVideoDndSourceData | MultipleVideoDndSourceData > = { ..._removeFromBoard, typeGuard: buildTypeGuard(_removeFromBoard.key), @@ -533,6 +557,16 @@ export const removeImageFromBoardDndTarget: DndTarget< return currentBoard !== 'none'; } + if (singleVideoDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.videoDTO.board_id ?? 'none'; + return currentBoard !== 'none'; + } + + if (multipleVideoDndSource.typeGuard(sourceData)) { + const currentBoard = sourceData.payload.board_id; + return currentBoard !== 'none'; + } + return false; }, handler: ({ sourceData, dispatch }) => { @@ -545,6 +579,16 @@ export const removeImageFromBoardDndTarget: DndTarget< const { image_names } = sourceData.payload; removeImagesFromBoard({ image_names, dispatch }); } + + if (singleVideoDndSource.typeGuard(sourceData)) { + const { videoDTO } = sourceData.payload; + removeVideosFromBoard({ video_ids: [videoDTO.video_id], dispatch }); + } + + if (multipleVideoDndSource.typeGuard(sourceData)) { + const { video_ids } = sourceData.payload; + removeVideosFromBoard({ video_ids, dispatch }); + } }, }; diff --git a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts index 24d6bea1680..8d2aeb30e74 100644 --- a/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts +++ b/invokeai/frontend/web/src/features/dnd/useDndMonitor.ts @@ -4,7 +4,13 @@ import { logger } from 'app/logging/logger'; import { getStore } from 'app/store/nanostores/store'; import { useAssertSingleton } from 'common/hooks/useAssertSingleton'; import { parseify } from 'common/util/serialize'; -import { dndTargets, multipleImageDndSource, singleImageDndSource } from 'features/dnd/dnd'; +import { + dndTargets, + multipleImageDndSource, + multipleVideoDndSource, + singleImageDndSource, + singleVideoDndSource, +} from 'features/dnd/dnd'; import { useEffect } from 'react'; const log = logger('dnd'); @@ -19,7 +25,12 @@ export const useDndMonitor = () => { const sourceData = source.data; // Check for allowed sources - if (!singleImageDndSource.typeGuard(sourceData) && !multipleImageDndSource.typeGuard(sourceData)) { + if ( + !singleImageDndSource.typeGuard(sourceData) && + !multipleImageDndSource.typeGuard(sourceData) && + !singleVideoDndSource.typeGuard(sourceData) && + !multipleVideoDndSource.typeGuard(sourceData) + ) { return false; } diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx index c4ba374ad1b..ade4d8b5ed5 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -112,7 +112,7 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { // multi-image drag. if (selection.length > 1 && selection.some((s) => s.id === videoDTO.video_id)) { return multipleVideoDndSource.getData({ - ids: selection.map((s) => s.id), + video_ids: selection.map((s) => s.id), board_id: boardId, }); } // Otherwise, initiate a single-image drag @@ -149,7 +149,10 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { onDragStart: ({ source }) => { // When we start dragging multiple images, set the dragging state to true if the dragged image is part of the // selection. This is called for all drag events. - if (multipleVideoDndSource.typeGuard(source.data) && source.data.payload.ids.includes(videoDTO.video_id)) { + if ( + multipleVideoDndSource.typeGuard(source.data) && + source.data.payload.video_ids.includes(videoDTO.video_id) + ) { setIsDragging(true); } }, diff --git a/invokeai/frontend/web/src/features/imageActions/actions.ts b/invokeai/frontend/web/src/features/imageActions/actions.ts index c53d6dab23b..c27f415da6d 100644 --- a/invokeai/frontend/web/src/features/imageActions/actions.ts +++ b/invokeai/frontend/web/src/features/imageActions/actions.ts @@ -37,6 +37,7 @@ import { getOptimalDimension } from 'features/parameters/util/optimalDimension'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { WORKSPACE_PANEL_ID } from 'features/ui/layouts/shared'; import { imageDTOToFile, imagesApi, uploadImage } from 'services/api/endpoints/images'; +import { videosApi } from 'services/api/endpoints/videos'; import type { ImageDTO } from 'services/api/types'; import type { Equals } from 'tsafe'; import { assert } from 'tsafe'; @@ -318,3 +319,15 @@ export const removeImagesFromBoard = (arg: { image_names: string[]; dispatch: Ap dispatch(imagesApi.endpoints.removeImagesFromBoard.initiate({ image_names }, { track: false })); dispatch(selectionChanged([])); }; + +export const addVideosToBoard = (arg: { video_ids: string[]; boardId: BoardId; dispatch: AppDispatch }) => { + const { video_ids, boardId, dispatch } = arg; + dispatch(videosApi.endpoints.addVideosToBoard.initiate({ video_ids, board_id: boardId }, { track: false })); + dispatch(selectionChanged([])); +}; + +export const removeVideosFromBoard = (arg: { video_ids: string[]; dispatch: AppDispatch }) => { + const { video_ids, dispatch } = arg; + dispatch(videosApi.endpoints.removeVideosFromBoard.initiate({ video_ids }, { track: false })); + dispatch(selectionChanged([])); +}; From 34fc50f4a577bfc6f95c601db657b06ef4ab6e1d Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 20:33:33 +1000 Subject: [PATCH 45/55] chore(ui): dpdm --- .../src/features/dnd/DndDragPreviewSingleVideo.tsx | 2 +- .../gallery/components/ImageGrid/GalleryVideo.tsx | 13 ++----------- .../ImageGrid/GalleryVideoPlaceholder.tsx | 11 +++++++++++ .../features/gallery/components/VideoGallery.tsx | 3 ++- .../features/gallery/components/scrollIntoView.tsx | 3 ++- 5 files changed, 18 insertions(+), 14 deletions(-) create mode 100644 invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx index ddf2a909c76..5efef5cbd69 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx @@ -3,7 +3,7 @@ import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/el import { chakra, Flex, Text } from '@invoke-ai/ui-library'; import type { SingleVideoDndSourceData } from 'features/dnd/dnd'; import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; -import { GalleryVideoPlaceholder } from 'features/gallery/components/ImageGrid/GalleryVideo'; +import { GalleryVideoPlaceholder } from 'features/gallery/components/ImageGrid/GalleryVideoPlaceholder'; import { memo } from 'react'; import { createPortal } from 'react-dom'; import type { VideoDTO } from 'services/api/types'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx index ade4d8b5ed5..fe249b6019e 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideo.tsx @@ -1,7 +1,6 @@ import { combine } from '@atlaskit/pragmatic-drag-and-drop/combine'; import { draggable, monitorForElements } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; -import type { FlexProps } from '@invoke-ai/ui-library'; -import { Flex, Icon, Image } from '@invoke-ai/ui-library'; +import { Flex, Image } from '@invoke-ai/ui-library'; import { createSelector } from '@reduxjs/toolkit'; import type { AppDispatch, AppGetState } from 'app/store/store'; import { useAppSelector, useAppStore } from 'app/store/storeHooks'; @@ -23,12 +22,12 @@ import { navigationApi } from 'features/ui/layouts/navigation-api'; import { VIEWER_PANEL_ID } from 'features/ui/layouts/shared'; import type { MouseEvent, MouseEventHandler } from 'react'; import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react'; -import { PiVideoBold } from 'react-icons/pi'; import { videosApi } from 'services/api/endpoints/videos'; import type { VideoDTO } from 'services/api/types'; import { galleryItemContainerSX } from './galleryItemContainerSX'; import { GalleryItemHoverIcons } from './GalleryItemHoverIcons'; +import { GalleryVideoPlaceholder } from './GalleryVideoPlaceholder'; interface Props { videoDTO: VideoDTO; @@ -217,11 +216,3 @@ export const GalleryVideo = memo(({ videoDTO }: Props) => { }); GalleryVideo.displayName = 'GalleryVideo'; - -export const GalleryVideoPlaceholder = memo((props: FlexProps) => ( - - - -)); - -GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx new file mode 100644 index 00000000000..6e53eb6d236 --- /dev/null +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx @@ -0,0 +1,11 @@ +import { type FlexProps, Flex, Icon } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { PiVideoBold } from 'react-icons/pi'; + +export const GalleryVideoPlaceholder = memo((props: FlexProps) => ( + + + +)); + +GalleryVideoPlaceholder.displayName = 'GalleryVideoPlaceholder'; diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index a11bb19c712..a0d9f5d2e3f 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -26,7 +26,8 @@ import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; import { getItemIndex } from './getItemIndex'; import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; -import { GalleryVideo, GalleryVideoPlaceholder } from './ImageGrid/GalleryVideo'; +import { GalleryVideo } from './ImageGrid/GalleryVideo'; +import { GalleryVideoPlaceholder } from './ImageGrid/GalleryVideoPlaceholder'; import { scrollIntoView } from './scrollIntoView'; import { useGalleryVideoIds } from './use-gallery-video-ids'; import { useScrollableGallery } from './useScrollableGallery'; diff --git a/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx index 7448c3703ec..ca34427a575 100644 --- a/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/scrollIntoView.tsx @@ -1,6 +1,7 @@ +import { logger } from 'app/logging/logger'; import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso'; -import { log } from './VideoGallery'; +const log = logger('gallery'); /** * Scroll the item at the given index into view if it is not currently visible. From 0acb9c8bd31169c1e7f7600803bc16338998103a Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 20:34:08 +1000 Subject: [PATCH 46/55] chore(ui): lint --- .../web/src/features/dnd/DndDragPreviewSingleVideo.tsx | 2 +- .../gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx | 2 +- .../web/src/features/gallery/components/NewGallery.tsx | 2 -- .../subpanels/ModelManagerPanel/ModelBaseBadge.tsx | 2 +- .../src/features/nodes/util/graph/buildLinearBatchConfig.ts | 5 +---- .../src/features/parameters/components/Prompts/Prompts.tsx | 2 +- .../frontend/web/src/features/queue/hooks/useEnqueueVideo.ts | 1 - .../web/src/features/video/components/VideoPlayer.tsx | 2 +- 8 files changed, 6 insertions(+), 12 deletions(-) diff --git a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx index 5efef5cbd69..0fe0fcec752 100644 --- a/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx +++ b/invokeai/frontend/web/src/features/dnd/DndDragPreviewSingleVideo.tsx @@ -1,6 +1,6 @@ import type { draggable } from '@atlaskit/pragmatic-drag-and-drop/element/adapter'; import { setCustomNativeDragPreview } from '@atlaskit/pragmatic-drag-and-drop/element/set-custom-native-drag-preview'; -import { chakra, Flex, Text } from '@invoke-ai/ui-library'; +import { chakra, Flex } from '@invoke-ai/ui-library'; import type { SingleVideoDndSourceData } from 'features/dnd/dnd'; import { DND_IMAGE_DRAG_PREVIEW_SIZE, preserveOffsetOnSourceFallbackCentered } from 'features/dnd/util'; import { GalleryVideoPlaceholder } from 'features/gallery/components/ImageGrid/GalleryVideoPlaceholder'; diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx index 6e53eb6d236..cca0f0aa51a 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageGrid/GalleryVideoPlaceholder.tsx @@ -1,4 +1,4 @@ -import { type FlexProps, Flex, Icon } from '@invoke-ai/ui-library'; +import { Flex, type FlexProps, Icon } from '@invoke-ai/ui-library'; import { memo } from 'react'; import { PiVideoBold } from 'react-icons/pi'; diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index b19e6b90b40..ea2cbebe756 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -25,14 +25,12 @@ import type { } from 'react-virtuoso'; import { VirtuosoGrid } from 'react-virtuoso'; import { imagesApi, useImageDTO, useStarImagesMutation, useUnstarImagesMutation } from 'services/api/endpoints/images'; -import { videosApi } from 'services/api/endpoints/videos'; import { useDebounce } from 'use-debounce'; import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; import { getItemIndex } from './getItemIndex'; import { GalleryImage, GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; -import { GalleryVideo } from './ImageGrid/GalleryVideo'; import { scrollIntoView } from './scrollIntoView'; import { useGalleryImageNames } from './use-gallery-image-names'; import { useScrollableGallery } from './useScrollableGallery'; diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx index bcf1c0e51cf..320d6128663 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx @@ -21,7 +21,7 @@ export const BASE_COLOR_MAP: Record = { 'chatgpt-4o': 'pink', 'flux-kontext': 'pink', veo3: 'white', - runway: 'white' + runway: 'white', }; const ModelBaseBadge = ({ base }: Props) => { diff --git a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts index 8662280b3e2..1b393d3a672 100644 --- a/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts +++ b/invokeai/frontend/web/src/features/nodes/util/graph/buildLinearBatchConfig.ts @@ -2,12 +2,10 @@ import type { RootState } from 'app/store/store'; import { generateSeeds } from 'common/util/generateSeeds'; import { range } from 'es-toolkit/compat'; import type { SeedBehaviour } from 'features/dynamicPrompts/store/dynamicPromptsSlice'; -import type { ModelIdentifierField } from 'features/nodes/types/common'; import type { Graph } from 'features/nodes/util/graph/generation/Graph'; import { API_BASE_MODELS, VIDEO_BASE_MODELS } from 'features/parameters/types/constants'; import type { components } from 'services/api/schema'; -import type { AnyModelConfig, BaseModelType, Batch, EnqueueBatchArg, Invocation } from 'services/api/types'; -import { assert } from 'tsafe'; +import type { BaseModelType, Batch, EnqueueBatchArg, Invocation } from 'services/api/types'; const getExtendedPrompts = (arg: { seedBehaviour: SeedBehaviour; @@ -62,7 +60,6 @@ export const prepareLinearUIBatch = (arg: { start: shouldRandomizeSeed ? undefined : seed, }); - console.log(seeds); secondBatchDatumList.push({ node_path: seedNode.id, field_name: 'value', diff --git a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx index 856ce00b0d6..21c8813769a 100644 --- a/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx +++ b/invokeai/frontend/web/src/features/parameters/components/Prompts/Prompts.tsx @@ -11,7 +11,7 @@ export const Prompts = memo(() => { const modelSupportsNegativePrompt = useAppSelector(selectModelSupportsNegativePrompt); const hasNegativePrompt = useAppSelector(selectHasNegativePrompt); const activeTab = useAppSelector(selectActiveTab); - + return ( diff --git a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts index a5445bbf6f1..d8d0a1ecf79 100644 --- a/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts +++ b/invokeai/frontend/web/src/features/queue/hooks/useEnqueueVideo.ts @@ -8,7 +8,6 @@ import { withResult, withResultAsync } from 'common/util/result'; import { prepareLinearUIBatch } from 'features/nodes/util/graph/buildLinearBatchConfig'; import { buildRunwayVideoGraph } from 'features/nodes/util/graph/generation/buildRunwayVideoGraph'; import { buildVeo3VideoGraph } from 'features/nodes/util/graph/generation/buildVeo3VideoGraph'; -import { selectCanvasDestination } from 'features/nodes/util/graph/graphBuilderUtils'; import type { GraphBuilderArg } from 'features/nodes/util/graph/types'; import { UnsupportedGenerationModeError } from 'features/nodes/util/graph/types'; import { toast } from 'features/toast/toast'; diff --git a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx index dec8a42f625..0b4a8b874aa 100644 --- a/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx +++ b/invokeai/frontend/web/src/features/video/components/VideoPlayer.tsx @@ -1,4 +1,4 @@ -import { Box, chakra, Flex, Text } from '@invoke-ai/ui-library'; +import { Flex, Text } from '@invoke-ai/ui-library'; import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { useFocusRegion } from 'common/hooks/focus'; From aa8af79fad8d1167b3eb8f3887d324ef90680510 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:09:35 +1000 Subject: [PATCH 47/55] fix(ui): locate in gallery, galleryview when selecting image/video --- .../listeners/boardIdSelected.ts | 4 +-- .../ContextMenuItemLocateInGalery.tsx | 27 ++++++++++++++++--- .../ImageViewer/CurrentImageButtons.tsx | 11 +++++++- .../features/gallery/store/gallerySlice.ts | 19 +++++++++---- .../services/events/onInvocationComplete.tsx | 10 +++++-- 5 files changed, 57 insertions(+), 14 deletions(-) diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts index 7dc33be0ae3..feaea59a95c 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/boardIdSelected.ts @@ -17,8 +17,8 @@ export const addBoardIdSelectedListener = (startAppListening: AppStartListening) // Cancel any in-progress instances of this listener, we don't want to select an image from a previous board cancelActiveListeners(); - if (boardIdSelected.match(action) && action.payload.selectedImageName) { - // This action already has a selected image name, we trust it is valid + if (boardIdSelected.match(action) && action.payload.select) { + // This action already has a resource selection - skip the below auto-selection logic return; } diff --git a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx index d9157604d2b..8055e592cfe 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ContextMenu/MenuItems/ContextMenuItemLocateInGalery.tsx @@ -2,6 +2,7 @@ import { MenuItem } from '@invoke-ai/ui-library'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { useItemDTOContext } from 'features/gallery/contexts/ItemDTOContext'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { navigationApi } from 'features/ui/layouts/navigation-api'; import { useGalleryPanel } from 'features/ui/layouts/use-gallery-panel'; import { selectActiveTab } from 'features/ui/store/uiSelectors'; @@ -23,14 +24,32 @@ export const ContextMenuItemLocateInGalery = memo(() => { }, [itemDTO]); const onClick = useCallback(() => { + navigationApi.expandRightPanel(); + galleryPanel.expand(); if (isImageDTO(itemDTO)) { - navigationApi.expandRightPanel(); - galleryPanel.expand(); flushSync(() => { - dispatch(boardIdSelected({ boardId: itemDTO.board_id ?? 'none', selectedImageName: itemDTO.image_name })); + dispatch( + boardIdSelected({ + boardId: itemDTO.board_id ?? 'none', + select: { + selection: [{ type: 'image', id: itemDTO.image_name }], + galleryView: IMAGE_CATEGORIES.includes(itemDTO.image_category) ? 'images' : 'assets', + }, + }) + ); }); } else { - // TODO: Implement video locate in gallery + flushSync(() => { + dispatch( + boardIdSelected({ + boardId: itemDTO.board_id ?? 'none', + select: { + selection: [{ type: 'video', id: itemDTO.video_id }], + galleryView: 'videos', + }, + }) + ); + }); } }, [dispatch, galleryPanel, itemDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx index 19eb7dd7cbb..e9c54f2a3f8 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageViewer/CurrentImageButtons.tsx @@ -11,6 +11,7 @@ import { useRecallPrompts } from 'features/gallery/hooks/useRecallPrompts'; import { useRecallRemix } from 'features/gallery/hooks/useRecallRemix'; import { useRecallSeed } from 'features/gallery/hooks/useRecallSeed'; import { boardIdSelected } from 'features/gallery/store/gallerySlice'; +import { IMAGE_CATEGORIES } from 'features/gallery/store/types'; import { PostProcessingPopover } from 'features/parameters/components/PostProcessing/PostProcessingPopover'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; import { navigationApi } from 'features/ui/layouts/navigation-api'; @@ -47,7 +48,15 @@ export const CurrentImageButtons = memo(({ imageDTO }: { imageDTO: ImageDTO }) = navigationApi.expandRightPanel(); galleryPanel.expand(); flushSync(() => { - dispatch(boardIdSelected({ boardId: imageDTO.board_id ?? 'none', selectedImageName: imageDTO.image_name })); + dispatch( + boardIdSelected({ + boardId: imageDTO.board_id ?? 'none', + select: { + selection: [{ type: 'image', id: imageDTO.image_name }], + galleryView: IMAGE_CATEGORIES.includes(imageDTO.image_category) ? 'images' : 'assets', + }, + }) + ); }); }, [dispatch, galleryPanel, imageDTO]); diff --git a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts index 6d4d67d1cbc..1f99fc7a6d3 100644 --- a/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts +++ b/invokeai/frontend/web/src/features/gallery/store/gallerySlice.ts @@ -86,12 +86,21 @@ const slice = createSlice({ autoAssignBoardOnClickChanged: (state, action: PayloadAction) => { state.autoAssignBoardOnClick = action.payload; }, - boardIdSelected: (state, action: PayloadAction<{ boardId: BoardId; selectedImageName?: string }>) => { - const { boardId, selectedImageName } = action.payload; + boardIdSelected: ( + state, + action: PayloadAction<{ + boardId: BoardId; + select?: { + selection: GalleryState['selection']; + galleryView: GalleryState['galleryView']; + }; + }> + ) => { + const { boardId, select } = action.payload; state.selectedBoardId = boardId; - state.galleryView = 'images'; - if (selectedImageName) { - state.selection = [{ type: 'image', id: selectedImageName }]; + if (select) { + state.selection = select.selection; + state.galleryView = select.galleryView; } }, autoAddBoardIdChanged: (state, action: PayloadAction) => { diff --git a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx index b63198f1d4a..57c8ab7b7b1 100644 --- a/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx +++ b/invokeai/frontend/web/src/services/events/onInvocationComplete.tsx @@ -171,7 +171,10 @@ export const buildOnInvocationComplete = ( dispatch( boardIdSelected({ boardId: board_id, - selectedImageName: image_name, + select: { + selection: [{ type: 'image', id: image_name }], + galleryView: 'images', + }, }) ); } else { @@ -320,7 +323,10 @@ export const buildOnInvocationComplete = ( dispatch( boardIdSelected({ boardId: board_id, - selectedImageName: video_id, + select: { + selection: [{ type: 'video', id: video_id }], + galleryView: 'videos', + }, }) ); } else { From 60755e8258d99e77f2a91318f6fc24679f1ea3f1 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:19:16 +1000 Subject: [PATCH 48/55] fix(ui): use correct placeholder for vidoes --- .../web/src/features/gallery/components/VideoGallery.tsx | 3 +-- 1 file changed, 1 insertion(+), 2 deletions(-) diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index a0d9f5d2e3f..508afe14060 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -24,7 +24,6 @@ import { useDebounce } from 'use-debounce'; import { getItemsPerRow } from '../../../../../../../getItemsPerRow'; import { getItemIndex } from './getItemIndex'; -import { GalleryImagePlaceholder } from './ImageGrid/GalleryImage'; import { GallerySelectionCountTag } from './ImageGrid/GallerySelectionCountTag'; import { GalleryVideo } from './ImageGrid/GalleryVideo'; import { GalleryVideoPlaceholder } from './ImageGrid/GalleryVideoPlaceholder'; @@ -59,7 +58,7 @@ const VideoAtPosition = memo(({ videoId }: { index: number; videoId: string }) = videosApi.endpoints.getVideoDTO.useQuerySubscription(videoId, { skip: isUninitialized }); if (!videoDTO) { - return ; + return ; } return ; From 93833c0c502290b45db20d46c5c90b153f120a77 Mon Sep 17 00:00:00 2001 From: psychedelicious <4822129+psychedelicious@users.noreply.github.com> Date: Fri, 22 Aug 2025 21:19:39 +1000 Subject: [PATCH 49/55] docs(ui): add note about visual jank in gallery --- .../web/src/features/gallery/components/NewGallery.tsx | 6 ++++++ .../web/src/features/gallery/components/VideoGallery.tsx | 5 +++++ 2 files changed, 11 insertions(+) diff --git a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx index ea2cbebe756..c2fa6e5eaf4 100644 --- a/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/NewGallery.tsx @@ -53,6 +53,12 @@ const ImageAtPosition = memo(({ imageName }: { index: number; imageName: string * - https://github.com/reduxjs/redux-toolkit/discussions/4213 * * This essentially means "subscribe to the query once it has some data". + * + * One issue with this approach. When an item DTO is already cached - for example, because it is selected and + * rendered in the viewer - it will show up in the grid before the other items have loaded. This is most + * noticeable when first loading a board. The first item in the board is selected and rendered immediately in + * the viewer, caching the DTO. The gallery grid renders, and that first item displays as a thumbnail while the + * others are still placeholders. After a moment, the rest of the items load up and display as thumbnails. */ // Use `currentData` instead of `data` to prevent a flash of previous image rendered at this index diff --git a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx index 508afe14060..26bd003fc81 100644 --- a/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/VideoGallery.tsx @@ -51,6 +51,11 @@ const VideoAtPosition = memo(({ videoId }: { index: number; videoId: string }) = * - https://github.com/reduxjs/redux-toolkit/discussions/4213 * * This essentially means "subscribe to the query once it has some data". + * One issue with this approach. When an item DTO is already cached - for example, because it is selected and + * rendered in the viewer - it will show up in the grid before the other items have loaded. This is most + * noticeable when first loading a board. The first item in the board is selected and rendered immediately in + * the viewer, caching the DTO. The gallery grid renders, and that first item displays as a thumbnail while the + * others are still placeholders. After a moment, the rest of the items load up and display as thumbnails. */ // Use `currentData` instead of `data` to prevent a flash of previous image rendered at this index From df8d2a75af703a01c9b85b5203e79bc6bf016b1a Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 22 Aug 2025 09:23:12 -0400 Subject: [PATCH 50/55] add Video as new model type --- invokeai/backend/model_manager/config.py | 22 ++++++++++++++++++++++ invokeai/backend/model_manager/taxonomy.py | 1 + 2 files changed, 23 insertions(+) diff --git a/invokeai/backend/model_manager/config.py b/invokeai/backend/model_manager/config.py index c1e4d4435b6..36e633caa89 100644 --- a/invokeai/backend/model_manager/config.py +++ b/invokeai/backend/model_manager/config.py @@ -491,6 +491,13 @@ class MainConfigBase(ABC, BaseModel): ) variant: AnyVariant = ModelVariantType.Normal +class VideoConfigBase(ABC, BaseModel): + type: Literal[ModelType.Video] = ModelType.Video + trigger_phrases: Optional[set[str]] = Field(description="Set of trigger phrases for this model", default=None) + default_settings: Optional[MainModelDefaultSettings] = Field( + description="Default settings for this model", default=None + ) + variant: AnyVariant = ModelVariantType.Normal class MainCheckpointConfig(CheckpointConfigBase, MainConfigBase, LegacyProbeMixin, ModelConfigBase): """Model config for main checkpoint models.""" @@ -648,6 +655,20 @@ def matches(cls, mod: ModelOnDisk) -> bool: def parse(cls, mod: ModelOnDisk) -> dict[str, Any]: raise NotImplementedError("API models are not parsed from disk.") +class VideoApiModelConfig(VideoConfigBase, ModelConfigBase): + """Model config for API-based video models.""" + + format: Literal[ModelFormat.Api] = ModelFormat.Api + + @classmethod + def matches(cls, mod: ModelOnDisk) -> bool: + # API models are not stored on disk, so we can't match them. + return False + + @classmethod + def parse(cls, mod: ModelOnDisk) -> dict[str, Any]: + raise NotImplementedError("API models are not parsed from disk.") + def get_model_discriminator_value(v: Any) -> str: """ @@ -718,6 +739,7 @@ def get_model_discriminator_value(v: Any) -> str: Annotated[FluxReduxConfig, FluxReduxConfig.get_tag()], Annotated[LlavaOnevisionConfig, LlavaOnevisionConfig.get_tag()], Annotated[ApiModelConfig, ApiModelConfig.get_tag()], + Annotated[VideoApiModelConfig, VideoApiModelConfig.get_tag()], ], Discriminator(get_model_discriminator_value), ] diff --git a/invokeai/backend/model_manager/taxonomy.py b/invokeai/backend/model_manager/taxonomy.py index 920fe3b3930..3f9c0cbbbd5 100644 --- a/invokeai/backend/model_manager/taxonomy.py +++ b/invokeai/backend/model_manager/taxonomy.py @@ -53,6 +53,7 @@ class ModelType(str, Enum): SigLIP = "siglip" FluxRedux = "flux_redux" LlavaOnevision = "llava_onevision" + Video = "video" class SubModelType(str, Enum): From b43c4cf5b07788480c9fcda10ebb2f5cebed7df8 Mon Sep 17 00:00:00 2001 From: Mary Hipp Date: Fri, 22 Aug 2025 09:42:33 -0400 Subject: [PATCH 51/55] add UI support for new model type Video --- .../ModelManagerPanel/ModelBaseBadge.tsx | 4 +- .../web/src/features/nodes/types/common.ts | 3 +- .../features/parameters/store/videoSlice.ts | 1 + .../VideoModelPicker.tsx | 13 ++- .../src/services/api/hooks/modelsByType.ts | 3 +- .../api/hooks/useSelectedModelConfig.ts | 9 ++ .../frontend/web/src/services/api/schema.ts | 109 ++++++++++++++++-- .../frontend/web/src/services/api/types.ts | 14 ++- 8 files changed, 132 insertions(+), 24 deletions(-) diff --git a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx index 320d6128663..2a0e8a62098 100644 --- a/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx +++ b/invokeai/frontend/web/src/features/modelManagerV2/subpanels/ModelManagerPanel/ModelBaseBadge.tsx @@ -20,8 +20,8 @@ export const BASE_COLOR_MAP: Record = { imagen4: 'pink', 'chatgpt-4o': 'pink', 'flux-kontext': 'pink', - veo3: 'white', - runway: 'white', + veo3: 'purple', + runway: 'green', }; const ModelBaseBadge = ({ base }: Props) => { diff --git a/invokeai/frontend/web/src/features/nodes/types/common.ts b/invokeai/frontend/web/src/features/nodes/types/common.ts index 231e562239c..5ada2797c9f 100644 --- a/invokeai/frontend/web/src/features/nodes/types/common.ts +++ b/invokeai/frontend/web/src/features/nodes/types/common.ts @@ -101,8 +101,6 @@ export const zMainModelBase = z.enum([ 'imagen4', 'chatgpt-4o', 'flux-kontext', - 'veo3', - 'runway', ]); type MainModelBase = z.infer; export const isMainModelBase = (base: unknown): base is MainModelBase => zMainModelBase.safeParse(base).success; @@ -123,6 +121,7 @@ export const zModelType = z.enum([ 'clip_embed', 'siglip', 'flux_redux', + 'video', ]); const zSubModelType = z.enum([ 'unet', diff --git a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts index 85fcfed47ab..d62363d1d0b 100644 --- a/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts +++ b/invokeai/frontend/web/src/features/parameters/store/videoSlice.ts @@ -93,6 +93,7 @@ const createVideoSelector = (selector: Selector) => createSele export const selectStartingFrameImage = createVideoSelector((video) => video.startingFrameImage); export const selectGeneratedVideo = createVideoSelector((video) => video.generatedVideo); export const selectVideoModel = createVideoSelector((video) => video.videoModel); +export const selectVideoModelKey = createVideoSelector((video) => video.videoModel?.key); export const selectVideoResolution = createVideoSelector((video) => video.videoResolution); export const selectVideoDuration = createVideoSelector((video) => video.videoDuration); diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx index 339a60f5b47..1b8a1f68b74 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx @@ -1,20 +1,21 @@ import { Flex, FormLabel } from '@invoke-ai/ui-library'; -import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { useAppDispatch } from 'app/store/storeHooks'; import { InformationalPopover } from 'common/components/InformationalPopover/InformationalPopover'; import { ModelPicker } from 'features/parameters/components/ModelPicker'; -import { selectVideoModel, videoModelChanged } from 'features/parameters/store/videoSlice'; +import { videoModelChanged } from 'features/parameters/store/videoSlice'; import { memo, useCallback } from 'react'; import { useTranslation } from 'react-i18next'; import { useVideoModels } from 'services/api/hooks/modelsByType'; -import type { RunwayModelConfig, Veo3ModelConfig } from 'services/api/types'; +import { useSelectedVideoModelConfig } from 'services/api/hooks/useSelectedModelConfig'; +import type { VideoApiModelConfig } from 'services/api/types'; export const VideoModelPicker = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const [modelConfigs] = useVideoModels(); - const selectedModelConfig = useAppSelector(selectVideoModel); + const selectedModelConfig = useSelectedVideoModelConfig(); const onChange = useCallback( - (modelConfig: Veo3ModelConfig | RunwayModelConfig) => { + (modelConfig: VideoApiModelConfig) => { dispatch(videoModelChanged(modelConfig)); }, [dispatch] @@ -28,7 +29,7 @@ export const VideoModelPicker = memo(() => { diff --git a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts index 9fcaa13b919..9d1bdd712cf 100644 --- a/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts +++ b/invokeai/frontend/web/src/services/api/hooks/modelsByType.ts @@ -39,6 +39,7 @@ import { isTIModelConfig, isVAEModelConfig, isVeo3ModelConfig, + isVideoModelConfig, } from 'services/api/types'; type ModelHookArgs = { excludeSubmodels?: boolean }; @@ -106,7 +107,7 @@ export const useChatGPT4oModels = buildModelsHook(isChatGPT4oModelConfig); export const useFluxKontextModels = buildModelsHook(isFluxKontextApiModelConfig); export const useVeo3Models = buildModelsHook(isVeo3ModelConfig); export const useRunwayModels = buildModelsHook(isRunwayModelConfig); -export const useVideoModels = buildModelsHook((config) => isVeo3ModelConfig(config) || isRunwayModelConfig(config)); +export const useVideoModels = buildModelsHook(isVideoModelConfig); const buildModelsSelector = (typeGuard: (config: AnyModelConfig) => config is T): Selector => diff --git a/invokeai/frontend/web/src/services/api/hooks/useSelectedModelConfig.ts b/invokeai/frontend/web/src/services/api/hooks/useSelectedModelConfig.ts index adf197b74a9..5e1f15e7900 100644 --- a/invokeai/frontend/web/src/services/api/hooks/useSelectedModelConfig.ts +++ b/invokeai/frontend/web/src/services/api/hooks/useSelectedModelConfig.ts @@ -1,7 +1,9 @@ import { skipToken } from '@reduxjs/toolkit/query'; import { useAppSelector } from 'app/store/storeHooks'; import { selectModelKey } from 'features/controlLayers/store/paramsSlice'; +import { selectVideoModelKey } from 'features/parameters/store/videoSlice'; import { useGetModelConfigQuery } from 'services/api/endpoints/models'; +import type { VideoApiModelConfig } from 'services/api/types'; export const useSelectedModelConfig = () => { const key = useAppSelector(selectModelKey); @@ -9,3 +11,10 @@ export const useSelectedModelConfig = () => { return modelConfig; }; + +export const useSelectedVideoModelConfig = () => { + const key = useAppSelector(selectVideoModelKey); + const { data: modelConfig } = useGetModelConfigQuery(key ?? skipToken); + + return modelConfig as VideoApiModelConfig | undefined; +}; diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 55f05d4ca96..091b99047b4 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -17135,7 +17135,7 @@ export type components = { * Config Out * @description After successful installation, this will hold the configuration object. */ - config_out?: (components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"]) | null; + config_out?: (components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"]) | null; /** * Inplace * @description Leave model in its current location; otherwise install under models directory @@ -17221,7 +17221,7 @@ export type components = { * Config * @description The model's config */ - config: components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"]; + config: components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"]; /** * @description The submodel type, if any * @default null @@ -17242,7 +17242,7 @@ export type components = { * Config * @description The model's config */ - config: components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"]; + config: components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"]; /** * @description The submodel type, if any * @default null @@ -17398,7 +17398,7 @@ export type components = { * @description Model type. * @enum {string} */ - ModelType: "onnx" | "main" | "vae" | "lora" | "control_lora" | "controlnet" | "embedding" | "ip_adapter" | "clip_vision" | "clip_embed" | "t2i_adapter" | "t5_encoder" | "spandrel_image_to_image" | "siglip" | "flux_redux" | "llava_onevision"; + ModelType: "onnx" | "main" | "vae" | "lora" | "control_lora" | "controlnet" | "embedding" | "ip_adapter" | "clip_vision" | "clip_embed" | "t2i_adapter" | "t5_encoder" | "spandrel_image_to_image" | "siglip" | "flux_redux" | "llava_onevision" | "video"; /** * ModelVariantType * @description Variant type. @@ -17411,7 +17411,7 @@ export type components = { */ ModelsList: { /** Models */ - models: (components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"])[]; + models: (components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"])[]; }; /** * Multiply Integers @@ -22206,6 +22206,97 @@ export type components = { */ output_fields: components["schemas"]["FieldIdentifier"][]; }; + /** + * VideoApiModelConfig + * @description Model config for API-based video models. + */ + VideoApiModelConfig: { + /** + * Key + * @description A unique key for this model. + */ + key: string; + /** + * Hash + * @description The hash of the model file(s). + */ + hash: string; + /** + * Path + * @description Path to the model on the filesystem. Relative paths are relative to the Invoke root directory. + */ + path: string; + /** + * File Size + * @description The size of the model in bytes. + */ + file_size: number; + /** + * Name + * @description Name of the model. + */ + name: string; + /** + * Type + * @default video + * @constant + */ + type: "video"; + /** + * Format + * @default api + * @constant + */ + format: "api"; + /** @description The base model. */ + base: components["schemas"]["BaseModelType"]; + /** + * Source + * @description The original source of the model (path, URL or repo_id). + */ + source: string; + /** @description The type of source */ + source_type: components["schemas"]["ModelSourceType"]; + /** + * Description + * @description Model description + */ + description?: string | null; + /** + * Source Api Response + * @description The original API response from the source, as stringified JSON. + */ + source_api_response?: string | null; + /** + * Cover Image + * @description Url for image to preview model + */ + cover_image?: string | null; + /** + * Submodels + * @description Loadable submodels in this model + */ + submodels?: { + [key: string]: components["schemas"]["SubmodelDefinition"]; + } | null; + /** + * Usage Info + * @description Usage information for this model + */ + usage_info?: string | null; + /** + * Trigger Phrases + * @description Set of trigger phrases for this model + */ + trigger_phrases?: string[] | null; + /** @description Default settings for this model */ + default_settings?: components["schemas"]["MainModelDefaultSettings"] | null; + /** + * Variant + * @default normal + */ + variant?: components["schemas"]["ModelVariantType"] | components["schemas"]["ClipVariantType"] | null; + }; /** * VideoDTO * @description Deserialized video record, enriched for the frontend. @@ -22772,7 +22863,7 @@ export interface operations { [name: string]: unknown; }; content: { - "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"]; + "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"]; }; }; /** @description Validation Error */ @@ -22822,7 +22913,7 @@ export interface operations { * "repo_variant": "fp16", * "upcast_attention": false * } */ - "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"]; + "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"]; }; }; /** @description Bad request */ @@ -22927,7 +23018,7 @@ export interface operations { * "repo_variant": "fp16", * "upcast_attention": false * } */ - "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"]; + "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"]; }; }; /** @description Bad request */ @@ -23441,7 +23532,7 @@ export interface operations { * "repo_variant": "fp16", * "upcast_attention": false * } */ - "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"]; + "application/json": components["schemas"]["MainDiffusersConfig"] | components["schemas"]["MainCheckpointConfig"] | components["schemas"]["MainBnbQuantized4bCheckpointConfig"] | components["schemas"]["MainGGUFCheckpointConfig"] | components["schemas"]["VAEDiffusersConfig"] | components["schemas"]["VAECheckpointConfig"] | components["schemas"]["ControlNetDiffusersConfig"] | components["schemas"]["ControlNetCheckpointConfig"] | components["schemas"]["LoRALyCORISConfig"] | components["schemas"]["LoRAOmiConfig"] | components["schemas"]["ControlLoRALyCORISConfig"] | components["schemas"]["ControlLoRADiffusersConfig"] | components["schemas"]["LoRADiffusersConfig"] | components["schemas"]["T5EncoderConfig"] | components["schemas"]["T5EncoderBnbQuantizedLlmInt8bConfig"] | components["schemas"]["TextualInversionFileConfig"] | components["schemas"]["TextualInversionFolderConfig"] | components["schemas"]["IPAdapterInvokeAIConfig"] | components["schemas"]["IPAdapterCheckpointConfig"] | components["schemas"]["T2IAdapterConfig"] | components["schemas"]["SpandrelImageToImageConfig"] | components["schemas"]["CLIPVisionDiffusersConfig"] | components["schemas"]["CLIPLEmbedDiffusersConfig"] | components["schemas"]["CLIPGEmbedDiffusersConfig"] | components["schemas"]["SigLIPConfig"] | components["schemas"]["FluxReduxConfig"] | components["schemas"]["LlavaOnevisionConfig"] | components["schemas"]["ApiModelConfig"] | components["schemas"]["VideoApiModelConfig"]; }; }; /** @description Bad request */ diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 51fe7af7312..855ac71e145 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -125,11 +125,12 @@ type CLIPVisionDiffusersConfig = S['CLIPVisionDiffusersConfig']; export type SigLipModelConfig = S['SigLIPConfig']; export type FLUXReduxModelConfig = S['FluxReduxConfig']; export type ApiModelConfig = S['ApiModelConfig']; +export type VideoApiModelConfig = S['VideoApiModelConfig']; export type MainModelConfig = DiffusersModelConfig | CheckpointModelConfig | ApiModelConfig; export type FLUXKontextModelConfig = MainModelConfig; export type ChatGPT4oModelConfig = ApiModelConfig; -export type Veo3ModelConfig = ApiModelConfig; -export type RunwayModelConfig = ApiModelConfig; +export type Veo3ModelConfig = VideoApiModelConfig; +export type RunwayModelConfig = VideoApiModelConfig; export type AnyModelConfig = | ControlLoRAModelConfig | LoRAModelConfig @@ -143,6 +144,7 @@ export type AnyModelConfig = | SpandrelImageToImageModelConfig | TextualInversionModelConfig | MainModelConfig + | VideoApiModelConfig | CLIPVisionDiffusersConfig | SigLipModelConfig | FLUXReduxModelConfig @@ -295,12 +297,16 @@ export const isChatGPT4oModelConfig = (config: AnyModelConfig): config is ChatGP return config.type === 'main' && config.base === 'chatgpt-4o'; }; +export const isVideoModelConfig = (config: AnyModelConfig): config is VideoApiModelConfig => { + return config.type === 'video'; +}; + export const isVeo3ModelConfig = (config: AnyModelConfig): config is Veo3ModelConfig => { - return config.type === 'main' && config.base === 'veo3'; + return config.base === 'veo3' && config.type === 'video'; }; export const isRunwayModelConfig = (config: AnyModelConfig): config is RunwayModelConfig => { - return config.type === 'main' && config.base === 'runway'; + return config.base === 'runway' && config.type === 'video'; }; export const isImagen3ModelConfig = (config: AnyModelConfig): config is ApiModelConfig => { From 70c5915f22b3c10f89dec029c1ae446ffa487d1f Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 Aug 2025 14:18:20 +0000 Subject: [PATCH 52/55] Checkpoint before follow-up message Co-authored-by: kent --- invokeai/frontend/web/public/locales/en.json | 9 + .../layouts/LaunchpadStartingFrameButton.tsx | 37 ++ .../ui/layouts/VideoLaunchpadPanel.tsx | 47 ++ .../ui/layouts/video-tab-auto-layout.tsx | 405 +++++++++--------- 4 files changed, 291 insertions(+), 207 deletions(-) create mode 100644 invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx create mode 100644 invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index 5cad21707ab..f6836cbe45f 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -2586,6 +2586,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", diff --git a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx new file mode 100644 index 00000000000..57bf43dee23 --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx @@ -0,0 +1,37 @@ +import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch } from 'app/store/storeHooks'; +import { useImageUploadButton } from 'common/hooks/useImageUploadButton'; +import { imageDTOToImageWithDims } from 'features/controlLayers/store/util'; +import { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton'; +import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; +import { memo, useCallback } from 'react'; +import { useTranslation } from 'react-i18next'; +import { PiUploadBold } from 'react-icons/pi'; +import type { ImageDTO } from 'services/api/types'; + +export const LaunchpadStartingFrameButton = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + + const onUpload = useCallback((imageDTO: ImageDTO) => { + dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + }, [dispatch]); + + const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); + + return ( + + + + {t('ui.launchpad.addStartingFrame.title')} + {t('ui.launchpad.addStartingFrame.description')} + + + + + + + ); +}); + +LaunchpadStartingFrameButton.displayName = 'LaunchpadStartingFrameButton'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx new file mode 100644 index 00000000000..000f9d42fdb --- /dev/null +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx @@ -0,0 +1,47 @@ +import { Alert, Button, Flex, Grid, Text } from '@invoke-ai/ui-library'; +import { memo } from 'react'; +import { useTranslation } from 'react-i18next'; + +import { InitialStateMainModelPicker } from './InitialStateMainModelPicker'; +import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference'; +import { LaunchpadContainer } from './LaunchpadContainer'; +import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton'; +import { LaunchpadStartingFrameButton } from './LaunchpadStartingFrameButton'; + +export const VideoLaunchpadPanel = memo(() => { + const { t } = useTranslation(); + + return ( + + + + + + {t('ui.launchpad.modelGuideText')}{' '} + + + + + + + + + + {t('ui.launchpad.video.startingFrameCalloutTitle')} + + {t('ui.launchpad.video.startingFrameCalloutDesc')} + + + ); +}); + +VideoLaunchpadPanel.displayName = 'VideoLaunchpadPanel'; \ No newline at end of file diff --git a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx index 1b2b76a9dc2..9f4b8135b71 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx @@ -6,11 +6,11 @@ import { ImageViewerPanel } from 'features/gallery/components/ImageViewer/ImageV import { FloatingLeftPanelButtons } from 'features/ui/components/FloatingLeftPanelButtons'; import { FloatingRightPanelButtons } from 'features/ui/components/FloatingRightPanelButtons'; import type { - AutoLayoutDockviewComponents, - AutoLayoutGridviewComponents, - DockviewPanelParameters, - GridviewPanelParameters, - RootLayoutGridviewComponents, + AutoLayoutDockviewComponents, + AutoLayoutGridviewComponents, + DockviewPanelParameters, + GridviewPanelParameters, + RootLayoutGridviewComponents, } from 'features/ui/layouts/auto-layout-context'; import { AutoLayoutProvider, useAutoLayoutContext, withPanelContainer } from 'features/ui/layouts/auto-layout-context'; import type { TabName } from 'features/ui/store/uiTypes'; @@ -21,258 +21,249 @@ import { memo, useCallback, useEffect } from 'react'; import { DockviewTab } from './DockviewTab'; import { DockviewTabLaunchpad } from './DockviewTabLaunchpad'; import { DockviewTabProgress } from './DockviewTabProgress'; -import { GenerateLaunchpadPanel } from './GenerateLaunchpadPanel'; +import { VideoLaunchpadPanel } from './VideoLaunchpadPanel'; import { navigationApi } from './navigation-api'; import { PanelHotkeysLogical } from './PanelHotkeysLogical'; import { - BOARD_PANEL_DEFAULT_HEIGHT_PX, - BOARD_PANEL_MIN_HEIGHT_PX, - BOARDS_PANEL_ID, - DOCKVIEW_TAB_ID, - DOCKVIEW_TAB_LAUNCHPAD_ID, - DOCKVIEW_TAB_PROGRESS_ID, - GALLERY_PANEL_DEFAULT_HEIGHT_PX, - GALLERY_PANEL_ID, - GALLERY_PANEL_MIN_HEIGHT_PX, - LAUNCHPAD_PANEL_ID, - LEFT_PANEL_ID, - LEFT_PANEL_MIN_SIZE_PX, - MAIN_PANEL_ID, - RIGHT_PANEL_ID, - RIGHT_PANEL_MIN_SIZE_PX, - SETTINGS_PANEL_ID, - VIEWER_PANEL_ID, + BOARD_PANEL_DEFAULT_HEIGHT_PX, + BOARD_PANEL_MIN_HEIGHT_PX, + BOARDS_PANEL_ID, + DOCKVIEW_TAB_ID, + DOCKVIEW_TAB_LAUNCHPAD_ID, + DOCKVIEW_TAB_PROGRESS_ID, + GALLERY_PANEL_DEFAULT_HEIGHT_PX, + GALLERY_PANEL_ID, + GALLERY_PANEL_MIN_HEIGHT_PX, + LAUNCHPAD_PANEL_ID, + LEFT_PANEL_ID, + LEFT_PANEL_MIN_SIZE_PX, + MAIN_PANEL_ID, + RIGHT_PANEL_ID, + RIGHT_PANEL_MIN_SIZE_PX, + SETTINGS_PANEL_ID, + VIEWER_PANEL_ID, } from './shared'; import { VideoTabLeftPanel } from './VideoTabLeftPanel'; const tabComponents = { - [DOCKVIEW_TAB_ID]: DockviewTab, - [DOCKVIEW_TAB_PROGRESS_ID]: DockviewTabProgress, - [DOCKVIEW_TAB_LAUNCHPAD_ID]: DockviewTabLaunchpad, + [DOCKVIEW_TAB_ID]: DockviewTab, + [DOCKVIEW_TAB_PROGRESS_ID]: DockviewTabProgress, + [DOCKVIEW_TAB_LAUNCHPAD_ID]: DockviewTabLaunchpad, }; const mainPanelComponents: AutoLayoutDockviewComponents = { - [LAUNCHPAD_PANEL_ID]: withPanelContainer(GenerateLaunchpadPanel), - [VIEWER_PANEL_ID]: withPanelContainer(ImageViewerPanel), + [LAUNCHPAD_PANEL_ID]: withPanelContainer(VideoLaunchpadPanel), + [VIEWER_PANEL_ID]: withPanelContainer(ImageViewerPanel), }; const initializeMainPanelLayout = (tab: TabName, api: DockviewApi) => { - navigationApi.registerContainer(tab, 'main', api, () => { - const launchpad = api.addPanel({ - id: LAUNCHPAD_PANEL_ID, - component: LAUNCHPAD_PANEL_ID, - title: t('ui.panels.launchpad'), - tabComponent: DOCKVIEW_TAB_LAUNCHPAD_ID, - params: { - tab, - focusRegion: 'launchpad', - i18nKey: 'ui.panels.launchpad', - }, - }); + navigationApi.registerContainer(tab, 'main', api, () => { + const launchpad = api.addPanel({ + id: LAUNCHPAD_PANEL_ID, + component: LAUNCHPAD_PANEL_ID, + title: t('ui.panels.launchpad'), + tabComponent: DOCKVIEW_TAB_LAUNCHPAD_ID, + params: { + tab, + focusRegion: 'launchpad', + i18nKey: 'ui.panels.launchpad', + }, + }); - api.addPanel({ - id: VIEWER_PANEL_ID, - component: VIEWER_PANEL_ID, - title: t('ui.panels.imageViewer'), - tabComponent: DOCKVIEW_TAB_PROGRESS_ID, - params: { - tab, - focusRegion: 'viewer', - i18nKey: 'ui.panels.imageViewer', - }, - position: { - direction: 'within', - referencePanel: launchpad.id, - }, - }); + api.addPanel({ + id: VIEWER_PANEL_ID, + component: VIEWER_PANEL_ID, + title: t('ui.panels.imageViewer'), + tabComponent: DOCKVIEW_TAB_PROGRESS_ID, + params: { + tab, + focusRegion: 'viewer', + i18nKey: 'ui.panels.imageViewer', + }, + position: { + direction: 'within', + referencePanel: launchpad.id, + }, + }); - launchpad.api.setActive(); - }); + launchpad.api.setActive(); + }); }; const MainPanel = memo(() => { - const { tab } = useAutoLayoutContext(); + const { tab } = useAutoLayoutContext(); - const onReady = useCallback( - ({ api }) => { - initializeMainPanelLayout(tab, api); - }, - [tab] - ); - return ( - <> - - - - - - ); + const onReady = useCallback( + ({ api }) => { + initializeMainPanelLayout(tab, api); + }, + [tab] + ); + return ( + <> + + + + + + ); }); MainPanel.displayName = 'MainPanel'; const rightPanelComponents: AutoLayoutGridviewComponents = { - [BOARDS_PANEL_ID]: withPanelContainer(BoardsPanel), - [GALLERY_PANEL_ID]: withPanelContainer(GalleryPanel), + [BOARDS_PANEL_ID]: withPanelContainer(BoardsPanel), + [GALLERY_PANEL_ID]: withPanelContainer(GalleryPanel), }; const initializeRightPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'right', api, () => { - const gallery = api.addPanel({ - id: GALLERY_PANEL_ID, - component: GALLERY_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'gallery', - }, - }); + navigationApi.registerContainer(tab, 'right', api, () => { + const gallery = api.addPanel({ + id: GALLERY_PANEL_ID, + component: GALLERY_PANEL_ID, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + minimumHeight: GALLERY_PANEL_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'gallery', + }, + }); - const boards = api.addPanel({ - id: BOARDS_PANEL_ID, - component: BOARDS_PANEL_ID, - minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, - params: { - tab, - focusRegion: 'boards', - }, - position: { - direction: 'above', - referencePanel: gallery.id, - }, - }); + const boards = api.addPanel({ + id: BOARDS_PANEL_ID, + component: BOARDS_PANEL_ID, + minimumHeight: BOARD_PANEL_MIN_HEIGHT_PX, + params: { + tab, + focusRegion: 'boards', + }, + position: { + direction: 'above', + referencePanel: gallery.id, + }, + }); - gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX }); - boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX }); - }); + gallery.api.setSize({ height: GALLERY_PANEL_DEFAULT_HEIGHT_PX }); + boards.api.setSize({ height: BOARD_PANEL_DEFAULT_HEIGHT_PX }); + }); }; const RightPanel = memo(() => { - const { tab } = useAutoLayoutContext(); + const { tab } = useAutoLayoutContext(); - const onReady = useCallback( - ({ api }) => { - initializeRightPanelLayout(tab, api); - }, - [tab] - ); - return ( - - ); + const onReady = useCallback( + ({ api }) => { + initializeRightPanelLayout(tab, api); + }, + [tab] + ); + return ( + + ); }); RightPanel.displayName = 'RightPanel'; const leftPanelComponents: AutoLayoutGridviewComponents = { - [SETTINGS_PANEL_ID]: withPanelContainer(VideoTabLeftPanel), + [SETTINGS_PANEL_ID]: withPanelContainer(VideoTabLeftPanel), }; const initializeLeftPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'left', api, () => { - api.addPanel({ - id: SETTINGS_PANEL_ID, - component: SETTINGS_PANEL_ID, - params: { - tab, - focusRegion: 'settings', - }, - }); - }); + navigationApi.registerContainer(tab, 'left', api, () => { + api.addPanel({ + id: SETTINGS_PANEL_ID, + component: SETTINGS_PANEL_ID, + params: { + tab, + focusRegion: 'settings', + }, + }); + }); }; const LeftPanel = memo(() => { - const { tab } = useAutoLayoutContext(); + const { tab } = useAutoLayoutContext(); - const onReady = useCallback( - ({ api }) => { - initializeLeftPanelLayout(tab, api); - }, - [tab] - ); - return ( - - ); + const onReady = useCallback( + ({ api }) => { + initializeLeftPanelLayout(tab, api); + }, + [tab] + ); + return ( + + ); }); LeftPanel.displayName = 'LeftPanel'; const rootPanelComponents: RootLayoutGridviewComponents = { - [LEFT_PANEL_ID]: LeftPanel, - [MAIN_PANEL_ID]: MainPanel, - [RIGHT_PANEL_ID]: RightPanel, -}; - -const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { - navigationApi.registerContainer(tab, 'root', api, () => { - const main = api.addPanel({ - id: MAIN_PANEL_ID, - component: MAIN_PANEL_ID, - priority: LayoutPriority.High, - }); - - const left = api.addPanel({ - id: LEFT_PANEL_ID, - component: LEFT_PANEL_ID, - minimumWidth: LEFT_PANEL_MIN_SIZE_PX, - position: { - direction: 'left', - referencePanel: main.id, - }, - }); - - const right = api.addPanel({ - id: RIGHT_PANEL_ID, - component: RIGHT_PANEL_ID, - minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - position: { - direction: 'right', - referencePanel: main.id, - }, - }); - - left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); - right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); - }); + [LEFT_PANEL_ID]: LeftPanel, + [MAIN_PANEL_ID]: MainPanel, + [RIGHT_PANEL_ID]: RightPanel, }; export const VideoTabAutoLayout = memo(() => { - const onReady = useCallback(({ api }) => { - initializeRootPanelLayout('video', api); - }, []); - - useEffect( - () => () => { - navigationApi.unregisterTab('video'); - }, - [] - ); + const onReady = useCallback(({ api }) => { + api.addPanel({ + id: LEFT_PANEL_ID, + component: LEFT_PANEL_ID, + minimumWidth: LEFT_PANEL_MIN_SIZE_PX, + params: { + priority: LayoutPriority.Low, + }, + }); + api.addPanel({ + id: MAIN_PANEL_ID, + component: MAIN_PANEL_ID, + params: { + priority: LayoutPriority.High, + }, + position: { + direction: 'right', + referencePanel: LEFT_PANEL_ID, + }, + }); + api.addPanel({ + id: RIGHT_PANEL_ID, + component: RIGHT_PANEL_ID, + minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, + params: { + priority: LayoutPriority.Low, + }, + position: { + direction: 'right', + referencePanel: MAIN_PANEL_ID, + }, + }); + api.setActivePanel(MAIN_PANEL_ID); + }, []); - return ( - - - - ); + return ( + + + + ); }); VideoTabAutoLayout.displayName = 'VideoTabAutoLayout'; From 3bc0dd6707fbfb08f6605b778318c6a8f9d30713 Mon Sep 17 00:00:00 2001 From: Cursor Agent Date: Fri, 22 Aug 2025 14:20:12 +0000 Subject: [PATCH 53/55] Refactor video tab layout and model picker with improved flexibility Co-authored-by: kent --- .../VideoModelPicker.tsx | 4 +- .../ui/layouts/VideoLaunchpadPanel.tsx | 4 +- .../ui/layouts/video-tab-auto-layout.tsx | 57 +++++++++++-------- 3 files changed, 37 insertions(+), 28 deletions(-) diff --git a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx index 1b8a1f68b74..30321867efc 100644 --- a/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx +++ b/invokeai/frontend/web/src/features/settingsAccordions/components/VideoSettingsAccordion/VideoModelPicker.tsx @@ -9,7 +9,7 @@ import { useVideoModels } from 'services/api/hooks/modelsByType'; import { useSelectedVideoModelConfig } from 'services/api/hooks/useSelectedModelConfig'; import type { VideoApiModelConfig } from 'services/api/types'; -export const VideoModelPicker = memo(() => { +export const VideoModelPicker = memo((props: { labelKey?: string }) => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const [modelConfigs] = useVideoModels(); @@ -24,7 +24,7 @@ export const VideoModelPicker = memo(() => { return ( - {t('modelManager.model')} + {t(props.labelKey ?? 'modelManager.model')} { const { t } = useTranslation(); @@ -14,7 +14,7 @@ export const VideoLaunchpadPanel = memo(() => { return ( - + {t('ui.launchpad.modelGuideText')}{' '} diff --git a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx index 9f4b8135b71..c2ba779ac70 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/video-tab-auto-layout.tsx @@ -219,49 +219,58 @@ const rootPanelComponents: RootLayoutGridviewComponents = { [RIGHT_PANEL_ID]: RightPanel, }; -export const VideoTabAutoLayout = memo(() => { - const onReady = useCallback(({ api }) => { - api.addPanel({ +const initializeRootPanelLayout = (tab: TabName, api: GridviewApi) => { + navigationApi.registerContainer(tab, 'root', api, () => { + const main = api.addPanel({ + id: MAIN_PANEL_ID, + component: MAIN_PANEL_ID, + priority: LayoutPriority.High, + }); + + const left = api.addPanel({ id: LEFT_PANEL_ID, component: LEFT_PANEL_ID, minimumWidth: LEFT_PANEL_MIN_SIZE_PX, - params: { - priority: LayoutPriority.Low, - }, - }); - api.addPanel({ - id: MAIN_PANEL_ID, - component: MAIN_PANEL_ID, - params: { - priority: LayoutPriority.High, - }, position: { - direction: 'right', - referencePanel: LEFT_PANEL_ID, + direction: 'left', + referencePanel: main.id, }, }); - api.addPanel({ + + const right = api.addPanel({ id: RIGHT_PANEL_ID, component: RIGHT_PANEL_ID, minimumWidth: RIGHT_PANEL_MIN_SIZE_PX, - params: { - priority: LayoutPriority.Low, - }, position: { direction: 'right', - referencePanel: MAIN_PANEL_ID, + referencePanel: main.id, }, }); - api.setActivePanel(MAIN_PANEL_ID); + + left.api.setSize({ width: LEFT_PANEL_MIN_SIZE_PX }); + right.api.setSize({ width: RIGHT_PANEL_MIN_SIZE_PX }); + }); +}; + +export const VideoTabAutoLayout = memo(() => { + const onReady = useCallback(({ api }) => { + initializeRootPanelLayout('video', api); }, []); + useEffect( + () => () => { + navigationApi.unregisterTab('video'); + }, + [] + ); + return ( - + ); From 5e119c9b96d22b8d367b11753dd2488e693aeec6 Mon Sep 17 00:00:00 2001 From: Kent Keirsey Date: Fri, 22 Aug 2025 10:41:47 -0400 Subject: [PATCH 54/55] Update Launchpad Panel --- .../web/src/features/ui/layouts/VideoLaunchpadPanel.tsx | 7 ------- 1 file changed, 7 deletions(-) diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx index 073e461445a..dde1fb3884c 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx @@ -33,13 +33,6 @@ export const VideoLaunchpadPanel = memo(() => { - - - - {t('ui.launchpad.video.startingFrameCalloutTitle')} - - {t('ui.launchpad.video.startingFrameCalloutDesc')} - ); }); From b47f124dfad14640ea6db5c1782be6f1616ce785 Mon Sep 17 00:00:00 2001 From: Kent Keirsey Date: Fri, 22 Aug 2025 11:00:18 -0400 Subject: [PATCH 55/55] updating to latest vibe --- .../layouts/LaunchpadStartingFrameButton.tsx | 33 +++++++++++++------ .../ui/layouts/VideoLaunchpadPanel.tsx | 1 - 2 files changed, 23 insertions(+), 11 deletions(-) diff --git a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx index 57bf43dee23..4dc5394a67e 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/LaunchpadStartingFrameButton.tsx @@ -1,27 +1,39 @@ import { Flex, Heading, Icon, Text } from '@invoke-ai/ui-library'; -import { useAppDispatch } from 'app/store/storeHooks'; +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 { LaunchpadButton } from 'features/ui/layouts/LaunchpadButton'; import { startingFrameImageChanged } from 'features/parameters/store/videoSlice'; -import { memo, useCallback } from 'react'; +import { memo, useMemo } from 'react'; import { useTranslation } from 'react-i18next'; -import { PiUploadBold } from 'react-icons/pi'; +import { PiUploadBold, PiVideoBold } from 'react-icons/pi'; import type { ImageDTO } from 'services/api/types'; -export const LaunchpadStartingFrameButton = memo(() => { +const dndTargetData = videoFrameFromImageDndTarget.getData({ frame: 'start' }); + +export const LaunchpadStartingFrameButton = memo((props: { extraAction?: () => void }) => { const { t } = useTranslation(); - const dispatch = useAppDispatch(); + const { dispatch, getState } = useAppStore(); - const onUpload = useCallback((imageDTO: ImageDTO) => { - dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); - }, [dispatch]); + const uploadOptions = useMemo( + () => + ({ + onUpload: (imageDTO: ImageDTO) => { + dispatch(startingFrameImageChanged(imageDTOToImageWithDims(imageDTO))); + props.extraAction?.(); + }, + allowMultiple: false, + }) as const, + [dispatch, props] + ); - const uploadApi = useImageUploadButton({ allowMultiple: false, onUpload }); + const uploadApi = useImageUploadButton(uploadOptions); return ( - + {t('ui.launchpad.addStartingFrame.title')} {t('ui.launchpad.addStartingFrame.description')} @@ -30,6 +42,7 @@ export const LaunchpadStartingFrameButton = memo(() => { + ); }); diff --git a/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx b/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx index dde1fb3884c..28e3e8ccb4e 100644 --- a/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx +++ b/invokeai/frontend/web/src/features/ui/layouts/VideoLaunchpadPanel.tsx @@ -2,7 +2,6 @@ import { Alert, Button, Flex, Grid, Text } from '@invoke-ai/ui-library'; import { memo } from 'react'; import { useTranslation } from 'react-i18next'; -import { LaunchpadAddStyleReference } from './LaunchpadAddStyleReference'; import { LaunchpadContainer } from './LaunchpadContainer'; import { LaunchpadGenerateFromTextButton } from './LaunchpadGenerateFromTextButton'; import { LaunchpadStartingFrameButton } from './LaunchpadStartingFrameButton';