Compare commits

..

2 Commits

Author SHA1 Message Date
Ryan Dick
9b763b9e4c Fix issue with seamless context managers when seamless is not configured. 2024-01-05 10:31:58 -05:00
Sergey Borisov
7f3be627c2 Add more seamless configuration options. 2024-01-05 09:57:28 -05:00
172 changed files with 2749 additions and 3329 deletions

View File

@@ -1,5 +1,6 @@
# Copyright (c) 2023 Kyle Schouviller (https://github.com/kyle0654)
import contextlib
from contextlib import ExitStack
from functools import singledispatchmethod
from typing import List, Literal, Optional, Union
@@ -716,10 +717,23 @@ class DenoiseLatentsInvocation(BaseInvocation):
**self.unet.unet.model_dump(),
context=context,
)
# Prepare seamless context, if configured.
seamless_context = contextlib.nullcontext()
seamless_config = self.unet.seamless
if seamless_config is not None:
seamless_context = set_seamless(
model=unet_info.context.model,
axes=seamless_config.axes,
skipped_layers=seamless_config.skipped_layers,
skip_second_resnet=seamless_config.skip_second_resnet,
skip_conv2=seamless_config.skip_conv2,
)
with (
ExitStack() as exit_stack,
ModelPatcher.apply_freeu(unet_info.context.model, self.unet.freeu_config),
set_seamless(unet_info.context.model, self.unet.seamless_axes),
seamless_context,
unet_info as unet,
# Apply the LoRA after unet has been moved to its target device for faster patching.
ModelPatcher.apply_lora_unet(unet, _lora_loader()),
@@ -826,7 +840,19 @@ class LatentsToImageInvocation(BaseInvocation, WithMetadata):
context=context,
)
with set_seamless(vae_info.context.model, self.vae.seamless_axes), vae_info as vae:
# Prepare seamless context, if configured.
seamless_context = contextlib.nullcontext()
seamless_config = self.vae.seamless
if seamless_config is not None:
seamless_context = set_seamless(
model=vae_info.context.model,
axes=seamless_config.axes,
skipped_layers=seamless_config.skipped_layers,
skip_second_resnet=seamless_config.skip_second_resnet,
skip_conv2=seamless_config.skip_conv2,
)
with seamless_context, vae_info as vae:
latents = latents.to(vae.device)
if self.fp32:
vae.to(dtype=torch.float32)

View File

@@ -19,6 +19,13 @@ from .baseinvocation import (
)
class SeamlessSettings(BaseModel):
axes: List[str] = Field(description="Axes('x' and 'y') to which apply seamless")
skipped_layers: int = Field(description="How much down layers skip when applying seamless")
skip_second_resnet: bool = Field(description="Skip or not second resnet in down blocks when applying seamless")
skip_conv2: bool = Field(description="Skip or not conv2 in down blocks when applying seamless")
class ModelInfo(BaseModel):
model_name: str = Field(description="Info to load submodel")
base_model: BaseModelType = Field(description="Base model")
@@ -36,8 +43,8 @@ class UNetField(BaseModel):
unet: ModelInfo = Field(description="Info to load unet submodel")
scheduler: ModelInfo = Field(description="Info to load scheduler submodel")
loras: List[LoraInfo] = Field(description="Loras to apply on model loading")
seamless_axes: List[str] = Field(default_factory=list, description='Axes("x" and "y") to which apply seamless')
freeu_config: Optional[FreeUConfig] = Field(default=None, description="FreeU configuration")
seamless: Optional[SeamlessSettings] = Field(default=None, description="Seamless settings applied to model")
class ClipField(BaseModel):
@@ -50,7 +57,7 @@ class ClipField(BaseModel):
class VaeField(BaseModel):
# TODO: better naming?
vae: ModelInfo = Field(description="Info to load vae submodel")
seamless_axes: List[str] = Field(default_factory=list, description='Axes("x" and "y") to which apply seamless')
seamless: Optional[SeamlessSettings] = Field(default=None, description="Seamless settings applied to model")
@invocation_output("unet_output")
@@ -451,6 +458,11 @@ class SeamlessModeInvocation(BaseInvocation):
)
seamless_y: bool = InputField(default=True, input=Input.Any, description="Specify whether Y axis is seamless")
seamless_x: bool = InputField(default=True, input=Input.Any, description="Specify whether X axis is seamless")
skipped_layers: int = InputField(default=0, input=Input.Any, description="How much model's down layers to skip")
skip_second_resnet: bool = InputField(
default=True, input=Input.Any, description="Skip or not second resnet in down layers"
)
skip_conv2: bool = InputField(default=True, input=Input.Any, description="Skip or not conv2 in down layers")
def invoke(self, context: InvocationContext) -> SeamlessModeOutput:
# Conditionally append 'x' and 'y' based on seamless_x and seamless_y
@@ -465,9 +477,19 @@ class SeamlessModeInvocation(BaseInvocation):
seamless_axes_list.append("y")
if unet is not None:
unet.seamless_axes = seamless_axes_list
unet.seamless = SeamlessSettings(
axes=seamless_axes_list,
skipped_layers=self.skipped_layers,
skip_second_resnet=self.skip_second_resnet,
skip_conv2=self.skip_conv2,
)
if vae is not None:
vae.seamless_axes = seamless_axes_list
vae.seamless = SeamlessSettings(
axes=seamless_axes_list,
skipped_layers=self.skipped_layers,
skip_second_resnet=self.skip_second_resnet,
skip_conv2=self.skip_conv2,
)
return SeamlessModeOutput(unet=unet, vae=vae)

View File

@@ -1,31 +0,0 @@
# Copyright (c) 2024 Lincoln Stein and the InvokeAI Development Team
"""
This module exports the function has_baked_in_sdxl_vae().
It returns True if an SDXL checkpoint model has the original SDXL 1.0 VAE,
which doesn't work properly in fp16 mode.
"""
import hashlib
from pathlib import Path
from safetensors.torch import load_file
SDXL_1_0_VAE_HASH = "bc40b16c3a0fa4625abdfc01c04ffc21bf3cefa6af6c7768ec61eb1f1ac0da51"
def has_baked_in_sdxl_vae(checkpoint_path: Path) -> bool:
"""Return true if the checkpoint contains a custom (non SDXL-1.0) VAE."""
hash = _vae_hash(checkpoint_path)
return hash != SDXL_1_0_VAE_HASH
def _vae_hash(checkpoint_path: Path) -> str:
checkpoint = load_file(checkpoint_path, device="cpu")
vae_keys = [x for x in checkpoint.keys() if x.startswith("first_stage_model.")]
hash = hashlib.new("sha256")
for key in vae_keys:
value = checkpoint[key]
hash.update(bytes(key, "UTF-8"))
hash.update(bytes(str(value), "UTF-8"))
return hash.hexdigest()

View File

@@ -13,7 +13,6 @@ from safetensors.torch import load_file
from transformers import CLIPTextModel, CLIPTokenizer
from invokeai.app.shared.models import FreeUConfig
from invokeai.backend.model_management.model_load_optimizations import skip_torch_weight_init
from .models.lora import LoRAModel
@@ -212,12 +211,8 @@ class ModelPatcher:
for i in range(ti_embedding.shape[0]):
new_tokens_added += ti_tokenizer.add_tokens(_get_trigger(ti_name, i))
# Modify text_encoder.
# resize_token_embeddings(...) constructs a new torch.nn.Embedding internally. Initializing the weights of
# this embedding is slow and unnecessary, so we wrap this step in skip_torch_weight_init() to save some
# time.
with skip_torch_weight_init():
text_encoder.resize_token_embeddings(init_tokens_count + new_tokens_added, pad_to_multiple_of)
# modify text_encoder
text_encoder.resize_token_embeddings(init_tokens_count + new_tokens_added, pad_to_multiple_of)
model_embeddings = text_encoder.get_input_embeddings()
for ti_name, ti in ti_list:

View File

@@ -1,16 +1,11 @@
import json
import os
from enum import Enum
from pathlib import Path
from typing import Literal, Optional
from omegaconf import OmegaConf
from pydantic import Field
from invokeai.app.services.config import InvokeAIAppConfig
from invokeai.backend.model_management.detect_baked_in_vae import has_baked_in_sdxl_vae
from invokeai.backend.util.logging import InvokeAILogger
from .base import (
BaseModelType,
DiffusersModel,
@@ -121,28 +116,14 @@ class StableDiffusionXLModel(DiffusersModel):
# The convert script adapted from the diffusers package uses
# strings for the base model type. To avoid making too many
# source code changes, we simply translate here
if Path(output_path).exists():
return output_path
if isinstance(config, cls.CheckpointConfig):
from invokeai.backend.model_management.models.stable_diffusion import _convert_ckpt_and_cache
# Hack in VAE-fp16 fix - If model sdxl-vae-fp16-fix is installed,
# then we bake it into the converted model unless there is already
# a nonstandard VAE installed.
kwargs = {}
app_config = InvokeAIAppConfig.get_config()
vae_path = app_config.models_path / "sdxl/vae/sdxl-vae-fp16-fix"
if vae_path.exists() and not has_baked_in_sdxl_vae(Path(model_path)):
InvokeAILogger.get_logger().warning("No baked-in VAE detected. Inserting sdxl-vae-fp16-fix.")
kwargs["vae_path"] = vae_path
return _convert_ckpt_and_cache(
version=base_model,
model_config=config,
output_path=output_path,
use_safetensors=False, # corrupts sdxl models for some reason
**kwargs,
)
else:
return model_path

View File

@@ -25,71 +25,55 @@ def _conv_forward_asymmetric(self, input, weight, bias):
@contextmanager
def set_seamless(model: Union[UNet2DConditionModel, AutoencoderKL], seamless_axes: List[str]):
def set_seamless(
model: Union[UNet2DConditionModel, AutoencoderKL],
axes: List[str],
skipped_layers: int,
skip_second_resnet: bool,
skip_conv2: bool,
):
try:
to_restore = []
for m_name, m in model.named_modules():
if isinstance(model, UNet2DConditionModel):
if ".attentions." in m_name:
if not isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
continue
if isinstance(model, UNet2DConditionModel) and m_name.startswith("down_blocks.") and ".resnets." in m_name:
# down_blocks.1.resnets.1.conv1
_, block_num, _, resnet_num, submodule_name = m_name.split(".")
block_num = int(block_num)
resnet_num = int(resnet_num)
# if block_num >= seamless_down_blocks:
if block_num >= len(model.down_blocks) - skipped_layers:
continue
if ".resnets." in m_name:
if ".conv2" in m_name:
continue
if ".conv_shortcut" in m_name:
continue
"""
if isinstance(model, UNet2DConditionModel):
if False and ".upsamplers." in m_name:
if resnet_num > 0 and skip_second_resnet:
continue
if False and ".downsamplers." in m_name:
if submodule_name == "conv2" and skip_conv2:
continue
if True and ".resnets." in m_name:
if True and ".conv1" in m_name:
if False and "down_blocks" in m_name:
continue
if False and "mid_block" in m_name:
continue
if False and "up_blocks" in m_name:
continue
m.asymmetric_padding_mode = {}
m.asymmetric_padding = {}
m.asymmetric_padding_mode["x"] = "circular" if ("x" in axes) else "constant"
m.asymmetric_padding["x"] = (
m._reversed_padding_repeated_twice[0],
m._reversed_padding_repeated_twice[1],
0,
0,
)
m.asymmetric_padding_mode["y"] = "circular" if ("y" in axes) else "constant"
m.asymmetric_padding["y"] = (
0,
0,
m._reversed_padding_repeated_twice[2],
m._reversed_padding_repeated_twice[3],
)
if True and ".conv2" in m_name:
continue
if True and ".conv_shortcut" in m_name:
continue
if True and ".attentions." in m_name:
continue
if False and m_name in ["conv_in", "conv_out"]:
continue
"""
if isinstance(m, (nn.Conv2d, nn.ConvTranspose2d)):
m.asymmetric_padding_mode = {}
m.asymmetric_padding = {}
m.asymmetric_padding_mode["x"] = "circular" if ("x" in seamless_axes) else "constant"
m.asymmetric_padding["x"] = (
m._reversed_padding_repeated_twice[0],
m._reversed_padding_repeated_twice[1],
0,
0,
)
m.asymmetric_padding_mode["y"] = "circular" if ("y" in seamless_axes) else "constant"
m.asymmetric_padding["y"] = (
0,
0,
m._reversed_padding_repeated_twice[2],
m._reversed_padding_repeated_twice[3],
)
to_restore.append((m, m._conv_forward))
m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d)
to_restore.append((m, m._conv_forward))
m._conv_forward = _conv_forward_asymmetric.__get__(m, nn.Conv2d)
yield

View File

@@ -1,3 +0,0 @@
<svg width="44" height="44" viewBox="0 0 44 44" fill="none" xmlns="http://www.w3.org/2000/svg">
<path d="M29.1951 10.6667H42V2H2V10.6667H14.8049L29.1951 33.3333H42V42H2V33.3333H14.8049" stroke="#E6FD13" stroke-width="2.8"/>
</svg>

Before

Width:  |  Height:  |  Size: 231 B

Binary file not shown.

After

Width:  |  Height:  |  Size: 116 KiB

View File

@@ -8,8 +8,8 @@
<meta http-equiv="Pragma" content="no-cache">
<meta http-equiv="Expires" content="0">
<title>InvokeAI - A Stable Diffusion Toolkit</title>
<link rel="mask-icon" type="icon" href="favicon-outline.svg" color="#E6FD13" sizes="any" />
<link rel="icon" type="icon" href="favicon-key.svg" />
<link rel="mask-icon" href="/invoke-key-ylw-sm.svg" color="#E6FD13" sizes="any" />
<link rel="icon" href="/invoke-key-char-on-ylw.svg" />
<style>
html,
body {

View File

@@ -73,11 +73,10 @@
"chakra-react-select": "^4.7.6",
"compare-versions": "^6.1.0",
"dateformat": "^5.0.3",
"framer-motion": "^10.17.9",
"i18next": "^23.7.16",
"framer-motion": "^10.16.16",
"i18next": "^23.7.13",
"i18next-http-backend": "^2.4.2",
"idb-keyval": "^6.2.1",
"jsondiffpatch": "^0.6.0",
"konva": "^9.3.0",
"lodash-es": "^4.17.21",
"nanostores": "^0.9.5",
@@ -91,7 +90,7 @@
"react-dropzone": "^14.2.3",
"react-error-boundary": "^4.0.12",
"react-hook-form": "^7.49.2",
"react-hotkeys-hook": "4.4.3",
"react-hotkeys-hook": "4.4.1",
"react-i18next": "^14.0.0",
"react-icons": "^4.12.0",
"react-konva": "^18.2.10",
@@ -103,10 +102,10 @@
"react-virtuoso": "^4.6.2",
"reactflow": "^11.10.1",
"redux-dynamic-middlewares": "^2.2.0",
"redux-remember": "^5.1.0",
"redux-remember": "^5.0.1",
"roarr": "^7.21.0",
"serialize-error": "^11.0.3",
"socket.io-client": "^4.7.3",
"socket.io-client": "^4.7.2",
"type-fest": "^4.9.0",
"use-debounce": "^10.0.0",
"use-image": "^1.1.1",
@@ -122,27 +121,27 @@
"ts-toolbelt": "^9.6.0"
},
"devDependencies": {
"@arthurgeron/eslint-plugin-react-usememo": "^2.2.3",
"@arthurgeron/eslint-plugin-react-usememo": "^2.2.2",
"@chakra-ui/cli": "^2.4.1",
"@storybook/addon-docs": "^7.6.7",
"@storybook/addon-essentials": "^7.6.7",
"@storybook/addon-interactions": "^7.6.7",
"@storybook/addon-links": "^7.6.7",
"@storybook/addon-storysource": "^7.6.7",
"@storybook/blocks": "^7.6.7",
"@storybook/manager-api": "^7.6.7",
"@storybook/react": "^7.6.7",
"@storybook/react-vite": "^7.6.7",
"@storybook/test": "^7.6.7",
"@storybook/theming": "^7.6.7",
"@storybook/addon-docs": "^7.6.6",
"@storybook/addon-essentials": "^7.6.6",
"@storybook/addon-interactions": "^7.6.6",
"@storybook/addon-links": "^7.6.6",
"@storybook/addon-storysource": "^7.6.6",
"@storybook/blocks": "^7.6.6",
"@storybook/manager-api": "^7.6.6",
"@storybook/react": "^7.6.6",
"@storybook/react-vite": "^7.6.6",
"@storybook/test": "^7.6.6",
"@storybook/theming": "^7.6.6",
"@types/dateformat": "^5.0.2",
"@types/lodash-es": "^4.17.12",
"@types/node": "^20.10.7",
"@types/react": "^18.2.47",
"@types/node": "^20.10.6",
"@types/react": "^18.2.46",
"@types/react-dom": "^18.2.18",
"@types/uuid": "^9.0.7",
"@typescript-eslint/eslint-plugin": "^6.18.0",
"@typescript-eslint/parser": "^6.18.0",
"@typescript-eslint/eslint-plugin": "^6.16.0",
"@typescript-eslint/parser": "^6.16.0",
"@vitejs/plugin-react-swc": "^3.5.0",
"concurrently": "^8.2.2",
"eslint": "^8.56.0",
@@ -160,10 +159,10 @@
"openapi-typescript": "^6.7.3",
"prettier": "^3.1.1",
"rollup-plugin-visualizer": "^5.12.0",
"storybook": "^7.6.7",
"storybook": "^7.6.6",
"ts-toolbelt": "^9.6.0",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vite": "^5.0.10",
"vite-plugin-css-injected-by-js": "^3.3.1",
"vite-plugin-dts": "^3.7.0",
"vite-plugin-eslint": "^1.8.1",

File diff suppressed because it is too large Load Diff

View File

Before

Width:  |  Height:  |  Size: 272 B

After

Width:  |  Height:  |  Size: 272 B

View File

@@ -121,11 +121,7 @@
"unsaved": "несохраненный",
"input": "Вход",
"details": "Детали",
"notInstalled": "Нет $t(common.installed)",
"preferencesLabel": "Предпочтения",
"or": "или",
"advancedOptions": "Расширенные настройки",
"free": "Свободно"
"notInstalled": "Нет $t(common.installed)"
},
"gallery": {
"generations": "Генерации",
@@ -369,22 +365,7 @@
"desc": "Открывает меню добавления узла",
"title": "Добавление узлов"
},
"nodesHotkeys": "Горячие клавиши узлов",
"cancelAndClear": {
"desc": "Отмена текущего элемента очереди и очистка всех ожидающих элементов",
"title": "Отменить и очистить"
},
"resetOptionsAndGallery": {
"title": "Сброс настроек и галереи",
"desc": "Сброс панелей галереи и настроек"
},
"searchHotkeys": "Поиск горячих клавиш",
"noHotkeysFound": "Горячие клавиши не найдены",
"toggleOptionsAndGallery": {
"desc": "Открытие и закрытие панели опций и галереи",
"title": "Переключить опции и галерею"
},
"clearSearch": "Очистить поиск"
"nodesHotkeys": "Горячие клавиши узлов"
},
"modelManager": {
"modelManager": "Менеджер моделей",
@@ -1207,8 +1188,7 @@
"handAndFace": "Руки и Лицо",
"enableIPAdapter": "Включить IP Adapter",
"maxFaces": "Макс Лица",
"mlsdDescription": "Минималистичный детектор отрезков линии",
"resizeSimple": "Изменить размер (простой)"
"mlsdDescription": "Минималистичный детектор отрезков линии"
},
"boards": {
"autoAddBoard": "Авто добавление Доски",
@@ -1560,8 +1540,7 @@
"cancelBatchFailed": "Проблема с отменой пакета",
"clearQueueAlertDialog2": "Вы уверены, что хотите очистить очередь?",
"item": "Элемент",
"graphFailedToQueue": "Не удалось поставить график в очередь",
"openQueue": "Открыть очередь"
"graphFailedToQueue": "Не удалось поставить график в очередь"
},
"sdxl": {
"refinerStart": "Запуск перерисовщика",
@@ -1656,38 +1635,9 @@
"selectModel": "Выберите модель",
"noRefinerModelsInstalled": "Модели SDXL Refiner не установлены",
"noLoRAsInstalled": "Нет установленных LoRA",
"selectLoRA": "Выберите LoRA",
"noMainModelSelected": "Базовая модель не выбрана",
"lora": "LoRA",
"allLoRAsAdded": "Все LoRA добавлены",
"defaultVAE": "Стандартное VAE",
"incompatibleBaseModel": "Несовместимая базовая модель",
"loraAlreadyAdded": "LoRA уже добавлена"
"selectLoRA": "Выберите LoRA"
},
"app": {
"storeNotInitialized": "Магазин не инициализирован"
},
"accordions": {
"compositing": {
"infillTab": "Заполнение",
"coherenceTab": "Согласованность",
"title": "Композиция"
},
"control": {
"controlAdaptersTab": "Адаптеры контроля",
"ipTab": "Запросы изображений",
"title": "Контроль"
},
"generation": {
"title": "Генерация",
"conceptsTab": "Концепты",
"modelTab": "Модель"
},
"advanced": {
"title": "Расширенные"
},
"image": {
"title": "Изображение"
}
}
}

