Merge branch 'main' into sdxl-convert-safetensors

This commit is contained in:
Brandon 2024-01-31 17:00:19 -05:00 committed by GitHub
commit 06bcc07f65
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
42 changed files with 1345 additions and 351 deletions

View File

@ -251,7 +251,11 @@ class InvokeAIAppConfig(InvokeAISettings):
log_level : Literal["debug", "info", "warning", "error", "critical"] = Field(default="info", description="Emit logging messages at this level or higher", json_schema_extra=Categories.Logging)
log_sql : bool = Field(default=False, description="Log SQL queries", json_schema_extra=Categories.Logging)
# Development
dev_reload : bool = Field(default=False, description="Automatically reload when Python sources are changed.", json_schema_extra=Categories.Development)
profile_graphs : bool = Field(default=False, description="Enable graph profiling", json_schema_extra=Categories.Development)
profile_prefix : Optional[str] = Field(default=None, description="An optional prefix for profile output files.", json_schema_extra=Categories.Development)
profiles_dir : Path = Field(default=Path('profiles'), description="Directory for graph profiles", json_schema_extra=Categories.Development)
version : bool = Field(default=False, description="Show InvokeAI version and exit", json_schema_extra=Categories.Other)
@ -280,6 +284,9 @@ class InvokeAIAppConfig(InvokeAISettings):
deny_nodes : Optional[List[str]] = Field(default=None, description="List of nodes to deny. Omit to deny none.", json_schema_extra=Categories.Nodes)
node_cache_size : int = Field(default=512, description="How many cached nodes to keep in memory", json_schema_extra=Categories.Nodes)
# MODEL IMPORT
civitai_api_key : Optional[str] = Field(default=os.environ.get("CIVITAI_API_KEY"), description="API key for CivitAI", json_schema_extra=Categories.Other)
# DEPRECATED FIELDS - STILL HERE IN ORDER TO OBTAN VALUES FROM PRE-3.1 CONFIG FILES
always_use_cpu : bool = Field(default=False, description="If true, use the CPU for rendering even if a GPU is available.", json_schema_extra=Categories.MemoryPerformance)
max_cache_size : Optional[float] = Field(default=None, gt=0, description="Maximum memory amount used by model cache for rapid switching", json_schema_extra=Categories.MemoryPerformance)
@ -289,6 +296,7 @@ class InvokeAIAppConfig(InvokeAISettings):
lora_dir : Optional[Path] = Field(default=None, description='Path to a directory of LoRA/LyCORIS models to be imported on startup.', json_schema_extra=Categories.Paths)
embedding_dir : Optional[Path] = Field(default=None, description='Path to a directory of Textual Inversion embeddings to be imported on startup.', json_schema_extra=Categories.Paths)
controlnet_dir : Optional[Path] = Field(default=None, description='Path to a directory of ControlNet embeddings to be imported on startup.', json_schema_extra=Categories.Paths)
# this is not referred to in the source code and can be removed entirely
#free_gpu_mem : Optional[bool] = Field(default=None, description="If true, purge model from GPU after each generation.", json_schema_extra=Categories.MemoryPerformance)
@ -449,6 +457,11 @@ class InvokeAIAppConfig(InvokeAISettings):
disabled_in_config = not self.xformers_enabled
return disabled_in_config and self.attention_type != "xformers"
@property
def profiles_path(self) -> Path:
"""Path to the graph profiles directory."""
return self._resolve(self.profiles_dir)
@staticmethod
def find_root() -> Path:
"""Choose the runtime root directory when not specified on command line or init file."""

View File

@ -1,11 +1,16 @@
import time
import traceback
from contextlib import suppress
from threading import BoundedSemaphore, Event, Thread
from typing import Optional
import invokeai.backend.util.logging as logger
from invokeai.app.invocations.baseinvocation import InvocationContext
from invokeai.app.services.invocation_queue.invocation_queue_common import InvocationQueueItem
from invokeai.app.services.invocation_stats.invocation_stats_common import (
GESStatsNotFoundError,
)
from invokeai.app.util.profiler import Profiler
from ..invoker import Invoker
from .invocation_processor_base import InvocationProcessorABC
@ -18,7 +23,7 @@ class DefaultInvocationProcessor(InvocationProcessorABC):
__invoker: Invoker
__threadLimit: BoundedSemaphore
def start(self, invoker) -> None:
def start(self, invoker: Invoker) -> None:
# if we do want multithreading at some point, we could make this configurable
self.__threadLimit = BoundedSemaphore(1)
self.__invoker = invoker
@ -39,6 +44,16 @@ class DefaultInvocationProcessor(InvocationProcessorABC):
self.__threadLimit.acquire()
queue_item: Optional[InvocationQueueItem] = None
profiler = (
Profiler(
logger=self.__invoker.services.logger,
output_dir=self.__invoker.services.configuration.profiles_path,
prefix=self.__invoker.services.configuration.profile_prefix,
)
if self.__invoker.services.configuration.profile_graphs
else None
)
while not stop_event.is_set():
try:
queue_item = self.__invoker.services.queue.get()
@ -49,6 +64,10 @@ class DefaultInvocationProcessor(InvocationProcessorABC):
# do not hammer the queue
time.sleep(0.5)
continue
if profiler and profiler.profile_id != queue_item.graph_execution_state_id:
profiler.start(profile_id=queue_item.graph_execution_state_id)
try:
graph_execution_state = self.__invoker.services.graph_execution_manager.get(
queue_item.graph_execution_state_id
@ -137,7 +156,8 @@ class DefaultInvocationProcessor(InvocationProcessorABC):
pass
except CanceledException:
self.__invoker.services.performance_statistics.reset_stats(graph_execution_state.id)
with suppress(GESStatsNotFoundError):
self.__invoker.services.performance_statistics.reset_stats(graph_execution_state.id)
pass
except Exception as e:
@ -162,7 +182,8 @@ class DefaultInvocationProcessor(InvocationProcessorABC):
error_type=e.__class__.__name__,
error=error,
)
self.__invoker.services.performance_statistics.reset_stats(graph_execution_state.id)
with suppress(GESStatsNotFoundError):
self.__invoker.services.performance_statistics.reset_stats(graph_execution_state.id)
pass
# Check queue to see if this is canceled, and skip if so
@ -194,13 +215,21 @@ class DefaultInvocationProcessor(InvocationProcessorABC):
error=traceback.format_exc(),
)
elif is_complete:
self.__invoker.services.performance_statistics.log_stats(graph_execution_state.id)
self.__invoker.services.events.emit_graph_execution_complete(
queue_batch_id=queue_item.session_queue_batch_id,
queue_item_id=queue_item.session_queue_item_id,
queue_id=queue_item.session_queue_id,
graph_execution_state_id=graph_execution_state.id,
)
with suppress(GESStatsNotFoundError):
self.__invoker.services.performance_statistics.log_stats(graph_execution_state.id)
self.__invoker.services.events.emit_graph_execution_complete(
queue_batch_id=queue_item.session_queue_batch_id,
queue_item_id=queue_item.session_queue_item_id,
queue_id=queue_item.session_queue_id,
graph_execution_state_id=graph_execution_state.id,
)
if profiler:
profile_path = profiler.stop()
stats_path = profile_path.with_suffix(".json")
self.__invoker.services.performance_statistics.dump_stats(
graph_execution_state_id=graph_execution_state.id, output_path=stats_path
)
self.__invoker.services.performance_statistics.reset_stats(graph_execution_state.id)
except KeyboardInterrupt:
pass # Log something? KeyboardInterrupt is probably not going to be seen by the processor

View File

@ -30,8 +30,10 @@ writes to the system log is stored in InvocationServices.performance_statistics.
from abc import ABC, abstractmethod
from contextlib import AbstractContextManager
from pathlib import Path
from invokeai.app.invocations.baseinvocation import BaseInvocation
from invokeai.app.services.invocation_stats.invocation_stats_common import InvocationStatsSummary
class InvocationStatsServiceBase(ABC):
@ -61,8 +63,9 @@ class InvocationStatsServiceBase(ABC):
@abstractmethod
def reset_stats(self, graph_execution_state_id: str):
"""
Reset all statistics for the indicated graph
:param graph_execution_state_id
Reset all statistics for the indicated graph.
:param graph_execution_state_id: The id of the session whose stats to reset.
:raises GESStatsNotFoundError: if the graph isn't tracked in the stats.
"""
pass
@ -70,5 +73,26 @@ class InvocationStatsServiceBase(ABC):
def log_stats(self, graph_execution_state_id: str):
"""
Write out the accumulated statistics to the log or somewhere else.
:param graph_execution_state_id: The id of the session whose stats to log.
:raises GESStatsNotFoundError: if the graph isn't tracked in the stats.
"""
pass
@abstractmethod
def get_stats(self, graph_execution_state_id: str) -> InvocationStatsSummary:
"""
Gets the accumulated statistics for the indicated graph.
:param graph_execution_state_id: The id of the session whose stats to get.
:raises GESStatsNotFoundError: if the graph isn't tracked in the stats.
"""
pass
@abstractmethod
def dump_stats(self, graph_execution_state_id: str, output_path: Path) -> None:
"""
Write out the accumulated statistics to the indicated path as JSON.
:param graph_execution_state_id: The id of the session whose stats to dump.
:param output_path: The file to write the stats to.
:raises GESStatsNotFoundError: if the graph isn't tracked in the stats.
"""
pass

View File

@ -1,5 +1,91 @@
from collections import defaultdict
from dataclasses import dataclass
from dataclasses import asdict, dataclass
from typing import Any, Optional
class GESStatsNotFoundError(Exception):
"""Raised when execution stats are not found for a given Graph Execution State."""
@dataclass
class NodeExecutionStatsSummary:
"""The stats for a specific type of node."""
node_type: str
num_calls: int
time_used_seconds: float
peak_vram_gb: float
@dataclass
class ModelCacheStatsSummary:
"""The stats for the model cache."""
high_water_mark_gb: float
cache_size_gb: float
total_usage_gb: float
cache_hits: int
cache_misses: int
models_cached: int
models_cleared: int
@dataclass
class GraphExecutionStatsSummary:
"""The stats for the graph execution state."""
graph_execution_state_id: str
execution_time_seconds: float
# `wall_time_seconds`, `ram_usage_gb` and `ram_change_gb` are derived from the node execution stats.
# In some situations, there are no node stats, so these values are optional.
wall_time_seconds: Optional[float]
ram_usage_gb: Optional[float]
ram_change_gb: Optional[float]
@dataclass
class InvocationStatsSummary:
"""
The accumulated stats for a graph execution.
Its `__str__` method returns a human-readable stats summary.
"""
vram_usage_gb: Optional[float]
graph_stats: GraphExecutionStatsSummary
model_cache_stats: ModelCacheStatsSummary
node_stats: list[NodeExecutionStatsSummary]
def __str__(self) -> str:
_str = ""
_str = f"Graph stats: {self.graph_stats.graph_execution_state_id}\n"
_str += f"{'Node':>30} {'Calls':>7} {'Seconds':>9} {'VRAM Used':>10}\n"
for summary in self.node_stats:
_str += f"{summary.node_type:>30} {summary.num_calls:>7} {summary.time_used_seconds:>8.3f}s {summary.peak_vram_gb:>9.3f}G\n"
_str += f"TOTAL GRAPH EXECUTION TIME: {self.graph_stats.execution_time_seconds:7.3f}s\n"
if self.graph_stats.wall_time_seconds is not None:
_str += f"TOTAL GRAPH WALL TIME: {self.graph_stats.wall_time_seconds:7.3f}s\n"
if self.graph_stats.ram_usage_gb is not None and self.graph_stats.ram_change_gb is not None:
_str += f"RAM used by InvokeAI process: {self.graph_stats.ram_usage_gb:4.2f}G ({self.graph_stats.ram_change_gb:+5.3f}G)\n"
_str += f"RAM used to load models: {self.model_cache_stats.total_usage_gb:4.2f}G\n"
if self.vram_usage_gb:
_str += f"VRAM in use: {self.vram_usage_gb:4.3f}G\n"
_str += "RAM cache statistics:\n"
_str += f" Model cache hits: {self.model_cache_stats.cache_hits}\n"
_str += f" Model cache misses: {self.model_cache_stats.cache_misses}\n"
_str += f" Models cached: {self.model_cache_stats.models_cached}\n"
_str += f" Models cleared from cache: {self.model_cache_stats.models_cleared}\n"
_str += f" Cache high water mark: {self.model_cache_stats.high_water_mark_gb:4.2f}/{self.model_cache_stats.cache_size_gb:4.2f}G\n"
return _str
def as_dict(self) -> dict[str, Any]:
"""Returns the stats as a dictionary."""
return asdict(self)
@dataclass
@ -55,12 +141,33 @@ class GraphExecutionStats:
return last_node
def get_pretty_log(self, graph_execution_state_id: str) -> str:
log = f"Graph stats: {graph_execution_state_id}\n"
log += f"{'Node':>30} {'Calls':>7}{'Seconds':>9} {'VRAM Used':>10}\n"
def get_graph_stats_summary(self, graph_execution_state_id: str) -> GraphExecutionStatsSummary:
"""Get a summary of the graph stats."""
first_node = self.get_first_node_stats()
last_node = self.get_last_node_stats()
# Log stats aggregated by node type.
wall_time_seconds: Optional[float] = None
ram_usage_gb: Optional[float] = None
ram_change_gb: Optional[float] = None
if last_node and first_node:
wall_time_seconds = last_node.end_time - first_node.start_time
ram_usage_gb = last_node.end_ram_gb
ram_change_gb = last_node.end_ram_gb - first_node.start_ram_gb
return GraphExecutionStatsSummary(
graph_execution_state_id=graph_execution_state_id,
execution_time_seconds=self.get_total_run_time(),
wall_time_seconds=wall_time_seconds,
ram_usage_gb=ram_usage_gb,
ram_change_gb=ram_change_gb,
)
def get_node_stats_summaries(self) -> list[NodeExecutionStatsSummary]:
"""Get a summary of the node stats."""
summaries: list[NodeExecutionStatsSummary] = []
node_stats_by_type: dict[str, list[NodeExecutionStats]] = defaultdict(list)
for node_stats in self._node_stats_list:
node_stats_by_type[node_stats.invocation_type].append(node_stats)
@ -68,17 +175,9 @@ class GraphExecutionStats:
num_calls = len(node_type_stats_list)
time_used = sum([n.total_time() for n in node_type_stats_list])
peak_vram = max([n.peak_vram_gb for n in node_type_stats_list])
log += f"{node_type:>30} {num_calls:>4} {time_used:7.3f}s {peak_vram:4.3f}G\n"
summary = NodeExecutionStatsSummary(
node_type=node_type, num_calls=num_calls, time_used_seconds=time_used, peak_vram_gb=peak_vram
)
summaries.append(summary)
# Log stats for the entire graph.
log += f"TOTAL GRAPH EXECUTION TIME: {self.get_total_run_time():7.3f}s\n"
first_node = self.get_first_node_stats()
last_node = self.get_last_node_stats()
if first_node is not None and last_node is not None:
total_wall_time = last_node.end_time - first_node.start_time
ram_change = last_node.end_ram_gb - first_node.start_ram_gb
log += f"TOTAL GRAPH WALL TIME: {total_wall_time:7.3f}s\n"
log += f"RAM used by InvokeAI process: {last_node.end_ram_gb:4.2f}G ({ram_change:+5.3f}G)\n"
return log
return summaries

View File

@ -1,5 +1,7 @@
import json
import time
from contextlib import contextmanager
from pathlib import Path
import psutil
import torch
@ -10,7 +12,15 @@ from invokeai.app.services.invoker import Invoker
from invokeai.backend.model_management.model_cache import CacheStats
from .invocation_stats_base import InvocationStatsServiceBase
from .invocation_stats_common import GraphExecutionStats, NodeExecutionStats
from .invocation_stats_common import (
GESStatsNotFoundError,
GraphExecutionStats,
GraphExecutionStatsSummary,
InvocationStatsSummary,
ModelCacheStatsSummary,
NodeExecutionStats,
NodeExecutionStatsSummary,
)
# Size of 1GB in bytes.
GB = 2**30
@ -95,31 +105,66 @@ class InvocationStatsService(InvocationStatsServiceBase):
del self._stats[graph_execution_state_id]
del self._cache_stats[graph_execution_state_id]
except KeyError as e:
logger.warning(f"Attempted to clear statistics for unknown graph {graph_execution_state_id}: {e}.")
msg = f"Attempted to clear statistics for unknown graph {graph_execution_state_id}: {e}."
logger.error(msg)
raise GESStatsNotFoundError(msg) from e
def log_stats(self, graph_execution_state_id: str):
def get_stats(self, graph_execution_state_id: str) -> InvocationStatsSummary:
graph_stats_summary = self._get_graph_summary(graph_execution_state_id)
node_stats_summaries = self._get_node_summaries(graph_execution_state_id)
model_cache_stats_summary = self._get_model_cache_summary(graph_execution_state_id)
vram_usage_gb = torch.cuda.memory_allocated() / GB if torch.cuda.is_available() else None
return InvocationStatsSummary(
graph_stats=graph_stats_summary,
model_cache_stats=model_cache_stats_summary,
node_stats=node_stats_summaries,
vram_usage_gb=vram_usage_gb,
)
def log_stats(self, graph_execution_state_id: str) -> None:
stats = self.get_stats(graph_execution_state_id)
logger.info(str(stats))
def dump_stats(self, graph_execution_state_id: str, output_path: Path) -> None:
stats = self.get_stats(graph_execution_state_id)
with open(output_path, "w") as f:
f.write(json.dumps(stats.as_dict(), indent=2))
def _get_model_cache_summary(self, graph_execution_state_id: str) -> ModelCacheStatsSummary:
try:
graph_stats = self._stats[graph_execution_state_id]
cache_stats = self._cache_stats[graph_execution_state_id]
except KeyError as e:
logger.warning(f"Attempted to log statistics for unknown graph {graph_execution_state_id}: {e}.")
return
msg = f"Attempted to get model cache statistics for unknown graph {graph_execution_state_id}: {e}."
logger.error(msg)
raise GESStatsNotFoundError(msg) from e
log = graph_stats.get_pretty_log(graph_execution_state_id)
return ModelCacheStatsSummary(
cache_hits=cache_stats.hits,
cache_misses=cache_stats.misses,
high_water_mark_gb=cache_stats.high_watermark / GB,
cache_size_gb=cache_stats.cache_size / GB,
total_usage_gb=sum(list(cache_stats.loaded_model_sizes.values())) / GB,
models_cached=cache_stats.in_cache,
models_cleared=cache_stats.cleared,
)
hwm = cache_stats.high_watermark / GB
tot = cache_stats.cache_size / GB
loaded = sum(list(cache_stats.loaded_model_sizes.values())) / GB
log += f"RAM used to load models: {loaded:4.2f}G\n"
if torch.cuda.is_available():
log += f"VRAM in use: {(torch.cuda.memory_allocated() / GB):4.3f}G\n"
log += "RAM cache statistics:\n"
log += f" Model cache hits: {cache_stats.hits}\n"
log += f" Model cache misses: {cache_stats.misses}\n"
log += f" Models cached: {cache_stats.in_cache}\n"
log += f" Models cleared from cache: {cache_stats.cleared}\n"
log += f" Cache high water mark: {hwm:4.2f}/{tot:4.2f}G\n"
logger.info(log)
def _get_graph_summary(self, graph_execution_state_id: str) -> GraphExecutionStatsSummary:
try:
graph_stats = self._stats[graph_execution_state_id]
except KeyError as e:
msg = f"Attempted to get graph statistics for unknown graph {graph_execution_state_id}: {e}."
logger.error(msg)
raise GESStatsNotFoundError(msg) from e
del self._stats[graph_execution_state_id]
del self._cache_stats[graph_execution_state_id]
return graph_stats.get_graph_stats_summary(graph_execution_state_id)
def _get_node_summaries(self, graph_execution_state_id: str) -> list[NodeExecutionStatsSummary]:
try:
graph_stats = self._stats[graph_execution_state_id]
except KeyError as e:
msg = f"Attempted to get node statistics for unknown graph {graph_execution_state_id}: {e}."
logger.error(msg)
raise GESStatsNotFoundError(msg) from e
return graph_stats.get_node_stats_summaries()

View File

@ -0,0 +1,67 @@
import cProfile
from logging import Logger
from pathlib import Path
from typing import Optional
class Profiler:
"""
Simple wrapper around cProfile.
Usage
```
# Create a profiler
profiler = Profiler(logger, output_dir, "sql_query_perf")
# Start a new profile
profiler.start("my_profile")
# Do stuff
profiler.stop()
```
Visualize a profile as a flamegraph with [snakeviz](https://jiffyclub.github.io/snakeviz/)
```sh
snakeviz my_profile.prof
```
Visualize a profile as directed graph with [graphviz](https://graphviz.org/download/) & [gprof2dot](https://github.com/jrfonseca/gprof2dot)
```sh
gprof2dot -f pstats my_profile.prof | dot -Tpng -o my_profile.png
# SVG or PDF may be nicer - you can search for function names
gprof2dot -f pstats my_profile.prof | dot -Tsvg -o my_profile.svg
gprof2dot -f pstats my_profile.prof | dot -Tpdf -o my_profile.pdf
```
"""
def __init__(self, logger: Logger, output_dir: Path, prefix: Optional[str] = None) -> None:
self._logger = logger.getChild(f"profiler.{prefix}" if prefix else "profiler")
self._output_dir = output_dir
self._output_dir.mkdir(parents=True, exist_ok=True)
self._profiler: Optional[cProfile.Profile] = None
self._prefix = prefix
self.profile_id: Optional[str] = None
def start(self, profile_id: str) -> None:
if self._profiler:
self.stop()
self.profile_id = profile_id
self._profiler = cProfile.Profile()
self._profiler.enable()
self._logger.info(f"Started profiling {self.profile_id}.")
def stop(self) -> Path:
if not self._profiler:
raise RuntimeError("Profiler not initialized. Call start() first.")
self._profiler.disable()
filename = f"{self._prefix}_{self.profile_id}.prof" if self._prefix else f"{self.profile_id}.prof"
path = Path(self._output_dir, filename)
self._profiler.dump_stats(path)
self._logger.info(f"Stopped profiling, profile dumped to {path}.")
self._profiler = None
self.profile_id = None
return path

View File

@ -104,12 +104,14 @@ class ModelInstall(object):
prediction_type_helper: Optional[Callable[[Path], SchedulerPredictionType]] = None,
model_manager: Optional[ModelManager] = None,
access_token: Optional[str] = None,
civitai_api_key: Optional[str] = None,
):
self.config = config
self.mgr = model_manager or ModelManager(config.model_conf_path)
self.datasets = OmegaConf.load(Dataset_path)
self.prediction_helper = prediction_type_helper
self.access_token = access_token or HfFolder.get_token()
self.civitai_api_key = civitai_api_key or config.civitai_api_key
self.reverse_paths = self._reverse_paths(self.datasets)
def all_models(self) -> Dict[str, ModelLoadInfo]:
@ -326,7 +328,11 @@ class ModelInstall(object):
def _install_url(self, url: str) -> AddModelResult:
with TemporaryDirectory(dir=self.config.models_path) as staging:
location = download_with_resume(url, Path(staging))
CIVITAI_RE = r".*civitai.com.*"
civit_url = re.match(CIVITAI_RE, url, re.IGNORECASE)
location = download_with_resume(
url, Path(staging), access_token=self.civitai_api_key if civit_url else None
)
if not location:
logger.error(f"Unable to download {url}. Skipping.")
info = ModelProbe().heuristic_probe(location, self.prediction_helper)

View File

@ -286,7 +286,7 @@ def download_with_resume(url: str, dest: Path, access_token: str = None) -> Path
open_mode = "wb"
exist_size = 0
resp = requests.get(url, header, stream=True)
resp = requests.get(url, headers=header, stream=True, allow_redirects=True)
content_length = int(resp.headers.get("content-length", 0))
if dest.is_dir():

View File

@ -152,7 +152,7 @@
"storybook": "^7.6.10",
"ts-toolbelt": "^9.6.0",
"typescript": "^5.3.3",
"vite": "^5.0.11",
"vite": "^5.0.12",
"vite-plugin-css-injected-by-js": "^3.3.1",
"vite-plugin-dts": "^3.7.1",
"vite-plugin-eslint": "^1.8.1",

View File