View File

@@ -121,11 +121,7 @@
"nextPage": "下一页",
"saveAs": "保存为",
"unsaved": "未保存",
"ai": "ai",
"preferencesLabel": "首选项",
"or": "或",
"advancedOptions": "高级选项",
"free": "自由"
"ai": "ai"
},
"gallery": {
"generations": "生成的图像",
@@ -168,18 +164,18 @@
"starImage": "收藏图像"
},
"hotkeys": {
"keyboardShortcuts": "快捷键",
"appHotkeys": "应用",
"generalHotkeys": "一般",
"galleryHotkeys": "图库",
"unifiedCanvasHotkeys": "统一画布",
"keyboardShortcuts": "键盘快捷键",
"appHotkeys": "应用快捷键",
"generalHotkeys": "一般快捷键",
"galleryHotkeys": "图库快捷键",
"unifiedCanvasHotkeys": "统一画布快捷键",
"invoke": {
"title": "Invoke",
"desc": "生成图像"
},
"cancel": {
"title": "取消",
"desc": "取消当前队列项目"
"desc": "取消图像生成"
},
"focusPrompt": {
"title": "打开提示词框",
@@ -365,26 +361,11 @@
"title": "接受暂存图像",
"desc": "接受当前暂存区中的图像"
},
"nodesHotkeys": "节点",
"nodesHotkeys": "节点快捷键",
"addNodes": {
"title": "添加节点",
"desc": "打开添加节点菜单"
},
"cancelAndClear": {
"desc": "取消当前队列项目并且清除所有待定项目",
"title": "取消和清除"
},
"resetOptionsAndGallery": {
"title": "重置选项和图库",
"desc": "重置选项和图库面板"
},
"searchHotkeys": "检索快捷键",
"noHotkeysFound": "未找到快捷键",
"toggleOptionsAndGallery": {
"desc": "打开和关闭选项和图库面板",
"title": "开关选项和图库"
},
"clearSearch": "清除检索项"
}
},
"modelManager": {
"modelManager": "模型管理器",
@@ -582,8 +563,8 @@
"info": "信息",
"initialImage": "初始图像",
"showOptionsPanel": "显示侧栏浮窗 (O 或 T)",
"seamlessYAxis": "无缝平铺 Y 轴",
"seamlessXAxis": "无缝平铺 X 轴",
"seamlessYAxis": "Y 轴",
"seamlessXAxis": "X 轴",
"boundingBoxWidth": "边界框宽度",
"boundingBoxHeight": "边界框高度",
"denoisingStrength": "去噪强度",
@@ -630,7 +611,7 @@
"readyToInvoke": "准备调用",
"noControlImageForControlAdapter": "有 #{{number}} 个 Control Adapter 缺失控制图像",
"noModelForControlAdapter": "有 #{{number}} 个 Control Adapter 没有选择模型。",
"incompatibleBaseModelForControlAdapter": "有 #{{number}} 个 Control Adapter 模型与主模型不兼容。"
"incompatibleBaseModelForControlAdapter": "有 #{{number}} 个 Control Adapter 模型与主模型不匹配。"
},
"patchmatchDownScaleSize": "缩小",
"coherenceSteps": "步数",
@@ -661,14 +642,7 @@
"unmasked": "取消遮罩",
"cfgRescaleMultiplier": "CFG 重缩放倍数",
"cfgRescale": "CFG 重缩放",
"useSize": "使用尺寸",
"setToOptimalSize": "优化模型大小",
"setToOptimalSizeTooSmall": "$t(parameters.setToOptimalSize) (可能过小)",
"imageSize": "图像尺寸",
"lockAspectRatio": "锁定纵横比",
"swapDimensions": "交换尺寸",
"aspect": "纵横",
"setToOptimalSizeTooLarge": "$t(parameters.setToOptimalSize) (可能过大)"
"useSize": "使用尺寸"
},
"settings": {
"models": "模型",
@@ -1227,8 +1201,7 @@
"openPose": "Openpose",
"controlAdapter_other": "Control Adapters",
"lineartAnime": "Lineart Anime",
"canny": "Canny",
"resizeSimple": "缩放(简单)"
"canny": "Canny"
},
"queue": {
"status": "状态",
@@ -1275,7 +1248,7 @@
"notReady": "无法排队",
"batchFailedToQueue": "批次加入队列失败",
"batchValues": "批次数",
"queueCountPrediction": "{{promptsCount}} 提示词 × {{iterations}} 迭代次数 -> {{count}} 次生成",
"queueCountPrediction": "添加 {{predicted}} 到队列",
"batchQueued": "加入队列的批次",
"queuedCount": "{{pending}} 待处理",
"front": "前",
@@ -1289,8 +1262,7 @@
"queueMaxExceeded": "超出最大值 {{max_queue_size}},将跳过 {{skip}}",
"graphFailedToQueue": "节点图加入队列失败",
"batchFieldValues": "批处理值",
"time": "时间",
"openQueue": "打开队列"
"time": "时间"
},
"sdxl": {
"refinerStart": "Refiner 开始作用时机",
@@ -1304,12 +1276,11 @@
"denoisingStrength": "去噪强度",
"refinermodel": "Refiner 模型",
"posAestheticScore": "正向美学评分",
"concatPromptStyle": "接提示词 & 样式",
"concatPromptStyle": "接提示词 & 样式",
"loading": "加载中...",
"steps": "步数",
"posStylePrompt": "正向样式提示词",
"refiner": "Refiner",
"freePromptStyle": "手动输入样式提示词"
"refiner": "Refiner"
},
"metadata": {
"positivePrompt": "正向提示词",
@@ -1353,13 +1324,7 @@
"noLoRAsInstalled": "无已安装的 LoRA",
"esrganModel": "ESRGAN 模型",
"addLora": "添加 LoRA",
"noLoRAsLoaded": "无已加载的 LoRA",
"noMainModelSelected": "未选择主模型",
"lora": "LoRA",
"allLoRAsAdded": "已添加所有 LoRA",
"defaultVAE": "默认 VAE",
"incompatibleBaseModel": "不兼容基础模型",
"loraAlreadyAdded": "LoRA 已经被添加"
"noLoRAsLoaded": "无已加载的 LoRA"
},
"boards": {
"autoAddBoard": "自动添加面板",
@@ -1403,9 +1368,7 @@
"maxPrompts": "最大提示词数",
"dynamicPrompts": "动态提示词",
"promptsWithCount_other": "{{count}} 个提示词",
"promptsPreview": "提示词预览",
"showDynamicPrompts": "显示动态提示词",
"loading": "生成动态提示词中..."
"promptsPreview": "提示词预览"
},
"popovers": {
"compositingMaskAdjustments": {
@@ -1687,28 +1650,5 @@
},
"app": {
"storeNotInitialized": "商店尚未初始化"
},
"accordions": {
"compositing": {
"infillTab": "内补",
"coherenceTab": "一致性层",
"title": "合成"
},
"control": {
"controlAdaptersTab": "Control Adapters",
"ipTab": "图像提示",
"title": "Control"
},
"generation": {
"title": "生成",
"conceptsTab": "概念",
"modelTab": "模型"
},
"advanced": {
"title": "高级"
},
"image": {
"title": "图像"
}
}
}

View File