@ -209,7 +209,7 @@ devDependencies:
version: 7.6.10(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3)
'@storybook/react-vite':
specifier: ^7.6.10
version: 7.6.10(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3)(vite@5.0.11)
version: 7.6.10(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3)(vite@5.0.12)
'@storybook/test':
specifier: ^7.6.10
version: 7.6.10
@ -242,7 +242,7 @@ devDependencies:
version: 6.19.0(eslint@8.56.0)(typescript@5.3.3)
'@vitejs/plugin-react-swc':
specifier: ^3.5.0
version: 3.5.0(vite@5.0.11)
version: 3.5.0(vite@5.0.12)
concurrently:
specifier: ^8.2.2
version: 8.2.2
@ -301,20 +301,20 @@ devDependencies:
specifier: ^5.3.3
version: 5.3.3
vite:
specifier: ^5.0.11
version: 5.0.11(@types/node@20.11.5)
specifier: ^5.0.12
version: 5.0.12(@types/node@20.11.5)
vite-plugin-css-injected-by-js:
specifier: ^3.3.1
version: 3.3.1(vite@5.0.11)
version: 3.3.1(vite@5.0.12)
vite-plugin-dts:
specifier: ^3.7.1
version: 3.7.1(@types/node@20.11.5)(typescript@5.3.3)(vite@5.0.11)
version: 3.7.1(@types/node@20.11.5)(typescript@5.3.3)(vite@5.0.12)
vite-plugin-eslint:
specifier: ^1.8.1
version: 1.8.1(eslint@8.56.0)(vite@5.0.11)
version: 1.8.1(eslint@8.56.0)(vite@5.0.12)
vite-tsconfig-paths:
specifier: ^4.3.1
version: 4.3.1(typescript@5.3.3)(vite@5.0.11)
version: 4.3.1(typescript@5.3.3)(vite@5.0.12)
packages:
@ -3713,7 +3713,7 @@ packages:
chalk: 4.1.2
dev: true
/@joshwooding/vite-plugin-react-docgen-typescript@0.3.0(typescript@5.3.3)(vite@5.0.11):
/@joshwooding/vite-plugin-react-docgen-typescript@0.3.0(typescript@5.3.3)(vite@5.0.12):
resolution: {integrity: sha512-2D6y7fNvFmsLmRt6UCOFJPvFoPMJGT0Uh1Wg0RaigUp7kdQPs6yYn8Dmx6GZkOH/NW0yMTwRz/p0SRMMRo50vA==}
peerDependencies:
typescript: '>= 4.3.x'
@ -3727,7 +3727,7 @@ packages:
magic-string: 0.27.0
react-docgen-typescript: 2.2.2(typescript@5.3.3)
typescript: 5.3.3
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
dev: true
/@jridgewell/gen-mapping@0.3.3:
@ -4962,7 +4962,7 @@ packages:
- supports-color
dev: true
/@storybook/builder-vite@7.6.10(typescript@5.3.3)(vite@5.0.11):
/@storybook/builder-vite@7.6.10(typescript@5.3.3)(vite@5.0.12):
resolution: {integrity: sha512-qxe19axiNJVdIKj943e1ucAmADwU42fTGgMSdBzzrvfH3pSOmx2057aIxRzd8YtBRnj327eeqpgCHYIDTunMYQ==}
peerDependencies:
'@preact/preset-vite': '*'
@ -4994,7 +4994,7 @@ packages:
magic-string: 0.30.5
rollup: 3.29.4
typescript: 5.3.3
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
transitivePeerDependencies:
- encoding
- supports-color
@ -5350,7 +5350,7 @@ packages:
react-dom: 18.2.0(react@18.2.0)
dev: true
/@storybook/react-vite@7.6.10(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3)(vite@5.0.11):
/@storybook/react-vite@7.6.10(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3)(vite@5.0.12):
resolution: {integrity: sha512-YE2+J1wy8nO+c6Nv/hBMu91Edew3K184L1KSnfoZV8vtq2074k1Me/8pfe0QNuq631AncpfCYNb37yBAXQ/80w==}
engines: {node: '>=16'}
peerDependencies:
@ -5358,16 +5358,16 @@ packages:
react-dom: ^16.8.0 || ^17.0.0 || ^18.0.0
vite: ^3.0.0 || ^4.0.0 || ^5.0.0
dependencies:
'@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.3.3)(vite@5.0.11)
'@joshwooding/vite-plugin-react-docgen-typescript': 0.3.0(typescript@5.3.3)(vite@5.0.12)
'@rollup/pluginutils': 5.1.0
'@storybook/builder-vite': 7.6.10(typescript@5.3.3)(vite@5.0.11)
'@storybook/builder-vite': 7.6.10(typescript@5.3.3)(vite@5.0.12)
'@storybook/react': 7.6.10(react-dom@18.2.0)(react@18.2.0)(typescript@5.3.3)
'@vitejs/plugin-react': 3.1.0(vite@5.0.11)
'@vitejs/plugin-react': 3.1.0(vite@5.0.12)
magic-string: 0.30.5
react: 18.2.0
react-docgen: 7.0.3
react-dom: 18.2.0(react@18.2.0)
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
transitivePeerDependencies:
- '@preact/preset-vite'
- encoding
@ -6442,18 +6442,18 @@ packages:
resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==}
dev: true
/@vitejs/plugin-react-swc@3.5.0(vite@5.0.11):
/@vitejs/plugin-react-swc@3.5.0(vite@5.0.12):
resolution: {integrity: sha512-1PrOvAaDpqlCV+Up8RkAh9qaiUjoDUcjtttyhXDKw53XA6Ve16SOp6cCOpRs8Dj8DqUQs6eTW5YkLcLJjrXAig==}
peerDependencies:
vite: ^4 || ^5
dependencies:
'@swc/core': 1.3.101
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
transitivePeerDependencies:
- '@swc/helpers'
dev: true
/@vitejs/plugin-react@3.1.0(vite@5.0.11):
/@vitejs/plugin-react@3.1.0(vite@5.0.12):
resolution: {integrity: sha512-AfgcRL8ZBhAlc3BFdigClmTUMISmmzHn7sB2h9U1odvc5U/MjWXsAaz18b/WoppUTDBzxOJwo2VdClfUcItu9g==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
@ -6464,7 +6464,7 @@ packages:
'@babel/plugin-transform-react-jsx-source': 7.23.3(@babel/core@7.23.7)
magic-string: 0.27.0
react-refresh: 0.14.0
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
transitivePeerDependencies:
- supports-color
dev: true
@ -8405,7 +8405,7 @@ packages:
dependencies:
debug: 4.3.4
is-url: 1.2.4
postcss: 8.4.32
postcss: 8.4.33
postcss-values-parser: 2.0.1
transitivePeerDependencies:
- supports-color
@ -8416,8 +8416,8 @@ packages:
engines: {node: ^12.0.0 || ^14.0.0 || >=16.0.0}
dependencies:
is-url: 1.2.4
postcss: 8.4.32
postcss-values-parser: 6.0.2(postcss@8.4.32)
postcss: 8.4.33
postcss-values-parser: 6.0.2(postcss@8.4.33)
dev: true
/detective-sass@3.0.2:
@ -11558,7 +11558,7 @@ packages:
uniq: 1.0.1
dev: true
/postcss-values-parser@6.0.2(postcss@8.4.32):
/postcss-values-parser@6.0.2(postcss@8.4.33):
resolution: {integrity: sha512-YLJpK0N1brcNJrs9WatuJFtHaV9q5aAOj+S4DI5S7jgHlRfm0PIbDCAFRYMQD5SHq7Fy6xsDhyutgS0QOAs0qw==}
engines: {node: '>=10'}
peerDependencies:
@ -11566,19 +11566,10 @@ packages:
dependencies:
color-name: 1.1.4
is-url-superb: 4.0.0
postcss: 8.4.32
postcss: 8.4.33
quote-unquote: 1.0.0
dev: true
/postcss@8.4.32:
resolution: {integrity: sha512-D/kj5JNu6oo2EIy+XL/26JEDTlIbB8hw85G8StOE6L74RQAVVP5rej6wxCNqyMbR4RkPfqvezVbPw81Ngd6Kcw==}
engines: {node: ^10 || ^12 || >=14}
dependencies:
nanoid: 3.3.7
picocolors: 1.0.0
source-map-js: 1.0.2
dev: true
/postcss@8.4.33:
resolution: {integrity: sha512-Kkpbhhdjw2qQs2O2DGX+8m5OVqEcbB9HRBvuYM9pgrjEFUg30A9LmXNlTAUj4S9kgtGyrMbTzVjH7E+s5Re2yg==}
engines: {node: ^10 || ^12 || >=14}
@ -13824,15 +13815,15 @@ packages:
engines: {node: '>= 0.8'}
dev: true
/vite-plugin-css-injected-by-js@3.3.1(vite@5.0.11):
/vite-plugin-css-injected-by-js@3.3.1(vite@5.0.12):
resolution: {integrity: sha512-PjM/X45DR3/V1K1fTRs8HtZHEQ55kIfdrn+dzaqNBFrOYO073SeSNCxp4j7gSYhV9NffVHaEnOL4myoko0ePAg==}
peerDependencies:
vite: '>2.0.0-0'
dependencies:
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
dev: true
/vite-plugin-dts@3.7.1(@types/node@20.11.5)(typescript@5.3.3)(vite@5.0.11):
/vite-plugin-dts@3.7.1(@types/node@20.11.5)(typescript@5.3.3)(vite@5.0.12):
resolution: {integrity: sha512-VZJckNFpVfRAkmOxhGT5OgTUVWVXxkNQqLpBUuiNGAr9HbtvmvsPLo2JB3Xhn+o/Z9+CT6YZfYa4bX9SGR5hNw==}
engines: {node: ^14.18.0 || >=16.0.0}
peerDependencies:
@ -13848,7 +13839,7 @@ packages:
debug: 4.3.4
kolorist: 1.8.0
typescript: 5.3.3
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
vue-tsc: 1.8.27(typescript@5.3.3)
transitivePeerDependencies:
- '@types/node'
@ -13856,7 +13847,7 @@ packages:
- supports-color
dev: true
/vite-plugin-eslint@1.8.1(eslint@8.56.0)(vite@5.0.11):
/vite-plugin-eslint@1.8.1(eslint@8.56.0)(vite@5.0.12):
resolution: {integrity: sha512-PqdMf3Y2fLO9FsNPmMX+//2BF5SF8nEWspZdgl4kSt7UvHDRHVVfHvxsD7ULYzZrJDGRxR81Nq7TOFgwMnUang==}
peerDependencies:
eslint: '>=7'
@ -13866,10 +13857,10 @@ packages:
'@types/eslint': 8.56.0
eslint: 8.56.0
rollup: 2.79.1
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
dev: true
/vite-tsconfig-paths@4.3.1(typescript@5.3.3)(vite@5.0.11):
/vite-tsconfig-paths@4.3.1(typescript@5.3.3)(vite@5.0.12):
resolution: {integrity: sha512-cfgJwcGOsIxXOLU/nELPny2/LUD/lcf1IbfyeKTv2bsupVbTH/xpFtdQlBmIP1GEK2CjjLxYhFfB+QODFAx5aw==}
peerDependencies:
vite: '*'
@ -13880,14 +13871,14 @@ packages:
debug: 4.3.4
globrex: 0.1.2
tsconfck: 3.0.1(typescript@5.3.3)
vite: 5.0.11(@types/node@20.11.5)
vite: 5.0.12(@types/node@20.11.5)
transitivePeerDependencies:
- supports-color
- typescript
dev: true
/vite@5.0.11(@types/node@20.11.5):
resolution: {integrity: sha512-XBMnDjZcNAw/G1gEiskiM1v6yzM4GE5aMGvhWTlHAYYhxb7S3/V1s3m2LDHa8Vh6yIWYYB0iJwsEaS523c4oYA==}
/vite@5.0.12(@types/node@20.11.5):
resolution: {integrity: sha512-4hsnEkG3q0N4Tzf1+t6NdN9dg/L3BM+q8SWgbSPnJvrgH2kgdyzfVJwbR1ic69/4uMJJ/3dqDZZE5/WwqW8U1w==}
engines: {node: ^18.0.0 || >=20.0.0}
hasBin: true
peerDependencies:

View File

@ -1014,6 +1014,9 @@
"newWorkflow": "New Workflow",
"newWorkflowDesc": "Create a new workflow?",
"newWorkflowDesc2": "Your current workflow has unsaved changes.",
"clearWorkflow": "Clear Workflow",
"clearWorkflowDesc": "Clear this workflow and start a new one?",
"clearWorkflowDesc2": "Your current workflow has unsaved changes.",
"scheduler": "Scheduler",
"schedulerDescription": "TODO",
"sDXLMainModelField": "SDXL Model",
@ -1698,6 +1701,7 @@
"downloadWorkflow": "Save to File",
"saveWorkflow": "Save Workflow",
"saveWorkflowAs": "Save Workflow As",
"saveWorkflowToProject": "Save Workflow to Project",
"savingWorkflow": "Saving Workflow...",
"problemSavingWorkflow": "Problem Saving Workflow",
"workflowSaved": "Workflow Saved",
@ -1712,6 +1716,7 @@
"clearWorkflowSearchFilter": "Clear Workflow Search Filter",
"workflowName": "Workflow Name",
"newWorkflowCreated": "New Workflow Created",
"workflowCleared": "Workflow Cleared",
"workflowEditorMenu": "Workflow Editor Menu",
"workflowIsOpen": "Workflow is Open"
},

View File

@ -125,7 +125,8 @@
"localSystem": "Sistema locale",
"green": "Verde",
"blue": "Blu",
"alpha": "Alfa"
"alpha": "Alfa",
"copy": "Copia"
},
"gallery": {
"generations": "Generazioni",

View File

@ -1,10 +1,10 @@
{
"accessibility": {
"invokeProgressBar": "Invoke ilerleme durumu",
"nextImage": "Sonraki İmaj",
"invokeProgressBar": "Invoke durum çubuğu",
"nextImage": "Sonraki Görsel",
"useThisParameter": "Bu ayarları kullan",
"copyMetadataJson": "Metadata verilerini kopyala (JSON)",
"exitViewer": "Görüntüleme Modundan Çık",
"copyMetadataJson": "Üstveriyi kopyala (JSON)",
"exitViewer": "Görüntüleyiciden Çık",
"zoomIn": "Yakınlaştır",
"zoomOut": "Uzaklaştır",
"rotateCounterClockwise": "Saat yönünün tersine döndür",
@ -17,8 +17,8 @@
"showOptionsPanel": "Yan Paneli Göster",
"modelSelect": "Model Seçimi",
"reset": "Resetle",
"uploadImage": "İmaj Yükle",
"previousImage": "Önceki İmaj",
"uploadImage": "Görsel Yükle",
"previousImage": "Önceki Görsel",
"menu": "Menü",
"about": "Hakkında",
"mode": "Kip",
@ -48,18 +48,18 @@
"langSimplifiedChinese": "Çince (Basit)",
"langUkranian": "Ukraynaca",
"langSpanish": "İspanyolca",
"txt2img": "Yazıdan İmaj",
"img2img": "İmajdan İmaj",
"txt2img": "Yazıdan Görsel",
"img2img": "Görselden Görsel",
"linear": "Doğrusal",
"nodes": "İş Akış Düzenleyici",
"nodes": "İş Akışı Düzenleyici",
"postprocessing": "Rötuş",
"postProcessing": "Rötuş",
"postProcessDesc2": "Daha gelişmiş iş akışlarına olanak sağlayacak özel bir arayüz yakında yayınlanacaktır.",
"postProcessDesc3": "Invoke AI Komut Satırı Arayüzü, Embiggen dahil birçok yeni özellik sunmaktadır.",
"postProcessDesc3": "Invoke AI Komut Satırı Arayüzü, içlerinde Embiggen da bulunan birçok özellik sunmaktadır.",
"langKorean": "Korece",
"unifiedCanvas": "Akıllı Tuval",
"nodesDesc": "İmaj oluşturmak için hazırladığımız çizge tabanlı sistem şu an geliştirme aşamasındadır. Bu harika özellik hakkındaki gelişmeler için bizi takip etmeye devam edin.",
"postProcessDesc1": "Invoke AI birçok rötuş (post-process) aracı sağlar. İmaj büyütme ve yüz iyileştirme halihazırda WebUI üzerinden kullanılabilir. Bunlara Yazıdan İmaj ve İmajdan İmaj sekmelerindeki Gelişmiş Ayarlar menüsünden ulaşabilirsiniz. İsterseniz mevcut görüntü ekranının üzerindeki veya görüntüleyicideki imajı doğrudan üstteki tuşlar yardımıyla düzenleyebilirsiniz.",
"unifiedCanvas": "Tuval",
"nodesDesc": "Görsel oluşturmaya yardımcı çizge tabanlı sistem şimdilik geliştirme aşamasındadır. Bu süper özellik hakkındaki gelişmeler için kulağınız bizde olsun.",
"postProcessDesc1": "Invoke AI birçok rötuş (post-process) aracı sağlar. Görsel büyütme ve yüz iyileştirme WebUI üzerinden kullanıma uygun durumdadır. Bunlara Yazıdan Görsel ve Görselden Görsel sekmelerindeki Gelişmiş Ayarlar menüsünden ulaşabilirsiniz. Ayrıca var olan görseli üzerindeki düğmeler yardımıyla düzenleyebilirsiniz.",
"batch": "Toplu İş Yöneticisi",
"accept": "Onayla",
"cancel": "Vazgeç",
@ -94,7 +94,7 @@
"save": "Kaydet",
"statusMergingModels": "Modeller Birleştiriliyor",
"statusGenerating": "Oluşturuluyor",
"statusGenerationComplete": "Oluşturma Tamamlandı",
"statusGenerationComplete": "Oluşturma Bitti",
"statusGeneratingOutpainting": "Dışboyama Oluşturuluyor",
"statusLoadingModel": "Model Yükleniyor",
"random": "Rastgele",
@ -111,20 +111,20 @@
"statusRestoringFacesGFPGAN": "Yüzler İyileştiriliyor (GFPGAN)",
"template": "Şablon",
"saveAs": "Farklı Kaydet",
"statusProcessingComplete": "İşlem Tamamlandı",
"statusSavingImage": "İmaj Kaydediliyor",
"statusProcessingComplete": "İşlem Bitti",
"statusSavingImage": "Görsel Kaydediliyor",
"somethingWentWrong": "Bir sorun oluştu",
"statusConvertingModel": "Model Dönüştürülüyor",
"statusDisconnected": "Bağlantı Kesildi",
"statusError": "Hata",
"statusGeneratingImageToImage": "İmajdan İmaj Oluşturuluyor",
"statusGeneratingImageToImage": "Görselden Görsel Oluşturuluyor",
"statusGeneratingInpainting": "İçboyama Oluşturuluyor",
"statusRestoringFaces": "Yüzler İyileştiriliyor",
"statusUpscaling": "Büyütme",
"statusUpscalingESRGAN": "Büyütme (ESRGAN)",
"training": "Eğitim",
"statusGeneratingTextToImage": "Yazıdan İmaj Oluşturuluyor",
"imagePrompt": "Resim İstemi",
"statusGeneratingTextToImage": "Yazıdan Görsel Oluşturuluyor",
"imagePrompt": "Görsel İstemi",
"unknown": "Bilinmeyen",
"green": "Yeşil",
"red": "Kırmızı",
@ -137,8 +137,8 @@
"error": "Hata",
"generate": "Oluştur",
"free": "Serbest",
"imageFailedToLoad": "İmaj Yüklenemedi",
"safetensors": "Safetensor",
"imageFailedToLoad": "Görsel Yüklenemedi",
"safetensors": "Safetensors",
"upload": "Yükle",
"nextPage": "Sonraki Sayfa",
"prevPage": "Önceki Sayfa",
@ -147,16 +147,22 @@
"direction": "Yön",
"darkMode": "Koyu Tema",
"unsaved": "Kaydedilmemiş",
"unknownError": "Bilinmeyen Hata"
"unknownError": "Bilinmeyen Hata",
"installed": "Yüklü",
"data": "Veri",
"input": "Giriş",
"copy": "Kopyala",
"created": "Yaratma",
"updated": "Güncelleme"
},
"accordions": {
"generation": {
"title": "Oluşturma",
"modelTab": "Model",
"conceptsTab": "Konseptler"
"conceptsTab": "Kavramlar"
},
"image": {
"title": "İmaj"
"title": "Görsel"
},
"advanced": {
"title": "Gelişmiş"
@ -167,7 +173,7 @@
"infillTab": "Doldurma"
},
"control": {
"ipTab": "Resim İstemleri"
"ipTab": "Görsel İstemleri"
}
},
"boards": {
@ -179,25 +185,25 @@
"myBoard": "Panom",
"selectBoard": "Bir Pano Seç",
"addBoard": "Pano Ekle",
"deleteBoardAndImages": "Panoyu ve İmajları Sil",
"deleteBoardAndImages": "Panoyu ve Görselleri Sil",
"deleteBoardOnly": "Sadece Panoyu Sil",
"deletedBoardsCannotbeRestored": "Silinen panolar geri getirilemez",
"menuItemAutoAdd": "Bu panoya otomatik olarak ekle",
"move": "Taşı",
"movingImagesToBoard_one": "{{count}} imajı şu panoya taşı:",
"movingImagesToBoard_other": "{{count}} imajı şu panoya taşı:",
"movingImagesToBoard_one": "{{count}} görseli şu panoya taşı:",
"movingImagesToBoard_other": "{{count}} görseli şu panoya taşı:",
"noMatching": "Eşleşen pano yok",
"searchBoard": "Pano Ara...",
"topMessage": "Bu pano, şu özelliklerde kullanılan imajlar içeriyor:",
"topMessage": "Bu pano, şuralarda kullanılan görseller içeriyor:",
"downloadBoard": "Panoyu İndir",
"uncategorized": "Kategorisiz",
"changeBoard": "Panoyu Değiştir",
"bottomMessage": "Bu panoyu ve imajları silmek, bunları kullanan özelliklerin resetlemesine neden olacaktır."
"bottomMessage": "Bu panoyu ve görselleri silmek, bunları kullanan özelliklerin resetlemesine neden olacaktır."
},
"controlnet": {
"balanced": "Dengeli",
"contentShuffle": "İçerik Karma",
"contentShuffleDescription": "İmajın içeriğini karıştırır",
"contentShuffle": "İçerik Karıştırma",
"contentShuffleDescription": "Görselin içeriğini karıştırır",
"depthZoe": "Derinlik (Zoe)",
"depthZoeDescription": "Zoe kullanarak derinlik haritası oluşturma",
"resizeMode": "Boyutlandırma Kipi",
@ -216,9 +222,9 @@
"noneDescription": "Hiçbir işlem uygulanmamış",
"selectModel": "Model seçin",
"showAdvanced": "Gelişmiş Ayarları Göster",
"controlNetT2IMutexDesc": "$t(common.controlNet) ve $t(common.t2iAdapter)'nün beraber kullanımı henüz desteklenmiyor.",
"controlNetT2IMutexDesc": "$t(common.controlNet) ve $t(common.t2iAdapter)'nün birlikte kullanımı şimdilik desteklenmiyor.",
"canny": "Canny",
"colorMapDescription": "İmajdan bir renk haritası oluşturur",
"colorMapDescription": "Görselden renk haritası oluşturur",
"handAndFace": "El ve Yüz",
"processor": "İşlemci",
"prompt": "İstem",
@ -233,26 +239,26 @@
"cannyDescription": "Canny kenar algılama",
"fill": "Doldur",
"highThreshold": "Üst Eşik",
"imageResolution": "İmaj Çözünürlüğü",
"imageResolution": "Görsel Çözünürlüğü",
"colorMapTileSize": "Karo Boyutu",
"importImageFromCanvas": "Tuvalden İmajı içe Aktar",
"importImageFromCanvas": "Tuvaldeki Görseli Al",
"importMaskFromCanvas": "Tuvalden Maskeyi İçe Aktar",
"lowThreshold": "Alt Eşik",
"base": "Taban",
"depthAnythingDescription": "Depth Anything tekniği ile derinlik haritası oluşturma"
"depthAnythingDescription": "Depth Anything yöntemi ile derinlik haritası oluşturma"
},
"queue": {
"queuedCount": "{{pending}} Sırada",
"resumeSucceeded": "İşlem Sürdürüldü",
"openQueue": "Sırayı Göster",
"cancelSucceeded": "Öğeden Vazgeçildi",
"cancelFailed": "Öğeden Vazgeçmede Sorun",
"cancelSucceeded": "İş Geri Çekildi",
"cancelFailed": "İşi Geri Çekmede Sorun",
"prune": "Arındır",
"pruneTooltip": "{{item_count}} Bitmiş İşi Sil",
"resumeFailed": "İşlemi Sürdürmede Sorun",
"pauseFailed": "İşlemi Duraklatmada Sorun",
"cancelBatchSucceeded": "Toplu İşten Vazgeçildi",
"pruneSucceeded": "{{item_count}} Bitmiş İş Sıradan Kaldırıldı",
"pruneSucceeded": "{{item_count}} Bitmiş İş Sıradan Silindi",
"in_progress": "İşleniyor",
"completed": "Bitti",
"canceled": "Vazgeçildi",
@ -269,34 +275,36 @@
"resume": "Sürdür",
"queueTotal": "Toplam {{total}}",
"queueEmpty": "Sıra Boş",
"clearQueueAlertDialog": "Sırayı boşaltma tuşu halihazırdaki işlemi durdurur ve sırayı tamamen boşaltır.",
"current": u Anki",
"clearQueueAlertDialog": "Sırayı boşaltma düğmesi geçerli işlemi durdurur ve sırayı boşaltır.",
"current": imdiki",
"time": "Süre",
"pause": "Duraklat",
"pauseTooltip": "İşlemi Duraklat",
"pruneFailed": "Sırayı Arındırmada Sorun",
"clearTooltip": "Vazgeç ve Tüm Öğeleri Sil",
"clearTooltip": "Vazgeç ve Tüm İşleri Sil",
"clear": "Boşalt",
"cancelBatchFailed": "Toplu İşten Vazgeçmede Sorun",
"next": "Sonraki",
"status": "Durum",
"failed": "Başarısız",
"item": "Öğe",
"item": "İş",
"enqueueing": "Toplu İş Sıraya Alınıyor",
"pauseSucceeded": "İşlem Duraklatıldı",
"cancel": "Vazgeç",
"cancelTooltip": "Şu Anki Öğeden Vazgeç",
"cancelTooltip": "Bu İşi Geri Çek",
"clearSucceeded": "Sıra Boşaltıldı",
"clearFailed": "Sırayı Boşaltmada Sorun",
"cancelBatch": "Toplu İşten Vazgeç",
"cancelItem": "Öğeden Vazgeç",
"cancelItem": "İşi Geri Çek",
"total": "Toplam",
"pending": "Sırada",
"completedIn": "'de bitirildi",
"batch": "Toplu İş",
"session": "Oturum",
"batchQueued": "Toplu İş Sıraya Alındı",
"notReady": "Sıraya Alınamadı"
"notReady": "Sıraya Alınamadı",
"batchFieldValues": "Toplu İş Değişkenleri",
"queueMaxExceeded": "Sıra sınırı {{max_queue_size}} aşıldı, {{skip}} atlanıyor"
},
"invocationCache": {
"cacheSize": "Önbellek Boyutu",
@ -307,8 +315,8 @@
"enable": "Aç"
},
"gallery": {
"deleteImageBin": "Silinen imajlar işletim sisteminin çöp kutusuna gönderilir.",
"deleteImagePermanent": "Silinen imajlar geri getirilemez.",
"deleteImageBin": "Silinen görseller işletim sisteminin çöp kutusuna gönderilir.",
"deleteImagePermanent": "Silinen görseller geri getirilemez.",
"assets": "Özkaynaklar",
"autoAssignBoardOnClick": "Tıklanan Panoya Otomatik Atama",
"loading": "Yükleniyor",
@ -316,28 +324,32 @@
"download": "İndir",
"deleteSelection": "Seçileni Sil",
"preparingDownloadFailed": "İndirme Hazırlanırken Sorun",
"problemDeletingImages": "İmaj Silmede Sorun",
"featuresWillReset": "Bu imajı silerseniz, o özellikler resetlenecektir.",
"problemDeletingImages": "Görsel Silmede Sorun",
"featuresWillReset": "Bu görseli silerseniz, o özellikler resetlenecektir.",
"galleryImageResetSize": "Boyutu Resetle",
"noImageSelected": "İmaj Seçili Değil",
"noImageSelected": "Görsel Seçilmedi",
"unstarImage": "Yıldızı Kaldır",
"uploads": "Yüklemeler",
"problemDeletingImagesDesc": "Bir ya da daha çok imaj silinemedi",
"problemDeletingImagesDesc": "Bir ya da daha çok görsel silinemedi",
"gallerySettings": "Galeri Ayarları",
"image": "imaj",
"galleryImageSize": "İmaj Boyutu",
"allImagesLoaded": "Bütün İmajlar Yüklendi",
"image": "görsel",
"galleryImageSize": "Görsel Boyutu",
"allImagesLoaded": "Tüm Görseller Yüklendi",
"copy": "Kopyala",
"noImagesInGallery": "Gösterilecek İmaj Yok",
"autoSwitchNewImages": "Yeni İmajı Biter Bitmez Gör",
"noImagesInGallery": "Gösterilecek Görsel Yok",
"autoSwitchNewImages": "Yeni Görseli Biter Bitmez Gör",
"maintainAspectRatio": "En-Boy Oranını Koru",
"currentlyInUse": "Bu imaj şu an bu bölümlerde kullanımda:",
"deleteImage": "İmajı Sil",
"currentlyInUse": "Bu görsel şurada kullanımda:",
"deleteImage": "Görseli Sil",
"loadMore": "Daha Getir",
"setCurrentImage": "Çalışma İmajı Yap",
"setCurrentImage": "Çalışma Görseli Yap",
"unableToLoad": "Galeri Yüklenemedi",
"downloadSelection": "Seçileni İndir",
"preparingDownload": "İndirmeye Hazırlanıyor"
"preparingDownload": "İndirmeye Hazırlanıyor",
"singleColumnLayout": "Tek Sütun Düzen",
"generations": ıktılar",
"showUploads": "Yüklenenleri Göster",
"showGenerations": ıktıları Göster"
},
"hrf": {
"hrf": "Yüksek Çözünürlük Kürü",
@ -350,17 +362,317 @@
"method": "Yüksek Çözünürlük Kürü Yöntemi"
},
"upscaleMethod": "Büyütme Yöntemi",
"enableHrfTooltip": "Daha düşük bir başlangıç çözünürlüğüyle oluşturup ana çözünürlüğe büyütür ve İmajdan İmaj yapar."
"enableHrfTooltip": "Daha düşük bir çözünürlükle oluşturmaya başlar, ana çözünürlüğe büyütür ve Görselden Görsel'i çalıştırır."
},
"hotkeys": {
"noHotkeysFound": "Kısayol Tuşu Bulanamadı",
"searchHotkeys": "Kısayol Tuşlarında Ara",
"clearSearch": "Aramayı Sil"
"clearSearch": "Aramayı Sil",
"colorPicker": {
"title": "Renk Seçici",
"desc": "Tuvalde renk seçiciye geçer"
},
"consoleToggle": {
"title": "Konsolu Aç-Kapat",
"desc": "Konsolu aç-kapat"
},
"hideMask": {
"desc": "Maskeyi gizle-göster",
"title": "Maskeyi Gizle"
},
"focusPrompt": {
"title": "İsteme Odaklan",
"desc": "Görsel istemi alanına odaklanır"
},
"keyboardShortcuts": "Kısayol Tuşları",
"nextImage": {
"title": "Sonraki Görsel",
"desc": "Galerideki sonraki görseli göster"
},
"maximizeWorkSpace": {
"desc": "Panelleri kapat ve çalışma alanını genişlet",
"title": "Çalışma Alanını Genişlet"
},
"pinOptions": {
"desc": "Ayar panelini iğnele",
"title": "Ayarları İğnele"
},
"nodesHotkeys": "Çizgeler",
"quickToggleMove": {
"desc": "Geçici olarak Kayma Aracına geçer",
"title": "Geçici Kayma"
},
"showHideBoundingBox": {
"title": "Sınırlayıcı Kutuyu Gizle/Göster",
"desc": "Sınırlayıcı kutunun görünürlüğünü değiştir"
},
"showInfo": {
"desc": "Seçili görselin üstverisini göster",
"title": "Bilgileri Göster"
},
"nextStagingImage": {
"desc": "Sonraki Görsel Parçayı Göster",
"title": "Sonraki Görsel Parça"
},
"acceptStagingImage": {
"desc": "Geçiçi Görsel Parçasını Onayla",
"title": "Geçiçi Görsel Parçasını Onayla"
},
"changeTabs": {
"desc": "Çalışma alanını değiştir",
"title": "Sekmeyi değiştir"
},
"closePanels": {
"title": "Panelleri Kapat",
"desc": "Açık panelleri kapat"
},
"decreaseBrushOpacity": {
"title": "Fırça Saydamlığını Artır",
"desc": "Tuval fırçasının saydamlığını artırır"
},
"clearMask": {
"title": "Maskeyi Sil",
"desc": "Tüm maskeyi sil"
},
"decreaseGalleryThumbSize": {
"desc": "Galerideki küçük görsel boyutunu düşürür",
"title": "Küçük Görsel Boyutunu Düşür"
},
"deleteImage": {
"desc": "Seçili görseli sil",
"title": "Görseli Sil"
},
"invoke": {
"desc": "Görsel Oluştur",
"title": "Invoke"
},
"increaseGalleryThumbSize": {
"title": "Küçük Görsel Boyutunu Artır",
"desc": "Galerideki küçük görsel boyutunu artırır"
},
"setParameters": {
"title": "Değişkenleri Kullan",
"desc": "Seçili görselin tüm değişkenlerini kullan"
},
"setPrompt": {
"desc": "Seçili görselin istemini kullan",
"title": "İstemi Kullan"
},
"toggleLayer": {
"desc": "Maske/Taban katmanları arasında geçiş yapar",
"title": "Katmanı Gizle-Göster"
},
"upscale": {
"title": "Büyüt",
"desc": "Seçili görseli büyüt"
},
"setSeed": {
"title": "Tohumu Kullan",
"desc": "Seçili görselin tohumunu kullan"
},
"appHotkeys": "Uygulama",
"cancel": {
"desc": "Geçerli İşi Sil",
"title": "Vazgeç"
},
"sendToImageToImage": {
"title": "Görselden Görsel'e Gönder",
"desc": "Seçili görseli Görselden Görsel'e gönder"
},
"fillBoundingBox": {
"title": "Sınırlayıcı Kutuyu Doldur",
"desc": "Sınırlayıcı kutuyu fırçadaki renkle doldurur"
},
"moveTool": {
"desc": "Tuvalde kaymayı sağlar",
"title": "Kayma Aracı"
},
"redoStroke": {
"desc": "Fırça vuruşunu yinele",
"title": "Vuruşu Yinele"
},
"increaseBrushOpacity": {
"title": "Fırçanın Saydamlığını Düşür",
"desc": "Tuval fırçasının saydamlığını düşürür"
},
"selectEraser": {
"desc": "Tuval silgisini kullan",
"title": "Silgiyi Kullan"
},
"toggleOptions": {
"desc": "Ayarlar panelini aç-kapat",
"title": "Ayarları Aç-Kapat"
},
"copyToClipboard": {
"desc": "Tuval içeriğini kopyala",
"title": "Kopyala"
},
"galleryHotkeys": "Galeri",
"generalHotkeys": "Genel",
"mergeVisible": {
"desc": "Tuvalin görünür tüm katmanlarını birleştir",
"title": "Katmanları Birleştir"
},
"toggleGallery": {
"title": "Galeriyi Aç-Kapat",
"desc": "Galeri panelini aç-kapat"
},
"downloadImage": {
"title": "Görseli İndir",
"desc": "Tuval içeriğini indir"
},
"previousStagingImage": {
"title": "Önceki Görsel Parça",
"desc": "Önceki Görsel Parçayı Göster"
},
"increaseBrushSize": {
"title": "Fırça Boyutunu Artır",
"desc": "Tuval fırçasının/silgisinin boyutunu artırır"
},
"previousImage": {
"desc": "Galerideki önceki görseli göster",
"title": "Önceki Görsel"
},
"toggleOptionsAndGallery": {
"title": "Ayarları ve Galeriyi Aç-Kapat",
"desc": "Ayarlar ve galeri panellerini aç-kapat"
},
"toggleSnap": {
"desc": "Kılavuza Uydur",
"title": "Kılavuza Uydur"
},
"resetView": {
"desc": "Tuval Görüşünü Resetle",
"title": "Görüşü Resetle"
},
"cancelAndClear": {
"desc": "Geçerli işi geri çek ve sıradaki tüm işleri sil",
"title": "Vazgeç ve Sil"
},
"decreaseBrushSize": {
"title": "Fırça Boyutunu Düşür",
"desc": "Tuval fırçasının/silgisinin boyutunu düşürür"
},
"resetOptionsAndGallery": {
"desc": "Ayarlar ve galeri panellerini resetler",
"title": "Ayarları ve Galeriyi Resetle"
},
"remixImage": {
"desc": "Seçili görselin tohumu hariç tüm değişkenlerini kullan",
"title": "Benzerini Türet"
},
"undoStroke": {
"title": "Vuruşu Geri Al",
"desc": "Fırça vuruşunu geri al"
},
"saveToGallery": {
"title": "Galeriye Gönder",
"desc": "Tuval içeriğini galeriye gönder"
},
"unifiedCanvasHotkeys": "Tuval",
"addNodes": {
"desc": "Çizge ekleme menüsünü açar",
"title": "Çizge Ekle"
},
"eraseBoundingBox": {
"desc": "Sınırlayıcı kutunun içini boşaltır",
"title": "Sınırlayıcı Kutuyu Boşalt"
},
"selectBrush": {
"desc": "Tuval fırçasını kullan",
"title": "Fırçayı Kullan"
}
},
"embedding": {
"incompatibleModel": "Uyumsuz ana model:"
},
"unifiedCanvas": {
"accept": "Onayla"
"accept": "Onayla",
"emptyTempImagesFolderMessage": "Geçici görsel klasörünü boşaltmak Tuvali resetler. Yineleme ve geri alma geçmişi, görsel parçası bölümü ve tuval taban katmanı da dolayısıla resetlenir.",
"clearCanvasHistoryMessage": "Tuval geçmişini silmek tuvale dokunmaz, ancak yineleme ve geri alma geçmişini geri dönülemez bir biçimde siler."
},
"nodes": {
"unableToValidateWorkflow": "İş Akışı Doğrulanamadı",
"workflowContact": "İletişim",
"loadWorkflow": "İş Akışı Yükle",
"workflowNotes": "Notlar",
"workflow": "İş Akışı",
"notesDescription": "İş akışınız hakkında not düşün",
"workflowTags": "Etiketler",
"workflowDescription": "Kısa Tanım",
"workflowValidation": "İş Akışı Doğrulama Sorunu",
"workflowVersion": "Sürüm",
"newWorkflow": "Yeni İş Akışı",
"currentImageDescription": "İşlemdeki görseli Çizge Düzenleyicide gösterir",
"workflowAuthor": "Yaratıcı",
"workflowName": "Ad",
"workflowSettings": "İş Akışı Düzenleyici Ayarları",
"currentImage": "İşlemdeki Görsel",
"noWorkflow": "İş Akışı Yok",
"newWorkflowDesc": "Yeni iş akışı?",
"problemReadingWorkflow": "Görselden iş akışı çağrılamadı",
"downloadWorkflow": "İş Akışını İndir (JSON)",
"unableToMigrateWorkflow": "İş Akışı Aktarılamadı",
"unknownErrorValidatingWorkflow": "İş akışını doğrulamada bilinmeyen bir sorun",
"unableToGetWorkflowVersion": "İş akışı sürümüne ulaşılamadı",
"unrecognizedWorkflowVersion": "Tanınmayan iş akışı sürümü {{version}}",
"newWorkflowDesc2": "Geçerli iş akışında kaydedilmemiş değişiklikler var.",
"unableToLoadWorkflow": "İş Akışı Yüklenemedi"
},
"workflows": {
"searchWorkflows": "İş Akışlarında Ara",
"workflowName": "İş Akışı Adı",
"problemSavingWorkflow": "İş Akışını Kaydetmede Sorun",
"saveWorkflow": "İş Akışını Kaydet",
"uploadWorkflow": "Dosyadan Yükle",
"newWorkflowCreated": "Yeni İş Akışı Yaratıldı",
"problemLoading": "İş Akışlarını Yüklemede Sorun",
"loading": "İş Akışları Yükleniyor",
"noDescription": "Tanımsız",
"workflowIsOpen": "İş Akışıık",
"clearWorkflowSearchFilter": "İş Akışı Aramasını Resetle",
"workflowEditorMenu": "İş Akışı Düzenleyici Menüsü",
"downloadWorkflow": "İndir",
"saveWorkflowAs": "İş Akışını Farklı Kaydet",
"savingWorkflow": "İş Akışı Kaydediliyor...",
"userWorkflows": "İş Akışlarım",
"defaultWorkflows": "Varsayılan İş Akışları",
"workflows": "İş Akışları",
"workflowLibrary": "Depo",
"deleteWorkflow": "İş Akışını Sil",
"unnamedWorkflow": "Adsız İş Akışı",
"noWorkflows": "İş Akışı Yok",
"workflowSaved": "İş Akışı Kaydedildi"
},
"toast": {
"problemDownloadingCanvasDesc": "Taban katman indirilemedi",
"problemSavingMaskDesc": "Maske kaydedilemedi",
"problemSavingCanvasDesc": "Taban katman kaydedilemedi",
"problemRetrievingWorkflow": "İş Akışını Getirmede Sorun",
"workflowDeleted": "İş Akışı Silindi",
"loadedWithWarnings": "İş Akışı Yüklendi Ancak Uyarılar Var",
"problemImportingMaskDesc": "Maske aktarılamadı",
"problemMergingCanvasDesc": "Taban katman aktarılamadı",
"problemCopyingCanvasDesc": "Taban katman aktarılamadı",
"workflowLoaded": "İş Akışı Yüklendi",
"problemDeletingWorkflow": "İş Akışını Silmede Sorun"
},
"parameters": {
"invoke": {
"noPrompts": "İstem oluşturulmadı"
}
},
"modelManager": {
"baseModel": "Ana Model"
},
"dynamicPrompts": {
"loading": "Devimsel İstemler Oluşturuluyor...",
"combinatorial": "Birleşimsel Oluşturma"
},
"models": {
"incompatibleBaseModel": "Uyumsuz ana model"
},
"settings": {
"generation": "Oluşturma"
}
}

View File

@ -36,10 +36,11 @@ export const addBoardIdSelectedListener = () => {
const { data: boardImagesData } = imagesApi.endpoints.listImages.select(queryArgs)(getState());
if (boardImagesData && boardIdSelected.match(action) && action.payload.selectedImageName) {
const firstImage = imagesSelectors.selectAll(boardImagesData)[0];
const selectedImage = imagesSelectors.selectById(boardImagesData, action.payload.selectedImageName);
dispatch(imageSelected(selectedImage || firstImage || null));
dispatch(imageSelected(selectedImage || null));
} else if (boardImagesData) {
const firstImage = imagesSelectors.selectAll(boardImagesData)[0];
dispatch(imageSelected(firstImage || null));
} else {
// board has no images - deselect
dispatch(imageSelected(null));

View File

@ -23,9 +23,7 @@ export type AppFeature =
| 'resumeQueue'
| 'prependQueue'
| 'invocationCache'
| 'bulkDownload'
| 'workflowLibrary';
| 'bulkDownload';
/**
* A disable-able Stable Diffusion feature
*/

View File

@ -1,7 +1,8 @@
import { $ctrl, $meta } from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { $isMoveStageKeyHeld } from 'features/canvas/store/canvasNanostore';
import { setStageCoordinates, setStageScale } from 'features/canvas/store/canvasSlice';
import { setBrushSize, setStageCoordinates, setStageScale } from 'features/canvas/store/canvasSlice';
import { CANVAS_SCALE_BY, MAX_CANVAS_SCALE, MIN_CANVAS_SCALE } from 'features/canvas/util/constants';
import type Konva from 'konva';
import type { KonvaEventObject } from 'konva/lib/Node';
@ -13,6 +14,7 @@ const useCanvasWheel = (stageRef: MutableRefObject<Konva.Stage | null>) => {
const dispatch = useAppDispatch();
const stageScale = useAppSelector((s) => s.canvas.stageScale);
const isMoveStageKeyHeld = useStore($isMoveStageKeyHeld);
const brushSize = useAppSelector((s) => s.canvas.brushSize);
return useCallback(
(e: KonvaEventObject<WheelEvent>) => {
@ -23,36 +25,49 @@ const useCanvasWheel = (stageRef: MutableRefObject<Konva.Stage | null>) => {
e.evt.preventDefault();
const cursorPos = stageRef.current.getPointerPosition();
// checking for ctrl key is pressed or not,
// so that brush size can be controlled using ctrl + scroll up/down
if (!cursorPos) {
return;
if ($ctrl.get() || $meta.get()) {
// This equation was derived by fitting a curve to the desired brush sizes and deltas
// see https://github.com/invoke-ai/InvokeAI/pull/5542#issuecomment-1915847565
const targetDelta = Math.sign(e.evt.deltaY) * 0.7363 * Math.pow(1.0394, brushSize);
// This needs to be clamped to prevent the delta from getting too large
const finalDelta = clamp(targetDelta, -20, 20);
// The new brush size is also clamped to prevent it from getting too large or small
const newBrushSize = clamp(brushSize + finalDelta, 1, 500);
dispatch(setBrushSize(newBrushSize));
} else {
const cursorPos = stageRef.current.getPointerPosition();
let delta = e.evt.deltaY;
if (!cursorPos) {
return;
}
const mousePointTo = {
x: (cursorPos.x - stageRef.current.x()) / stageScale,
y: (cursorPos.y - stageRef.current.y()) / stageScale,
};
// when we zoom on trackpad, e.evt.ctrlKey is true
// in that case lets revert direction
if (e.evt.ctrlKey) {
delta = -delta;
}
const newScale = clamp(stageScale * CANVAS_SCALE_BY ** delta, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE);
const newCoordinates = {
x: cursorPos.x - mousePointTo.x * newScale,
y: cursorPos.y - mousePointTo.y * newScale,
};
dispatch(setStageScale(newScale));
dispatch(setStageCoordinates(newCoordinates));
}
const mousePointTo = {
x: (cursorPos.x - stageRef.current.x()) / stageScale,
y: (cursorPos.y - stageRef.current.y()) / stageScale,
};
let delta = e.evt.deltaY;
// when we zoom on trackpad, e.evt.ctrlKey is true
// in that case lets revert direction
if (e.evt.ctrlKey) {
delta = -delta;
}
const newScale = clamp(stageScale * CANVAS_SCALE_BY ** delta, MIN_CANVAS_SCALE, MAX_CANVAS_SCALE);
const newCoordinates = {
x: cursorPos.x - mousePointTo.x * newScale,
y: cursorPos.y - mousePointTo.y * newScale,
};
dispatch(setStageScale(newScale));
dispatch(setStageCoordinates(newCoordinates));
},
[stageRef, isMoveStageKeyHeld, stageScale, dispatch]
[stageRef, isMoveStageKeyHeld, stageScale, dispatch, brushSize]
);
};

View File

@ -22,7 +22,6 @@ export const $isModifyingBoundingBox = computed(
export const resetCanvasInteractionState = () => {
$cursorPosition.set(null);
$isDrawing.set(false);
$isMouseOverBoundingBox.set(false);
$isMoveBoundingBoxKeyHeld.set(false);
$isMoveStageKeyHeld.set(false);
$isMovingBoundingBox.set(false);
@ -31,7 +30,6 @@ export const resetCanvasInteractionState = () => {
export const resetToolInteractionState = () => {
$isTransformingBoundingBox.set(false);
$isMouseOverBoundingBox.set(false);
$isMovingBoundingBox.set(false);
$isMovingStage.set(false);
};

View File

@ -4,12 +4,14 @@ import {
CardHeader,
CompositeNumberInput,
CompositeSlider,
Flex,
IconButton,
Switch,
Text,
} from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import type { LoRA } from 'features/lora/store/loraSlice';
import { loraRemoved, loraWeightChanged } from 'features/lora/store/loraSlice';
import { loraIsEnabledChanged, loraRemoved, loraWeightChanged } from 'features/lora/store/loraSlice';
import { memo, useCallback } from 'react';
import { PiTrashSimpleBold } from 'react-icons/pi';
@ -28,6 +30,10 @@ export const LoRACard = memo((props: LoRACardProps) => {
[dispatch, lora.id]
);
const handleSetLoraToggle = useCallback(() => {
dispatch(loraIsEnabledChanged({ id: lora.id, isEnabled: !lora.isEnabled }));
}, [dispatch, lora.id, lora.isEnabled]);
const handleRemoveLora = useCallback(() => {
dispatch(loraRemoved(lora.id));
}, [dispatch, lora.id]);
@ -35,16 +41,21 @@ export const LoRACard = memo((props: LoRACardProps) => {
return (
<Card variant="lora">
<CardHeader>
<Text noOfLines={1} wordBreak="break-all" color="base.200">
{lora.model_name}
</Text>
<IconButton
aria-label="Remove LoRA"
variant="ghost"
size="sm"
onClick={handleRemoveLora}
icon={<PiTrashSimpleBold />}
/>
<Flex alignItems="center" justifyContent="space-between" width="100%" gap={2}>
<Text noOfLines={1} wordBreak="break-all" color={lora.isEnabled ? 'base.200' : 'base.500'}>
{lora.model_name}
</Text>
<Flex alignItems="center" gap={2}>
<Switch size="sm" onChange={handleSetLoraToggle} isChecked={lora.isEnabled} />
<IconButton
aria-label="Remove LoRA"
variant="ghost"
size="sm"
onClick={handleRemoveLora}
icon={<PiTrashSimpleBold />}
/>
</Flex>
</Flex>
</CardHeader>
<CardBody>
<CompositeSlider
@ -55,6 +66,7 @@ export const LoRACard = memo((props: LoRACardProps) => {
step={0.01}
marks={marks}
defaultValue={0.75}
isDisabled={!lora.isEnabled}
/>
<CompositeNumberInput
value={lora.weight}
@ -65,6 +77,7 @@ export const LoRACard = memo((props: LoRACardProps) => {
w={20}
flexShrink={0}
defaultValue={0.75}
isDisabled={!lora.isEnabled}
/>
</CardBody>
</Card>

View File

@ -7,10 +7,12 @@ import type { LoRAModelConfigEntity } from 'services/api/endpoints/models';
export type LoRA = ParameterLoRAModel & {
id: string;
weight: number;
isEnabled?: boolean;
};
export const defaultLoRAConfig = {
export const defaultLoRAConfig: Pick<LoRA, 'weight' | 'isEnabled'> = {
weight: 0.75,
isEnabled: true,
};
export type LoraState = {
@ -58,11 +60,26 @@ export const loraSlice = createSlice({
}
lora.weight = defaultLoRAConfig.weight;
},
loraIsEnabledChanged: (state, action: PayloadAction<Pick<LoRA, 'id' | 'isEnabled'>>) => {
const { id, isEnabled } = action.payload;
const lora = state.loras[id];
if (!lora) {
return;
}
lora.isEnabled = isEnabled;
},
},
});
export const { loraAdded, loraRemoved, loraWeightChanged, loraWeightReset, lorasCleared, loraRecalled } =
loraSlice.actions;
export const {
loraAdded,
loraRemoved,
loraWeightChanged,
loraWeightReset,
loraIsEnabledChanged,
lorasCleared,
loraRecalled,
} = loraSlice.actions;
export default loraSlice.reducer;

View File

@ -4,6 +4,7 @@ import { Flex } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { IAINoContentFallback } from 'common/components/IAIImageFallback';
import TopPanel from 'features/nodes/components/flow/panels/TopPanel/TopPanel';
import { SaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/SaveWorkflowAsDialog';
import type { AnimationProps } from 'framer-motion';
import { AnimatePresence, motion } from 'framer-motion';
import type { CSSProperties } from 'react';
@ -59,6 +60,7 @@ const NodeEditor = () => {
<TopPanel />
<BottomLeftPanel />
<MinimapPanel />
<SaveWorkflowAsDialog />
</motion.div>
)}
</AnimatePresence>

View File

@ -179,6 +179,7 @@ const AddNodePopover = () => {
closeOnBlur={true}
returnFocusOnClose={true}
initialFocusRef={inputRef}
isLazy
>
<PopoverAnchor>
<Flex position="absolute" top="15%" insetInlineStart="50%" pointerEvents="none" />

View File

@ -0,0 +1,64 @@
import { ConfirmationAlertDialog, Flex, IconButton, Text, useDisclosure } from '@invoke-ai/ui-library';
import { useAppDispatch, useAppSelector } from 'app/store/storeHooks';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiTrashSimpleFill } from 'react-icons/pi';
import { addToast } from '../../../../../system/store/systemSlice';
import { makeToast } from '../../../../../system/util/makeToast';
import { nodeEditorReset } from '../../../../store/nodesSlice';
const ClearFlowButton = () => {
const dispatch = useAppDispatch();
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const handleNewWorkflow = useCallback(() => {
dispatch(nodeEditorReset());
dispatch(
addToast(
makeToast({
title: t('workflows.workflowCleared'),
status: 'success',
})
)
);
onClose();
}, [dispatch, onClose, t]);
const onClick = useCallback(() => {
if (!isTouched) {
handleNewWorkflow();
return;
}
onOpen();
}, [handleNewWorkflow, isTouched, onOpen]);
return (
<>
<IconButton
tooltip={t('nodes.clearWorkflow')}
aria-label={t('nodes.clearWorkflow')}
icon={<PiTrashSimpleFill />}
onClick={onClick}
pointerEvents="auto"
/>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('nodes.clearWorkflow')}
acceptCallback={handleNewWorkflow}
>
<Flex flexDir="column" gap={2}>
<Text>{t('nodes.clearWorkflowDesc')}</Text>
<Text variant="subtext">{t('nodes.clearWorkflowDesc2')}</Text>
</Flex>
</ConfirmationAlertDialog>
</>
);
};
export default memo(ClearFlowButton);

View File

@ -0,0 +1,42 @@
import { IconButton } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
import { isWorkflowWithID, useSaveLibraryWorkflow } from '../../../../../workflowLibrary/hooks/useSaveWorkflow';
import { $builtWorkflow } from '../../../../hooks/useWorkflowWatcher';
const SaveWorkflowButton = () => {
const { t } = useTranslation();
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const { onOpen } = useSaveWorkflowAsDialog();
const { saveWorkflow } = useSaveLibraryWorkflow();
const handleClickSave = useCallback(async () => {
const builtWorkflow = $builtWorkflow.get();
if (!builtWorkflow) {
return;
}
if (isWorkflowWithID(builtWorkflow)) {
saveWorkflow();
} else {
onOpen();
}
}, [onOpen, saveWorkflow]);
return (
<IconButton
tooltip={t('workflows.saveWorkflow')}
aria-label={t('workflows.saveWorkflow')}
icon={<PiFloppyDiskBold />}
isDisabled={!isTouched}
onClick={handleClickSave}
pointerEvents="auto"
/>
);
};
export default memo(SaveWorkflowButton);

View File

@ -1,23 +1,28 @@
import { Flex, Spacer } from '@invoke-ai/ui-library';
import AddNodeButton from 'features/nodes/components/flow/panels/TopPanel/AddNodeButton';
import ClearFlowButton from 'features/nodes/components/flow/panels/TopPanel/ClearFlowButton';
import SaveWorkflowButton from 'features/nodes/components/flow/panels/TopPanel/SaveWorkflowButton';
import UpdateNodesButton from 'features/nodes/components/flow/panels/TopPanel/UpdateNodesButton';
import WorkflowName from 'features/nodes/components/flow/panels/TopPanel/WorkflowName';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
const TopCenterPanel = () => {
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
return (
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="center" pointerEvents="none">
<AddNodeButton />
<UpdateNodesButton />
<Flex gap={2} top={2} left={2} right={2} position="absolute" alignItems="flex-start" pointerEvents="none">
<Flex flexDir="column" gap="2">
<Flex gap="2">
<AddNodeButton />
<WorkflowLibraryButton />
</Flex>
<UpdateNodesButton />
</Flex>
<Spacer />
<WorkflowName />
<Spacer />
{isWorkflowLibraryEnabled && <WorkflowLibraryButton />}
<ClearFlowButton />
<SaveWorkflowButton />
<WorkflowLibraryMenu />
</Flex>
);

View File

@ -1,4 +1,4 @@
import { Button } from '@invoke-ai/ui-library';
import { IconButton } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { useGetNodesNeedUpdate } from 'features/nodes/hooks/useGetNodesNeedUpdate';
import { updateAllNodesRequested } from 'features/nodes/store/actions';
@ -19,9 +19,13 @@ const UpdateNodesButton = () => {
}
return (
<Button leftIcon={<PiWarningBold />} onClick={handleClickUpdateNodes} pointerEvents="auto">
{t('nodes.updateAllNodes')}
</Button>
<IconButton
tooltip={t('nodes.updateAllNodes')}
aria-label={t('nodes.updateAllNodes')}
icon={<PiWarningBold />}
onClick={handleClickUpdateNodes}
pointerEvents="auto"
/>
);
};

View File

@ -1,26 +1,13 @@
import { Text } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import { memo, useMemo } from 'react';
import { useTranslation } from 'react-i18next';
import { memo } from 'react';
const TopCenterPanel = () => {
const { t } = useTranslation();
const name = useAppSelector((s) => s.workflow.name);
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
const displayName = useMemo(() => {
let _displayName = name || t('workflows.unnamedWorkflow');
if (isTouched && isWorkflowLibraryEnabled) {
_displayName += ` (${t('common.unsaved')})`;
}
return _displayName;
}, [t, name, isTouched, isWorkflowLibraryEnabled]);
return (
<Text m={2} fontSize="lg" userSelect="none" noOfLines={1} wordBreak="break-all" fontWeight="semibold" opacity={0.8}>
{displayName}
{name}
</Text>
);
};

View File

@ -1,15 +1,12 @@
import { Flex } from '@invoke-ai/ui-library';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import WorkflowLibraryButton from 'features/workflowLibrary/components/WorkflowLibraryButton';
import WorkflowLibraryMenu from 'features/workflowLibrary/components/WorkflowLibraryMenu/WorkflowLibraryMenu';
import { memo } from 'react';
const TopRightPanel = () => {
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
return (
<Flex gap={2} position="absolute" top={2} insetInlineEnd={2}>
{isWorkflowLibraryEnabled && <WorkflowLibraryButton />}
<WorkflowLibraryButton />
<WorkflowLibraryMenu />
</Flex>
);

View File

@ -1,7 +1,9 @@
import type { FormLabelProps } from '@invoke-ai/ui-library';
import {
Divider,
Flex,
FormControl,
FormControlGroup,
FormHelperText,
FormLabel,
Heading,
@ -30,6 +32,8 @@ import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { SelectionMode } from 'reactflow';
const formLabelProps: FormLabelProps = { flexGrow: 1 };
const selector = createMemoizedSelector(selectNodesSlice, (nodes) => {
const { shouldAnimateEdges, shouldValidateGraph, shouldSnapToGrid, shouldColorEdges, selectionMode } = nodes;
return {
@ -100,37 +104,51 @@ const WorkflowEditorSettings = ({ children }: Props) => {
<ModalBody>
<Flex flexDirection="column" gap={4} py={4}>
<Heading size="sm">{t('parameters.general')}</Heading>
<FormControl>
<FormLabel>{t('nodes.animatedEdges')}</FormLabel>
<Switch onChange={handleChangeShouldAnimate} isChecked={shouldAnimateEdges} />
<FormHelperText>{t('nodes.animatedEdgesHelp')}</FormHelperText>
</FormControl>
<Divider />
<FormControl>
<FormLabel>{t('nodes.snapToGrid')}</FormLabel>
<Switch isChecked={shouldSnapToGrid} onChange={handleChangeShouldSnap} />
<FormHelperText>{t('nodes.snapToGridHelp')}</FormHelperText>
</FormControl>
<Divider />
<FormControl>
<FormLabel>{t('nodes.colorCodeEdges')}</FormLabel>
<Switch isChecked={shouldColorEdges} onChange={handleChangeShouldColor} />
<FormHelperText>{t('nodes.colorCodeEdgesHelp')}</FormHelperText>
</FormControl>
<Divider />
<FormControl>
<FormLabel>{t('nodes.fullyContainNodes')}</FormLabel>
<Switch isChecked={selectionModeIsChecked} onChange={handleChangeSelectionMode} />
<FormHelperText>{t('nodes.fullyContainNodesHelp')}</FormHelperText>
</FormControl>
<Heading size="sm" pt={4}>
{t('common.advanced')}
</Heading>
<FormControl>
<FormLabel>{t('nodes.validateConnections')}</FormLabel>
<Switch isChecked={shouldValidateGraph} onChange={handleChangeShouldValidate} />
<FormHelperText>{t('nodes.validateConnectionsHelp')}</FormHelperText>
</FormControl>
<FormControlGroup orientation="vertical" formLabelProps={formLabelProps}>
<FormControl>
<Flex w="full">
<FormLabel>{t('nodes.animatedEdges')}</FormLabel>
<Switch onChange={handleChangeShouldAnimate} isChecked={shouldAnimateEdges} />
</Flex>
<FormHelperText>{t('nodes.animatedEdgesHelp')}</FormHelperText>
</FormControl>
<Divider />
<FormControl>
<Flex w="full">
<FormLabel>{t('nodes.snapToGrid')}</FormLabel>
<Switch isChecked={shouldSnapToGrid} onChange={handleChangeShouldSnap} />
</Flex>
<FormHelperText>{t('nodes.snapToGridHelp')}</FormHelperText>
</FormControl>
<Divider />
<FormControl>
<Flex w="full">
<FormLabel>{t('nodes.colorCodeEdges')}</FormLabel>
<Switch isChecked={shouldColorEdges} onChange={handleChangeShouldColor} />
</Flex>
<FormHelperText>{t('nodes.colorCodeEdgesHelp')}</FormHelperText>
</FormControl>
<Divider />
<FormControl>
<Flex w="full">
<FormLabel>{t('nodes.fullyContainNodes')}</FormLabel>
<Switch isChecked={selectionModeIsChecked} onChange={handleChangeSelectionMode} />
</Flex>
<FormHelperText>{t('nodes.fullyContainNodesHelp')}</FormHelperText>
</FormControl>
<Divider />
<Heading size="sm" pt={4}>
{t('common.advanced')}
</Heading>
<FormControl>
<Flex w="full">
<FormLabel>{t('nodes.validateConnections')}</FormLabel>
<Switch isChecked={shouldValidateGraph} onChange={handleChangeShouldValidate} />
</Flex>
<FormHelperText>{t('nodes.validateConnectionsHelp')}</FormHelperText>
</FormControl>
<Divider />
</FormControlGroup>
<ReloadNodeTemplatesButton />
</Flex>
</ModalBody>

View File

@ -5,7 +5,7 @@ import { workflowLoaded } from 'features/nodes/store/actions';
import { isAnyNodeOrEdgeMutation, nodeEditorReset, nodesDeleted } from 'features/nodes/store/nodesSlice';
import type { WorkflowsState as WorkflowState } from 'features/nodes/store/types';
import type { FieldIdentifier } from 'features/nodes/types/field';
import type { WorkflowV2 } from 'features/nodes/types/workflow';
import type { WorkflowCategory, WorkflowV2 } from 'features/nodes/types/workflow';
import { cloneDeep, isEqual, uniqBy } from 'lodash-es';
export const blankWorkflow: Omit<WorkflowV2, 'nodes' | 'edges'> = {
@ -46,6 +46,11 @@ const workflowSlice = createSlice({
state.name = action.payload;
state.isTouched = true;
},
workflowCategoryChanged: (state, action: PayloadAction<WorkflowCategory | undefined>) => {
if (action.payload) {
state.meta.category = action.payload;
}
},
workflowDescriptionChanged: (state, action: PayloadAction<string>) => {
state.description = action.payload;
state.isTouched = true;
@ -102,6 +107,7 @@ export const {
workflowExposedFieldAdded,
workflowExposedFieldRemoved,
workflowNameChanged,
workflowCategoryChanged,
workflowDescriptionChanged,
workflowTagsChanged,
workflowAuthorChanged,

View File

@ -1,5 +1,5 @@
import type { RootState } from 'app/store/store';
import { forEach, size } from 'lodash-es';
import { filter, size } from 'lodash-es';
import type { CoreMetadataInvocation, LoraLoaderInvocation, NonNullableGraph } from 'services/api/types';
import {
@ -28,8 +28,8 @@ export const addLoRAsToGraph = (
* So we need to inject a LoRA chain into the graph.
*/
const { loras } = state.lora;
const loraCount = size(loras);
const enabledLoRAs = filter(state.lora.loras, (l) => l.isEnabled ?? false);
const loraCount = size(enabledLoRAs);
if (loraCount === 0) {
return;
@ -47,7 +47,7 @@ export const addLoRAsToGraph = (
let currentLoraIndex = 0;
const loraMetadata: CoreMetadataInvocation['loras'] = [];
forEach(loras, (lora) => {
enabledLoRAs.forEach((lora) => {
const { model_name, base_model, weight } = lora;
const currentLoraNodeId = `${LORA_LOADER}_${model_name.replace('.', '_')}`;

View File

@ -23,7 +23,7 @@ import ParamMainModelSelect from 'features/parameters/components/MainModel/Param
import { selectGenerationSlice } from 'features/parameters/store/generationSlice';
import { useExpanderToggle } from 'features/settingsAccordions/hooks/useExpanderToggle';
import { useStandaloneAccordionToggle } from 'features/settingsAccordions/hooks/useStandaloneAccordionToggle';
import { size } from 'lodash-es';
import { filter, size } from 'lodash-es';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@ -32,7 +32,8 @@ const formLabelProps: FormLabelProps = {
};
const badgesSelector = createMemoizedSelector(selectLoraSlice, selectGenerationSlice, (lora, generation) => {
const loraTabBadges = size(lora.loras) ? [size(lora.loras)] : [];
const enabledLoRAsCount = filter(lora.loras, (l) => !!l.isEnabled).length;
const loraTabBadges = size(lora.loras) ? [enabledLoRAsCount] : [];
const accordionBadges: (string | number)[] = [];
if (generation.model) {
accordionBadges.push(generation.model.model_name);

View File

@ -0,0 +1,102 @@
import {
AlertDialog,
AlertDialogBody,
AlertDialogContent,
AlertDialogFooter,
AlertDialogHeader,
AlertDialogOverlay,
Button,
Checkbox,
Flex,
FormControl,
FormLabel,
Input,
} from '@invoke-ai/ui-library';
import { useStore } from '@nanostores/react';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { t } from 'i18next';
import type { ChangeEvent } from 'react';
import { useCallback, useRef } from 'react';
import { $workflowCategories } from '../../../../app/store/nanostores/workflowCategories';
import { useSaveWorkflowAs } from '../../hooks/useSaveWorkflowAs';
export const SaveWorkflowAsDialog = () => {
const { isOpen, onClose, workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject } =
useSaveWorkflowAsDialog();
const workflowCategories = useStore($workflowCategories);
const { saveWorkflowAs } = useSaveWorkflowAs();
const cancelRef = useRef<HTMLButtonElement>(null);
const inputRef = useRef<HTMLInputElement>(null);
const onChange = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setWorkflowName(e.target.value);
},
[setWorkflowName]
);
const onChangeCheckbox = useCallback(
(e: ChangeEvent<HTMLInputElement>) => {
setShouldSaveToProject(e.target.checked);
},
[setShouldSaveToProject]
);
const clearAndClose = useCallback(() => {
onClose();
}, [onClose]);
const onSave = useCallback(async () => {
const category = shouldSaveToProject ? 'project' : 'user';
await saveWorkflowAs({
name: workflowName,
category,
onSuccess: clearAndClose,
onError: clearAndClose,
});
}, [workflowName, saveWorkflowAs, shouldSaveToProject, clearAndClose]);
return (
<AlertDialog isOpen={isOpen} onClose={onClose} leastDestructiveRef={cancelRef} isCentered={true}>
<AlertDialogOverlay>
<AlertDialogContent>
<AlertDialogHeader fontSize="lg" fontWeight="bold">
{t('workflows.saveWorkflowAs')}
</AlertDialogHeader>
<AlertDialogBody>
<FormControl alignItems="flex-start">
<FormLabel mt="2">{t('workflows.workflowName')}</FormLabel>
<Flex flexDir="column" width="full" gap="2">
<Input
ref={inputRef}
value={workflowName}
onChange={onChange}
placeholder={t('workflows.workflowName')}
/>
{workflowCategories.includes('project') && (
<Checkbox isChecked={shouldSaveToProject} onChange={onChangeCheckbox}>
<FormLabel>{t('workflows.saveWorkflowToProject')}</FormLabel>
</Checkbox>
)}
</Flex>
</FormControl>
</AlertDialogBody>
<AlertDialogFooter>
<Button ref={cancelRef} onClick={clearAndClose}>
{t('common.cancel')}
</Button>
<Button colorScheme="invokeBlue" onClick={onSave} ml={3} isDisabled={!workflowName || !workflowName.length}>
{t('common.saveAs')}
</Button>
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialogOverlay>
</AlertDialog>
);
};

View File

@ -0,0 +1,52 @@
import { useStore } from '@nanostores/react';
import { createSelector } from '@reduxjs/toolkit';
import { useAppSelector } from 'app/store/storeHooks';
import { selectWorkflowSlice } from 'features/nodes/store/workflowSlice';
import { getWorkflowCopyName } from 'features/workflowLibrary/util/getWorkflowCopyName';
import { atom } from 'nanostores';
import { useCallback } from 'react';
const $isOpen = atom(false);
const $workflowName = atom('');
const $shouldSaveToProject = atom(false);
const selectNewWorkflowName = createSelector(selectWorkflowSlice, ({ name, id }): string => {
// If the workflow has no ID, it's a new workflow that has never been saved to the server. The dialog should use
// whatever the user has entered in the workflow name field.
if (!id) {
return name;
}
// Else, the workflow is already saved to the server. The dialog should use the workflow's name with " (copy)"
// appended to it.
if (name.length) {
return getWorkflowCopyName(name);
}
// Else, we have a workflow that has been saved to the server, but has no name. This should never happen, but if
// it does, we just return an empty string and let the dialog use the default name.
return '';
});
export const useSaveWorkflowAsDialog = () => {
const newWorkflowName = useAppSelector(selectNewWorkflowName);
const isOpen = useStore($isOpen);
const onOpen = useCallback(() => {
$workflowName.set(newWorkflowName);
$isOpen.set(true);
}, [newWorkflowName]);
const onClose = useCallback(() => {
$isOpen.set(false);
$workflowName.set('');
$shouldSaveToProject.set(false);
}, []);
const workflowName = useStore($workflowName);
const setWorkflowName = useCallback((workflowName: string) => $workflowName.set(workflowName), []);
const shouldSaveToProject = useStore($shouldSaveToProject);
const setShouldSaveToProject = useCallback((shouldSaveToProject: boolean) => {
$shouldSaveToProject.set(shouldSaveToProject);
}, []);
return { workflowName, setWorkflowName, shouldSaveToProject, setShouldSaveToProject, isOpen, onOpen, onClose };
};

View File

@ -1,4 +1,4 @@
import { Button, useDisclosure } from '@invoke-ai/ui-library';
import { IconButton, useDisclosure } from '@invoke-ai/ui-library';
import { WorkflowLibraryModalContext } from 'features/workflowLibrary/context/WorkflowLibraryModalContext';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
@ -12,9 +12,13 @@ const WorkflowLibraryButton = () => {
return (
<WorkflowLibraryModalContext.Provider value={disclosure}>
<Button leftIcon={<PiBooksBold />} onClick={disclosure.onOpen} pointerEvents="auto">
{t('workflows.workflowLibrary')}
</Button>
<IconButton
aria-label={t('workflows.workflowLibrary')}
tooltip={t('workflows.workflowLibrary')}
icon={<PiBooksBold />}
onClick={disclosure.onOpen}
pointerEvents="auto"
/>
<WorkflowLibraryModal />
</WorkflowLibraryModalContext.Provider>
);

View File

@ -1,53 +1,18 @@
import { ConfirmationAlertDialog, FormControl, FormLabel, Input, MenuItem, useDisclosure } from '@invoke-ai/ui-library';
import { useAppSelector } from 'app/store/storeHooks';
import { useSaveWorkflowAs } from 'features/workflowLibrary/hooks/useSaveWorkflowAs';
import { getWorkflowCopyName } from 'features/workflowLibrary/util/getWorkflowCopyName';
import type { ChangeEvent } from 'react';
import { memo, useCallback, useRef, useState } from 'react';
import { MenuItem } from '@invoke-ai/ui-library';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { memo } from 'react';
import { useTranslation } from 'react-i18next';
import { PiCopyBold } from 'react-icons/pi';
const SaveWorkflowAsButton = () => {
const currentName = useAppSelector((s) => s.workflow.name);
const SaveWorkflowAsMenuItem = () => {
const { t } = useTranslation();
const { saveWorkflowAs } = useSaveWorkflowAs();
const [name, setName] = useState(getWorkflowCopyName(currentName));
const { isOpen, onOpen, onClose } = useDisclosure();
const inputRef = useRef<HTMLInputElement>(null);
const onOpenCallback = useCallback(() => {
setName(getWorkflowCopyName(currentName));
onOpen();
inputRef.current?.focus();
}, [currentName, onOpen]);
const onSave = useCallback(async () => {
saveWorkflowAs({ name, onSuccess: onClose, onError: onClose });
}, [name, onClose, saveWorkflowAs]);
const onChange = useCallback((e: ChangeEvent<HTMLInputElement>) => {
setName(e.target.value);
}, []);
const { onOpen } = useSaveWorkflowAsDialog();
return (
<>
<MenuItem as="button" icon={<PiCopyBold />} onClick={onOpenCallback}>
{t('workflows.saveWorkflowAs')}
</MenuItem>
<ConfirmationAlertDialog
isOpen={isOpen}
onClose={onClose}
title={t('workflows.saveWorkflowAs')}
acceptCallback={onSave}
>
<FormControl>
<FormLabel>{t('workflows.workflowName')}</FormLabel>
<Input ref={inputRef} value={name} onChange={onChange} placeholder={t('workflows.workflowName')} />
</FormControl>
</ConfirmationAlertDialog>
</>
<MenuItem as="button" icon={<PiCopyBold />} onClick={onOpen}>
{t('workflows.saveWorkflowAs')}
</MenuItem>
);
};
export default memo(SaveWorkflowAsButton);
export default memo(SaveWorkflowAsMenuItem);

View File

@ -1,17 +1,37 @@
import { MenuItem } from '@invoke-ai/ui-library';
import { useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow';
import { memo } from 'react';
import { useSaveWorkflowAsDialog } from 'features/workflowLibrary/components/SaveWorkflowAsDialog/useSaveWorkflowAsDialog';
import { isWorkflowWithID, useSaveLibraryWorkflow } from 'features/workflowLibrary/hooks/useSaveWorkflow';
import { memo, useCallback } from 'react';
import { useTranslation } from 'react-i18next';
import { PiFloppyDiskBold } from 'react-icons/pi';
const SaveLibraryWorkflowMenuItem = () => {
import { useAppSelector } from '../../../../app/store/storeHooks';
import { $builtWorkflow } from '../../../nodes/hooks/useWorkflowWatcher';
const SaveWorkflowMenuItem = () => {
const { t } = useTranslation();
const { saveWorkflow } = useSaveLibraryWorkflow();
const { onOpen } = useSaveWorkflowAsDialog();
const isTouched = useAppSelector((s) => s.workflow.isTouched);
const handleClickSave = useCallback(async () => {
const builtWorkflow = $builtWorkflow.get();
if (!builtWorkflow) {
return;
}
if (isWorkflowWithID(builtWorkflow)) {
saveWorkflow();
} else {
onOpen();
}
}, [onOpen, saveWorkflow]);
return (
<MenuItem as="button" icon={<PiFloppyDiskBold />} onClick={saveWorkflow}>
<MenuItem as="button" isDisabled={!isTouched} icon={<PiFloppyDiskBold />} onClick={handleClickSave}>
{t('workflows.saveWorkflow')}
</MenuItem>
);
};
export default memo(SaveLibraryWorkflowMenuItem);
export default memo(SaveWorkflowMenuItem);

View File

@ -7,7 +7,6 @@ import {
useDisclosure,
useGlobalMenuClose,
} from '@invoke-ai/ui-library';
import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus';
import DownloadWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/DownloadWorkflowMenuItem';
import NewWorkflowMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/NewWorkflowMenuItem';
import SaveWorkflowAsMenuItem from 'features/workflowLibrary/components/WorkflowLibraryMenu/SaveWorkflowAsMenuItem';
@ -22,9 +21,6 @@ const WorkflowLibraryMenu = () => {
const { t } = useTranslation();
const { isOpen, onOpen, onClose } = useDisclosure();
useGlobalMenuClose(onClose);
const isWorkflowLibraryEnabled = useFeatureStatus('workflowLibrary').isFeatureEnabled;
return (
<Menu isOpen={isOpen} onOpen={onOpen} onClose={onClose}>
<MenuButton
@ -34,11 +30,12 @@ const WorkflowLibraryMenu = () => {
pointerEvents="auto"
/>
<MenuList pointerEvents="auto">
{isWorkflowLibraryEnabled && <SaveWorkflowMenuItem />}
{isWorkflowLibraryEnabled && <SaveWorkflowAsMenuItem />}
<DownloadWorkflowMenuItem />
<UploadWorkflowMenuItem />
<NewWorkflowMenuItem />
<UploadWorkflowMenuItem />
<MenuDivider />
<SaveWorkflowMenuItem />
<SaveWorkflowAsMenuItem />
<DownloadWorkflowMenuItem />
<MenuDivider />
<SettingsMenuItem />
</MenuList>

View File

@ -17,7 +17,8 @@ type UseSaveLibraryWorkflowReturn = {
type UseSaveLibraryWorkflow = () => UseSaveLibraryWorkflowReturn;
const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required<WorkflowV2, 'id'> => Boolean(workflow.id);
export const isWorkflowWithID = (workflow: WorkflowV2): workflow is O.Required<WorkflowV2, 'id'> =>
Boolean(workflow.id);
export const useSaveLibraryWorkflow: UseSaveLibraryWorkflow = () => {
const { t } = useTranslation();

View File

@ -2,13 +2,21 @@ import type { ToastId } from '@invoke-ai/ui-library';
import { useToast } from '@invoke-ai/ui-library';
import { useAppDispatch } from 'app/store/storeHooks';
import { $builtWorkflow } from 'features/nodes/hooks/useWorkflowWatcher';
import { workflowIDChanged, workflowNameChanged, workflowSaved } from 'features/nodes/store/workflowSlice';
import {
workflowCategoryChanged,
workflowIDChanged,
workflowNameChanged,
workflowSaved,
} from 'features/nodes/store/workflowSlice';
import { useCallback, useRef } from 'react';
import { useTranslation } from 'react-i18next';
import { useCreateWorkflowMutation, workflowsApi } from 'services/api/endpoints/workflows';
import type { WorkflowCategory } from '../../nodes/types/workflow';
type SaveWorkflowAsArg = {
name: string;
category: WorkflowCategory;
onSuccess?: () => void;
onError?: () => void;
};
@ -28,7 +36,7 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
const toast = useToast();
const toastRef = useRef<ToastId | undefined>();
const saveWorkflowAs = useCallback(
async ({ name: newName, onSuccess, onError }: SaveWorkflowAsArg) => {
async ({ name: newName, category, onSuccess, onError }: SaveWorkflowAsArg) => {
const workflow = $builtWorkflow.get();
if (!workflow) {
return;
@ -42,10 +50,14 @@ export const useSaveWorkflowAs: UseSaveWorkflowAs = () => {
try {
workflow.id = undefined;
workflow.name = newName;
workflow.meta.category = category;
const data = await createWorkflow(workflow).unwrap();
dispatch(workflowIDChanged(data.workflow.id));
dispatch(workflowNameChanged(data.workflow.name));
dispatch(workflowCategoryChanged(data.workflow.meta.category));
dispatch(workflowSaved());
onSuccess && onSuccess();
toast.update(toastRef.current, {
title: t('workflows.workflowSaved'),

View File

@ -46,13 +46,13 @@ dependencies = [
"onnxruntime==1.16.3",
"opencv-python==4.9.0.80",
"pytorch-lightning==2.1.3",
"safetensors==0.4.1",
"safetensors==0.4.2",
"timm==0.6.13", # needed to override timm latest in controlnet_aux, see https://github.com/isl-org/ZoeDepth/issues/26
"torch==2.1.2",
"torchmetrics==0.11.4",
"torchsde==0.2.6",
"torchvision==0.16.2",
"transformers==4.37.0",
"transformers==4.37.2",
# Core application dependencies, pinned for reproducible builds.
"fastapi-events==0.10.0",
@ -109,7 +109,7 @@ dependencies = [
"mkdocs-git-revision-date-localized-plugin",
"mkdocs-redirects==1.2.0",
]
"dev" = ["jurigged", "pudb"]
"dev" = ["jurigged", "pudb", "snakeviz", "gprof2dot"]
"test" = [
"ruff==0.1.11",
"ruff-lsp",

View File

@ -0,0 +1,27 @@
#!/bin/bash
# Accepts a path to a directory containing .prof files and generates a graphs
# for each of them. The default output format is pdf, but can be changed by
# providing a second argument.
# Usage: ./generate_profile_graphs.sh <path_to_profiles> <type>
# <path_to_profiles> is the path to the directory containing the .prof files
# <type> is the type of graph to generate. Defaults to 'pdf' if not provided.
# Valid types are: 'svg', 'png' and 'pdf'.
# Requires:
# - graphviz: https://graphviz.org/download/
# - gprof2dot: https://github.com/jrfonseca/gprof2dot
if [ -z "$1" ]; then
echo "Missing path to profiles directory"
exit 1
fi
type=${2:-pdf}
for file in $1/*.prof; do
base_name=$(basename "$file" .prof)
gprof2dot -f pstats "$file" | dot -T$type -Glabel="Session ID ${base_name}" -Glabelloc="t" -o "$1/$base_name.$type"
echo "Generated $1/$base_name.$type"
done

53
tests/test_profiler.py Normal file
View File

@ -0,0 +1,53 @@
import re
from logging import Logger
from pathlib import Path
from tempfile import TemporaryDirectory
import pytest
from invokeai.app.util.profiler import Profiler
def test_profiler_starts():
with TemporaryDirectory() as tempdir:
profiler = Profiler(logger=Logger("test_profiler"), output_dir=Path(tempdir))
assert not profiler._profiler
assert not profiler.profile_id
profiler.start("test")
assert profiler._profiler
assert profiler.profile_id == "test"
profiler.stop()
assert not profiler._profiler
assert not profiler.profile_id
profiler.start("test2")
assert profiler._profiler
assert profiler.profile_id == "test2"
profiler.stop()
def test_profiler_profiles():
with TemporaryDirectory() as tempdir:
profiler = Profiler(logger=Logger("test_profiler"), output_dir=Path(tempdir))
profiler.start("test")
for _ in range(1000000):
pass
profiler.stop()
assert (Path(tempdir) / "test.prof").exists()
def test_profiler_profiles_with_prefix():
with TemporaryDirectory() as tempdir:
profiler = Profiler(logger=Logger("test_profiler"), output_dir=Path(tempdir), prefix="prefix")
profiler.start("test")
for _ in range(1000000):
pass
profiler.stop()
assert (Path(tempdir) / "prefix_test.prof").exists()
def test_profile_fails_if_not_set_up():
with TemporaryDirectory() as tempdir:
profiler = Profiler(logger=Logger("test_profiler"), output_dir=Path(tempdir))
match = re.escape("Profiler not initialized. Call start() first.")
with pytest.raises(RuntimeError, match=match):
profiler.stop()