@@ -43,7 +43,7 @@ export const useSocketIO = () => {
}, [baseUrl]);
const socketOptions = useMemo(() => {
const options: Partial<ManagerOptions & SocketOptions> = {
const options: Parameters<typeof io>[0] = {
timeout: 60000,
path: '/ws/socket.io',
autoConnect: false, // achtung! removing this breaks the dynamic middleware
@@ -71,7 +71,7 @@ export const useSocketIO = () => {
setEventListeners({ dispatch, socket });
socket.connect();
if ($isDebugging.get() || import.meta.env.MODE === 'development') {
if ($isDebugging.get()) {
window.$socketOptions = $socketOptions;
console.log('Socket initialized', socket);
}
@@ -79,7 +79,7 @@ export const useSocketIO = () => {
$isSocketInitialized.set(true);
return () => {
if ($isDebugging.get() || import.meta.env.MODE === 'development') {
if ($isDebugging.get()) {
window.$socketOptions = undefined;
console.log('Socket teardown', socket);
}

View File

@@ -1,14 +1,9 @@
import { createLogWriter } from '@roarr/browser-log-writer';
import { atom } from 'nanostores';
import type { Logger, MessageSerializer } from 'roarr';
import type { Logger } from 'roarr';
import { ROARR, Roarr } from 'roarr';
import { z } from 'zod';
const serializeMessage: MessageSerializer = (message) => {
return JSON.stringify(message);
};
ROARR.serializeMessage = serializeMessage;
ROARR.write = createLogWriter();
export const BASE_CONTEXT = {};

View File

@@ -1,37 +0,0 @@
import { StorageError } from 'app/store/enhancers/reduxRemember/errors';
import type { UseStore } from 'idb-keyval';
import {
clear,
createStore as createIDBKeyValStore,
get,
set,
} from 'idb-keyval';
import { action, atom } from 'nanostores';
import type { Driver } from 'redux-remember';
// Create a custom idb-keyval store (just needed to customize the name)
export const $idbKeyValStore = atom<UseStore>(
createIDBKeyValStore('invoke', 'invoke-store')
);
export const clearIdbKeyValStore = action($idbKeyValStore, 'clear', (store) => {
clear(store.get());
});
// Create redux-remember driver, wrapping idb-keyval
export const idbKeyValDriver: Driver = {
getItem: (key) => {
try {
return get(key, $idbKeyValStore.get());
} catch (originalError) {
throw new StorageError({ key, originalError });
}
},
setItem: (key, value) => {
try {
return set(key, value, $idbKeyValStore.get());
} catch (originalError) {
throw new StorageError({ key, value, originalError });
}
},
};

View File

@@ -1,41 +0,0 @@
import { logger } from 'app/logging/logger';
import { parseify } from 'common/util/serialize';
import { PersistError, RehydrateError } from 'redux-remember';
import { serializeError } from 'serialize-error';
export type StorageErrorArgs = {
key: string;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ // any is correct
value?: any;
originalError?: unknown;
};
export class StorageError extends Error {
key: string;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */ // any is correct
value?: any;
originalError?: Error;
constructor({ key, value, originalError }: StorageErrorArgs) {
super(`Error setting ${key}`);
this.name = 'StorageSetError';
this.key = key;
if (value !== undefined) {
this.value = value;
}
if (originalError instanceof Error) {
this.originalError = originalError;
}
}
}
export const errorHandler = (err: PersistError | RehydrateError) => {
const log = logger('system');
if (err instanceof PersistError) {
log.error({ error: serializeError(err) }, 'Problem persisting state');
} else if (err instanceof RehydrateError) {
log.error({ error: serializeError(err) }, 'Problem rehydrating state');
} else {
log.error({ error: parseify(err) }, 'Problem in persistence layer');
}
};

View File

@@ -0,0 +1,30 @@
import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist';
import { controlAdaptersPersistDenylist } from 'features/controlAdapters/store/controlAdaptersPersistDenylist';
import { dynamicPromptsPersistDenylist } from 'features/dynamicPrompts/store/dynamicPromptsPersistDenylist';
import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist';
import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist';
import { generationPersistDenylist } from 'features/parameters/store/generationPersistDenylist';
import { postprocessingPersistDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
import { systemPersistDenylist } from 'features/system/store/systemPersistDenylist';
import { uiPersistDenylist } from 'features/ui/store/uiPersistDenylist';
import { omit } from 'lodash-es';
import type { SerializeFunction } from 'redux-remember';
const serializationDenylist: {
[key: string]: string[];
} = {
canvas: canvasPersistDenylist,
gallery: galleryPersistDenylist,
generation: generationPersistDenylist,
nodes: nodesPersistDenylist,
postprocessing: postprocessingPersistDenylist,
system: systemPersistDenylist,
ui: uiPersistDenylist,
controlNet: controlAdaptersPersistDenylist,
dynamicPrompts: dynamicPromptsPersistDenylist,
};
export const serialize: SerializeFunction = (data, key) => {
const result = omit(data, serializationDenylist[key] ?? []);
return JSON.stringify(result);
};

View File

@@ -0,0 +1,34 @@
import { initialCanvasState } from 'features/canvas/store/canvasSlice';
import { initialControlAdapterState } from 'features/controlAdapters/store/controlAdaptersSlice';
import { initialDynamicPromptsState } from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { initialGalleryState } from 'features/gallery/store/gallerySlice';
import { initialNodesState } from 'features/nodes/store/nodesSlice';
import { initialGenerationState } from 'features/parameters/store/generationSlice';
import { initialPostprocessingState } from 'features/parameters/store/postprocessingSlice';
import { initialSDXLState } from 'features/sdxl/store/sdxlSlice';
import { initialConfigState } from 'features/system/store/configSlice';
import { initialSystemState } from 'features/system/store/systemSlice';
import { initialUIState } from 'features/ui/store/uiSlice';
import { defaultsDeep } from 'lodash-es';
import type { UnserializeFunction } from 'redux-remember';
const initialStates: {
[key: string]: object; // TODO: type this properly
} = {
canvas: initialCanvasState,
gallery: initialGalleryState,
generation: initialGenerationState,
nodes: initialNodesState,
postprocessing: initialPostprocessingState,
system: initialSystemState,
config: initialConfigState,
ui: initialUIState,
controlAdapters: initialControlAdapterState,
dynamicPrompts: initialDynamicPromptsState,
sdxl: initialSDXLState,
};
export const unserialize: UnserializeFunction = (data, key) => {
const result = defaultsDeep(JSON.parse(data), initialStates[key]);
return result;
};

View File

@@ -1,16 +0,0 @@
import type { Middleware, MiddlewareAPI } from '@reduxjs/toolkit';
import { diff } from 'jsondiffpatch';
/**
* Super simple logger middleware. Useful for debugging when the redux devtools are awkward.
*/
export const debugLoggerMiddleware: Middleware =
(api: MiddlewareAPI) => (next) => (action) => {
const originalState = api.getState();
console.log('REDUX: dispatching', action);
const result = next(action);
const nextState = api.getState();
console.log('REDUX: next state', nextState);
console.log('REDUX: diff', diff(originalState, nextState));
return result;
};

View File

@@ -1,10 +1,8 @@
import type { UnknownAction } from '@reduxjs/toolkit';
import { isAnyGraphBuilt } from 'features/nodes/store/actions';
import { nodeTemplatesBuilt } from 'features/nodes/store/nodeTemplatesSlice';
import { cloneDeep } from 'lodash-es';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import type { Graph } from 'services/api/types';
import { socketGeneratorProgress } from 'services/events/actions';
export const actionSanitizer = <A extends UnknownAction>(action: A): A => {
if (isAnyGraphBuilt(action)) {
@@ -32,14 +30,5 @@ export const actionSanitizer = <A extends UnknownAction>(action: A): A => {
};
}
if (socketGeneratorProgress.match(action)) {
const sanitized = cloneDeep(action);
if (sanitized.payload.data.progress_image) {
sanitized.payload.data.progress_image.dataURL =
'<Progress image omitted>';
}
return sanitized;
}
return action;
};

View File

@@ -1,16 +1,16 @@
/**
* This is a list of actions that should be excluded in the Redux DevTools.
*/
export const actionsDenylist: string[] = [
export const actionsDenylist = [
// very spammy canvas actions
// 'canvas/setStageCoordinates',
// 'canvas/setStageScale',
// 'canvas/setBoundingBoxCoordinates',
// 'canvas/setBoundingBoxDimensions',
// 'canvas/addPointToCurrentLine',
'canvas/setStageCoordinates',
'canvas/setStageScale',
'canvas/setBoundingBoxCoordinates',
'canvas/setBoundingBoxDimensions',
'canvas/addPointToCurrentLine',
// bazillions during generation
// 'socket/socketGeneratorProgress',
// 'socket/appSocketGeneratorProgress',
'socket/socketGeneratorProgress',
'socket/appSocketGeneratorProgress',
// this happens after every state change
// '@@REMEMBER_PERSISTED',
'@@REMEMBER_PERSISTED',
];

View File

@@ -11,7 +11,7 @@ import {
import { getShouldProcessPrompt } from 'features/dynamicPrompts/util/getShouldProcessPrompt';
import { setPositivePrompt } from 'features/parameters/store/generationSlice';
import { utilitiesApi } from 'services/api/endpoints/utilities';
import { socketConnected } from 'services/events/actions';
import { appSocketConnected } from 'services/events/actions';
import { startAppListening } from '..';
@@ -20,7 +20,7 @@ const matcher = isAnyOf(
combinatorialToggled,
maxPromptsChanged,
maxPromptsReset,
socketConnected
appSocketConnected
);
export const addDynamicPromptsListener = () => {

View File

@@ -3,16 +3,16 @@ import { isInitializedChanged } from 'features/system/store/systemSlice';
import { size } from 'lodash-es';
import { api } from 'services/api';
import { receivedOpenAPISchema } from 'services/api/thunks/schema';
import { socketConnected } from 'services/events/actions';
import { appSocketConnected, socketConnected } from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketConnectedEventListener = () => {
startAppListening({
actionCreator: socketConnected,
effect: (action, { dispatch, getState }) => {
const log = logger('socketio');
log.debug('Connected');
const { nodeTemplates, config, system } = getState();
@@ -29,6 +29,9 @@ export const addSocketConnectedEventListener = () => {
} else {
dispatch(isInitializedChanged(true));
}
// pass along the socket event as an application action
dispatch(appSocketConnected(action.payload));
},
});
};

View File

@@ -1,15 +1,20 @@
import { logger } from 'app/logging/logger';
import { socketDisconnected } from 'services/events/actions';
import {
appSocketDisconnected,
socketDisconnected,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketDisconnectedEventListener = () => {
startAppListening({
actionCreator: socketDisconnected,
effect: () => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.debug('Disconnected');
// pass along the socket event as an application action
dispatch(appSocketDisconnected(action.payload));
},
});
};

View File

@@ -1,15 +1,20 @@
import { logger } from 'app/logging/logger';
import { socketGeneratorProgress } from 'services/events/actions';
import {
appSocketGeneratorProgress,
socketGeneratorProgress,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addGeneratorProgressEventListener = () => {
startAppListening({
actionCreator: socketGeneratorProgress,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.trace(action.payload, `Generator progress`);
dispatch(appSocketGeneratorProgress(action.payload));
},
});
};

View File

@@ -1,15 +1,19 @@
import { logger } from 'app/logging/logger';
import { socketGraphExecutionStateComplete } from 'services/events/actions';
import {
appSocketGraphExecutionStateComplete,
socketGraphExecutionStateComplete,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addGraphExecutionStateCompleteEventListener = () => {
startAppListening({
actionCreator: socketGraphExecutionStateComplete,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.debug(action.payload, 'Session complete');
// pass along the socket event as an application action
dispatch(appSocketGraphExecutionStateComplete(action.payload));
},
});
};

View File

@@ -15,19 +15,21 @@ import {
import { boardsApi } from 'services/api/endpoints/boards';
import { imagesApi } from 'services/api/endpoints/images';
import { imagesAdapter } from 'services/api/util';
import { socketInvocationComplete } from 'services/events/actions';
import {
appSocketInvocationComplete,
socketInvocationComplete,
} from 'services/events/actions';
import { startAppListening } from '../..';
// These nodes output an image, but do not actually *save* an image, so we don't want to handle the gallery logic on them
const nodeTypeDenylist = ['load_image', 'image'];
const log = logger('socketio');
export const addInvocationCompleteEventListener = () => {
startAppListening({
actionCreator: socketInvocationComplete,
effect: async (action, { dispatch, getState }) => {
const log = logger('socketio');
const { data } = action.payload;
log.debug(
{ data: parseify(data) },
@@ -134,6 +136,8 @@ export const addInvocationCompleteEventListener = () => {
}
}
}
// pass along the socket event as an application action
dispatch(appSocketInvocationComplete(action.payload));
},
});
};

View File

@@ -1,18 +1,21 @@
import { logger } from 'app/logging/logger';
import { socketInvocationError } from 'services/events/actions';
import {
appSocketInvocationError,
socketInvocationError,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addInvocationErrorEventListener = () => {
startAppListening({
actionCreator: socketInvocationError,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.error(
action.payload,
`Invocation error (${action.payload.data.node.type})`
);
dispatch(appSocketInvocationError(action.payload));
},
});
};

View File

@@ -1,18 +1,21 @@
import { logger } from 'app/logging/logger';
import { socketInvocationRetrievalError } from 'services/events/actions';
import {
appSocketInvocationRetrievalError,
socketInvocationRetrievalError,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addInvocationRetrievalErrorEventListener = () => {
startAppListening({
actionCreator: socketInvocationRetrievalError,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.error(
action.payload,
`Invocation retrieval error (${action.payload.data.graph_execution_state_id})`
);
dispatch(appSocketInvocationRetrievalError(action.payload));
},
});
};

View File

@@ -1,18 +1,23 @@
import { logger } from 'app/logging/logger';
import { socketInvocationStarted } from 'services/events/actions';
import {
appSocketInvocationStarted,
socketInvocationStarted,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addInvocationStartedEventListener = () => {
startAppListening({
actionCreator: socketInvocationStarted,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.debug(
action.payload,
`Invocation started (${action.payload.data.node.type})`
);
dispatch(appSocketInvocationStarted(action.payload));
},
});
};

View File

@@ -1,17 +1,18 @@
import { logger } from 'app/logging/logger';
import {
appSocketModelLoadCompleted,
appSocketModelLoadStarted,
socketModelLoadCompleted,
socketModelLoadStarted,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addModelLoadEventListener = () => {
startAppListening({
actionCreator: socketModelLoadStarted,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
const { base_model, model_name, model_type, submodel } =
action.payload.data;
@@ -22,12 +23,16 @@ export const addModelLoadEventListener = () => {
}
log.debug(action.payload, message);
// pass along the socket event as an application action
dispatch(appSocketModelLoadStarted(action.payload));
},
});
startAppListening({
actionCreator: socketModelLoadCompleted,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
const { base_model, model_name, model_type, submodel } =
action.payload.data;
@@ -38,6 +43,8 @@ export const addModelLoadEventListener = () => {
}
log.debug(action.payload, message);
// pass along the socket event as an application action
dispatch(appSocketModelLoadCompleted(action.payload));
},
});
};

View File

@@ -1,15 +1,18 @@
import { logger } from 'app/logging/logger';
import { queueApi, queueItemsAdapter } from 'services/api/endpoints/queue';
import { socketQueueItemStatusChanged } from 'services/events/actions';
import {
appSocketQueueItemStatusChanged,
socketQueueItemStatusChanged,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketQueueItemStatusChangedEventListener = () => {
startAppListening({
actionCreator: socketQueueItemStatusChanged,
effect: async (action, { dispatch }) => {
const log = logger('socketio');
// we've got new status for the queue item, batch and queue
const { queue_item, batch_status, queue_status } = action.payload.data;
@@ -70,6 +73,9 @@ export const addSocketQueueItemStatusChangedEventListener = () => {
'InvocationCacheStatus',
])
);
// Pass the event along
dispatch(appSocketQueueItemStatusChanged(action.payload));
},
});
};

View File

@@ -1,18 +1,21 @@
import { logger } from 'app/logging/logger';
import { socketSessionRetrievalError } from 'services/events/actions';
import {
appSocketSessionRetrievalError,
socketSessionRetrievalError,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSessionRetrievalErrorEventListener = () => {
startAppListening({
actionCreator: socketSessionRetrievalError,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.error(
action.payload,
`Session retrieval error (${action.payload.data.graph_execution_state_id})`
);
dispatch(appSocketSessionRetrievalError(action.payload));
},
});
};

View File

@@ -1,15 +1,18 @@
import { logger } from 'app/logging/logger';
import { socketSubscribedSession } from 'services/events/actions';
import {
appSocketSubscribedSession,
socketSubscribedSession,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketSubscribedEventListener = () => {
startAppListening({
actionCreator: socketSubscribedSession,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.debug(action.payload, 'Subscribed');
dispatch(appSocketSubscribedSession(action.payload));
},
});
};

View File

@@ -1,14 +1,18 @@
import { logger } from 'app/logging/logger';
import { socketUnsubscribedSession } from 'services/events/actions';
import {
appSocketUnsubscribedSession,
socketUnsubscribedSession,
} from 'services/events/actions';
import { startAppListening } from '../..';
const log = logger('socketio');
export const addSocketUnsubscribedEventListener = () => {
startAppListening({
actionCreator: socketUnsubscribedSession,
effect: (action) => {
effect: (action, { dispatch }) => {
const log = logger('socketio');
log.debug(action.payload, 'Unsubscribed');
dispatch(appSocketUnsubscribedSession(action.payload));
},
});
};

View File

@@ -4,94 +4,40 @@ import {
combineReducers,
configureStore,
} from '@reduxjs/toolkit';
import { logger } from 'app/logging/logger';
import { idbKeyValDriver } from 'app/store/enhancers/reduxRemember/driver';
import { errorHandler } from 'app/store/enhancers/reduxRemember/errors';
import { canvasPersistDenylist } from 'features/canvas/store/canvasPersistDenylist';
import canvasReducer, {
initialCanvasState,
migrateCanvasState,
} from 'features/canvas/store/canvasSlice';
import canvasReducer from 'features/canvas/store/canvasSlice';
import changeBoardModalReducer from 'features/changeBoardModal/store/slice';
import { controlAdaptersPersistDenylist } from 'features/controlAdapters/store/controlAdaptersPersistDenylist';
import controlAdaptersReducer, {
initialControlAdaptersState,
migrateControlAdaptersState,
} from 'features/controlAdapters/store/controlAdaptersSlice';
import controlAdaptersReducer from 'features/controlAdapters/store/controlAdaptersSlice';
import deleteImageModalReducer from 'features/deleteImageModal/store/slice';
import { dynamicPromptsPersistDenylist } from 'features/dynamicPrompts/store/dynamicPromptsPersistDenylist';
import dynamicPromptsReducer, {
initialDynamicPromptsState,
migrateDynamicPromptsState,
} from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import { galleryPersistDenylist } from 'features/gallery/store/galleryPersistDenylist';
import galleryReducer, {
initialGalleryState,
migrateGalleryState,
} from 'features/gallery/store/gallerySlice';
import hrfReducer, {
initialHRFState,
migrateHRFState,
} from 'features/hrf/store/hrfSlice';
import loraReducer, {
initialLoraState,
migrateLoRAState,
} from 'features/lora/store/loraSlice';
import modelmanagerReducer, {
initialModelManagerState,
migrateModelManagerState,
} from 'features/modelManager/store/modelManagerSlice';
import { nodesPersistDenylist } from 'features/nodes/store/nodesPersistDenylist';
import nodesReducer, {
initialNodesState,
migrateNodesState,
} from 'features/nodes/store/nodesSlice';
import dynamicPromptsReducer from 'features/dynamicPrompts/store/dynamicPromptsSlice';
import galleryReducer from 'features/gallery/store/gallerySlice';
import hrfReducer from 'features/hrf/store/hrfSlice';
import loraReducer from 'features/lora/store/loraSlice';
import modelmanagerReducer from 'features/modelManager/store/modelManagerSlice';
import nodesReducer from 'features/nodes/store/nodesSlice';
import nodeTemplatesReducer from 'features/nodes/store/nodeTemplatesSlice';
import workflowReducer, {
initialWorkflowState,
migrateWorkflowState,
} from 'features/nodes/store/workflowSlice';
import { generationPersistDenylist } from 'features/parameters/store/generationPersistDenylist';
import generationReducer, {
initialGenerationState,
migrateGenerationState,
} from 'features/parameters/store/generationSlice';
import { postprocessingPersistDenylist } from 'features/parameters/store/postprocessingPersistDenylist';
import postprocessingReducer, {
initialPostprocessingState,
migratePostprocessingState,
} from 'features/parameters/store/postprocessingSlice';
import workflowReducer from 'features/nodes/store/workflowSlice';
import generationReducer from 'features/parameters/store/generationSlice';
import postprocessingReducer from 'features/parameters/store/postprocessingSlice';
import queueReducer from 'features/queue/store/queueSlice';
import sdxlReducer, {
initialSDXLState,
migrateSDXLState,
} from 'features/sdxl/store/sdxlSlice';
import sdxlReducer from 'features/sdxl/store/sdxlSlice';
import configReducer from 'features/system/store/configSlice';
import { systemPersistDenylist } from 'features/system/store/systemPersistDenylist';
import systemReducer, {
initialSystemState,
migrateSystemState,
} from 'features/system/store/systemSlice';
import { uiPersistDenylist } from 'features/ui/store/uiPersistDenylist';
import uiReducer, {
initialUIState,
migrateUIState,
} from 'features/ui/store/uiSlice';
import { diff } from 'jsondiffpatch';
import { defaultsDeep, keys, omit, pick } from 'lodash-es';
import systemReducer from 'features/system/store/systemSlice';
import uiReducer from 'features/ui/store/uiSlice';
import { createStore as createIDBKeyValStore, get, set } from 'idb-keyval';
import dynamicMiddlewares from 'redux-dynamic-middlewares';
import type { SerializeFunction, UnserializeFunction } from 'redux-remember';
import type { Driver } from 'redux-remember';
import { rememberEnhancer, rememberReducer } from 'redux-remember';
import { serializeError } from 'serialize-error';
import { api } from 'services/api';
import { authToastMiddleware } from 'services/api/authToastMiddleware';
import type { JsonObject } from 'type-fest';
import { STORAGE_PREFIX } from './constants';
import { serialize } from './enhancers/reduxRemember/serialize';
import { unserialize } from './enhancers/reduxRemember/unserialize';
import { actionSanitizer } from './middleware/devtools/actionSanitizer';
import { actionsDenylist } from './middleware/devtools/actionsDenylist';
import { stateSanitizer } from './middleware/devtools/stateSanitizer';
import { listenerMiddleware } from './middleware/listenerMiddleware';
const allReducers = {
canvas: canvasReducer,
gallery: galleryReducer,
@@ -119,7 +65,7 @@ const rootReducer = combineReducers(allReducers);
const rememberedRootReducer = rememberReducer(rootReducer);
const rememberedKeys = [
const rememberedKeys: (keyof typeof allReducers)[] = [
'canvas',
'gallery',
'generation',
@@ -134,106 +80,15 @@ const rememberedKeys = [
'lora',
'modelmanager',
'hrf',
] satisfies (keyof typeof allReducers)[];
];
type SliceConfig = {
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
initialState: any;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
migrate: (state: any) => any;
};
// Create a custom idb-keyval store (just needed to customize the name)
export const idbKeyValStore = createIDBKeyValStore('invoke', 'invoke-store');
const sliceConfigs: {
[key in (typeof rememberedKeys)[number]]: SliceConfig;
} = {
canvas: { initialState: initialCanvasState, migrate: migrateCanvasState },
gallery: { initialState: initialGalleryState, migrate: migrateGalleryState },
generation: {
initialState: initialGenerationState,
migrate: migrateGenerationState,
},
nodes: { initialState: initialNodesState, migrate: migrateNodesState },
postprocessing: {
initialState: initialPostprocessingState,
migrate: migratePostprocessingState,
},
system: { initialState: initialSystemState, migrate: migrateSystemState },
workflow: {
initialState: initialWorkflowState,
migrate: migrateWorkflowState,
},
ui: { initialState: initialUIState, migrate: migrateUIState },
controlAdapters: {
initialState: initialControlAdaptersState,
migrate: migrateControlAdaptersState,
},
dynamicPrompts: {
initialState: initialDynamicPromptsState,
migrate: migrateDynamicPromptsState,
},
sdxl: { initialState: initialSDXLState, migrate: migrateSDXLState },
lora: { initialState: initialLoraState, migrate: migrateLoRAState },
modelmanager: {
initialState: initialModelManagerState,
migrate: migrateModelManagerState,
},
hrf: { initialState: initialHRFState, migrate: migrateHRFState },
};
const unserialize: UnserializeFunction = (data, key) => {
const log = logger('system');
const config = sliceConfigs[key as keyof typeof sliceConfigs];
if (!config) {
throw new Error(`No unserialize config for slice "${key}"`);
}
try {
const { initialState, migrate } = config;
const parsed = JSON.parse(data);
// strip out old keys
const stripped = pick(parsed, keys(initialState));
// run (additive) migrations
const migrated = migrate(stripped);
// merge in initial state as default values, covering any missing keys
const transformed = defaultsDeep(migrated, initialState);
log.debug(
{
persistedData: parsed,
rehydratedData: transformed,
diff: diff(parsed, transformed) as JsonObject, // this is always serializable
},
`Rehydrated slice "${key}"`
);
return transformed;
} catch (err) {
log.warn(
{ error: serializeError(err) },
`Error rehydrating slice "${key}", falling back to default initial state`
);
return config.initialState;
}
};
const serializationDenylist: {
[key in (typeof rememberedKeys)[number]]?: string[];
} = {
canvas: canvasPersistDenylist,
gallery: galleryPersistDenylist,
generation: generationPersistDenylist,
nodes: nodesPersistDenylist,
postprocessing: postprocessingPersistDenylist,
system: systemPersistDenylist,
ui: uiPersistDenylist,
controlAdapters: controlAdaptersPersistDenylist,
dynamicPrompts: dynamicPromptsPersistDenylist,
};
export const serialize: SerializeFunction = (data, key) => {
const result = omit(
data,
serializationDenylist[key as keyof typeof serializationDenylist] ?? []
);
return JSON.stringify(result);
// Create redux-remember driver, wrapping idb-keyval
const idbKeyValDriver: Driver = {
getItem: (key) => get(key, idbKeyValStore),
setItem: (key, value) => set(key, value, idbKeyValStore),
};
export const createStore = (uniqueStoreKey?: string, persist = true) =>
@@ -259,7 +114,6 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
prefix: uniqueStoreKey
? `${STORAGE_PREFIX}${uniqueStoreKey}-`
: STORAGE_PREFIX,
errorHandler,
})
);
}
@@ -270,9 +124,21 @@ export const createStore = (uniqueStoreKey?: string, persist = true) =>
stateSanitizer,
trace: true,
predicate: (state, action) => {
// TODO: hook up to the log level param in system slice
// manually type state, cannot type the arg
// const typedState = state as ReturnType<typeof rootReducer>;
// TODO: doing this breaks the rtk query devtools, commenting out for now
// if (action.type.startsWith('api/')) {
// // don't log api actions, with manual cache updates they are extremely noisy
// return false;
// }
if (actionsDenylist.includes(action.type)) {
// don't log other noisy actions
return false;
}
return true;
},
},

View File

@@ -43,16 +43,6 @@ export type SDFeature =
| 'vae'
| 'hrf';
export type NumericalParameterConfig = {
initial: number;
sliderMin: number;
sliderMax: number;
numberInputMin: number;
numberInputMax: number;
fineStep: number;
coarseStep: number;
};
/**
* Configuration options for the InvokeAI UI.
* Distinct from system settings which may be changed inside the app.
@@ -76,32 +66,69 @@ export type AppConfig = {
defaultModel?: string;
disabledControlNetModels: string[];
disabledControlNetProcessors: (keyof typeof CONTROLNET_PROCESSORS)[];
// Core parameters
iterations: NumericalParameterConfig;
width: NumericalParameterConfig; // initial value comes from model
height: NumericalParameterConfig; // initial value comes from model
steps: NumericalParameterConfig;
guidance: NumericalParameterConfig;
cfgRescaleMultiplier: NumericalParameterConfig;
img2imgStrength: NumericalParameterConfig;
// Canvas
boundingBoxHeight: NumericalParameterConfig; // initial value comes from model
boundingBoxWidth: NumericalParameterConfig; // initial value comes from model
scaledBoundingBoxHeight: NumericalParameterConfig; // initial value comes from model
scaledBoundingBoxWidth: NumericalParameterConfig; // initial value comes from model
canvasCoherenceStrength: NumericalParameterConfig;
canvasCoherenceSteps: NumericalParameterConfig;
infillTileSize: NumericalParameterConfig;
infillPatchmatchDownscaleSize: NumericalParameterConfig;
// Misc advanced
clipSkip: NumericalParameterConfig; // slider and input max are ignored for this, because the values depend on the model
maskBlur: NumericalParameterConfig;
hrfStrength: NumericalParameterConfig;
dynamicPrompts: {
maxPrompts: NumericalParameterConfig;
iterations: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
ca: {
weight: NumericalParameterConfig;
width: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
height: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
steps: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
guidance: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
img2imgStrength: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
hrfStrength: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
fineStep: number;
coarseStep: number;
};
dynamicPrompts: {
maxPrompts: {
initial: number;
min: number;
sliderMax: number;
inputMax: number;
};
};
};
};

Binary file not shown.

After

Width:  |  Height:  |  Size: 336 KiB

Binary file not shown.

After

Width:  |  Height:  |  Size: 43 KiB

View File

@@ -45,7 +45,6 @@ export const InvControl = memo(
orientation={orientation}
isDisabled={isDisabled}
{...formControlProps}
{...ctx.controlProps}
>
<Flex className="invcontrol-label-wrapper">
{label && (

View File

@@ -1,10 +1,9 @@
import type { FormControlProps, FormLabelProps } from '@chakra-ui/react';
import type { FormLabelProps } from '@chakra-ui/react';
import type { PropsWithChildren } from 'react';
import { createContext, memo } from 'react';
export type InvControlGroupProps = {
labelProps?: FormLabelProps;
controlProps?: FormControlProps;
isDisabled?: boolean;
orientation?: 'horizontal' | 'vertical';
};

View File

@@ -42,9 +42,9 @@ const line = definePartsStyle(() => ({
px: 4,
py: 1,
fontSize: 'sm',
color: 'base.200',
color: 'base.400',
_selected: {
color: 'blue.200',
color: 'blue.400',
},
},
tabpanel: {

View File

@@ -1,9 +1,10 @@
import { clearIdbKeyValStore } from 'app/store/enhancers/reduxRemember/driver';
import { idbKeyValStore } from 'app/store/store';
import { clear } from 'idb-keyval';
import { useCallback } from 'react';
export const useClearStorage = () => {
const clearStorage = useCallback(() => {
clearIdbKeyValStore();
clear(idbKeyValStore);
localStorage.clear();
}, []);

View File

@@ -57,6 +57,7 @@ export const useGlobalHotkeys = () => {
{
enabled: () => !isDisabledCancelQueueItem && !isLoadingCancelQueueItem,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
[cancelQueueItem, isDisabledCancelQueueItem, isLoadingCancelQueueItem]
);
@@ -73,6 +74,7 @@ export const useGlobalHotkeys = () => {
{
enabled: () => !isDisabledClearQueue && !isLoadingClearQueue,
preventDefault: true,
enableOnFormTags: ['input', 'textarea', 'select'],
},
[clearQueue, isDisabledClearQueue, isLoadingClearQueue]
);

View File

@@ -6,7 +6,7 @@ import { isStagingSelector } from 'features/canvas/store/canvasSelectors';
import { clearCanvasHistory } from 'features/canvas/store/canvasSlice';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
import { FaTrash } from 'react-icons/fa';
const ClearCanvasHistoryButtonModal = () => {
const isStaging = useAppSelector(isStagingSelector);
@@ -23,7 +23,7 @@ const ClearCanvasHistoryButtonModal = () => {
<InvButton
onClick={onOpen}
size="sm"
leftIcon={<PiTrashSimpleFill />}
leftIcon={<FaTrash />}
isDisabled={isStaging}
>
{t('unifiedCanvas.clearCanvasHistory')}

View File

@@ -19,14 +19,14 @@ import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiArrowLeftBold,
PiArrowRightBold,
PiCheckBold,
PiEyeBold,
PiEyeSlashBold,
PiFloppyDiskBold,
PiXBold,
} from 'react-icons/pi';
FaArrowLeft,
FaArrowRight,
FaCheck,
FaEye,
FaEyeSlash,
FaSave,
FaTimes,
} from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
const selector = createMemoizedSelector(selectCanvasSlice, (canvas) => {
@@ -140,7 +140,7 @@ const IAICanvasStagingAreaToolbar = () => {
<InvIconButton
tooltip={`${t('unifiedCanvas.previous')} (Left)`}
aria-label={`${t('unifiedCanvas.previous')} (Left)`}
icon={<PiArrowLeftBold />}
icon={<FaArrowLeft />}
onClick={handlePrevImage}
colorScheme="invokeBlue"
isDisabled={!shouldShowStagingImage}
@@ -154,7 +154,7 @@ const IAICanvasStagingAreaToolbar = () => {
<InvIconButton
tooltip={`${t('unifiedCanvas.next')} (Right)`}
aria-label={`${t('unifiedCanvas.next')} (Right)`}
icon={<PiArrowRightBold />}
icon={<FaArrowRight />}
onClick={handleNextImage}
colorScheme="invokeBlue"
isDisabled={!shouldShowStagingImage}
@@ -164,7 +164,7 @@ const IAICanvasStagingAreaToolbar = () => {
<InvIconButton
tooltip={`${t('unifiedCanvas.accept')} (Enter)`}
aria-label={`${t('unifiedCanvas.accept')} (Enter)`}
icon={<PiCheckBold />}
icon={<FaCheck />}
onClick={handleAccept}
colorScheme="invokeBlue"
/>
@@ -180,7 +180,7 @@ const IAICanvasStagingAreaToolbar = () => {
: t('unifiedCanvas.showResultsOff')
}
data-alert={!shouldShowStagingImage}
icon={shouldShowStagingImage ? <PiEyeBold /> : <PiEyeSlashBold />}
icon={shouldShowStagingImage ? <FaEye /> : <FaEyeSlash />}
onClick={handleToggleShouldShowStagingImage}
colorScheme="invokeBlue"
/>
@@ -188,14 +188,14 @@ const IAICanvasStagingAreaToolbar = () => {
tooltip={t('unifiedCanvas.saveToGallery')}
aria-label={t('unifiedCanvas.saveToGallery')}
isDisabled={!imageDTO || !imageDTO.is_intermediate}
icon={<PiFloppyDiskBold />}
icon={<FaSave />}
onClick={handleSaveToGallery}
colorScheme="invokeBlue"
/>
<InvIconButton
tooltip={t('unifiedCanvas.discardAll')}
aria-label={t('unifiedCanvas.discardAll')}
icon={<PiXBold />}
icon={<FaTimes />}
onClick={handleDiscardStagingArea}
colorScheme="error"
fontSize={20}

View File

@@ -25,11 +25,7 @@ import { memo, useCallback } from 'react';
import type { RgbaColor } from 'react-colorful';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiExcludeBold,
PiFloppyDiskBackFill,
PiTrashSimpleFill,
} from 'react-icons/pi';
import { FaMask, FaSave, FaTrash } from 'react-icons/fa';
const IAICanvasMaskOptions = () => {
const dispatch = useAppDispatch();
@@ -114,7 +110,7 @@ const IAICanvasMaskOptions = () => {
<InvIconButton
aria-label={t('unifiedCanvas.maskingOptions')}
tooltip={t('unifiedCanvas.maskingOptions')}
icon={<PiExcludeBold />}
icon={<FaMask />}
isChecked={layer === 'mask'}
isDisabled={isStaging}
/>
@@ -140,16 +136,12 @@ const IAICanvasMaskOptions = () => {
onChange={handleChangeMaskColor}
/>
</Box>
<InvButton
size="sm"
leftIcon={<PiFloppyDiskBackFill />}
onClick={handleSaveMask}
>
<InvButton size="sm" leftIcon={<FaSave />} onClick={handleSaveMask}>
{t('unifiedCanvas.saveMask')}
</InvButton>
<InvButton
size="sm"
leftIcon={<PiTrashSimpleFill />}
leftIcon={<FaTrash />}
onClick={handleClearMask}
>
{t('unifiedCanvas.clearMask')}

View File

@@ -5,7 +5,7 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowClockwiseBold } from 'react-icons/pi';
import { FaRedo } from 'react-icons/fa';
const IAICanvasRedoButton = () => {
const dispatch = useAppDispatch();
@@ -34,7 +34,7 @@ const IAICanvasRedoButton = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.redo')} (Ctrl+Shift+Z)`}
tooltip={`${t('unifiedCanvas.redo')} (Ctrl+Shift+Z)`}
icon={<PiArrowClockwiseBold />}
icon={<FaRedo />}
onClick={handleRedo}
isDisabled={!canRedo}
/>

View File

@@ -24,7 +24,7 @@ import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiGearSixBold } from 'react-icons/pi';
import { FaWrench } from 'react-icons/fa';
const IAICanvasSettingsButtonPopover = () => {
const dispatch = useAppDispatch();
@@ -114,7 +114,7 @@ const IAICanvasSettingsButtonPopover = () => {
<InvIconButton
tooltip={t('unifiedCanvas.canvasSettings')}
aria-label={t('unifiedCanvas.canvasSettings')}
icon={<PiGearSixBold />}
icon={<FaWrench />}
/>
</InvPopoverTrigger>
<InvPopoverContent>

View File

@@ -26,13 +26,13 @@ import type { RgbaColor } from 'react-colorful';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiEraserBold,
PiEyedropperBold,
PiPaintBrushBold,
PiPaintBucketBold,
PiSlidersHorizontalBold,
PiXBold,
} from 'react-icons/pi';
FaEraser,
FaEyeDropper,
FaFillDrip,
FaPaintBrush,
FaSlidersH,
FaTimes,
} from 'react-icons/fa';
const IAICanvasToolChooserOptions = () => {
const dispatch = useAppDispatch();
@@ -198,7 +198,7 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.brush')} (B)`}
tooltip={`${t('unifiedCanvas.brush')} (B)`}
icon={<PiPaintBrushBold />}
icon={<FaPaintBrush />}
isChecked={tool === 'brush' && !isStaging}
onClick={handleSelectBrushTool}
isDisabled={isStaging}
@@ -206,7 +206,7 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.eraser')} (E)`}
tooltip={`${t('unifiedCanvas.eraser')} (E)`}
icon={<PiEraserBold />}
icon={<FaEraser />}
isChecked={tool === 'eraser' && !isStaging}
isDisabled={isStaging}
onClick={handleSelectEraserTool}
@@ -214,21 +214,21 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.fillBoundingBox')} (Shift+F)`}
tooltip={`${t('unifiedCanvas.fillBoundingBox')} (Shift+F)`}
icon={<PiPaintBucketBold />}
icon={<FaFillDrip />}
isDisabled={isStaging}
onClick={handleFillRect}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.eraseBoundingBox')} (Del/Backspace)`}
tooltip={`${t('unifiedCanvas.eraseBoundingBox')} (Del/Backspace)`}
icon={<PiXBold />}
icon={<FaTimes />}
isDisabled={isStaging}
onClick={handleEraseBoundingBox}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.colorPicker')} (C)`}
tooltip={`${t('unifiedCanvas.colorPicker')} (C)`}
icon={<PiEyedropperBold />}
icon={<FaEyeDropper />}
isChecked={tool === 'colorPicker' && !isStaging}
isDisabled={isStaging}
onClick={handleSelectColorPickerTool}
@@ -238,7 +238,7 @@ const IAICanvasToolChooserOptions = () => {
<InvIconButton
aria-label={t('unifiedCanvas.brushOptions')}
tooltip={t('unifiedCanvas.brushOptions')}
icon={<PiSlidersHorizontalBold />}
icon={<FaSlidersH />}
/>
</InvPopoverTrigger>
<InvPopoverContent>

View File

@@ -30,15 +30,15 @@ import { memo, useCallback, useMemo } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiCopyBold,
PiCrosshairSimpleBold,
PiDownloadSimpleBold,
PiFloppyDiskBold,
PiHandGrabbingBold,
PiStackBold,
PiTrashSimpleBold,
PiUploadSimpleBold,
} from 'react-icons/pi';
FaArrowsAlt,
FaCopy,
FaCrosshairs,
FaDownload,
FaLayerGroup,
FaSave,
FaTrash,
FaUpload,
} from 'react-icons/fa';
import IAICanvasMaskOptions from './IAICanvasMaskOptions';
import IAICanvasRedoButton from './IAICanvasRedoButton';
@@ -217,14 +217,14 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.move')} (V)`}
tooltip={`${t('unifiedCanvas.move')} (V)`}
icon={<PiHandGrabbingBold />}
icon={<FaArrowsAlt />}
isChecked={tool === 'move' || isStaging}
onClick={handleSelectMoveTool}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.resetView')} (R)`}
tooltip={`${t('unifiedCanvas.resetView')} (R)`}
icon={<PiCrosshairSimpleBold />}
icon={<FaCrosshairs />}
onClick={handleClickResetCanvasView}
/>
</InvButtonGroup>
@@ -233,14 +233,14 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
tooltip={`${t('unifiedCanvas.mergeVisible')} (Shift+M)`}
icon={<PiStackBold />}
icon={<FaLayerGroup />}
onClick={handleMergeVisible}
isDisabled={isStaging}
/>
<InvIconButton
aria-label={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
tooltip={`${t('unifiedCanvas.saveToGallery')} (Shift+S)`}
icon={<PiFloppyDiskBold />}
icon={<FaSave />}
onClick={handleSaveToGallery}
isDisabled={isStaging}
/>
@@ -248,7 +248,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
tooltip={`${t('unifiedCanvas.copyToClipboard')} (Cmd/Ctrl+C)`}
icon={<PiCopyBold />}
icon={<FaCopy />}
onClick={handleCopyImageToClipboard}
isDisabled={isStaging}
/>
@@ -256,7 +256,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
tooltip={`${t('unifiedCanvas.downloadAsImage')} (Shift+D)`}
icon={<PiDownloadSimpleBold />}
icon={<FaDownload />}
onClick={handleDownloadAsImage}
isDisabled={isStaging}
/>
@@ -270,7 +270,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('common.upload')}`}
tooltip={`${t('common.upload')}`}
icon={<PiUploadSimpleBold />}
icon={<FaUpload />}
isDisabled={isStaging}
{...getUploadButtonProps()}
/>
@@ -278,7 +278,7 @@ const IAICanvasToolbar = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.clearCanvas')}`}
tooltip={`${t('unifiedCanvas.clearCanvas')}`}
icon={<PiTrashSimpleBold />}
icon={<FaTrash />}
onClick={handleResetCanvas}
colorScheme="error"
isDisabled={isStaging}

View File

@@ -5,7 +5,7 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { PiArrowCounterClockwiseBold } from 'react-icons/pi';
import { FaUndo } from 'react-icons/fa';
const IAICanvasUndoButton = () => {
const dispatch = useAppDispatch();
@@ -33,7 +33,7 @@ const IAICanvasUndoButton = () => {
<InvIconButton
aria-label={`${t('unifiedCanvas.undo')} (Ctrl+Z)`}
tooltip={`${t('unifiedCanvas.undo')} (Ctrl+Z)`}
icon={<PiArrowCounterClockwiseBold />}
icon={<FaUndo />}
onClick={handleUndo}
isDisabled={!canUndo}
/>

View File

@@ -11,7 +11,6 @@ import { STAGE_PADDING_PERCENTAGE } from 'features/canvas/util/constants';
import floorCoordinates from 'features/canvas/util/floorCoordinates';
import getScaledBoundingBoxDimensions from 'features/canvas/util/getScaledBoundingBoxDimensions';
import roundDimensionsToMultiple from 'features/canvas/util/roundDimensionsToMultiple';
import { initialAspectRatioState } from 'features/parameters/components/ImageSize/constants';
import type { AspectRatioState } from 'features/parameters/components/ImageSize/types';
import { modelChanged } from 'features/parameters/store/generationSlice';
import type { PayloadActionWithOptimalDimension } from 'features/parameters/store/types';
@@ -24,7 +23,7 @@ import { clamp, cloneDeep } from 'lodash-es';
import type { RgbaColor } from 'react-colorful';
import { queueApi } from 'services/api/endpoints/queue';
import type { ImageDTO } from 'services/api/types';
import { socketQueueItemStatusChanged } from 'services/events/actions';
import { appSocketQueueItemStatusChanged } from 'services/events/actions';
import type {
BoundingBoxScaleMethod,
@@ -54,11 +53,10 @@ export const initialLayerState: CanvasLayerState = {
};
export const initialCanvasState: CanvasState = {
_version: 1,
boundingBoxCoordinates: { x: 0, y: 0 },
boundingBoxDimensions: { width: 512, height: 512 },
boundingBoxPreviewFill: { r: 0, g: 0, b: 0, a: 0.5 },
boundingBoxScaleMethod: 'auto',
boundingBoxScaleMethod: 'none',
brushColor: { r: 90, g: 90, b: 255, a: 1 },
brushSize: 50,
colorPickerColor: { r: 90, g: 90, b: 255, a: 1 },
@@ -697,7 +695,7 @@ export const canvasSlice = createSlice({
);
});
builder.addCase(socketQueueItemStatusChanged, (state, action) => {
builder.addCase(appSocketQueueItemStatusChanged, (state, action) => {
const batch_status = action.payload.data.batch_status;
if (!state.batchIds.includes(batch_status.batch_id)) {
return;
@@ -786,12 +784,3 @@ export const {
export default canvasSlice.reducer;
export const selectCanvasSlice = (state: RootState) => state.canvas;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateCanvasState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
state.aspectRatio = initialAspectRatioState;
}
return state;
};

View File

@@ -117,7 +117,6 @@ export const isCanvasAnyLine = (
): obj is CanvasMaskLine | CanvasBaseLine => obj.kind === 'line';
export interface CanvasState {
_version: 1;
boundingBoxCoordinates: Vector2d;
boundingBoxDimensions: Dimensions;
boundingBoxPreviewFill: RgbaColor;

View File

@@ -16,7 +16,7 @@ import { activeTabNameSelector } from 'features/ui/store/uiSelectors';
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold, PiTrashSimpleBold } from 'react-icons/pi';
import { FaCopy, FaTrash } from 'react-icons/fa';
import { useToggle } from 'react-use';
import ControlAdapterImagePreview from './ControlAdapterImagePreview';
@@ -106,7 +106,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
tooltip={t('controlnet.duplicate')}
aria-label={t('controlnet.duplicate')}
onClick={handleDuplicate}
icon={<PiCopyBold />}
icon={<FaCopy />}
/>
<InvIconButton
size="sm"
@@ -114,7 +114,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
aria-label={t('controlnet.delete')}
colorScheme="error"
onClick={handleDelete}
icon={<PiTrashSimpleBold />}
icon={<FaTrash />}
/>
<InvIconButton
size="sm"
@@ -132,6 +132,8 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
variant="ghost"
icon={
<ChevronUpIcon
boxSize={4}
color="base.300"
transform={isExpanded ? 'rotate(0deg)' : 'rotate(180deg)'}
transitionProperty="common"
transitionDuration="normal"
@@ -141,7 +143,7 @@ const ControlAdapterConfig = (props: { id: string; number: number }) => {
</Flex>
<Flex w="full" flexDir="column" gap={4}>
<Flex gap={8} w="full" alignItems="center">
<Flex gap={4} w="full" alignItems="center">
<Flex flexDir="column" gap={2} h={32} w="full">
<ParamControlAdapterWeight id={id} />
<ParamControlAdapterBeginEnd id={id} />

View File

@@ -7,7 +7,7 @@ import {
} from 'features/canvas/store/actions';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiExcludeBold, PiImageSquareBold } from 'react-icons/pi';
import { FaImage, FaMask } from 'react-icons/fa';
type ControlNetCanvasImageImportsProps = {
id: string;
@@ -29,17 +29,17 @@ const ControlNetCanvasImageImports = (
}, [id, dispatch]);
return (
<Flex gap={4}>
<Flex gap={2}>
<InvIconButton
size="sm"
icon={<PiImageSquareBold />}
icon={<FaImage />}
tooltip={t('controlnet.importImageFromCanvas')}
aria-label={t('controlnet.importImageFromCanvas')}
onClick={handleImportImageFromCanvas}
/>
<InvIconButton
size="sm"
icon={<PiExcludeBold />}
icon={<FaMask />}
tooltip={t('controlnet.importMaskFromCanvas')}
aria-label={t('controlnet.importMaskFromCanvas')}
onClick={handleImportMaskFromCanvas}

View File

@@ -1,4 +1,4 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useAppDispatch } from 'app/store/storeHooks';
import { InvControl } from 'common/components/InvControl/InvControl';
import { InvControlGroup } from 'common/components/InvControl/InvControlGroup';
import { InvNumberInput } from 'common/components/InvNumberInput/InvNumberInput';
@@ -17,22 +17,10 @@ type ParamControlAdapterWeightProps = {
const formatValue = (v: number) => v.toFixed(2);
const ParamControlAdapterWeight = ({ id }: ParamControlAdapterWeightProps) => {
const { t } = useTranslation();
const dispatch = useAppDispatch();
const isEnabled = useControlAdapterIsEnabled(id);
const weight = useControlAdapterWeight(id);
const initial = useAppSelector((s) => s.config.sd.ca.weight.initial);
const sliderMin = useAppSelector((s) => s.config.sd.ca.weight.sliderMin);
const sliderMax = useAppSelector((s) => s.config.sd.ca.weight.sliderMax);
const numberInputMin = useAppSelector(
(s) => s.config.sd.ca.weight.numberInputMin
);
const numberInputMax = useAppSelector(
(s) => s.config.sd.ca.weight.numberInputMax
);
const coarseStep = useAppSelector((s) => s.config.sd.ca.weight.coarseStep);
const fineStep = useAppSelector((s) => s.config.sd.ca.weight.fineStep);
const dispatch = useAppDispatch();
const { t } = useTranslation();
const onChange = useCallback(
(weight: number) => {
dispatch(controlAdapterWeightChanged({ id, weight }));
@@ -55,23 +43,23 @@ const ParamControlAdapterWeight = ({ id }: ParamControlAdapterWeightProps) => {
<InvSlider
value={weight}
onChange={onChange}
defaultValue={initial}
min={sliderMin}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
defaultValue={1}
min={0}
max={2}
step={0.05}
fineStep={0.01}
marks={marks}
formatValue={formatValue}
/>
<InvNumberInput
value={weight}
onChange={onChange}
min={numberInputMin}
max={numberInputMax}
step={coarseStep}
fineStep={fineStep}
min={-1}
max={2}
step={0.05}
fineStep={0.01}
maxW={20}
defaultValue={initial}
defaultValue={1}
/>
</InvControl>
</InvControlGroup>

View File

@@ -9,7 +9,7 @@ import type {
ParameterT2IAdapterModel,
} from 'features/parameters/types/parameterSchemas';
import { cloneDeep, merge, uniq } from 'lodash-es';
import { socketInvocationError } from 'services/events/actions';
import { appSocketInvocationError } from 'services/events/actions';
import { v4 as uuidv4 } from 'uuid';
import { controlAdapterImageProcessed } from './actions';
@@ -51,12 +51,10 @@ export const {
selectTotal: selectControlAdapterTotal,
} = caAdapterSelectors;
export const initialControlAdaptersState: ControlAdaptersState =
export const initialControlAdapterState: ControlAdaptersState =
caAdapter.getInitialState<{
_version: 1;
pendingControlImages: string[];
}>({
_version: 1,
pendingControlImages: [],
});
@@ -98,7 +96,7 @@ export const selectValidT2IAdapters = (controlAdapters: ControlAdaptersState) =>
export const controlAdaptersSlice = createSlice({
name: 'controlAdapters',
initialState: initialControlAdaptersState,
initialState: initialControlAdapterState,
reducers: {
controlAdapterAdded: {
reducer: (
@@ -269,29 +267,31 @@ export const controlAdaptersSlice = createSlice({
const update: Update<ControlNetConfig | T2IAdapterConfig, string> = {
id,
changes: { model, shouldAutoConfig: true },
changes: { model },
};
update.changes.processedControlImage = null;
let processorType: ControlAdapterProcessorType | undefined = undefined;
if (cn.shouldAutoConfig) {
let processorType: ControlAdapterProcessorType | undefined = undefined;
for (const modelSubstring in CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS) {
if (model.model_name.includes(modelSubstring)) {
processorType =
CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS[modelSubstring];
break;
for (const modelSubstring in CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS) {
if (model.model_name.includes(modelSubstring)) {
processorType =
CONTROLADAPTER_MODEL_DEFAULT_PROCESSORS[modelSubstring];
break;
}
}
}
if (processorType) {
update.changes.processorType = processorType;
update.changes.processorNode = CONTROLNET_PROCESSORS[processorType]
.default as RequiredControlAdapterProcessorNode;
} else {
update.changes.processorType = 'none';
update.changes.processorNode = CONTROLNET_PROCESSORS.none
.default as RequiredControlAdapterProcessorNode;
if (processorType) {
update.changes.processorType = processorType;
update.changes.processorNode = CONTROLNET_PROCESSORS[processorType]
.default as RequiredControlAdapterProcessorNode;
} else {
update.changes.processorType = 'none';
update.changes.processorNode = CONTROLNET_PROCESSORS.none
.default as RequiredControlAdapterProcessorNode;
}
}
caAdapter.updateOne(state, update);
@@ -435,7 +435,7 @@ export const controlAdaptersSlice = createSlice({
caAdapter.updateOne(state, update);
},
controlAdaptersReset: () => {
return cloneDeep(initialControlAdaptersState);
return cloneDeep(initialControlAdapterState);
},
pendingControlImagesCleared: (state) => {
state.pendingControlImages = [];
@@ -454,7 +454,7 @@ export const controlAdaptersSlice = createSlice({
}
});
builder.addCase(socketInvocationError, (state) => {
builder.addCase(appSocketInvocationError, (state) => {
state.pendingControlImages = [];
});
},
@@ -493,11 +493,3 @@ export const isAnyControlAdapterAdded = isAnyOf(
export const selectControlAdaptersSlice = (state: RootState) =>
state.controlAdapters;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateControlAdaptersState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -3,7 +3,7 @@ import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import type { InvIconButtonProps } from 'common/components/InvIconButton/types';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import { FaTrash } from 'react-icons/fa';
type DeleteImageButtonProps = Omit<InvIconButtonProps, 'aria-label'> & {
onClick: () => void;
@@ -17,7 +17,7 @@ export const DeleteImageButton = memo((props: DeleteImageButtonProps) => {
return (
<InvIconButton
onClick={onClick}
icon={<PiTrashSimpleBold />}
icon={<FaTrash />}
tooltip={`${t('gallery.deleteImage')} (Del)`}
aria-label={`${t('gallery.deleteImage')} (Del)`}
isDisabled={isDisabled || !isConnected}

View File

@@ -7,17 +7,12 @@ import { useTranslation } from 'react-i18next';
const ParamDynamicPromptsMaxPrompts = () => {
const maxPrompts = useAppSelector((s) => s.dynamicPrompts.maxPrompts);
const sliderMin = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.sliderMin
);
const min = useAppSelector((s) => s.config.sd.dynamicPrompts.maxPrompts.min);
const sliderMax = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.sliderMax
);
const numberInputMin = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.numberInputMin
);
const numberInputMax = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.numberInputMax
const inputMax = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.inputMax
);
const initial = useAppSelector(
(s) => s.config.sd.dynamicPrompts.maxPrompts.initial
@@ -41,15 +36,14 @@ const ParamDynamicPromptsMaxPrompts = () => {
renderInfoPopoverInPortal={false}
>
<InvSlider
min={sliderMin}
min={min}
max={sliderMax}
value={maxPrompts}
defaultValue={initial}
onChange={handleChange}
marks
withNumberInput
numberInputMin={numberInputMin}
numberInputMax={numberInputMax}
numberInputMax={inputMax}
/>
</InvControl>
);

View File

@@ -7,9 +7,7 @@ export const zSeedBehaviour = z.enum(['PER_ITERATION', 'PER_PROMPT']);
export type SeedBehaviour = z.infer<typeof zSeedBehaviour>;
export const isSeedBehaviour = (v: unknown): v is SeedBehaviour =>
zSeedBehaviour.safeParse(v).success;
export interface DynamicPromptsState {
_version: 1;
maxPrompts: number;
combinatorial: boolean;
prompts: string[];
@@ -20,7 +18,6 @@ export interface DynamicPromptsState {
}
export const initialDynamicPromptsState: DynamicPromptsState = {
_version: 1,
maxPrompts: 100,
combinatorial: true,
prompts: [],
@@ -81,11 +78,3 @@ export default dynamicPromptsSlice.reducer;
export const selectDynamicPromptsSlice = (state: RootState) =>
state.dynamicPrompts;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateDynamicPromptsState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -2,6 +2,7 @@ import type { ChakraProps } from '@chakra-ui/react';
import { useAppSelector } from 'app/store/storeHooks';
import { InvControl } from 'common/components/InvControl/InvControl';
import { InvSelect } from 'common/components/InvSelect/InvSelect';
import { InvSelectFallback } from 'common/components/InvSelect/InvSelectFallback';
import { useGroupedModelInvSelect } from 'common/components/InvSelect/useGroupedModelInvSelect';
import type { EmbeddingSelectProps } from 'features/embedding/types';
import { t } from 'i18next';
@@ -46,16 +47,23 @@ export const EmbeddingSelect = memo(
onChange: _onChange,
});
if (isLoading) {
return <InvSelectFallback label={t('common.loading')} />;
}
if (options.length === 0) {
return <InvSelectFallback label={t('embedding.noEmbeddingsLoaded')} />;
}
return (
<InvControl>
<InvControl isDisabled={!options.length}>
<InvSelect
placeholder={
isLoading ? t('common.loading') : t('embedding.addEmbedding')
}
placeholder={t('embedding.addEmbedding')}
defaultMenuIsOpen
autoFocus
value={null}
options={options}
isDisabled={!options.length}
noOptionsMessage={noOptionsMessage}
onChange={onChange}
onMenuClose={onClose}

View File

@@ -1,7 +1,7 @@
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiPlusBold } from 'react-icons/pi';
import { FaPlus } from 'react-icons/fa';
import { useCreateBoardMutation } from 'services/api/endpoints/boards';
const AddBoardButton = () => {
@@ -14,7 +14,7 @@ const AddBoardButton = () => {
return (
<InvIconButton
icon={<PiPlusBold />}
icon={<FaPlus />}
isLoading={isLoading}
tooltip={t('boards.addBoard')}
aria-label={t('boards.addBoard')}

View File

@@ -1,3 +1,4 @@
import { CloseIcon } from '@chakra-ui/icons';
import { Input, InputGroup, InputRightElement } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
@@ -5,7 +6,6 @@ import { boardSearchTextChanged } from 'features/gallery/store/gallerySlice';
import type { ChangeEvent, KeyboardEvent } from 'react';
import { memo, useCallback, useEffect, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiXBold } from 'react-icons/pi';
const BoardsSearch = () => {
const dispatch = useAppDispatch();
@@ -66,7 +66,7 @@ const BoardsSearch = () => {
size="sm"
variant="ghost"
aria-label={t('boards.clearSearch')}
icon={<PiXBold />}
icon={<CloseIcon boxSize={3} />}
/>
</InputRightElement>
)}

View File

@@ -1,6 +1,6 @@
import { Box, Flex, Image } from '@chakra-ui/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import InvokeLogoSVG from 'assets/images/invoke-key-wht-lrg.svg';
import InvokeAILogoImage from 'assets/images/logo.png';
import IAIDroppable from 'common/components/IAIDroppable';
import { InvText } from 'common/components/InvText/wrapper';
import { InvTooltip } from 'common/components/InvTooltip/InvTooltip';
@@ -101,10 +101,10 @@ const NoBoardBoard = memo(({ isSelected }: Props) => {
alignItems="center"
>
<Image
src={InvokeLogoSVG}
src={InvokeAILogoImage}
alt="invoke-ai-logo"
opacity={0.7}
mixBlendMode="overlay"
opacity={0.4}
filter="grayscale(1)"
mt={-6}
w={16}
h={16}

View File

@@ -151,7 +151,7 @@ const DeleteBoardModal = (props: Props) => {
</Flex>
</InvAlertDialogBody>
<InvAlertDialogFooter>
<Flex w="full" gap={2} justifyContent="end">
<Flex w="full" gap={2} justifyContent="space-between">
<InvButton ref={cancelRef} onClick={handleClose}>
{t('boards.cancel')}
</InvButton>

View File

@@ -29,15 +29,14 @@ import { memo, useCallback } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import {
PiAsteriskBold,
PiDotsThreeOutlineFill,
PiFlowArrowBold,
PiHourglassHighBold,
PiInfoBold,
PiPlantBold,
PiQuotesBold,
PiRulerBold,
} from 'react-icons/pi';
FaAsterisk,
FaCode,
FaHourglassHalf,
FaQuoteRight,
FaRulerVertical,
FaSeedling,
} from 'react-icons/fa';
import { FaCircleNodes, FaEllipsis } from 'react-icons/fa6';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
import { useDebouncedMetadata } from 'services/api/hooks/useDebouncedMetadata';
@@ -211,7 +210,7 @@ const CurrentImageButtons = () => {
aria-label={t('parameters.imageActions')}
tooltip={t('parameters.imageActions')}
isDisabled={!imageDTO}
icon={<PiDotsThreeOutlineFill />}
icon={<FaEllipsis />}
/>
<InvMenuList>
{imageDTO && <SingleSelectionMenuItems imageDTO={imageDTO} />}
@@ -221,7 +220,7 @@ const CurrentImageButtons = () => {
<InvButtonGroup isDisabled={shouldDisableToolbarButtons}>
<InvIconButton
icon={<PiFlowArrowBold />}
icon={<FaCircleNodes />}
tooltip={`${t('nodes.loadWorkflow')} (W)`}
aria-label={`${t('nodes.loadWorkflow')} (W)`}
isDisabled={!imageDTO?.has_workflow}
@@ -230,7 +229,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<PiQuotesBold />}
icon={<FaQuoteRight />}
tooltip={`${t('parameters.usePrompt')} (P)`}
aria-label={`${t('parameters.usePrompt')} (P)`}
isDisabled={!metadata?.positive_prompt}
@@ -238,7 +237,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<PiPlantBold />}
icon={<FaSeedling />}
tooltip={`${t('parameters.useSeed')} (S)`}
aria-label={`${t('parameters.useSeed')} (S)`}
isDisabled={metadata?.seed === null || metadata?.seed === undefined}
@@ -246,7 +245,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<PiRulerBold />}
icon={<FaRulerVertical />}
tooltip={`${t('parameters.useSize')} (D)`}
aria-label={`${t('parameters.useSize')} (D)`}
isDisabled={
@@ -259,7 +258,7 @@ const CurrentImageButtons = () => {
/>
<InvIconButton
isLoading={isLoadingMetadata}
icon={<PiAsteriskBold />}
icon={<FaAsterisk />}
tooltip={`${t('parameters.useAll')} (A)`}
aria-label={`${t('parameters.useAll')} (A)`}
isDisabled={!metadata}
@@ -275,7 +274,7 @@ const CurrentImageButtons = () => {
<InvButtonGroup>
<InvIconButton
icon={<PiInfoBold />}
icon={<FaCode />}
tooltip={`${t('parameters.info')} (I)`}
aria-label={`${t('parameters.info')} (I)`}
isChecked={shouldShowImageDetails}
@@ -287,7 +286,7 @@ const CurrentImageButtons = () => {
<InvIconButton
aria-label={t('settings.displayInProgress')}
tooltip={t('settings.displayInProgress')}
icon={<PiHourglassHighBold />}
icon={<FaHourglassHalf />}
isChecked={shouldShowProgressInViewer}
onClick={handleClickProgressImagesToggle}
/>

View File

@@ -11,11 +11,13 @@ import type {
import ProgressImage from 'features/gallery/components/CurrentImage/ProgressImage';
import ImageMetadataViewer from 'features/gallery/components/ImageMetadataViewer/ImageMetadataViewer';
import NextPrevImageButtons from 'features/gallery/components/NextPrevImageButtons';
import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import type { CSSProperties } from 'react';
import { memo, useCallback, useMemo, useRef, useState } from 'react';
import { useHotkeys } from 'react-hotkeys-hook';
import { useTranslation } from 'react-i18next';
import { FaImage } from 'react-icons/fa';
import { useGetImageDTOQuery } from 'services/api/endpoints/images';
@@ -37,6 +39,43 @@ const CurrentImagePreview = () => {
(s) => s.ui.shouldShowProgressInViewer
);
const {
handlePrevImage,
handleNextImage,
isOnLastImage,
handleLoadMoreImages,
areMoreImagesAvailable,
isFetching,
} = useNextPrevImage();
useHotkeys(
'left',
() => {
handlePrevImage();
},
[handlePrevImage]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleNextImage();
}
},
[
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleNextImage,
]
);
const { currentData: imageDTO } = useGetImageDTOQuery(imageName ?? skipToken);
const draggableData = useMemo<TypesafeDraggableData | undefined>(() => {

View File

@@ -19,7 +19,7 @@ import {
import type { ChangeEvent } from 'react';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { RiSettings4Fill } from 'react-icons/ri';
import { FaWrench } from 'react-icons/fa';
import BoardAutoAddSelect from './Boards/BoardAutoAddSelect';
@@ -61,7 +61,7 @@ const GallerySettingsPopover = () => {
tooltip={t('gallery.gallerySettings')}
aria-label={t('gallery.gallerySettings')}
size="sm"
icon={<RiSettings4Fill />}
icon={<FaWrench />}
/>
</InvPopoverTrigger>
<InvPopoverContent>

View File

@@ -2,7 +2,6 @@ import { useStore } from '@nanostores/react';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvMenuItem } from 'common/components/InvMenu/InvMenuItem';
import { InvMenuDivider } from 'common/components/InvMenu/wrapper';
import {
imagesToChangeSelected,
isModalOpenChanged,
@@ -12,13 +11,8 @@ import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { addToast } from 'features/system/store/systemSlice';
import { memo, useCallback, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import {
PiDownloadSimpleBold,
PiFoldersBold,
PiStarBold,
PiStarFill,
PiTrashSimpleBold,
} from 'react-icons/pi';
import { FaDownload, FaFolder, FaTrash } from 'react-icons/fa';
import { MdStar, MdStarBorder } from 'react-icons/md';
import {
useBulkDownloadImagesMutation,
useStarImagesMutation,
@@ -96,7 +90,7 @@ const MultipleSelectionMenuItems = () => {
<>
{areAllStarred && (
<InvMenuItem
icon={customStarUi ? customStarUi.on.icon : <PiStarBold />}
icon={customStarUi ? customStarUi.on.icon : <MdStarBorder />}
onClickCapture={handleUnstarSelection}
>
{customStarUi ? customStarUi.off.text : `Unstar All`}
@@ -104,27 +98,23 @@ const MultipleSelectionMenuItems = () => {
)}
{(areAllUnstarred || (!areAllStarred && !areAllUnstarred)) && (
<InvMenuItem
icon={customStarUi ? customStarUi.on.icon : <PiStarFill />}
icon={customStarUi ? customStarUi.on.icon : <MdStar />}
onClickCapture={handleStarSelection}
>
{customStarUi ? customStarUi.on.text : `Star All`}
</InvMenuItem>
)}
{isBulkDownloadEnabled && (
<InvMenuItem
icon={<PiDownloadSimpleBold />}
onClickCapture={handleBulkDownload}
>
<InvMenuItem icon={<FaDownload />} onClickCapture={handleBulkDownload}>
{t('gallery.downloadSelection')}
</InvMenuItem>
)}
<InvMenuItem icon={<PiFoldersBold />} onClickCapture={handleChangeBoard}>
<InvMenuItem icon={<FaFolder />} onClickCapture={handleChangeBoard}>
{t('boards.changeBoard')}
</InvMenuItem>
<InvMenuDivider />
<InvMenuItem
color="error.300"
icon={<PiTrashSimpleBold />}
icon={<FaTrash />}
onClickCapture={handleDeleteSelection}
>
{t('gallery.deleteSelection')}

View File

@@ -4,7 +4,6 @@ import { useAppToaster } from 'app/components/Toaster';
import { $customStarUI } from 'app/store/nanostores/customStarUI';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { InvMenuItem } from 'common/components/InvMenu/InvMenuItem';
import { InvMenuDivider } from 'common/components/InvMenu/wrapper';
import { useCopyImageToClipboard } from 'common/hooks/useCopyImageToClipboard';
import { setInitialCanvasImage } from 'features/canvas/store/canvasSlice';
import {
@@ -26,18 +25,18 @@ import { memo, useCallback } from 'react';
import { flushSync } from 'react-dom';
import { useTranslation } from 'react-i18next';
import {
PiAsteriskBold,
PiCopyBold,
PiDownloadSimpleBold,
PiFlowArrowBold,
PiFoldersBold,
PiPlantBold,
PiQuotesBold,
PiShareFatBold,
PiStarBold,
PiStarFill,
PiTrashSimpleBold,
} from 'react-icons/pi';
FaAsterisk,
FaCopy,
FaDownload,
FaExternalLinkAlt,
FaFolder,
FaQuoteRight,
FaSeedling,
FaShare,
FaTrash,
} from 'react-icons/fa';
import { FaCircleNodes } from 'react-icons/fa6';
import { MdStar, MdStarBorder } from 'react-icons/md';
import {
useStarImagesMutation,
useUnstarImagesMutation,
@@ -156,12 +155,12 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
as="a"
href={imageDTO.image_url}
target="_blank"
icon={<PiShareFatBold />}
icon={<FaExternalLinkAlt />}
>
{t('common.openInNewTab')}
</InvMenuItem>
{isClipboardAPIAvailable && (
<InvMenuItem icon={<PiCopyBold />} onClickCapture={handleCopyImage}>
<InvMenuItem icon={<FaCopy />} onClickCapture={handleCopyImage}>
{t('parameters.copyImage')}
</InvMenuItem>
)}
@@ -170,18 +169,17 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
download={true}
href={imageDTO.image_url}
target="_blank"
icon={<PiDownloadSimpleBold />}
icon={<FaDownload />}
w="100%"
>
{t('parameters.downloadImage')}
</InvMenuItem>
<InvMenuDivider />
<InvMenuItem
icon={
getAndLoadEmbeddedWorkflowResult.isLoading ? (
<SpinnerIcon />
) : (
<PiFlowArrowBold />
<FaCircleNodes />
)
}
onClickCapture={handleLoadWorkflow}
@@ -190,7 +188,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('nodes.loadWorkflow')}
</InvMenuItem>
<InvMenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiQuotesBold />}
icon={isLoadingMetadata ? <SpinnerIcon /> : <FaQuoteRight />}
onClickCapture={handleRecallPrompt}
isDisabled={
isLoadingMetadata ||
@@ -201,22 +199,21 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
{t('parameters.usePrompt')}
</InvMenuItem>
<InvMenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiPlantBold />}
icon={isLoadingMetadata ? <SpinnerIcon /> : <FaSeedling />}
onClickCapture={handleRecallSeed}
isDisabled={isLoadingMetadata || metadata?.seed === undefined}
>
{t('parameters.useSeed')}
</InvMenuItem>
<InvMenuItem
icon={isLoadingMetadata ? <SpinnerIcon /> : <PiAsteriskBold />}
icon={isLoadingMetadata ? <SpinnerIcon /> : <FaAsterisk />}
onClickCapture={handleUseAllParameters}
isDisabled={isLoadingMetadata || !metadata}
>
{t('parameters.useAll')}
</InvMenuItem>
<InvMenuDivider />
<InvMenuItem
icon={<PiShareFatBold />}
icon={<FaShare />}
onClickCapture={handleSendToImageToImage}
id="send-to-img2img"
>
@@ -224,36 +221,34 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => {
</InvMenuItem>
{isCanvasEnabled && (
<InvMenuItem
icon={<PiShareFatBold />}
icon={<FaShare />}
onClickCapture={handleSendToCanvas}
id="send-to-canvas"
>
{t('parameters.sendToUnifiedCanvas')}
</InvMenuItem>
)}
<InvMenuDivider />
<InvMenuItem icon={<PiFoldersBold />} onClickCapture={handleChangeBoard}>
<InvMenuItem icon={<FaFolder />} onClickCapture={handleChangeBoard}>
{t('boards.changeBoard')}
</InvMenuItem>
{imageDTO.starred ? (
<InvMenuItem
icon={customStarUi ? customStarUi.off.icon : <PiStarFill />}
icon={customStarUi ? customStarUi.off.icon : <MdStar />}
onClickCapture={handleUnstarImage}
>
{customStarUi ? customStarUi.off.text : t('gallery.unstarImage')}
</InvMenuItem>
) : (
<InvMenuItem
icon={customStarUi ? customStarUi.on.icon : <PiStarBold />}
icon={customStarUi ? customStarUi.on.icon : <MdStarBorder />}
onClickCapture={handleStarImage}
>
{customStarUi ? customStarUi.on.text : t('gallery.starImage')}
</InvMenuItem>
)}
<InvMenuDivider />
<InvMenuItem
color="error.300"
icon={<PiTrashSimpleBold />}
icon={<FaTrash />}
onClickCapture={handleDelete}
>
{t('gallery.deleteImage')}

View File

@@ -15,8 +15,7 @@ import { InvButtonGroup } from 'common/components/InvButtonGroup/InvButtonGroup'
import { galleryViewChanged } from 'features/gallery/store/gallerySlice';
import { memo, useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { PiImagesBold } from 'react-icons/pi';
import { RiServerLine } from 'react-icons/ri';
import { FaImages, FaServer } from 'react-icons/fa';
import BoardsList from './Boards/BoardsList/BoardsList';
import GalleryBoardName from './GalleryBoardName';
@@ -84,7 +83,7 @@ const ImageGalleryContent = () => {
isChecked={galleryView === 'images'}
onClick={handleClickImages}
w="full"
leftIcon={<PiImagesBold size="16px" />}
leftIcon={<FaImages />}
data-testid="images-tab"
>
{t('parameters.images')}
@@ -95,7 +94,7 @@ const ImageGalleryContent = () => {
isChecked={galleryView === 'assets'}
onClick={handleClickAssets}
w="full"
leftIcon={<RiServerLine size="16px" />}
leftIcon={<FaServer />}
data-testid="assets-tab"
>
{t('gallery.assets')}

View File

@@ -13,13 +13,14 @@ import type {
ImageDTOsDraggableData,
TypesafeDraggableData,
} from 'features/dnd/types';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { useMultiselect } from 'features/gallery/hooks/useMultiselect';
import { useScrollIntoView } from 'features/gallery/hooks/useScrollIntoView';
import { useScrollToVisible } from 'features/gallery/hooks/useScrollToVisible';
import type { MouseEvent } from 'react';
import { memo, useCallback, useMemo, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { PiStarBold, PiStarFill, PiTrashSimpleFill } from 'react-icons/pi';
import { FaTrash } from 'react-icons/fa';
import { MdStar, MdStarBorder } from 'react-icons/md';
import {
useGetImageDTOQuery,
useStarImagesMutation,
@@ -34,11 +35,12 @@ const imageIconStyleOverrides: SystemStyleObject = {
interface HoverableImageProps {
imageName: string;
index: number;
virtuosoContext: VirtuosoGalleryContext;
}
const GalleryImage = (props: HoverableImageProps) => {
const dispatch = useAppDispatch();
const { imageName } = props;
const { imageName, virtuosoContext } = props;
const { currentData: imageDTO } = useGetImageDTOQuery(imageName);
const shift = useStore($shift);
const { t } = useTranslation();
@@ -48,10 +50,11 @@ const GalleryImage = (props: HoverableImageProps) => {
const customStarUi = useStore($customStarUI);
const imageContainerRef = useScrollIntoView(
const imageContainerRef = useScrollToVisible(
isSelected,
props.index,
selectionCount
selectionCount,
virtuosoContext
);
const handleDelete = useCallback(
@@ -111,10 +114,10 @@ const GalleryImage = (props: HoverableImageProps) => {
const starIcon = useMemo(() => {
if (imageDTO?.starred) {
return customStarUi ? customStarUi.on.icon : <PiStarFill size="20" />;
return customStarUi ? customStarUi.on.icon : <MdStar size="20" />;
}
if (!imageDTO?.starred && isHovered) {
return customStarUi ? customStarUi.off.icon : <PiStarBold size="20" />;
return customStarUi ? customStarUi.off.icon : <MdStarBorder size="20" />;
}
}, [imageDTO?.starred, isHovered, customStarUi]);
@@ -128,22 +131,12 @@ const GalleryImage = (props: HoverableImageProps) => {
return '';
}, [imageDTO?.starred, customStarUi]);
const dataTestId = useMemo(
() => getGalleryImageDataTestId(imageDTO?.image_name),
[imageDTO?.image_name]
);
if (!imageDTO) {
return <IAIFillSkeleton />;
}
return (
<Box
w="full"
h="full"
className="gallerygrid-image"
data-testid={dataTestId}
>
<Box w="full" h="full" data-testid={`image-${imageDTO.image_name}`}>
<Flex
ref={imageContainerRef}
userSelect="none"
@@ -176,7 +169,7 @@ const GalleryImage = (props: HoverableImageProps) => {
{isHovered && shift && (
<IAIDndImageIcon
onClick={handleDelete}
icon={<PiTrashSimpleFill size="16px" />}
icon={<FaTrash />}
tooltip={t('gallery.deleteImage')}
styleOverrides={imageIconStyleOverrides}
/>

View File

@@ -4,12 +4,13 @@ import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import { InvButton } from 'common/components/InvButton/InvButton';
import { overlayScrollbarsParams } from 'common/components/OverlayScrollbars/constants';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryHotkeys } from 'features/gallery/hooks/useGalleryHotkeys';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { $useNextPrevImageState } from 'features/gallery/hooks/useNextPrevImage';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { useOverlayScrollbars } from 'overlayscrollbars-react';
import type { CSSProperties } from 'react';
import { memo, useCallback, useEffect, useRef, useState } from 'react';
import { memo, useCallback, useEffect, useMemo, useRef, useState } from 'react';
import { useTranslation } from 'react-i18next';
import { FaExclamationCircle, FaImage } from 'react-icons/fa';
import type {
@@ -19,6 +20,10 @@ import type {
VirtuosoGridHandle,
} from 'react-virtuoso';
import { VirtuosoGrid } from 'react-virtuoso';
import {
useLazyListImagesQuery,
useListImagesQuery,
} from 'services/api/endpoints/images';
import { useBoardTotal } from 'services/api/hooks/useBoardTotal';
import GalleryImage from './GalleryImage';
@@ -41,24 +46,56 @@ const GalleryImageGrid = () => {
);
const selectedBoardId = useAppSelector((s) => s.gallery.selectedBoardId);
const { currentViewTotal } = useBoardTotal(selectedBoardId);
const queryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const virtuosoRangeRef = useRef<ListRange | null>(null);
const virtuosoRef = useRef<VirtuosoGridHandle>(null);
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { currentData, isFetching, isSuccess, isError },
} = useGalleryImages();
useGalleryHotkeys();
const itemContentFunc: ItemContent<EntityId, void> = useCallback(
(index, imageName) => (
<GalleryImage
key={imageName}
index={index}
imageName={imageName as string}
/>
),
[]
);
const { currentData, isFetching, isSuccess, isError } =
useListImagesQuery(queryArgs);
const [listImages] = useLazyListImagesQuery();
const areMoreAvailable = useMemo(() => {
if (!currentData || !currentViewTotal) {
return false;
}
return currentData.ids.length < currentViewTotal;
}, [currentData, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
if (!areMoreAvailable) {
return;
}
listImages({
...queryArgs,
offset: currentData?.ids.length ?? 0,
limit: IMAGE_LIMIT,
});
}, [areMoreAvailable, listImages, queryArgs, currentData?.ids.length]);
const virtuosoContext = useMemo<VirtuosoGalleryContext>(() => {
return {
virtuosoRef,
rootRef,
virtuosoRangeRef,
};
}, []);
const itemContentFunc: ItemContent<EntityId, VirtuosoGalleryContext> =
useCallback(
(index, imageName, virtuosoContext) => (
<GalleryImage
key={imageName}
index={index}
imageName={imageName as string}
virtuosoContext={virtuosoContext}
/>
),
[]
);
useEffect(() => {
// Initialize the gallery's custom scrollbar
@@ -79,10 +116,8 @@ const GalleryImageGrid = () => {
}, []);
useEffect(() => {
virtuosoGridRefs.set({ rootRef, virtuosoRangeRef, virtuosoRef });
return () => {
virtuosoGridRefs.set({});
};
$useNextPrevImageState.setKey('virtuosoRef', virtuosoRef);
$useNextPrevImageState.setKey('virtuosoRangeRef', virtuosoRangeRef);
}, []);
if (!currentData) {
@@ -107,7 +142,7 @@ const GalleryImageGrid = () => {
if (isSuccess && currentData) {
return (
<>
<Box ref={rootRef} data-overlayscrollbars="" h="100%" id="gallery-grid">
<Box ref={rootRef} data-overlayscrollbars="" h="100%">
<VirtuosoGrid
style={virtuosoStyles}
data={currentData.ids}
@@ -117,12 +152,13 @@ const GalleryImageGrid = () => {
itemContent={itemContentFunc}
ref={virtuosoRef}
rangeChanged={onRangeChanged}
context={virtuosoContext}
overscan={10}
/>
</Box>
<InvButton
onClick={handleLoadMoreImages}
isDisabled={!areMoreImagesAvailable}
isDisabled={!areMoreAvailable}
isLoading={isFetching}
loadingText={t('gallery.loading')}
flexShrink={0}

View File

@@ -3,15 +3,13 @@ import { Box, forwardRef } from '@chakra-ui/react';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const imageItemContainerTestId = 'image-item-container';
type ItemContainerProps = PropsWithChildren & FlexProps;
const ItemContainer = forwardRef((props: ItemContainerProps, ref) => (
<Box
className="item-container"
ref={ref}
p={1.5}
data-testid={imageItemContainerTestId}
data-testid="image-item-container"
>
{props.children}
</Box>

View File

@@ -4,8 +4,6 @@ import { useAppSelector } from 'app/store/storeHooks';
import type { PropsWithChildren } from 'react';
import { memo } from 'react';
export const imageListContainerTestId = 'image-list-container';
type ListContainerProps = PropsWithChildren & FlexProps;
const ListContainer = forwardRef((props: ListContainerProps, ref) => {
const galleryImageMinimumWidth = useAppSelector(
@@ -18,7 +16,7 @@ const ListContainer = forwardRef((props: ListContainerProps, ref) => {
className="list-container"
ref={ref}
gridTemplateColumns={`repeat(auto-fill, minmax(${galleryImageMinimumWidth}px, 1fr))`}
data-testid={imageListContainerTestId}
data-testid="image-list-container"
>
{props.children}
</Grid>

View File

@@ -1,2 +0,0 @@
export const getGalleryImageDataTestId = (imageName?: string) =>
`gallery-image-${imageName}`;

View File

@@ -1,11 +1,8 @@
import { atom } from 'nanostores';
import type { RefObject } from 'react';
import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso';
export type VirtuosoGridRefs = {
virtuosoRef?: RefObject<VirtuosoGridHandle>;
rootRef?: RefObject<HTMLDivElement>;
virtuosoRangeRef?: RefObject<ListRange>;
export type VirtuosoGalleryContext = {
virtuosoRef: RefObject<VirtuosoGridHandle>;
rootRef: RefObject<HTMLDivElement>;
virtuosoRangeRef: RefObject<ListRange>;
};
export const virtuosoGridRefs = atom<VirtuosoGridRefs>({});

View File

@@ -1,8 +1,7 @@
import type { ChakraProps } from '@chakra-ui/react';
import { Box, Flex, Spinner } from '@chakra-ui/react';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useNextPrevImage } from 'features/gallery/hooks/useNextPrevImage';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { FaAngleDoubleRight, FaAngleLeft, FaAngleRight } from 'react-icons/fa';
@@ -15,14 +14,15 @@ const nextPrevButtonStyles: ChakraProps['sx'] = {
const NextPrevImageButtons = () => {
const { t } = useTranslation();
const { handleLeftImage, handleRightImage, isOnFirstImage, isOnLastImage } =
useGalleryNavigation();
const {
areMoreImagesAvailable,
handlePrevImage,
handleNextImage,
isOnFirstImage,
isOnLastImage,
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
areMoreImagesAvailable,
isFetching,
} = useNextPrevImage();
return (
<Box pos="relative" h="full" w="full">
@@ -37,7 +37,7 @@ const NextPrevImageButtons = () => {
aria-label={t('accessibility.previousImage')}
icon={<FaAngleLeft size={64} />}
variant="unstyled"
onClick={handleLeftImage}
onClick={handlePrevImage}
boxSize={16}
sx={nextPrevButtonStyles}
/>
@@ -54,7 +54,7 @@ const NextPrevImageButtons = () => {
aria-label={t('accessibility.nextImage')}
icon={<FaAngleRight size={64} />}
variant="unstyled"
onClick={handleRightImage}
onClick={handleNextImage}
boxSize={16}
sx={nextPrevButtonStyles}
/>

View File

@@ -1,79 +0,0 @@
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { useGalleryNavigation } from 'features/gallery/hooks/useGalleryNavigation';
import { useHotkeys } from 'react-hotkeys-hook';
/**
* Registers gallery hotkeys. This hook is a singleton.
*/
export const useGalleryHotkeys = () => {
const {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult: { isFetching },
} = useGalleryImages();
const {
handleLeftImage,
handleRightImage,
handleUpImage,
handleDownImage,
isOnLastImage,
areImagesBelowCurrent,
} = useGalleryNavigation();
useHotkeys(
'left',
() => {
handleLeftImage();
},
[handleLeftImage]
);
useHotkeys(
'right',
() => {
if (isOnLastImage && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
if (!isOnLastImage) {
handleRightImage();
}
},
[
isOnLastImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleRightImage,
]
);
useHotkeys(
'up',
() => {
handleUpImage();
},
{ preventDefault: true },
[handleUpImage]
);
useHotkeys(
'down',
() => {
if (!areImagesBelowCurrent && areMoreImagesAvailable && !isFetching) {
handleLoadMoreImages();
return;
}
handleDownImage();
},
{ preventDefault: true },
[
areImagesBelowCurrent,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
handleDownImage,
]
);
};

View File

@@ -1,73 +0,0 @@
import { useStore } from '@nanostores/react';
import { useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { atom } from 'nanostores';
import { useCallback, useMemo } from 'react';
import {
useGetBoardAssetsTotalQuery,
useGetBoardImagesTotalQuery,
} from 'services/api/endpoints/boards';
import { useListImagesQuery } from 'services/api/endpoints/images';
import type { ListImagesArgs } from 'services/api/types';
export type UseGalleryImagesReturn = {
handleLoadMoreImages: () => void;
areMoreImagesAvailable: boolean;
queryResult: ReturnType<typeof useListImagesQuery>;
};
// The gallery is a singleton but multiple components need access to its query data.
// If we don't define the query args outside of the hook, then each component will
// have its own query args and trigger multiple requests. We use an atom to store
// the query args outside of the hook so that all consumers use the same query args.
const $queryArgs = atom<ListImagesArgs | null>(null);
/**
* Provides access to the gallery images and a way to imperatively fetch more.
*
* This hook is a singleton.
*/
export const useGalleryImages = (): UseGalleryImagesReturn => {
const galleryView = useAppSelector((s) => s.gallery.galleryView);
const baseQueryArgs = useAppSelector(selectListImagesBaseQueryArgs);
const queryArgs = useStore($queryArgs);
const queryResult = useListImagesQuery(queryArgs ?? baseQueryArgs);
const boardId = useMemo(
() => baseQueryArgs.board_id ?? 'none',
[baseQueryArgs.board_id]
);
const { data: assetsTotal } = useGetBoardAssetsTotalQuery(boardId);
const { data: imagesTotal } = useGetBoardImagesTotalQuery(boardId);
const currentViewTotal = useMemo(
() => (galleryView === 'images' ? imagesTotal?.total : assetsTotal?.total),
[assetsTotal?.total, galleryView, imagesTotal?.total]
);
const loadedImagesCount = useMemo(
() => queryResult.data?.ids.length ?? 0,
[queryResult.data?.ids.length]
);
const areMoreImagesAvailable = useMemo(() => {
if (!currentViewTotal || !queryResult.data) {
return false;
}
return queryResult.data.ids.length < currentViewTotal;
}, [queryResult.data, currentViewTotal]);
const handleLoadMoreImages = useCallback(() => {
// To load more images, we update the query args with an offset and limit.
const _queryArgs: ListImagesArgs = loadedImagesCount
? {
...baseQueryArgs,
offset: loadedImagesCount,
limit: IMAGE_LIMIT,
}
: baseQueryArgs;
$queryArgs.set(_queryArgs);
}, [baseQueryArgs, loadedImagesCount]);
return {
areMoreImagesAvailable,
handleLoadMoreImages,
queryResult,
};
};

View File

@@ -1,211 +0,0 @@
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { getGalleryImageDataTestId } from 'features/gallery/components/ImageGrid/getGalleryImageDataTestId';
import { imageItemContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridItemContainer';
import { imageListContainerTestId } from 'features/gallery/components/ImageGrid/ImageGridListContainer';
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { selectLastSelectedImage } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { getIsVisible } from 'features/gallery/util/getIsVisible';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { clamp } from 'lodash-es';
import { useCallback, useMemo } from 'react';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
/**
* This hook is used to navigate the gallery using the arrow keys.
*
* The gallery is rendered as a grid. In order to navigate the grid,
* we need to know how many images are in each row and whether or not
* an image is visible in the gallery.
*
* We use direct DOM query selectors to check if an image is visible
* to avoid having to track a ref for each image.
*/
/**
* Gets the number of images per row in the gallery by grabbing their DOM elements.
*/
const getImagesPerRow = (): number => {
const widthOfGalleryImage =
document
.querySelector(`[data-testid="${imageItemContainerTestId}"]`)
?.getBoundingClientRect().width ?? 1;
const widthOfGalleryGrid =
document
.querySelector(`[data-testid="${imageListContainerTestId}"]`)
?.getBoundingClientRect().width ?? 0;
const imagesPerRow = Math.round(widthOfGalleryGrid / widthOfGalleryImage);
return imagesPerRow;
};
/**
* Scrolls to the image with the given name.
* If the image is not fully visible, it will not be scrolled to.
* @param imageName The image name to scroll to.
* @param index The index of the image in the gallery.
*/
const scrollToImage = (imageName: string, index: number) => {
const virtuosoContext = virtuosoGridRefs.get();
const range = virtuosoContext.virtuosoRangeRef?.current;
const root = virtuosoContext.rootRef?.current;
const virtuoso = virtuosoContext.virtuosoRef?.current;
if (!range || !virtuoso || !root) {
return;
}
const imageElement = document.querySelector(
`[data-testid="${getGalleryImageDataTestId(imageName)}"]`
);
const itemRect = imageElement?.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
if (!itemRect || !getIsVisible(itemRect, rootRect)) {
virtuoso.scrollToIndex({
index,
align: getScrollToIndexAlign(index, range),
});
}
};
// Utilities to get the image to the left, right, up, or down of the current image.
const getLeftImage = (images: ImageDTO[], currentIndex: number) => {
const index = clamp(currentIndex - 1, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getRightImage = (images: ImageDTO[], currentIndex: number) => {
const index = clamp(currentIndex + 1, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getUpImage = (images: ImageDTO[], currentIndex: number) => {
const imagesPerRow = getImagesPerRow();
// If we are on the first row, we want to stay on the first row, not go to first image
const isOnFirstRow = currentIndex < imagesPerRow;
const index = isOnFirstRow
? currentIndex
: clamp(currentIndex - imagesPerRow, 0, images.length - 1);
const image = images[index];
return { index, image };
};
const getDownImage = (images: ImageDTO[], currentIndex: number) => {
const imagesPerRow = getImagesPerRow();
// If there are no images below the current image, we want to stay where we are
const areImagesBelow = currentIndex < images.length - imagesPerRow;
const index = areImagesBelow
? clamp(currentIndex + imagesPerRow, 0, images.length - 1)
: currentIndex;
const image = images[index];
return { index, image };
};
const getImageFuncs = {
left: getLeftImage,
right: getRightImage,
up: getUpImage,
down: getDownImage,
};
export type UseGalleryNavigationReturn = {
handleLeftImage: () => void;
handleRightImage: () => void;
handleUpImage: () => void;
handleDownImage: () => void;
isOnFirstImage: boolean;
isOnLastImage: boolean;
areImagesBelowCurrent: boolean;
};
/**
* Provides access to the gallery navigation via arrow keys.
* Also provides information about the current image's position in the gallery,
* useful for determining whether to load more images or display navigatin
* buttons.
*/
export const useGalleryNavigation = (): UseGalleryNavigationReturn => {
const dispatch = useAppDispatch();
const lastSelectedImage = useAppSelector(selectLastSelectedImage);
const {
queryResult: { data },
} = useGalleryImages();
const loadedImagesCount = useMemo(
() => data?.ids.length ?? 0,
[data?.ids.length]
);
const lastSelectedImageIndex = useMemo(() => {
if (!data || !lastSelectedImage) {
return 0;
}
return imagesSelectors
.selectAll(data)
.findIndex((i) => i.image_name === lastSelectedImage.image_name);
}, [lastSelectedImage, data]);
const handleNavigation = useCallback(
(direction: 'left' | 'right' | 'up' | 'down') => {
if (!data) {
return;
}
const { index, image } = getImageFuncs[direction](
imagesSelectors.selectAll(data),
lastSelectedImageIndex
);
if (!image || index === lastSelectedImageIndex) {
return;
}
dispatch(imageSelected(image));
scrollToImage(image.image_name, index);
},
[dispatch, lastSelectedImageIndex, data]
);
const isOnFirstImage = useMemo(
() => lastSelectedImageIndex === 0,
[lastSelectedImageIndex]
);
const isOnLastImage = useMemo(
() => lastSelectedImageIndex === loadedImagesCount - 1,
[lastSelectedImageIndex, loadedImagesCount]
);
const areImagesBelowCurrent = useMemo(() => {
const imagesPerRow = getImagesPerRow();
return lastSelectedImageIndex + imagesPerRow < loadedImagesCount;
}, [lastSelectedImageIndex, loadedImagesCount]);
const handleLeftImage = useCallback(() => {
handleNavigation('left');
}, [handleNavigation]);
const handleRightImage = useCallback(() => {
handleNavigation('right');
}, [handleNavigation]);
const handleUpImage = useCallback(() => {
handleNavigation('up');
}, [handleNavigation]);
const handleDownImage = useCallback(() => {
handleNavigation('down');
}, [handleNavigation]);
return {
handleLeftImage,
handleRightImage,
handleUpImage,
handleDownImage,
isOnFirstImage,
isOnLastImage,
areImagesBelowCurrent,
};
};

View File

@@ -1,6 +1,6 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { useGalleryImages } from 'features/gallery/hooks/useGalleryImages';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import {
selectGallerySlice,
selectionChanged,
@@ -8,24 +8,29 @@ import {
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import type { MouseEvent } from 'react';
import { useCallback, useMemo } from 'react';
import { useListImagesQuery } from 'services/api/endpoints/images';
import type { ImageDTO } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
const selectGallerySelection = createMemoizedSelector(
selectGallerySlice,
(gallery) => gallery.selection
const selector = createMemoizedSelector(
[selectGallerySlice, selectListImagesBaseQueryArgs],
(gallery, queryArgs) => {
return {
queryArgs,
selection: gallery.selection,
};
}
);
const EMPTY_ARRAY: ImageDTO[] = [];
export const useMultiselect = (imageDTO?: ImageDTO) => {
const dispatch = useAppDispatch();
const selection = useAppSelector(selectGallerySelection);
const { data } = useGalleryImages().queryResult;
const imageDTOs = useMemo(
() => (data ? imagesSelectors.selectAll(data) : EMPTY_ARRAY),
[data]
);
const { queryArgs, selection } = useAppSelector(selector);
const { imageDTOs } = useListImagesQuery(queryArgs, {
selectFromResult: (result) => ({
imageDTOs: result.data ? imagesSelectors.selectAll(result.data) : [],
}),
});
const isMultiSelectEnabled = useFeatureStatus('multiselect').isFeatureEnabled;

View File

@@ -0,0 +1,174 @@
import { createMemoizedSelector } from 'app/store/createMemoizedSelector';
import type { RootState } from 'app/store/store';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { selectListImagesBaseQueryArgs } from 'features/gallery/store/gallerySelectors';
import { imageSelected } from 'features/gallery/store/gallerySlice';
import { IMAGE_LIMIT } from 'features/gallery/store/types';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { clamp } from 'lodash-es';
import { map } from 'nanostores';
import type { RefObject } from 'react';
import { useCallback } from 'react';
import type { ListRange, VirtuosoGridHandle } from 'react-virtuoso';
import { boardsApi } from 'services/api/endpoints/boards';
import {
imagesApi,
useLazyListImagesQuery,
} from 'services/api/endpoints/images';
import type { ListImagesArgs } from 'services/api/types';
import { imagesSelectors } from 'services/api/util';
export type UseNextPrevImageState = {
virtuosoRef: RefObject<VirtuosoGridHandle> | undefined;
virtuosoRangeRef: RefObject<ListRange> | undefined;
};
export const $useNextPrevImageState = map<UseNextPrevImageState>({
virtuosoRef: undefined,
virtuosoRangeRef: undefined,
});
export const nextPrevImageButtonsSelector = createMemoizedSelector(
[(state: RootState) => state, selectListImagesBaseQueryArgs],
(state, baseQueryArgs) => {
const { data, status } =
imagesApi.endpoints.listImages.select(baseQueryArgs)(state);
const { data: totalsData } =
state.gallery.galleryView === 'images'
? boardsApi.endpoints.getBoardImagesTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state)
: boardsApi.endpoints.getBoardAssetsTotal.select(
baseQueryArgs.board_id ?? 'none'
)(state);
const lastSelectedImage =
state.gallery.selection[state.gallery.selection.length - 1];
const isFetching = status === 'pending';
if (!data || !lastSelectedImage || totalsData?.total === 0) {
return {
isFetching,
queryArgs: baseQueryArgs,
isOnFirstImage: true,
isOnLastImage: true,
};
}
const queryArgs: ListImagesArgs = {
...baseQueryArgs,
offset: data.ids.length,
limit: IMAGE_LIMIT,
};
const images = imagesSelectors.selectAll(data);
const currentImageIndex = images.findIndex(
(i) => i.image_name === lastSelectedImage.image_name
);
const nextImageIndex = clamp(currentImageIndex + 1, 0, images.length - 1);
const prevImageIndex = clamp(currentImageIndex - 1, 0, images.length - 1);
const nextImageId = images[nextImageIndex]?.image_name;
const prevImageId = images[prevImageIndex]?.image_name;
const nextImage = nextImageId
? imagesSelectors.selectById(data, nextImageId)
: undefined;
const prevImage = prevImageId
? imagesSelectors.selectById(data, prevImageId)
: undefined;
const imagesLength = images.length;
return {
loadedImagesCount: images.length,
currentImageIndex,
areMoreImagesAvailable: (totalsData?.total ?? 0) > imagesLength,
isFetching: status === 'pending',
nextImage,
prevImage,
nextImageIndex,
prevImageIndex,
queryArgs,
};
}
);
export const useNextPrevImage = () => {
const dispatch = useAppDispatch();
const {
nextImage,
nextImageIndex,
prevImage,
prevImageIndex,
areMoreImagesAvailable,
isFetching,
queryArgs,
loadedImagesCount,
currentImageIndex,
} = useAppSelector(nextPrevImageButtonsSelector);
const handlePrevImage = useCallback(() => {
prevImage && dispatch(imageSelected(prevImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
prevImageIndex !== undefined &&
(prevImageIndex < range.startIndex || prevImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: prevImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(prevImageIndex, range),
});
}
}, [dispatch, prevImage, prevImageIndex]);
const handleNextImage = useCallback(() => {
nextImage && dispatch(imageSelected(nextImage));
const range = $useNextPrevImageState.get().virtuosoRangeRef?.current;
const virtuoso = $useNextPrevImageState.get().virtuosoRef?.current;
if (!range || !virtuoso) {
return;
}
if (
nextImageIndex !== undefined &&
(nextImageIndex < range.startIndex || nextImageIndex > range.endIndex)
) {
virtuoso.scrollToIndex({
index: nextImageIndex,
behavior: 'smooth',
align: getScrollToIndexAlign(nextImageIndex, range),
});
}
}, [dispatch, nextImage, nextImageIndex]);
const [listImages] = useLazyListImagesQuery();
const handleLoadMoreImages = useCallback(() => {
listImages(queryArgs);
}, [listImages, queryArgs]);
return {
handlePrevImage,
handleNextImage,
isOnFirstImage: currentImageIndex === 0,
isOnLastImage:
currentImageIndex !== undefined &&
currentImageIndex === loadedImagesCount - 1,
nextImage,
prevImage,
areMoreImagesAvailable,
handleLoadMoreImages,
isFetching,
};
};

View File

@@ -1,52 +0,0 @@
import { virtuosoGridRefs } from 'features/gallery/components/ImageGrid/types';
import { getIsVisible } from 'features/gallery/util/getIsVisible';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { useEffect, useRef } from 'react';
/**
* Scrolls an image into view when it is selected. This is necessary because
* the image grid is virtualized, so the image may not be visible when it is
* selected.
*
* Also handles when an image is selected programmatically - for example, when
* auto-switching the new gallery images.
*
* @param isSelected Whether the image is selected.
* @param index The index of the image in the gallery.
* @param selectionCount The number of images selected.
* @returns
*/
export const useScrollIntoView = (
isSelected: boolean,
index: number,
selectionCount: number
) => {
const imageContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (!isSelected || selectionCount !== 1) {
return;
}
const virtuosoContext = virtuosoGridRefs.get();
const range = virtuosoContext.virtuosoRangeRef?.current;
const root = virtuosoContext.rootRef?.current;
const virtuoso = virtuosoContext.virtuosoRef?.current;
if (!range || !virtuoso || !root) {
return;
}
const itemRect = imageContainerRef.current?.getBoundingClientRect();
const rootRect = root.getBoundingClientRect();
if (!itemRect || !getIsVisible(itemRect, rootRect)) {
virtuoso.scrollToIndex({
index,
align: getScrollToIndexAlign(index, range),
});
}
}, [isSelected, index, selectionCount]);
return imageContainerRef;
};

View File

@@ -0,0 +1,46 @@
import type { VirtuosoGalleryContext } from 'features/gallery/components/ImageGrid/types';
import { getScrollToIndexAlign } from 'features/gallery/util/getScrollToIndexAlign';
import { useEffect, useRef } from 'react';
export const useScrollToVisible = (
isSelected: boolean,
index: number,
selectionCount: number,
virtuosoContext: VirtuosoGalleryContext
) => {
const imageContainerRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (
!isSelected ||
selectionCount !== 1 ||
!virtuosoContext.rootRef.current ||
!virtuosoContext.virtuosoRef.current ||
!virtuosoContext.virtuosoRangeRef.current ||
!imageContainerRef.current
) {
return;
}
const itemRect = imageContainerRef.current.getBoundingClientRect();
const rootRect = virtuosoContext.rootRef.current.getBoundingClientRect();
const itemIsVisible =
itemRect.top >= rootRect.top &&
itemRect.bottom <= rootRect.bottom &&
itemRect.left >= rootRect.left &&
itemRect.right <= rootRect.right;
if (!itemIsVisible) {
virtuosoContext.virtuosoRef.current.scrollToIndex({
index,
behavior: 'smooth',
align: getScrollToIndexAlign(
index,
virtuosoContext.virtuosoRangeRef.current
),
});
}
}, [isSelected, index, selectionCount, virtuosoContext]);
return imageContainerRef;
};

View File

@@ -106,11 +106,3 @@ const isAnyBoardDeleted = isAnyOf(
);
export const selectGallerySlice = (state: RootState) => state.gallery;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateGalleryState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -1,12 +0,0 @@
/**
* Gets whether the item is visible in the root element.
*/
export const getIsVisible = (itemRect: DOMRect, rootRect: DOMRect) => {
return (
itemRect.top >= rootRect.top &&
itemRect.bottom <= rootRect.bottom &&
itemRect.left >= rootRect.left &&
itemRect.right <= rootRect.right
);
};

View File

@@ -8,14 +8,8 @@ import { useTranslation } from 'react-i18next';
const ParamHrfStrength = () => {
const hrfStrength = useAppSelector((s) => s.hrf.hrfStrength);
const initial = useAppSelector((s) => s.config.sd.hrfStrength.initial);
const sliderMin = useAppSelector((s) => s.config.sd.hrfStrength.sliderMin);
const min = useAppSelector((s) => s.config.sd.hrfStrength.min);
const sliderMax = useAppSelector((s) => s.config.sd.hrfStrength.sliderMax);
const numberInputMin = useAppSelector(
(s) => s.config.sd.hrfStrength.numberInputMin
);
const numberInputMax = useAppSelector(
(s) => s.config.sd.hrfStrength.numberInputMax
);
const coarseStep = useAppSelector((s) => s.config.sd.hrfStrength.coarseStep);
const fineStep = useAppSelector((s) => s.config.sd.hrfStrength.fineStep);
const dispatch = useAppDispatch();
@@ -31,7 +25,7 @@ const ParamHrfStrength = () => {
return (
<InvControl label={t('parameters.denoisingStrength')}>
<InvSlider
min={sliderMin}
min={min}
max={sliderMax}
step={coarseStep}
fineStep={fineStep}
@@ -40,8 +34,6 @@ const ParamHrfStrength = () => {
onChange={onChange}
marks
withNumberInput
numberInputMin={numberInputMin}
numberInputMax={numberInputMax}
/>
</InvControl>
);

View File

@@ -7,14 +7,12 @@ import type {
} from 'features/parameters/types/parameterSchemas';
export interface HRFState {
_version: 1;
hrfEnabled: boolean;
hrfStrength: ParameterStrength;
hrfMethod: ParameterHRFMethod;
}
export const initialHRFState: HRFState = {
_version: 1,
hrfStrength: 0.45,
hrfEnabled: false,
hrfMethod: 'ESRGAN',
@@ -43,11 +41,3 @@ export const { setHrfEnabled, setHrfStrength, setHrfMethod } = hrfSlice.actions;
export default hrfSlice.reducer;
export const selectHrfSlice = (state: RootState) => state.hrf;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateHRFState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -4,10 +4,10 @@ import {
InvCardBody,
InvCardHeader,
} from 'common/components/InvCard/wrapper';
import { InvLabel } from 'common/components/InvControl/InvLabel';
import { InvIconButton } from 'common/components/InvIconButton/InvIconButton';
import { InvNumberInput } from 'common/components/InvNumberInput/InvNumberInput';
import { InvSlider } from 'common/components/InvSlider/InvSlider';
import { InvText } from 'common/components/InvText/wrapper';
import type { LoRA } from 'features/lora/store/loraSlice';
import { loraRemoved, loraWeightChanged } from 'features/lora/store/loraSlice';
import { memo, useCallback } from 'react';
@@ -35,9 +35,9 @@ export const LoRACard = memo((props: LoRACardProps) => {
return (
<InvCard variant="lora">
<InvCardHeader>
<InvLabel noOfLines={1} wordBreak="break-all">
<InvText noOfLines={1} wordBreak="break-all">
{lora.model_name}
</InvLabel>
</InvText>
<InvIconButton
aria-label="Remove LoRA"
variant="ghost"

View File

@@ -14,18 +14,16 @@ export const defaultLoRAConfig = {
};
export type LoraState = {
_version: 1;
loras: Record<string, LoRA>;
};
export const initialLoraState: LoraState = {
_version: 1,
export const intialLoraState: LoraState = {
loras: {},
};
export const loraSlice = createSlice({
name: 'lora',
initialState: initialLoraState,
initialState: intialLoraState,
reducers: {
loraAdded: (state, action: PayloadAction<LoRAModelConfigEntity>) => {
const { model_name, id, base_model } = action.payload;
@@ -79,11 +77,3 @@ export const {
export default loraSlice.reducer;
export const selectLoraSlice = (state: RootState) => state.lora;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateLoRAState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -3,7 +3,7 @@ import type { InvButtonProps } from 'common/components/InvButton/types';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { FaSync } from 'react-icons/fa';
import { useSyncModels } from './useSyncModels';
@@ -21,7 +21,7 @@ export const SyncModelsButton = memo(
<InvButton
isLoading={isLoading}
onClick={syncModels}
leftIcon={<PiArrowsClockwiseBold />}
leftIcon={<FaSync />}
minW="max-content"
{...props}
>

View File

@@ -3,7 +3,7 @@ import type { InvIconButtonProps } from 'common/components/InvIconButton/types';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiArrowsClockwiseBold } from 'react-icons/pi';
import { FaSync } from 'react-icons/fa';
import { useSyncModels } from './useSyncModels';
@@ -19,7 +19,7 @@ export const SyncModelsIconButton = memo(
return (
<InvIconButton
icon={<PiArrowsClockwiseBold />}
icon={<FaSync />}
tooltip={t('modelManager.syncModels')}
aria-label={t('modelManager.syncModels')}
isLoading={isLoading}

View File

@@ -3,13 +3,11 @@ import { createSlice } from '@reduxjs/toolkit';
import type { RootState } from 'app/store/store';
type ModelManagerState = {
_version: 1;
searchFolder: string | null;
advancedAddScanModel: string | null;
};
export const initialModelManagerState: ModelManagerState = {
_version: 1,
const initialModelManagerState: ModelManagerState = {
searchFolder: null,
advancedAddScanModel: null,
};
@@ -33,11 +31,3 @@ export const { setSearchFolder, setAdvancedAddScanModel } =
export default modelManagerSlice.reducer;
export const selectModelManagerSlice = (state: RootState) => state.modelmanager;
/* eslint-disable-next-line @typescript-eslint/no-explicit-any */
export const migrateModelManagerState = (state: any): any => {
if (!('_version' in state)) {
state._version = 1;
}
return state;
};

View File

@@ -1,3 +1,4 @@
import { DeleteIcon } from '@chakra-ui/icons';
import { Badge, Flex, useDisclosure } from '@chakra-ui/react';
import { useAppDispatch } from 'app/store/storeHooks';
import { InvButton } from 'common/components/InvButton/InvButton';
@@ -10,7 +11,6 @@ import { addToast } from 'features/system/store/systemSlice';
import { makeToast } from 'features/system/util/makeToast';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleBold } from 'react-icons/pi';
import type {
LoRAModelConfigEntity,
MainModelConfigEntity,
@@ -109,7 +109,7 @@ const ModelListItem = (props: ModelListItemProps) => {
</Flex>
<InvIconButton
onClick={onOpen}
icon={<PiTrashSimpleBold />}
icon={<DeleteIcon />}
aria-label={t('modelManager.deleteConfig')}
colorScheme="error"
/>

View File

@@ -155,9 +155,8 @@ const AddNodePopover = () => {
return;
}
addNode(v.value);
dispatch(addNodePopoverClosed());
},
[addNode, dispatch]
[addNode]
);
const onClose = useCallback(() => {
@@ -236,7 +235,6 @@ const AddNodePopover = () => {
onMenuClose={onClose}
onKeyDown={onKeyDown}
inputRef={inputRef}
closeMenuOnSelect={false}
/>
</InvPopoverBody>
</InvPopoverContent>

View File

@@ -1,6 +1,4 @@
import type { ChakraProps } from '@chakra-ui/react';
import { Flex } from '@chakra-ui/react';
import { InvControlGroup } from 'common/components/InvControl/InvControlGroup';
import { useHasImageOutput } from 'features/nodes/hooks/useHasImageOutput';
import { DRAG_HANDLE_CLASSNAME } from 'features/nodes/types/constants';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
@@ -13,8 +11,6 @@ type Props = {
nodeId: string;
};
const props: ChakraProps = { w: 'unset' };
const InvocationNodeFooter = ({ nodeId }: Props) => {
const hasImageOutput = useHasImageOutput(nodeId);
const isCacheEnabled = useFeatureStatus('invocationCache').isFeatureEnabled;
@@ -24,16 +20,13 @@ const InvocationNodeFooter = ({ nodeId }: Props) => {
layerStyle="nodeFooter"
w="full"
borderBottomRadius="base"
gap={4}
px={2}
py={0}
h={8}
justifyContent="space-between"
>
<InvControlGroup controlProps={props} labelProps={props}>
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
</InvControlGroup>
{isCacheEnabled && <UseCacheCheckbox nodeId={nodeId} />}
{hasImageOutput && <SaveToGalleryCheckbox nodeId={nodeId} />}
</Flex>
);
};

Some files were not shown because too many files have changed in this diff Show More