mirror of
https://github.com/invoke-ai/InvokeAI
synced 2024-08-30 20:32:17 +00:00
Merge branch 'main' into feat/db/graceful-migrate-workflows
This commit is contained in:
commit
60629cba3c
@ -270,7 +270,7 @@ upgrade script.** See the next section for a Windows recipe.
|
|||||||
3. Select option [1] to upgrade to the latest release.
|
3. Select option [1] to upgrade to the latest release.
|
||||||
|
|
||||||
4. Once the upgrade is finished you will be returned to the launcher
|
4. Once the upgrade is finished you will be returned to the launcher
|
||||||
menu. Select option [7] "Re-run the configure script to fix a broken
|
menu. Select option [6] "Re-run the configure script to fix a broken
|
||||||
install or to complete a major upgrade".
|
install or to complete a major upgrade".
|
||||||
|
|
||||||
This will run the configure script against the v2.3 directory and
|
This will run the configure script against the v2.3 directory and
|
||||||
|
@ -11,5 +11,5 @@ INVOKEAI_ROOT=
|
|||||||
# HUGGING_FACE_HUB_TOKEN=
|
# HUGGING_FACE_HUB_TOKEN=
|
||||||
|
|
||||||
## optional variables specific to the docker setup.
|
## optional variables specific to the docker setup.
|
||||||
# GPU_DRIVER=cuda # or rocm
|
# GPU_DRIVER=nvidia #| rocm
|
||||||
# CONTAINER_UID=1000
|
# CONTAINER_UID=1000
|
||||||
|
@ -1,6 +1,14 @@
|
|||||||
# InvokeAI Containerized
|
# InvokeAI Containerized
|
||||||
|
|
||||||
All commands are to be run from the `docker` directory: `cd docker`
|
All commands should be run within the `docker` directory: `cd docker`
|
||||||
|
|
||||||
|
## Quickstart :rocket:
|
||||||
|
|
||||||
|
On a known working Linux+Docker+CUDA (Nvidia) system, execute `./run.sh` in this directory. It will take a few minutes - depending on your internet speed - to install the core models. Once the application starts up, open `http://localhost:9090` in your browser to Invoke!
|
||||||
|
|
||||||
|
For more configuration options (using an AMD GPU, custom root directory location, etc): read on.
|
||||||
|
|
||||||
|
## Detailed setup
|
||||||
|
|
||||||
#### Linux
|
#### Linux
|
||||||
|
|
||||||
@ -18,7 +26,7 @@ All commands are to be run from the `docker` directory: `cd docker`
|
|||||||
|
|
||||||
This is done via Docker Desktop preferences
|
This is done via Docker Desktop preferences
|
||||||
|
|
||||||
## Quickstart
|
### Configure Invoke environment
|
||||||
|
|
||||||
1. Make a copy of `env.sample` and name it `.env` (`cp env.sample .env` (Mac/Linux) or `copy example.env .env` (Windows)). Make changes as necessary. Set `INVOKEAI_ROOT` to an absolute path to:
|
1. Make a copy of `env.sample` and name it `.env` (`cp env.sample .env` (Mac/Linux) or `copy example.env .env` (Windows)). Make changes as necessary. Set `INVOKEAI_ROOT` to an absolute path to:
|
||||||
a. the desired location of the InvokeAI runtime directory, or
|
a. the desired location of the InvokeAI runtime directory, or
|
||||||
@ -37,19 +45,21 @@ The runtime directory (holding models and outputs) will be created in the locati
|
|||||||
|
|
||||||
The Docker daemon on the system must be already set up to use the GPU. In case of Linux, this involves installing `nvidia-docker-runtime` and configuring the `nvidia` runtime as default. Steps will be different for AMD. Please see Docker documentation for the most up-to-date instructions for using your GPU with Docker.
|
The Docker daemon on the system must be already set up to use the GPU. In case of Linux, this involves installing `nvidia-docker-runtime` and configuring the `nvidia` runtime as default. Steps will be different for AMD. Please see Docker documentation for the most up-to-date instructions for using your GPU with Docker.
|
||||||
|
|
||||||
|
To use an AMD GPU, set `GPU_DRIVER=rocm` in your `.env` file.
|
||||||
|
|
||||||
## Customize
|
## Customize
|
||||||
|
|
||||||
Check the `.env.sample` file. It contains some environment variables for running in Docker. Copy it, name it `.env`, and fill it in with your own values. Next time you run `run.sh`, your custom values will be used.
|
Check the `.env.sample` file. It contains some environment variables for running in Docker. Copy it, name it `.env`, and fill it in with your own values. Next time you run `run.sh`, your custom values will be used.
|
||||||
|
|
||||||
You can also set these values in `docker-compose.yml` directly, but `.env` will help avoid conflicts when code is updated.
|
You can also set these values in `docker-compose.yml` directly, but `.env` will help avoid conflicts when code is updated.
|
||||||
|
|
||||||
Example (values are optional, but setting `INVOKEAI_ROOT` is highly recommended):
|
Values are optional, but setting `INVOKEAI_ROOT` is highly recommended. The default is `~/invokeai`. Example:
|
||||||
|
|
||||||
```bash
|
```bash
|
||||||
INVOKEAI_ROOT=/Volumes/WorkDrive/invokeai
|
INVOKEAI_ROOT=/Volumes/WorkDrive/invokeai
|
||||||
HUGGINGFACE_TOKEN=the_actual_token
|
HUGGINGFACE_TOKEN=the_actual_token
|
||||||
CONTAINER_UID=1000
|
CONTAINER_UID=1000
|
||||||
GPU_DRIVER=cuda
|
GPU_DRIVER=nvidia
|
||||||
```
|
```
|
||||||
|
|
||||||
Any environment variables supported by InvokeAI can be set here - please see the [Configuration docs](https://invoke-ai.github.io/InvokeAI/features/CONFIGURATION/) for further detail.
|
Any environment variables supported by InvokeAI can be set here - please see the [Configuration docs](https://invoke-ai.github.io/InvokeAI/features/CONFIGURATION/) for further detail.
|
||||||
|
@ -1,5 +1,5 @@
|
|||||||
#!/usr/bin/env bash
|
#!/usr/bin/env bash
|
||||||
set -e
|
set -e -o pipefail
|
||||||
|
|
||||||
run() {
|
run() {
|
||||||
local scriptdir=$(dirname "${BASH_SOURCE[0]}")
|
local scriptdir=$(dirname "${BASH_SOURCE[0]}")
|
||||||
@ -8,14 +8,18 @@ run() {
|
|||||||
local build_args=""
|
local build_args=""
|
||||||
local profile=""
|
local profile=""
|
||||||
|
|
||||||
[[ -f ".env" ]] &&
|
touch .env
|
||||||
build_args=$(awk '$1 ~ /=[^$]/ && $0 !~ /^#/ {print "--build-arg " $0 " "}' .env) &&
|
build_args=$(awk '$1 ~ /=[^$]/ && $0 !~ /^#/ {print "--build-arg " $0 " "}' .env) &&
|
||||||
profile="$(awk -F '=' '/GPU_DRIVER/ {print $2}' .env)"
|
profile="$(awk -F '=' '/GPU_DRIVER/ {print $2}' .env)"
|
||||||
|
|
||||||
|
[[ -z "$profile" ]] && profile="nvidia"
|
||||||
|
|
||||||
local service_name="invokeai-$profile"
|
local service_name="invokeai-$profile"
|
||||||
|
|
||||||
printf "%s\n" "docker compose build args:"
|
if [[ ! -z "$build_args" ]]; then
|
||||||
printf "%s\n" "$build_args"
|
printf "%s\n" "docker compose build args:"
|
||||||
|
printf "%s\n" "$build_args"
|
||||||
|
fi
|
||||||
|
|
||||||
docker compose build $build_args
|
docker compose build $build_args
|
||||||
unset build_args
|
unset build_args
|
||||||
|
277
docs/contributing/DOWNLOAD_QUEUE.md
Normal file
277
docs/contributing/DOWNLOAD_QUEUE.md
Normal file
@ -0,0 +1,277 @@
|
|||||||
|
# The InvokeAI Download Queue
|
||||||
|
|
||||||
|
The DownloadQueueService provides a multithreaded parallel download
|
||||||
|
queue for arbitrary URLs, with queue prioritization, event handling,
|
||||||
|
and restart capabilities.
|
||||||
|
|
||||||
|
## Simple Example
|
||||||
|
|
||||||
|
```
|
||||||
|
from invokeai.app.services.download import DownloadQueueService, TqdmProgress
|
||||||
|
|
||||||
|
download_queue = DownloadQueueService()
|
||||||
|
for url in ['https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/a-painting-of-a-fire.png?raw=true',
|
||||||
|
'https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/birdhouse.png?raw=true',
|
||||||
|
'https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/missing.png',
|
||||||
|
'https://civitai.com/api/download/models/152309?type=Model&format=SafeTensor',
|
||||||
|
]:
|
||||||
|
|
||||||
|
# urls start downloading as soon as download() is called
|
||||||
|
download_queue.download(source=url,
|
||||||
|
dest='/tmp/downloads',
|
||||||
|
on_progress=TqdmProgress().update
|
||||||
|
)
|
||||||
|
|
||||||
|
download_queue.join() # wait for all downloads to finish
|
||||||
|
for job in download_queue.list_jobs():
|
||||||
|
print(job.model_dump_json(exclude_none=True, indent=4),"\n")
|
||||||
|
```
|
||||||
|
|
||||||
|
Output:
|
||||||
|
|
||||||
|
```
|
||||||
|
{
|
||||||
|
"source": "https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/a-painting-of-a-fire.png?raw=true",
|
||||||
|
"dest": "/tmp/downloads",
|
||||||
|
"id": 0,
|
||||||
|
"priority": 10,
|
||||||
|
"status": "completed",
|
||||||
|
"download_path": "/tmp/downloads/a-painting-of-a-fire.png",
|
||||||
|
"job_started": "2023-12-04T05:34:41.742174",
|
||||||
|
"job_ended": "2023-12-04T05:34:42.592035",
|
||||||
|
"bytes": 666734,
|
||||||
|
"total_bytes": 666734
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"source": "https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/birdhouse.png?raw=true",
|
||||||
|
"dest": "/tmp/downloads",
|
||||||
|
"id": 1,
|
||||||
|
"priority": 10,
|
||||||
|
"status": "completed",
|
||||||
|
"download_path": "/tmp/downloads/birdhouse.png",
|
||||||
|
"job_started": "2023-12-04T05:34:41.741975",
|
||||||
|
"job_ended": "2023-12-04T05:34:42.652841",
|
||||||
|
"bytes": 774949,
|
||||||
|
"total_bytes": 774949
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"source": "https://github.com/invoke-ai/InvokeAI/blob/main/invokeai/assets/missing.png",
|
||||||
|
"dest": "/tmp/downloads",
|
||||||
|
"id": 2,
|
||||||
|
"priority": 10,
|
||||||
|
"status": "error",
|
||||||
|
"job_started": "2023-12-04T05:34:41.742079",
|
||||||
|
"job_ended": "2023-12-04T05:34:42.147625",
|
||||||
|
"bytes": 0,
|
||||||
|
"total_bytes": 0,
|
||||||
|
"error_type": "HTTPError(Not Found)",
|
||||||
|
"error": "Traceback (most recent call last):\n File \"/home/lstein/Projects/InvokeAI/invokeai/app/services/download/download_default.py\", line 182, in _download_next_item\n self._do_download(job)\n File \"/home/lstein/Projects/InvokeAI/invokeai/app/services/download/download_default.py\", line 206, in _do_download\n raise HTTPError(resp.reason)\nrequests.exceptions.HTTPError: Not Found\n"
|
||||||
|
}
|
||||||
|
|
||||||
|
{
|
||||||
|
"source": "https://civitai.com/api/download/models/152309?type=Model&format=SafeTensor",
|
||||||
|
"dest": "/tmp/downloads",
|
||||||
|
"id": 3,
|
||||||
|
"priority": 10,
|
||||||
|
"status": "completed",
|
||||||
|
"download_path": "/tmp/downloads/xl_more_art-full_v1.safetensors",
|
||||||
|
"job_started": "2023-12-04T05:34:42.147645",
|
||||||
|
"job_ended": "2023-12-04T05:34:43.735990",
|
||||||
|
"bytes": 719020768,
|
||||||
|
"total_bytes": 719020768
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## The API
|
||||||
|
|
||||||
|
The default download queue is `DownloadQueueService`, an
|
||||||
|
implementation of ABC `DownloadQueueServiceBase`. It juggles multiple
|
||||||
|
background download requests and provides facilities for interrogating
|
||||||
|
and cancelling the requests. Access to a current or past download task
|
||||||
|
is mediated via `DownloadJob` objects which report the current status
|
||||||
|
of a job request
|
||||||
|
|
||||||
|
### The Queue Object
|
||||||
|
|
||||||
|
A default download queue is located in
|
||||||
|
`ApiDependencies.invoker.services.download_queue`. However, you can
|
||||||
|
create additional instances if you need to isolate your queue from the
|
||||||
|
main one.
|
||||||
|
|
||||||
|
```
|
||||||
|
queue = DownloadQueueService(event_bus=events)
|
||||||
|
```
|
||||||
|
|
||||||
|
`DownloadQueueService()` takes three optional arguments:
|
||||||
|
|
||||||
|
| **Argument** | **Type** | **Default** | **Description** |
|
||||||
|
|----------------|-----------------|---------------|-----------------|
|
||||||
|
| `max_parallel_dl` | int | 5 | Maximum number of simultaneous downloads allowed |
|
||||||
|
| `event_bus` | EventServiceBase | None | System-wide FastAPI event bus for reporting download events |
|
||||||
|
| `requests_session` | requests.sessions.Session | None | An alternative requests Session object to use for the download |
|
||||||
|
|
||||||
|
`max_parallel_dl` specifies how many download jobs are allowed to run
|
||||||
|
simultaneously. Each will run in a different thread of execution.
|
||||||
|
|
||||||
|
`event_bus` is an EventServiceBase, typically the one created at
|
||||||
|
InvokeAI startup. If present, download events are periodically emitted
|
||||||
|
on this bus to allow clients to follow download progress.
|
||||||
|
|
||||||
|
`requests_session` is a url library requests Session object. It is
|
||||||
|
used for testing.
|
||||||
|
|
||||||
|
### The Job object
|
||||||
|
|
||||||
|
The queue operates on a series of download job objects. These objects
|
||||||
|
specify the source and destination of the download, and keep track of
|
||||||
|
the progress of the download.
|
||||||
|
|
||||||
|
The only job type currently implemented is `DownloadJob`, a pydantic object with the
|
||||||
|
following fields:
|
||||||
|
|
||||||
|
| **Field** | **Type** | **Default** | **Description** |
|
||||||
|
|----------------|-----------------|---------------|-----------------|
|
||||||
|
| _Fields passed in at job creation time_ |
|
||||||
|
| `source` | AnyHttpUrl | | Where to download from |
|
||||||
|
| `dest` | Path | | Where to download to |
|
||||||
|
| `access_token` | str | | [optional] string containing authentication token for access |
|
||||||
|
| `on_start` | Callable | | [optional] callback when the download starts |
|
||||||
|
| `on_progress` | Callable | | [optional] callback called at intervals during download progress |
|
||||||
|
| `on_complete` | Callable | | [optional] callback called after successful download completion |
|
||||||
|
| `on_error` | Callable | | [optional] callback called after an error occurs |
|
||||||
|
| `id` | int | auto assigned | Job ID, an integer >= 0 |
|
||||||
|
| `priority` | int | 10 | Job priority. Lower priorities run before higher priorities |
|
||||||
|
| |
|
||||||
|
| _Fields updated over the course of the download task_
|
||||||
|
| `status` | DownloadJobStatus| | Status code |
|
||||||
|
| `download_path` | Path | | Path to the location of the downloaded file |
|
||||||
|
| `job_started` | float | | Timestamp for when the job started running |
|
||||||
|
| `job_ended` | float | | Timestamp for when the job completed or errored out |
|
||||||
|
| `job_sequence` | int | | A counter that is incremented each time a model is dequeued |
|
||||||
|
| `bytes` | int | 0 | Bytes downloaded so far |
|
||||||
|
| `total_bytes` | int | 0 | Total size of the file at the remote site |
|
||||||
|
| `error_type` | str | | String version of the exception that caused an error during download |
|
||||||
|
| `error` | str | | String version of the traceback associated with an error |
|
||||||
|
| `cancelled` | bool | False | Set to true if the job was cancelled by the caller|
|
||||||
|
|
||||||
|
When you create a job, you can assign it a `priority`. If multiple
|
||||||
|
jobs are queued, the job with the lowest priority runs first.
|
||||||
|
|
||||||
|
Every job has a `source` and a `dest`. `source` is a pydantic.networks AnyHttpUrl object.
|
||||||
|
The `dest` is a path on the local filesystem that specifies the
|
||||||
|
destination for the downloaded object. Its semantics are
|
||||||
|
described below.
|
||||||
|
|
||||||
|
When the job is submitted, it is assigned a numeric `id`. The id can
|
||||||
|
then be used to fetch the job object from the queue.
|
||||||
|
|
||||||
|
The `status` field is updated by the queue to indicate where the job
|
||||||
|
is in its lifecycle. Values are defined in the string enum
|
||||||
|
`DownloadJobStatus`, a symbol available from
|
||||||
|
`invokeai.app.services.download_manager`. Possible values are:
|
||||||
|
|
||||||
|
| **Value** | **String Value** | ** Description ** |
|
||||||
|
|--------------|---------------------|-------------------|
|
||||||
|
| `WAITING` | waiting | Job is on the queue but not yet running|
|
||||||
|
| `RUNNING` | running | The download is started |
|
||||||
|
| `COMPLETED` | completed | Job has finished its work without an error |
|
||||||
|
| `ERROR` | error | Job encountered an error and will not run again|
|
||||||
|
|
||||||
|
`job_started` and `job_ended` indicate when the job
|
||||||
|
was started (using a python timestamp) and when it completed.
|
||||||
|
|
||||||
|
In case of an error, the job's status will be set to `DownloadJobStatus.ERROR`, the text of the
|
||||||
|
Exception that caused the error will be placed in the `error_type`
|
||||||
|
field and the traceback that led to the error will be in `error`.
|
||||||
|
|
||||||
|
A cancelled job will have status `DownloadJobStatus.ERROR` and an
|
||||||
|
`error_type` field of "DownloadJobCancelledException". In addition,
|
||||||
|
the job's `cancelled` property will be set to True.
|
||||||
|
|
||||||
|
### Callbacks
|
||||||
|
|
||||||
|
Download jobs can be associated with a series of callbacks, each with
|
||||||
|
the signature `Callable[["DownloadJob"], None]`. The callbacks are assigned
|
||||||
|
using optional arguments `on_start`, `on_progress`, `on_complete` and
|
||||||
|
`on_error`. When the corresponding event occurs, the callback wil be
|
||||||
|
invoked and passed the job. The callback will be run in a `try:`
|
||||||
|
context in the same thread as the download job. Any exceptions that
|
||||||
|
occur during execution of the callback will be caught and converted
|
||||||
|
into a log error message, thereby allowing the download to continue.
|
||||||
|
|
||||||
|
#### `TqdmProgress`
|
||||||
|
|
||||||
|
The `invokeai.app.services.download.download_default` module defines a
|
||||||
|
class named `TqdmProgress` which can be used as an `on_progress`
|
||||||
|
handler to display a completion bar in the console. Use as follows:
|
||||||
|
|
||||||
|
```
|
||||||
|
from invokeai.app.services.download import TqdmProgress
|
||||||
|
|
||||||
|
download_queue.download(source='http://some.server.somewhere/some_file',
|
||||||
|
dest='/tmp/downloads',
|
||||||
|
on_progress=TqdmProgress().update
|
||||||
|
)
|
||||||
|
|
||||||
|
```
|
||||||
|
|
||||||
|
### Events
|
||||||
|
|
||||||
|
If the queue was initialized with the InvokeAI event bus (the case
|
||||||
|
when using `ApiDependencies.invoker.services.download_queue`), then
|
||||||
|
download events will also be issued on the bus. The events are:
|
||||||
|
|
||||||
|
* `download_started` -- This is issued when a job is taken off the
|
||||||
|
queue and a request is made to the remote server for the URL headers, but before any data
|
||||||
|
has been downloaded. The event payload will contain the keys `source`
|
||||||
|
and `download_path`. The latter contains the path that the URL will be
|
||||||
|
downloaded to.
|
||||||
|
|
||||||
|
* `download_progress -- This is issued periodically as the download
|
||||||
|
runs. The payload contains the keys `source`, `download_path`,
|
||||||
|
`current_bytes` and `total_bytes`. The latter two fields can be
|
||||||
|
used to display the percent complete.
|
||||||
|
|
||||||
|
* `download_complete` -- This is issued when the download completes
|
||||||
|
successfully. The payload contains the keys `source`, `download_path`
|
||||||
|
and `total_bytes`.
|
||||||
|
|
||||||
|
* `download_error` -- This is issued when the download stops because
|
||||||
|
of an error condition. The payload contains the fields `error_type`
|
||||||
|
and `error`. The former is the text representation of the exception,
|
||||||
|
and the latter is a traceback showing where the error occurred.
|
||||||
|
|
||||||
|
### Job control
|
||||||
|
|
||||||
|
To create a job call the queue's `download()` method. You can list all
|
||||||
|
jobs using `list_jobs()`, fetch a single job by its with
|
||||||
|
`id_to_job()`, cancel a running job with `cancel_job()`, cancel all
|
||||||
|
running jobs with `cancel_all_jobs()`, and wait for all jobs to finish
|
||||||
|
with `join()`.
|
||||||
|
|
||||||
|
#### job = queue.download(source, dest, priority, access_token)
|
||||||
|
|
||||||
|
Create a new download job and put it on the queue, returning the
|
||||||
|
DownloadJob object.
|
||||||
|
|
||||||
|
#### jobs = queue.list_jobs()
|
||||||
|
|
||||||
|
Return a list of all active and inactive `DownloadJob`s.
|
||||||
|
|
||||||
|
#### job = queue.id_to_job(id)
|
||||||
|
|
||||||
|
Return the job corresponding to given ID.
|
||||||
|
|
||||||
|
Return a list of all active and inactive `DownloadJob`s.
|
||||||
|
|
||||||
|
#### queue.prune_jobs()
|
||||||
|
|
||||||
|
Remove inactive (complete or errored) jobs from the listing returned
|
||||||
|
by `list_jobs()`.
|
||||||
|
|
||||||
|
#### queue.join()
|
||||||
|
|
||||||
|
Block until all pending jobs have run to completion or errored out.
|
||||||
|
|
@ -11,6 +11,7 @@ from ..services.board_images.board_images_default import BoardImagesService
|
|||||||
from ..services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
from ..services.board_records.board_records_sqlite import SqliteBoardRecordStorage
|
||||||
from ..services.boards.boards_default import BoardService
|
from ..services.boards.boards_default import BoardService
|
||||||
from ..services.config import InvokeAIAppConfig
|
from ..services.config import InvokeAIAppConfig
|
||||||
|
from ..services.download import DownloadQueueService
|
||||||
from ..services.image_files.image_files_disk import DiskImageFileStorage
|
from ..services.image_files.image_files_disk import DiskImageFileStorage
|
||||||
from ..services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
from ..services.image_records.image_records_sqlite import SqliteImageRecordStorage
|
||||||
from ..services.images.images_default import ImageService
|
from ..services.images.images_default import ImageService
|
||||||
@ -29,8 +30,7 @@ from ..services.model_records import ModelRecordServiceSQL
|
|||||||
from ..services.names.names_default import SimpleNameService
|
from ..services.names.names_default import SimpleNameService
|
||||||
from ..services.session_processor.session_processor_default import DefaultSessionProcessor
|
from ..services.session_processor.session_processor_default import DefaultSessionProcessor
|
||||||
from ..services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
from ..services.session_queue.session_queue_sqlite import SqliteSessionQueue
|
||||||
from ..services.shared.default_graphs import create_system_graphs
|
from ..services.shared.graph import GraphExecutionState
|
||||||
from ..services.shared.graph import GraphExecutionState, LibraryGraph
|
|
||||||
from ..services.urls.urls_default import LocalUrlService
|
from ..services.urls.urls_default import LocalUrlService
|
||||||
from ..services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
|
from ..services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
|
||||||
from .events import FastAPIEventService
|
from .events import FastAPIEventService
|
||||||
@ -80,13 +80,13 @@ class ApiDependencies:
|
|||||||
boards = BoardService()
|
boards = BoardService()
|
||||||
events = FastAPIEventService(event_handler_id)
|
events = FastAPIEventService(event_handler_id)
|
||||||
graph_execution_manager = SqliteItemStorage[GraphExecutionState](db=db, table_name="graph_executions")
|
graph_execution_manager = SqliteItemStorage[GraphExecutionState](db=db, table_name="graph_executions")
|
||||||
graph_library = SqliteItemStorage[LibraryGraph](db=db, table_name="graphs")
|
|
||||||
image_records = SqliteImageRecordStorage(db=db)
|
image_records = SqliteImageRecordStorage(db=db)
|
||||||
images = ImageService()
|
images = ImageService()
|
||||||
invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size)
|
invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size)
|
||||||
latents = ForwardCacheLatentsStorage(DiskLatentsStorage(f"{output_folder}/latents"))
|
latents = ForwardCacheLatentsStorage(DiskLatentsStorage(f"{output_folder}/latents"))
|
||||||
model_manager = ModelManagerService(config, logger)
|
model_manager = ModelManagerService(config, logger)
|
||||||
model_record_service = ModelRecordServiceSQL(db=db)
|
model_record_service = ModelRecordServiceSQL(db=db)
|
||||||
|
download_queue_service = DownloadQueueService(event_bus=events)
|
||||||
model_install_service = ModelInstallService(
|
model_install_service = ModelInstallService(
|
||||||
app_config=config, record_store=model_record_service, event_bus=events
|
app_config=config, record_store=model_record_service, event_bus=events
|
||||||
)
|
)
|
||||||
@ -107,7 +107,6 @@ class ApiDependencies:
|
|||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
events=events,
|
events=events,
|
||||||
graph_execution_manager=graph_execution_manager,
|
graph_execution_manager=graph_execution_manager,
|
||||||
graph_library=graph_library,
|
|
||||||
image_files=image_files,
|
image_files=image_files,
|
||||||
image_records=image_records,
|
image_records=image_records,
|
||||||
images=images,
|
images=images,
|
||||||
@ -116,6 +115,7 @@ class ApiDependencies:
|
|||||||
logger=logger,
|
logger=logger,
|
||||||
model_manager=model_manager,
|
model_manager=model_manager,
|
||||||
model_records=model_record_service,
|
model_records=model_record_service,
|
||||||
|
download_queue=download_queue_service,
|
||||||
model_install=model_install_service,
|
model_install=model_install_service,
|
||||||
names=names,
|
names=names,
|
||||||
performance_statistics=performance_statistics,
|
performance_statistics=performance_statistics,
|
||||||
@ -127,8 +127,6 @@ class ApiDependencies:
|
|||||||
workflow_records=workflow_records,
|
workflow_records=workflow_records,
|
||||||
)
|
)
|
||||||
|
|
||||||
create_system_graphs(services.graph_library)
|
|
||||||
|
|
||||||
ApiDependencies.invoker = Invoker(services)
|
ApiDependencies.invoker = Invoker(services)
|
||||||
db.clean()
|
db.clean()
|
||||||
|
|
||||||
|
111
invokeai/app/api/routers/download_queue.py
Normal file
111
invokeai/app/api/routers/download_queue.py
Normal file
@ -0,0 +1,111 @@
|
|||||||
|
# Copyright (c) 2023 Lincoln D. Stein
|
||||||
|
"""FastAPI route for the download queue."""
|
||||||
|
|
||||||
|
from typing import List, Optional
|
||||||
|
|
||||||
|
from fastapi import Body, Path, Response
|
||||||
|
from fastapi.routing import APIRouter
|
||||||
|
from pydantic.networks import AnyHttpUrl
|
||||||
|
from starlette.exceptions import HTTPException
|
||||||
|
|
||||||
|
from invokeai.app.services.download import (
|
||||||
|
DownloadJob,
|
||||||
|
UnknownJobIDException,
|
||||||
|
)
|
||||||
|
|
||||||
|
from ..dependencies import ApiDependencies
|
||||||
|
|
||||||
|
download_queue_router = APIRouter(prefix="/v1/download_queue", tags=["download_queue"])
|
||||||
|
|
||||||
|
|
||||||
|
@download_queue_router.get(
|
||||||
|
"/",
|
||||||
|
operation_id="list_downloads",
|
||||||
|
)
|
||||||
|
async def list_downloads() -> List[DownloadJob]:
|
||||||
|
"""Get a list of active and inactive jobs."""
|
||||||
|
queue = ApiDependencies.invoker.services.download_queue
|
||||||
|
return queue.list_jobs()
|
||||||
|
|
||||||
|
|
||||||
|
@download_queue_router.patch(
|
||||||
|
"/",
|
||||||
|
operation_id="prune_downloads",
|
||||||
|
responses={
|
||||||
|
204: {"description": "All completed jobs have been pruned"},
|
||||||
|
400: {"description": "Bad request"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def prune_downloads():
|
||||||
|
"""Prune completed and errored jobs."""
|
||||||
|
queue = ApiDependencies.invoker.services.download_queue
|
||||||
|
queue.prune_jobs()
|
||||||
|
return Response(status_code=204)
|
||||||
|
|
||||||
|
|
||||||
|
@download_queue_router.post(
|
||||||
|
"/i/",
|
||||||
|
operation_id="download",
|
||||||
|
)
|
||||||
|
async def download(
|
||||||
|
source: AnyHttpUrl = Body(description="download source"),
|
||||||
|
dest: str = Body(description="download destination"),
|
||||||
|
priority: int = Body(default=10, description="queue priority"),
|
||||||
|
access_token: Optional[str] = Body(default=None, description="token for authorization to download"),
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""Download the source URL to the file or directory indicted in dest."""
|
||||||
|
queue = ApiDependencies.invoker.services.download_queue
|
||||||
|
return queue.download(source, dest, priority, access_token)
|
||||||
|
|
||||||
|
|
||||||
|
@download_queue_router.get(
|
||||||
|
"/i/{id}",
|
||||||
|
operation_id="get_download_job",
|
||||||
|
responses={
|
||||||
|
200: {"description": "Success"},
|
||||||
|
404: {"description": "The requested download JobID could not be found"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def get_download_job(
|
||||||
|
id: int = Path(description="ID of the download job to fetch."),
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""Get a download job using its ID."""
|
||||||
|
try:
|
||||||
|
job = ApiDependencies.invoker.services.download_queue.id_to_job(id)
|
||||||
|
return job
|
||||||
|
except UnknownJobIDException as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@download_queue_router.delete(
|
||||||
|
"/i/{id}",
|
||||||
|
operation_id="cancel_download_job",
|
||||||
|
responses={
|
||||||
|
204: {"description": "Job has been cancelled"},
|
||||||
|
404: {"description": "The requested download JobID could not be found"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def cancel_download_job(
|
||||||
|
id: int = Path(description="ID of the download job to cancel."),
|
||||||
|
):
|
||||||
|
"""Cancel a download job using its ID."""
|
||||||
|
try:
|
||||||
|
queue = ApiDependencies.invoker.services.download_queue
|
||||||
|
job = queue.id_to_job(id)
|
||||||
|
queue.cancel_job(job)
|
||||||
|
return Response(status_code=204)
|
||||||
|
except UnknownJobIDException as e:
|
||||||
|
raise HTTPException(status_code=404, detail=str(e))
|
||||||
|
|
||||||
|
|
||||||
|
@download_queue_router.delete(
|
||||||
|
"/i",
|
||||||
|
operation_id="cancel_all_download_jobs",
|
||||||
|
responses={
|
||||||
|
204: {"description": "Download jobs have been cancelled"},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
async def cancel_all_download_jobs():
|
||||||
|
"""Cancel all download jobs."""
|
||||||
|
ApiDependencies.invoker.services.download_queue.cancel_all_jobs()
|
||||||
|
return Response(status_code=204)
|
@ -45,6 +45,7 @@ if True: # hack to make flake8 happy with imports coming after setting up the c
|
|||||||
app_info,
|
app_info,
|
||||||
board_images,
|
board_images,
|
||||||
boards,
|
boards,
|
||||||
|
download_queue,
|
||||||
images,
|
images,
|
||||||
model_records,
|
model_records,
|
||||||
models,
|
models,
|
||||||
@ -116,6 +117,7 @@ app.include_router(sessions.session_router, prefix="/api")
|
|||||||
app.include_router(utilities.utilities_router, prefix="/api")
|
app.include_router(utilities.utilities_router, prefix="/api")
|
||||||
app.include_router(models.models_router, prefix="/api")
|
app.include_router(models.models_router, prefix="/api")
|
||||||
app.include_router(model_records.model_records_router, prefix="/api")
|
app.include_router(model_records.model_records_router, prefix="/api")
|
||||||
|
app.include_router(download_queue.download_queue_router, prefix="/api")
|
||||||
app.include_router(images.images_router, prefix="/api")
|
app.include_router(images.images_router, prefix="/api")
|
||||||
app.include_router(boards.boards_router, prefix="/api")
|
app.include_router(boards.boards_router, prefix="/api")
|
||||||
app.include_router(board_images.board_images_router, prefix="/api")
|
app.include_router(board_images.board_images_router, prefix="/api")
|
||||||
|
@ -1,4 +1,3 @@
|
|||||||
import re
|
|
||||||
from dataclasses import dataclass
|
from dataclasses import dataclass
|
||||||
from typing import List, Optional, Union
|
from typing import List, Optional, Union
|
||||||
|
|
||||||
@ -17,6 +16,7 @@ from invokeai.backend.stable_diffusion.diffusion.conditioning_data import (
|
|||||||
from ...backend.model_management.lora import ModelPatcher
|
from ...backend.model_management.lora import ModelPatcher
|
||||||
from ...backend.model_management.models import ModelNotFoundException, ModelType
|
from ...backend.model_management.models import ModelNotFoundException, ModelType
|
||||||
from ...backend.util.devices import torch_dtype
|
from ...backend.util.devices import torch_dtype
|
||||||
|
from ..util.ti_utils import extract_ti_triggers_from_prompt
|
||||||
from .baseinvocation import (
|
from .baseinvocation import (
|
||||||
BaseInvocation,
|
BaseInvocation,
|
||||||
BaseInvocationOutput,
|
BaseInvocationOutput,
|
||||||
@ -87,7 +87,7 @@ class CompelInvocation(BaseInvocation):
|
|||||||
# loras = [(context.services.model_manager.get_model(**lora.dict(exclude={"weight"})).context.model, lora.weight) for lora in self.clip.loras]
|
# loras = [(context.services.model_manager.get_model(**lora.dict(exclude={"weight"})).context.model, lora.weight) for lora in self.clip.loras]
|
||||||
|
|
||||||
ti_list = []
|
ti_list = []
|
||||||
for trigger in re.findall(r"<[a-zA-Z0-9., _-]+>", self.prompt):
|
for trigger in extract_ti_triggers_from_prompt(self.prompt):
|
||||||
name = trigger[1:-1]
|
name = trigger[1:-1]
|
||||||
try:
|
try:
|
||||||
ti_list.append(
|
ti_list.append(
|
||||||
@ -210,7 +210,7 @@ class SDXLPromptInvocationBase:
|
|||||||
# loras = [(context.services.model_manager.get_model(**lora.dict(exclude={"weight"})).context.model, lora.weight) for lora in self.clip.loras]
|
# loras = [(context.services.model_manager.get_model(**lora.dict(exclude={"weight"})).context.model, lora.weight) for lora in self.clip.loras]
|
||||||
|
|
||||||
ti_list = []
|
ti_list = []
|
||||||
for trigger in re.findall(r"<[a-zA-Z0-9., _-]+>", prompt):
|
for trigger in extract_ti_triggers_from_prompt(prompt):
|
||||||
name = trigger[1:-1]
|
name = trigger[1:-1]
|
||||||
try:
|
try:
|
||||||
ti_list.append(
|
ti_list.append(
|
||||||
|
@ -1,7 +1,6 @@
|
|||||||
# Copyright (c) 2023 Borisov Sergey (https://github.com/StAlKeR7779)
|
# Copyright (c) 2023 Borisov Sergey (https://github.com/StAlKeR7779)
|
||||||
|
|
||||||
import inspect
|
import inspect
|
||||||
import re
|
|
||||||
|
|
||||||
# from contextlib import ExitStack
|
# from contextlib import ExitStack
|
||||||
from typing import List, Literal, Union
|
from typing import List, Literal, Union
|
||||||
@ -21,6 +20,7 @@ from invokeai.backend import BaseModelType, ModelType, SubModelType
|
|||||||
from ...backend.model_management import ONNXModelPatcher
|
from ...backend.model_management import ONNXModelPatcher
|
||||||
from ...backend.stable_diffusion import PipelineIntermediateState
|
from ...backend.stable_diffusion import PipelineIntermediateState
|
||||||
from ...backend.util import choose_torch_device
|
from ...backend.util import choose_torch_device
|
||||||
|
from ..util.ti_utils import extract_ti_triggers_from_prompt
|
||||||
from .baseinvocation import (
|
from .baseinvocation import (
|
||||||
BaseInvocation,
|
BaseInvocation,
|
||||||
BaseInvocationOutput,
|
BaseInvocationOutput,
|
||||||
@ -78,7 +78,7 @@ class ONNXPromptInvocation(BaseInvocation):
|
|||||||
]
|
]
|
||||||
|
|
||||||
ti_list = []
|
ti_list = []
|
||||||
for trigger in re.findall(r"<[a-zA-Z0-9., _-]+>", self.prompt):
|
for trigger in extract_ti_triggers_from_prompt(self.prompt):
|
||||||
name = trigger[1:-1]
|
name = trigger[1:-1]
|
||||||
try:
|
try:
|
||||||
ti_list.append(
|
ti_list.append(
|
||||||
|
@ -356,7 +356,7 @@ class InvokeAIAppConfig(InvokeAISettings):
|
|||||||
else:
|
else:
|
||||||
root = self.find_root().expanduser().absolute()
|
root = self.find_root().expanduser().absolute()
|
||||||
self.root = root # insulate ourselves from relative paths that may change
|
self.root = root # insulate ourselves from relative paths that may change
|
||||||
return root
|
return root.resolve()
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def root_dir(self) -> Path:
|
def root_dir(self) -> Path:
|
||||||
|
12
invokeai/app/services/download/__init__.py
Normal file
12
invokeai/app/services/download/__init__.py
Normal file
@ -0,0 +1,12 @@
|
|||||||
|
"""Init file for download queue."""
|
||||||
|
from .download_base import DownloadJob, DownloadJobStatus, DownloadQueueServiceBase, UnknownJobIDException
|
||||||
|
from .download_default import DownloadQueueService, TqdmProgress
|
||||||
|
|
||||||
|
__all__ = [
|
||||||
|
"DownloadJob",
|
||||||
|
"DownloadQueueServiceBase",
|
||||||
|
"DownloadQueueService",
|
||||||
|
"TqdmProgress",
|
||||||
|
"DownloadJobStatus",
|
||||||
|
"UnknownJobIDException",
|
||||||
|
]
|
217
invokeai/app/services/download/download_base.py
Normal file
217
invokeai/app/services/download/download_base.py
Normal file
@ -0,0 +1,217 @@
|
|||||||
|
# Copyright (c) 2023 Lincoln D. Stein and the InvokeAI Development Team
|
||||||
|
"""Model download service."""
|
||||||
|
|
||||||
|
from abc import ABC, abstractmethod
|
||||||
|
from enum import Enum
|
||||||
|
from functools import total_ordering
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Callable, List, Optional
|
||||||
|
|
||||||
|
from pydantic import BaseModel, Field, PrivateAttr
|
||||||
|
from pydantic.networks import AnyHttpUrl
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadJobStatus(str, Enum):
|
||||||
|
"""State of a download job."""
|
||||||
|
|
||||||
|
WAITING = "waiting" # not enqueued, will not run
|
||||||
|
RUNNING = "running" # actively downloading
|
||||||
|
COMPLETED = "completed" # finished running
|
||||||
|
CANCELLED = "cancelled" # user cancelled
|
||||||
|
ERROR = "error" # terminated with an error message
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadJobCancelledException(Exception):
|
||||||
|
"""This exception is raised when a download job is cancelled."""
|
||||||
|
|
||||||
|
|
||||||
|
class UnknownJobIDException(Exception):
|
||||||
|
"""This exception is raised when an invalid job id is referened."""
|
||||||
|
|
||||||
|
|
||||||
|
class ServiceInactiveException(Exception):
|
||||||
|
"""This exception is raised when user attempts to initiate a download before the service is started."""
|
||||||
|
|
||||||
|
|
||||||
|
DownloadEventHandler = Callable[["DownloadJob"], None]
|
||||||
|
|
||||||
|
|
||||||
|
@total_ordering
|
||||||
|
class DownloadJob(BaseModel):
|
||||||
|
"""Class to monitor and control a model download request."""
|
||||||
|
|
||||||
|
# required variables to be passed in on creation
|
||||||
|
source: AnyHttpUrl = Field(description="Where to download from. Specific types specified in child classes.")
|
||||||
|
dest: Path = Field(description="Destination of downloaded model on local disk; a directory or file path")
|
||||||
|
access_token: Optional[str] = Field(default=None, description="authorization token for protected resources")
|
||||||
|
# automatically assigned on creation
|
||||||
|
id: int = Field(description="Numeric ID of this job", default=-1) # default id is a sentinel
|
||||||
|
priority: int = Field(default=10, description="Queue priority; lower values are higher priority")
|
||||||
|
|
||||||
|
# set internally during download process
|
||||||
|
status: DownloadJobStatus = Field(default=DownloadJobStatus.WAITING, description="Status of the download")
|
||||||
|
download_path: Optional[Path] = Field(default=None, description="Final location of downloaded file")
|
||||||
|
job_started: Optional[str] = Field(default=None, description="Timestamp for when the download job started")
|
||||||
|
job_ended: Optional[str] = Field(
|
||||||
|
default=None, description="Timestamp for when the download job ende1d (completed or errored)"
|
||||||
|
)
|
||||||
|
bytes: int = Field(default=0, description="Bytes downloaded so far")
|
||||||
|
total_bytes: int = Field(default=0, description="Total file size (bytes)")
|
||||||
|
|
||||||
|
# set when an error occurs
|
||||||
|
error_type: Optional[str] = Field(default=None, description="Name of exception that caused an error")
|
||||||
|
error: Optional[str] = Field(default=None, description="Traceback of the exception that caused an error")
|
||||||
|
|
||||||
|
# internal flag
|
||||||
|
_cancelled: bool = PrivateAttr(default=False)
|
||||||
|
|
||||||
|
# optional event handlers passed in on creation
|
||||||
|
_on_start: Optional[DownloadEventHandler] = PrivateAttr(default=None)
|
||||||
|
_on_progress: Optional[DownloadEventHandler] = PrivateAttr(default=None)
|
||||||
|
_on_complete: Optional[DownloadEventHandler] = PrivateAttr(default=None)
|
||||||
|
_on_cancelled: Optional[DownloadEventHandler] = PrivateAttr(default=None)
|
||||||
|
_on_error: Optional[DownloadEventHandler] = PrivateAttr(default=None)
|
||||||
|
|
||||||
|
def __le__(self, other: "DownloadJob") -> bool:
|
||||||
|
"""Return True if this job's priority is less than another's."""
|
||||||
|
return self.priority <= other.priority
|
||||||
|
|
||||||
|
def cancel(self) -> None:
|
||||||
|
"""Call to cancel the job."""
|
||||||
|
self._cancelled = True
|
||||||
|
|
||||||
|
# cancelled and the callbacks are private attributes in order to prevent
|
||||||
|
# them from being serialized and/or used in the Json Schema
|
||||||
|
@property
|
||||||
|
def cancelled(self) -> bool:
|
||||||
|
"""Call to cancel the job."""
|
||||||
|
return self._cancelled
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_start(self) -> Optional[DownloadEventHandler]:
|
||||||
|
"""Return the on_start event handler."""
|
||||||
|
return self._on_start
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_progress(self) -> Optional[DownloadEventHandler]:
|
||||||
|
"""Return the on_progress event handler."""
|
||||||
|
return self._on_progress
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_complete(self) -> Optional[DownloadEventHandler]:
|
||||||
|
"""Return the on_complete event handler."""
|
||||||
|
return self._on_complete
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_error(self) -> Optional[DownloadEventHandler]:
|
||||||
|
"""Return the on_error event handler."""
|
||||||
|
return self._on_error
|
||||||
|
|
||||||
|
@property
|
||||||
|
def on_cancelled(self) -> Optional[DownloadEventHandler]:
|
||||||
|
"""Return the on_cancelled event handler."""
|
||||||
|
return self._on_cancelled
|
||||||
|
|
||||||
|
def set_callbacks(
|
||||||
|
self,
|
||||||
|
on_start: Optional[DownloadEventHandler] = None,
|
||||||
|
on_progress: Optional[DownloadEventHandler] = None,
|
||||||
|
on_complete: Optional[DownloadEventHandler] = None,
|
||||||
|
on_cancelled: Optional[DownloadEventHandler] = None,
|
||||||
|
on_error: Optional[DownloadEventHandler] = None,
|
||||||
|
) -> None:
|
||||||
|
"""Set the callbacks for download events."""
|
||||||
|
self._on_start = on_start
|
||||||
|
self._on_progress = on_progress
|
||||||
|
self._on_complete = on_complete
|
||||||
|
self._on_error = on_error
|
||||||
|
self._on_cancelled = on_cancelled
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueueServiceBase(ABC):
|
||||||
|
"""Multithreaded queue for downloading models via URL."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def start(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
"""Start the download worker threads."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def stop(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
"""Stop the download worker threads."""
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
source: AnyHttpUrl,
|
||||||
|
dest: Path,
|
||||||
|
priority: int = 10,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
on_start: Optional[DownloadEventHandler] = None,
|
||||||
|
on_progress: Optional[DownloadEventHandler] = None,
|
||||||
|
on_complete: Optional[DownloadEventHandler] = None,
|
||||||
|
on_cancelled: Optional[DownloadEventHandler] = None,
|
||||||
|
on_error: Optional[DownloadEventHandler] = None,
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""
|
||||||
|
Create a download job.
|
||||||
|
|
||||||
|
:param source: Source of the download as a URL.
|
||||||
|
:param dest: Path to download to. See below.
|
||||||
|
:param on_start, on_progress, on_complete, on_error: Callbacks for the indicated
|
||||||
|
events.
|
||||||
|
:returns: A DownloadJob object for monitoring the state of the download.
|
||||||
|
|
||||||
|
The `dest` argument is a Path object. Its behavior is:
|
||||||
|
|
||||||
|
1. If the path exists and is a directory, then the URL contents will be downloaded
|
||||||
|
into that directory using the filename indicated in the response's `Content-Disposition` field.
|
||||||
|
If no content-disposition is present, then the last component of the URL will be used (similar to
|
||||||
|
wget's behavior).
|
||||||
|
2. If the path does not exist, then it is taken as the name of a new file to create with the downloaded
|
||||||
|
content.
|
||||||
|
3. If the path exists and is an existing file, then the downloader will try to resume the download from
|
||||||
|
the end of the existing file.
|
||||||
|
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def list_jobs(self) -> List[DownloadJob]:
|
||||||
|
"""
|
||||||
|
List active download jobs.
|
||||||
|
|
||||||
|
:returns List[DownloadJob]: List of download jobs whose state is not "completed."
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def id_to_job(self, id: int) -> DownloadJob:
|
||||||
|
"""
|
||||||
|
Return the DownloadJob corresponding to the integer ID.
|
||||||
|
|
||||||
|
:param id: ID of the DownloadJob.
|
||||||
|
|
||||||
|
Exceptions:
|
||||||
|
* UnknownJobIDException
|
||||||
|
"""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def cancel_all_jobs(self):
|
||||||
|
"""Cancel all active and enquedjobs."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def prune_jobs(self):
|
||||||
|
"""Prune completed and errored queue items from the job list."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def cancel_job(self, job: DownloadJob):
|
||||||
|
"""Cancel the job, clearing partial downloads and putting it into ERROR state."""
|
||||||
|
pass
|
||||||
|
|
||||||
|
@abstractmethod
|
||||||
|
def join(self):
|
||||||
|
"""Wait until all jobs are off the queue."""
|
||||||
|
pass
|
418
invokeai/app/services/download/download_default.py
Normal file
418
invokeai/app/services/download/download_default.py
Normal file
@ -0,0 +1,418 @@
|
|||||||
|
# Copyright (c) 2023, Lincoln D. Stein
|
||||||
|
"""Implementation of multithreaded download queue for invokeai."""
|
||||||
|
|
||||||
|
import os
|
||||||
|
import re
|
||||||
|
import threading
|
||||||
|
import traceback
|
||||||
|
from logging import Logger
|
||||||
|
from pathlib import Path
|
||||||
|
from queue import Empty, PriorityQueue
|
||||||
|
from typing import Any, Dict, List, Optional, Set
|
||||||
|
|
||||||
|
import requests
|
||||||
|
from pydantic.networks import AnyHttpUrl
|
||||||
|
from requests import HTTPError
|
||||||
|
from tqdm import tqdm
|
||||||
|
|
||||||
|
from invokeai.app.services.events.events_base import EventServiceBase
|
||||||
|
from invokeai.app.util.misc import get_iso_timestamp
|
||||||
|
from invokeai.backend.util.logging import InvokeAILogger
|
||||||
|
|
||||||
|
from .download_base import (
|
||||||
|
DownloadEventHandler,
|
||||||
|
DownloadJob,
|
||||||
|
DownloadJobCancelledException,
|
||||||
|
DownloadJobStatus,
|
||||||
|
DownloadQueueServiceBase,
|
||||||
|
ServiceInactiveException,
|
||||||
|
UnknownJobIDException,
|
||||||
|
)
|
||||||
|
|
||||||
|
# Maximum number of bytes to download during each call to requests.iter_content()
|
||||||
|
DOWNLOAD_CHUNK_SIZE = 100000
|
||||||
|
|
||||||
|
|
||||||
|
class DownloadQueueService(DownloadQueueServiceBase):
|
||||||
|
"""Class for queued download of models."""
|
||||||
|
|
||||||
|
_jobs: Dict[int, DownloadJob]
|
||||||
|
_max_parallel_dl: int = 5
|
||||||
|
_worker_pool: Set[threading.Thread]
|
||||||
|
_queue: PriorityQueue[DownloadJob]
|
||||||
|
_stop_event: threading.Event
|
||||||
|
_lock: threading.Lock
|
||||||
|
_logger: Logger
|
||||||
|
_events: Optional[EventServiceBase] = None
|
||||||
|
_next_job_id: int = 0
|
||||||
|
_accept_download_requests: bool = False
|
||||||
|
_requests: requests.sessions.Session
|
||||||
|
|
||||||
|
def __init__(
|
||||||
|
self,
|
||||||
|
max_parallel_dl: int = 5,
|
||||||
|
event_bus: Optional[EventServiceBase] = None,
|
||||||
|
requests_session: Optional[requests.sessions.Session] = None,
|
||||||
|
):
|
||||||
|
"""
|
||||||
|
Initialize DownloadQueue.
|
||||||
|
|
||||||
|
:param max_parallel_dl: Number of simultaneous downloads allowed [5].
|
||||||
|
:param requests_session: Optional requests.sessions.Session object, for unit tests.
|
||||||
|
"""
|
||||||
|
self._jobs = {}
|
||||||
|
self._next_job_id = 0
|
||||||
|
self._queue = PriorityQueue()
|
||||||
|
self._stop_event = threading.Event()
|
||||||
|
self._worker_pool = set()
|
||||||
|
self._lock = threading.Lock()
|
||||||
|
self._logger = InvokeAILogger.get_logger("DownloadQueueService")
|
||||||
|
self._event_bus = event_bus
|
||||||
|
self._requests = requests_session or requests.Session()
|
||||||
|
self._accept_download_requests = False
|
||||||
|
self._max_parallel_dl = max_parallel_dl
|
||||||
|
|
||||||
|
def start(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
"""Start the download worker threads."""
|
||||||
|
with self._lock:
|
||||||
|
if self._worker_pool:
|
||||||
|
raise Exception("Attempt to start the download service twice")
|
||||||
|
self._stop_event.clear()
|
||||||
|
self._start_workers(self._max_parallel_dl)
|
||||||
|
self._accept_download_requests = True
|
||||||
|
|
||||||
|
def stop(self, *args: Any, **kwargs: Any) -> None:
|
||||||
|
"""Stop the download worker threads."""
|
||||||
|
with self._lock:
|
||||||
|
if not self._worker_pool:
|
||||||
|
raise Exception("Attempt to stop the download service before it was started")
|
||||||
|
self._accept_download_requests = False # reject attempts to add new jobs to queue
|
||||||
|
queued_jobs = [x for x in self.list_jobs() if x.status == DownloadJobStatus.WAITING]
|
||||||
|
active_jobs = [x for x in self.list_jobs() if x.status == DownloadJobStatus.RUNNING]
|
||||||
|
if queued_jobs:
|
||||||
|
self._logger.warning(f"Cancelling {len(queued_jobs)} queued downloads")
|
||||||
|
if active_jobs:
|
||||||
|
self._logger.info(f"Waiting for {len(active_jobs)} active download jobs to complete")
|
||||||
|
with self._queue.mutex:
|
||||||
|
self._queue.queue.clear()
|
||||||
|
self.join() # wait for all active jobs to finish
|
||||||
|
self._stop_event.set()
|
||||||
|
self._worker_pool.clear()
|
||||||
|
|
||||||
|
def download(
|
||||||
|
self,
|
||||||
|
source: AnyHttpUrl,
|
||||||
|
dest: Path,
|
||||||
|
priority: int = 10,
|
||||||
|
access_token: Optional[str] = None,
|
||||||
|
on_start: Optional[DownloadEventHandler] = None,
|
||||||
|
on_progress: Optional[DownloadEventHandler] = None,
|
||||||
|
on_complete: Optional[DownloadEventHandler] = None,
|
||||||
|
on_cancelled: Optional[DownloadEventHandler] = None,
|
||||||
|
on_error: Optional[DownloadEventHandler] = None,
|
||||||
|
) -> DownloadJob:
|
||||||
|
"""Create a download job and return its ID."""
|
||||||
|
if not self._accept_download_requests:
|
||||||
|
raise ServiceInactiveException(
|
||||||
|
"The download service is not currently accepting requests. Please call start() to initialize the service."
|
||||||
|
)
|
||||||
|
with self._lock:
|
||||||
|
id = self._next_job_id
|
||||||
|
self._next_job_id += 1
|
||||||
|
job = DownloadJob(
|
||||||
|
id=id,
|
||||||
|
source=source,
|
||||||
|
dest=dest,
|
||||||
|
priority=priority,
|
||||||
|
access_token=access_token,
|
||||||
|
)
|
||||||
|
job.set_callbacks(
|
||||||
|
on_start=on_start,
|
||||||
|
on_progress=on_progress,
|
||||||
|
on_complete=on_complete,
|
||||||
|
on_cancelled=on_cancelled,
|
||||||
|
on_error=on_error,
|
||||||
|
)
|
||||||
|
self._jobs[id] = job
|
||||||
|
self._queue.put(job)
|
||||||
|
return job
|
||||||
|
|
||||||
|
def join(self) -> None:
|
||||||
|
"""Wait for all jobs to complete."""
|
||||||
|
self._queue.join()
|
||||||
|
|
||||||
|
def list_jobs(self) -> List[DownloadJob]:
|
||||||
|
"""List all the jobs."""
|
||||||
|
return list(self._jobs.values())
|
||||||
|
|
||||||
|
def prune_jobs(self) -> None:
|
||||||
|
"""Prune completed and errored queue items from the job list."""
|
||||||
|
with self._lock:
|
||||||
|
to_delete = set()
|
||||||
|
for job_id, job in self._jobs.items():
|
||||||
|
if self._in_terminal_state(job):
|
||||||
|
to_delete.add(job_id)
|
||||||
|
for job_id in to_delete:
|
||||||
|
del self._jobs[job_id]
|
||||||
|
|
||||||
|
def id_to_job(self, id: int) -> DownloadJob:
|
||||||
|
"""Translate a job ID into a DownloadJob object."""
|
||||||
|
try:
|
||||||
|
return self._jobs[id]
|
||||||
|
except KeyError as excp:
|
||||||
|
raise UnknownJobIDException("Unrecognized job") from excp
|
||||||
|
|
||||||
|
def cancel_job(self, job: DownloadJob) -> None:
|
||||||
|
"""
|
||||||
|
Cancel the indicated job.
|
||||||
|
|
||||||
|
If it is running it will be stopped.
|
||||||
|
job.status will be set to DownloadJobStatus.CANCELLED
|
||||||
|
"""
|
||||||
|
with self._lock:
|
||||||
|
job.cancel()
|
||||||
|
|
||||||
|
def cancel_all_jobs(self, preserve_partial: bool = False) -> None:
|
||||||
|
"""Cancel all jobs (those not in enqueued, running or paused state)."""
|
||||||
|
for job in self._jobs.values():
|
||||||
|
if not self._in_terminal_state(job):
|
||||||
|
self.cancel_job(job)
|
||||||
|
|
||||||
|
def _in_terminal_state(self, job: DownloadJob) -> bool:
|
||||||
|
return job.status in [
|
||||||
|
DownloadJobStatus.COMPLETED,
|
||||||
|
DownloadJobStatus.CANCELLED,
|
||||||
|
DownloadJobStatus.ERROR,
|
||||||
|
]
|
||||||
|
|
||||||
|
def _start_workers(self, max_workers: int) -> None:
|
||||||
|
"""Start the requested number of worker threads."""
|
||||||
|
self._stop_event.clear()
|
||||||
|
for i in range(0, max_workers): # noqa B007
|
||||||
|
worker = threading.Thread(target=self._download_next_item, daemon=True)
|
||||||
|
self._logger.debug(f"Download queue worker thread {worker.name} starting.")
|
||||||
|
worker.start()
|
||||||
|
self._worker_pool.add(worker)
|
||||||
|
|
||||||
|
def _download_next_item(self) -> None:
|
||||||
|
"""Worker thread gets next job on priority queue."""
|
||||||
|
done = False
|
||||||
|
while not done:
|
||||||
|
if self._stop_event.is_set():
|
||||||
|
done = True
|
||||||
|
continue
|
||||||
|
try:
|
||||||
|
job = self._queue.get(timeout=1)
|
||||||
|
except Empty:
|
||||||
|
continue
|
||||||
|
|
||||||
|
try:
|
||||||
|
job.job_started = get_iso_timestamp()
|
||||||
|
self._do_download(job)
|
||||||
|
self._signal_job_complete(job)
|
||||||
|
|
||||||
|
except (OSError, HTTPError) as excp:
|
||||||
|
job.error_type = excp.__class__.__name__ + f"({str(excp)})"
|
||||||
|
job.error = traceback.format_exc()
|
||||||
|
self._signal_job_error(job)
|
||||||
|
except DownloadJobCancelledException:
|
||||||
|
self._signal_job_cancelled(job)
|
||||||
|
self._cleanup_cancelled_job(job)
|
||||||
|
|
||||||
|
finally:
|
||||||
|
job.job_ended = get_iso_timestamp()
|
||||||
|
self._queue.task_done()
|
||||||
|
self._logger.debug(f"Download queue worker thread {threading.current_thread().name} exiting.")
|
||||||
|
|
||||||
|
def _do_download(self, job: DownloadJob) -> None:
|
||||||
|
"""Do the actual download."""
|
||||||
|
url = job.source
|
||||||
|
header = {"Authorization": f"Bearer {job.access_token}"} if job.access_token else {}
|
||||||
|
open_mode = "wb"
|
||||||
|
|
||||||
|
# Make a streaming request. This will retrieve headers including
|
||||||
|
# content-length and content-disposition, but not fetch any content itself
|
||||||
|
resp = self._requests.get(str(url), headers=header, stream=True)
|
||||||
|
if not resp.ok:
|
||||||
|
raise HTTPError(resp.reason)
|
||||||
|
content_length = int(resp.headers.get("content-length", 0))
|
||||||
|
job.total_bytes = content_length
|
||||||
|
|
||||||
|
if job.dest.is_dir():
|
||||||
|
file_name = os.path.basename(str(url.path)) # default is to use the last bit of the URL
|
||||||
|
|
||||||
|
if match := re.search('filename="(.+)"', resp.headers.get("Content-Disposition", "")):
|
||||||
|
remote_name = match.group(1)
|
||||||
|
if self._validate_filename(job.dest.as_posix(), remote_name):
|
||||||
|
file_name = remote_name
|
||||||
|
|
||||||
|
job.download_path = job.dest / file_name
|
||||||
|
|
||||||
|
else:
|
||||||
|
job.dest.parent.mkdir(parents=True, exist_ok=True)
|
||||||
|
job.download_path = job.dest
|
||||||
|
|
||||||
|
assert job.download_path
|
||||||
|
|
||||||
|
# Don't clobber an existing file. See commit 82c2c85202f88c6d24ff84710f297cfc6ae174af
|
||||||
|
# for code that instead resumes an interrupted download.
|
||||||
|
if job.download_path.exists():
|
||||||
|
raise OSError(f"[Errno 17] File {job.download_path} exists")
|
||||||
|
|
||||||
|
# append ".downloading" to the path
|
||||||
|
in_progress_path = self._in_progress_path(job.download_path)
|
||||||
|
|
||||||
|
# signal caller that the download is starting. At this point, key fields such as
|
||||||
|
# download_path and total_bytes will be populated. We call it here because the might
|
||||||
|
# discover that the local file is already complete and generate a COMPLETED status.
|
||||||
|
self._signal_job_started(job)
|
||||||
|
|
||||||
|
# "range not satisfiable" - local file is at least as large as the remote file
|
||||||
|
if resp.status_code == 416 or (content_length > 0 and job.bytes >= content_length):
|
||||||
|
self._logger.warning(f"{job.download_path}: complete file found. Skipping.")
|
||||||
|
return
|
||||||
|
|
||||||
|
# "partial content" - local file is smaller than remote file
|
||||||
|
elif resp.status_code == 206 or job.bytes > 0:
|
||||||
|
self._logger.warning(f"{job.download_path}: partial file found. Resuming")
|
||||||
|
|
||||||
|
# some other error
|
||||||
|
elif resp.status_code != 200:
|
||||||
|
raise HTTPError(resp.reason)
|
||||||
|
|
||||||
|
self._logger.debug(f"{job.source}: Downloading {job.download_path}")
|
||||||
|
report_delta = job.total_bytes / 100 # report every 1% change
|
||||||
|
last_report_bytes = 0
|
||||||
|
|
||||||
|
# DOWNLOAD LOOP
|
||||||
|
with open(in_progress_path, open_mode) as file:
|
||||||
|
for data in resp.iter_content(chunk_size=DOWNLOAD_CHUNK_SIZE):
|
||||||
|
if job.cancelled:
|
||||||
|
raise DownloadJobCancelledException("Job was cancelled at caller's request")
|
||||||
|
|
||||||
|
job.bytes += file.write(data)
|
||||||
|
if (job.bytes - last_report_bytes >= report_delta) or (job.bytes >= job.total_bytes):
|
||||||
|
last_report_bytes = job.bytes
|
||||||
|
self._signal_job_progress(job)
|
||||||
|
|
||||||
|
# if we get here we are done and can rename the file to the original dest
|
||||||
|
in_progress_path.rename(job.download_path)
|
||||||
|
|
||||||
|
def _validate_filename(self, directory: str, filename: str) -> bool:
|
||||||
|
pc_name_max = os.pathconf(directory, "PC_NAME_MAX") if hasattr(os, "pathconf") else 260 # hardcoded for windows
|
||||||
|
pc_path_max = (
|
||||||
|
os.pathconf(directory, "PC_PATH_MAX") if hasattr(os, "pathconf") else 32767
|
||||||
|
) # hardcoded for windows with long names enabled
|
||||||
|
if "/" in filename:
|
||||||
|
return False
|
||||||
|
if filename.startswith(".."):
|
||||||
|
return False
|
||||||
|
if len(filename) > pc_name_max:
|
||||||
|
return False
|
||||||
|
if len(os.path.join(directory, filename)) > pc_path_max:
|
||||||
|
return False
|
||||||
|
return True
|
||||||
|
|
||||||
|
def _in_progress_path(self, path: Path) -> Path:
|
||||||
|
return path.with_name(path.name + ".downloading")
|
||||||
|
|
||||||
|
def _signal_job_started(self, job: DownloadJob) -> None:
|
||||||
|
job.status = DownloadJobStatus.RUNNING
|
||||||
|
if job.on_start:
|
||||||
|
try:
|
||||||
|
job.on_start(job)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(e)
|
||||||
|
if self._event_bus:
|
||||||
|
assert job.download_path
|
||||||
|
self._event_bus.emit_download_started(str(job.source), job.download_path.as_posix())
|
||||||
|
|
||||||
|
def _signal_job_progress(self, job: DownloadJob) -> None:
|
||||||
|
if job.on_progress:
|
||||||
|
try:
|
||||||
|
job.on_progress(job)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(e)
|
||||||
|
if self._event_bus:
|
||||||
|
assert job.download_path
|
||||||
|
self._event_bus.emit_download_progress(
|
||||||
|
str(job.source),
|
||||||
|
download_path=job.download_path.as_posix(),
|
||||||
|
current_bytes=job.bytes,
|
||||||
|
total_bytes=job.total_bytes,
|
||||||
|
)
|
||||||
|
|
||||||
|
def _signal_job_complete(self, job: DownloadJob) -> None:
|
||||||
|
job.status = DownloadJobStatus.COMPLETED
|
||||||
|
if job.on_complete:
|
||||||
|
try:
|
||||||
|
job.on_complete(job)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(e)
|
||||||
|
if self._event_bus:
|
||||||
|
assert job.download_path
|
||||||
|
self._event_bus.emit_download_complete(
|
||||||
|
str(job.source), download_path=job.download_path.as_posix(), total_bytes=job.total_bytes
|
||||||
|
)
|
||||||
|
|
||||||
|
def _signal_job_cancelled(self, job: DownloadJob) -> None:
|
||||||
|
job.status = DownloadJobStatus.CANCELLED
|
||||||
|
if job.on_cancelled:
|
||||||
|
try:
|
||||||
|
job.on_cancelled(job)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(e)
|
||||||
|
if self._event_bus:
|
||||||
|
self._event_bus.emit_download_cancelled(str(job.source))
|
||||||
|
|
||||||
|
def _signal_job_error(self, job: DownloadJob) -> None:
|
||||||
|
job.status = DownloadJobStatus.ERROR
|
||||||
|
if job.on_error:
|
||||||
|
try:
|
||||||
|
job.on_error(job)
|
||||||
|
except Exception as e:
|
||||||
|
self._logger.error(e)
|
||||||
|
if self._event_bus:
|
||||||
|
assert job.error_type
|
||||||
|
assert job.error
|
||||||
|
self._event_bus.emit_download_error(str(job.source), error_type=job.error_type, error=job.error)
|
||||||
|
|
||||||
|
def _cleanup_cancelled_job(self, job: DownloadJob) -> None:
|
||||||
|
self._logger.warning(f"Cleaning up leftover files from cancelled download job {job.download_path}")
|
||||||
|
try:
|
||||||
|
if job.download_path:
|
||||||
|
partial_file = self._in_progress_path(job.download_path)
|
||||||
|
partial_file.unlink()
|
||||||
|
except OSError as excp:
|
||||||
|
self._logger.warning(excp)
|
||||||
|
|
||||||
|
|
||||||
|
# Example on_progress event handler to display a TQDM status bar
|
||||||
|
# Activate with:
|
||||||
|
# download_service.download('http://foo.bar/baz', '/tmp', on_progress=TqdmProgress().job_update
|
||||||
|
class TqdmProgress(object):
|
||||||
|
"""TQDM-based progress bar object to use in on_progress handlers."""
|
||||||
|
|
||||||
|
_bars: Dict[int, tqdm] # the tqdm object
|
||||||
|
_last: Dict[int, int] # last bytes downloaded
|
||||||
|
|
||||||
|
def __init__(self) -> None: # noqa D107
|
||||||
|
self._bars = {}
|
||||||
|
self._last = {}
|
||||||
|
|
||||||
|
def update(self, job: DownloadJob) -> None: # noqa D102
|
||||||
|
job_id = job.id
|
||||||
|
# new job
|
||||||
|
if job_id not in self._bars:
|
||||||
|
assert job.download_path
|
||||||
|
dest = Path(job.download_path).name
|
||||||
|
self._bars[job_id] = tqdm(
|
||||||
|
desc=dest,
|
||||||
|
initial=0,
|
||||||
|
total=job.total_bytes,
|
||||||
|
unit="iB",
|
||||||
|
unit_scale=True,
|
||||||
|
)
|
||||||
|
self._last[job_id] = 0
|
||||||
|
self._bars[job_id].update(job.bytes - self._last[job_id])
|
||||||
|
self._last[job_id] = job.bytes
|
@ -17,6 +17,7 @@ from invokeai.backend.model_management.models.base import BaseModelType, ModelTy
|
|||||||
|
|
||||||
class EventServiceBase:
|
class EventServiceBase:
|
||||||
queue_event: str = "queue_event"
|
queue_event: str = "queue_event"
|
||||||
|
download_event: str = "download_event"
|
||||||
model_event: str = "model_event"
|
model_event: str = "model_event"
|
||||||
|
|
||||||
"""Basic event bus, to have an empty stand-in when not needed"""
|
"""Basic event bus, to have an empty stand-in when not needed"""
|
||||||
@ -32,6 +33,13 @@ class EventServiceBase:
|
|||||||
payload={"event": event_name, "data": payload},
|
payload={"event": event_name, "data": payload},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def __emit_download_event(self, event_name: str, payload: dict) -> None:
|
||||||
|
payload["timestamp"] = get_timestamp()
|
||||||
|
self.dispatch(
|
||||||
|
event_name=EventServiceBase.download_event,
|
||||||
|
payload={"event": event_name, "data": payload},
|
||||||
|
)
|
||||||
|
|
||||||
def __emit_model_event(self, event_name: str, payload: dict) -> None:
|
def __emit_model_event(self, event_name: str, payload: dict) -> None:
|
||||||
payload["timestamp"] = get_timestamp()
|
payload["timestamp"] = get_timestamp()
|
||||||
self.dispatch(
|
self.dispatch(
|
||||||
@ -323,6 +331,79 @@ class EventServiceBase:
|
|||||||
payload={"queue_id": queue_id},
|
payload={"queue_id": queue_id},
|
||||||
)
|
)
|
||||||
|
|
||||||
|
def emit_download_started(self, source: str, download_path: str) -> None:
|
||||||
|
"""
|
||||||
|
Emit when a download job is started.
|
||||||
|
|
||||||
|
:param url: The downloaded url
|
||||||
|
"""
|
||||||
|
self.__emit_download_event(
|
||||||
|
event_name="download_started",
|
||||||
|
payload={"source": source, "download_path": download_path},
|
||||||
|
)
|
||||||
|
|
||||||
|
def emit_download_progress(self, source: str, download_path: str, current_bytes: int, total_bytes: int) -> None:
|
||||||
|
"""
|
||||||
|
Emit "download_progress" events at regular intervals during a download job.
|
||||||
|
|
||||||
|
:param source: The downloaded source
|
||||||
|
:param download_path: The local downloaded file
|
||||||
|
:param current_bytes: Number of bytes downloaded so far
|
||||||
|
:param total_bytes: The size of the file being downloaded (if known)
|
||||||
|
"""
|
||||||
|
self.__emit_download_event(
|
||||||
|
event_name="download_progress",
|
||||||
|
payload={
|
||||||
|
"source": source,
|
||||||
|
"download_path": download_path,
|
||||||
|
"current_bytes": current_bytes,
|
||||||
|
"total_bytes": total_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def emit_download_complete(self, source: str, download_path: str, total_bytes: int) -> None:
|
||||||
|
"""
|
||||||
|
Emit a "download_complete" event at the end of a successful download.
|
||||||
|
|
||||||
|
:param source: Source URL
|
||||||
|
:param download_path: Path to the locally downloaded file
|
||||||
|
:param total_bytes: The size of the downloaded file
|
||||||
|
"""
|
||||||
|
self.__emit_download_event(
|
||||||
|
event_name="download_complete",
|
||||||
|
payload={
|
||||||
|
"source": source,
|
||||||
|
"download_path": download_path,
|
||||||
|
"total_bytes": total_bytes,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def emit_download_cancelled(self, source: str) -> None:
|
||||||
|
"""Emit a "download_cancelled" event in the event that the download was cancelled by user."""
|
||||||
|
self.__emit_download_event(
|
||||||
|
event_name="download_cancelled",
|
||||||
|
payload={
|
||||||
|
"source": source,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
def emit_download_error(self, source: str, error_type: str, error: str) -> None:
|
||||||
|
"""
|
||||||
|
Emit a "download_error" event when an download job encounters an exception.
|
||||||
|
|
||||||
|
:param source: Source URL
|
||||||
|
:param error_type: The name of the exception that raised the error
|
||||||
|
:param error: The traceback from this error
|
||||||
|
"""
|
||||||
|
self.__emit_download_event(
|
||||||
|
event_name="download_error",
|
||||||
|
payload={
|
||||||
|
"source": source,
|
||||||
|
"error_type": error_type,
|
||||||
|
"error": error,
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
def emit_model_install_started(self, source: str) -> None:
|
def emit_model_install_started(self, source: str) -> None:
|
||||||
"""
|
"""
|
||||||
Emitted when an install job is started.
|
Emitted when an install job is started.
|
||||||
|
@ -11,6 +11,7 @@ if TYPE_CHECKING:
|
|||||||
from .board_records.board_records_base import BoardRecordStorageBase
|
from .board_records.board_records_base import BoardRecordStorageBase
|
||||||
from .boards.boards_base import BoardServiceABC
|
from .boards.boards_base import BoardServiceABC
|
||||||
from .config import InvokeAIAppConfig
|
from .config import InvokeAIAppConfig
|
||||||
|
from .download import DownloadQueueServiceBase
|
||||||
from .events.events_base import EventServiceBase
|
from .events.events_base import EventServiceBase
|
||||||
from .image_files.image_files_base import ImageFileStorageBase
|
from .image_files.image_files_base import ImageFileStorageBase
|
||||||
from .image_records.image_records_base import ImageRecordStorageBase
|
from .image_records.image_records_base import ImageRecordStorageBase
|
||||||
@ -27,7 +28,7 @@ if TYPE_CHECKING:
|
|||||||
from .names.names_base import NameServiceBase
|
from .names.names_base import NameServiceBase
|
||||||
from .session_processor.session_processor_base import SessionProcessorBase
|
from .session_processor.session_processor_base import SessionProcessorBase
|
||||||
from .session_queue.session_queue_base import SessionQueueBase
|
from .session_queue.session_queue_base import SessionQueueBase
|
||||||
from .shared.graph import GraphExecutionState, LibraryGraph
|
from .shared.graph import GraphExecutionState
|
||||||
from .urls.urls_base import UrlServiceBase
|
from .urls.urls_base import UrlServiceBase
|
||||||
from .workflow_records.workflow_records_base import WorkflowRecordsStorageBase
|
from .workflow_records.workflow_records_base import WorkflowRecordsStorageBase
|
||||||
|
|
||||||
@ -43,7 +44,6 @@ class InvocationServices:
|
|||||||
configuration: "InvokeAIAppConfig"
|
configuration: "InvokeAIAppConfig"
|
||||||
events: "EventServiceBase"
|
events: "EventServiceBase"
|
||||||
graph_execution_manager: "ItemStorageABC[GraphExecutionState]"
|
graph_execution_manager: "ItemStorageABC[GraphExecutionState]"
|
||||||
graph_library: "ItemStorageABC[LibraryGraph]"
|
|
||||||
images: "ImageServiceABC"
|
images: "ImageServiceABC"
|
||||||
image_records: "ImageRecordStorageBase"
|
image_records: "ImageRecordStorageBase"
|
||||||
image_files: "ImageFileStorageBase"
|
image_files: "ImageFileStorageBase"
|
||||||
@ -51,6 +51,7 @@ class InvocationServices:
|
|||||||
logger: "Logger"
|
logger: "Logger"
|
||||||
model_manager: "ModelManagerServiceBase"
|
model_manager: "ModelManagerServiceBase"
|
||||||
model_records: "ModelRecordServiceBase"
|
model_records: "ModelRecordServiceBase"
|
||||||
|
download_queue: "DownloadQueueServiceBase"
|
||||||
model_install: "ModelInstallServiceBase"
|
model_install: "ModelInstallServiceBase"
|
||||||
processor: "InvocationProcessorABC"
|
processor: "InvocationProcessorABC"
|
||||||
performance_statistics: "InvocationStatsServiceBase"
|
performance_statistics: "InvocationStatsServiceBase"
|
||||||
@ -71,7 +72,6 @@ class InvocationServices:
|
|||||||
configuration: "InvokeAIAppConfig",
|
configuration: "InvokeAIAppConfig",
|
||||||
events: "EventServiceBase",
|
events: "EventServiceBase",
|
||||||
graph_execution_manager: "ItemStorageABC[GraphExecutionState]",
|
graph_execution_manager: "ItemStorageABC[GraphExecutionState]",
|
||||||
graph_library: "ItemStorageABC[LibraryGraph]",
|
|
||||||
images: "ImageServiceABC",
|
images: "ImageServiceABC",
|
||||||
image_files: "ImageFileStorageBase",
|
image_files: "ImageFileStorageBase",
|
||||||
image_records: "ImageRecordStorageBase",
|
image_records: "ImageRecordStorageBase",
|
||||||
@ -79,6 +79,7 @@ class InvocationServices:
|
|||||||
logger: "Logger",
|
logger: "Logger",
|
||||||
model_manager: "ModelManagerServiceBase",
|
model_manager: "ModelManagerServiceBase",
|
||||||
model_records: "ModelRecordServiceBase",
|
model_records: "ModelRecordServiceBase",
|
||||||
|
download_queue: "DownloadQueueServiceBase",
|
||||||
model_install: "ModelInstallServiceBase",
|
model_install: "ModelInstallServiceBase",
|
||||||
processor: "InvocationProcessorABC",
|
processor: "InvocationProcessorABC",
|
||||||
performance_statistics: "InvocationStatsServiceBase",
|
performance_statistics: "InvocationStatsServiceBase",
|
||||||
@ -97,7 +98,6 @@ class InvocationServices:
|
|||||||
self.configuration = configuration
|
self.configuration = configuration
|
||||||
self.events = events
|
self.events = events
|
||||||
self.graph_execution_manager = graph_execution_manager
|
self.graph_execution_manager = graph_execution_manager
|
||||||
self.graph_library = graph_library
|
|
||||||
self.images = images
|
self.images = images
|
||||||
self.image_files = image_files
|
self.image_files = image_files
|
||||||
self.image_records = image_records
|
self.image_records = image_records
|
||||||
@ -105,6 +105,7 @@ class InvocationServices:
|
|||||||
self.logger = logger
|
self.logger = logger
|
||||||
self.model_manager = model_manager
|
self.model_manager = model_manager
|
||||||
self.model_records = model_records
|
self.model_records = model_records
|
||||||
|
self.download_queue = download_queue
|
||||||
self.model_install = model_install
|
self.model_install = model_install
|
||||||
self.processor = processor
|
self.processor = processor
|
||||||
self.performance_statistics = performance_statistics
|
self.performance_statistics = performance_statistics
|
||||||
|
@ -11,7 +11,6 @@ from typing_extensions import Annotated
|
|||||||
|
|
||||||
from invokeai.app.services.config import InvokeAIAppConfig
|
from invokeai.app.services.config import InvokeAIAppConfig
|
||||||
from invokeai.app.services.events import EventServiceBase
|
from invokeai.app.services.events import EventServiceBase
|
||||||
from invokeai.app.services.invoker import Invoker
|
|
||||||
from invokeai.app.services.model_records import ModelRecordServiceBase
|
from invokeai.app.services.model_records import ModelRecordServiceBase
|
||||||
from invokeai.backend.model_manager import AnyModelConfig
|
from invokeai.backend.model_manager import AnyModelConfig
|
||||||
|
|
||||||
@ -157,12 +156,12 @@ class ModelInstallServiceBase(ABC):
|
|||||||
:param event_bus: InvokeAI event bus for reporting events to.
|
:param event_bus: InvokeAI event bus for reporting events to.
|
||||||
"""
|
"""
|
||||||
|
|
||||||
def start(self, invoker: Invoker) -> None:
|
@abstractmethod
|
||||||
"""Call at InvokeAI startup time."""
|
def start(self, *args: Any, **kwarg: Any) -> None:
|
||||||
self.sync_to_config()
|
"""Start the installer service."""
|
||||||
|
|
||||||
@abstractmethod
|
@abstractmethod
|
||||||
def stop(self) -> None:
|
def stop(self, *args: Any, **kwarg: Any) -> None:
|
||||||
"""Stop the model install service. After this the objection can be safely deleted."""
|
"""Stop the model install service. After this the objection can be safely deleted."""
|
||||||
|
|
||||||
@property
|
@property
|
||||||
|
@ -71,7 +71,6 @@ class ModelInstallService(ModelInstallServiceBase):
|
|||||||
self._install_queue = Queue()
|
self._install_queue = Queue()
|
||||||
self._cached_model_paths = set()
|
self._cached_model_paths = set()
|
||||||
self._models_installed = set()
|
self._models_installed = set()
|
||||||
self._start_installer_thread()
|
|
||||||
|
|
||||||
@property
|
@property
|
||||||
def app_config(self) -> InvokeAIAppConfig: # noqa D102
|
def app_config(self) -> InvokeAIAppConfig: # noqa D102
|
||||||
@ -85,8 +84,13 @@ class ModelInstallService(ModelInstallServiceBase):
|
|||||||
def event_bus(self) -> Optional[EventServiceBase]: # noqa D102
|
def event_bus(self) -> Optional[EventServiceBase]: # noqa D102
|
||||||
return self._event_bus
|
return self._event_bus
|
||||||
|
|
||||||
def stop(self, *args, **kwargs) -> None:
|
def start(self, *args: Any, **kwarg: Any) -> None:
|
||||||
"""Stop the install thread; after this the object can be deleted and garbage collected."""
|
"""Start the installer thread."""
|
||||||
|
self._start_installer_thread()
|
||||||
|
self.sync_to_config()
|
||||||
|
|
||||||
|
def stop(self, *args: Any, **kwarg: Any) -> None:
|
||||||
|
"""Stop the installer thread; after this the object can be deleted and garbage collected."""
|
||||||
self._install_queue.put(STOP_JOB)
|
self._install_queue.put(STOP_JOB)
|
||||||
|
|
||||||
def _start_installer_thread(self) -> None:
|
def _start_installer_thread(self) -> None:
|
||||||
|
8
invokeai/app/util/ti_utils.py
Normal file
8
invokeai/app/util/ti_utils.py
Normal file
@ -0,0 +1,8 @@
|
|||||||
|
import re
|
||||||
|
|
||||||
|
|
||||||
|
def extract_ti_triggers_from_prompt(prompt: str) -> list[str]:
|
||||||
|
ti_triggers = []
|
||||||
|
for trigger in re.findall(r"<[a-zA-Z0-9., _-]+>", prompt):
|
||||||
|
ti_triggers.append(trigger)
|
||||||
|
return ti_triggers
|
@ -28,7 +28,7 @@ def check_invokeai_root(config: InvokeAIAppConfig):
|
|||||||
print("== STARTUP ABORTED ==")
|
print("== STARTUP ABORTED ==")
|
||||||
print("** One or more necessary files is missing from your InvokeAI root directory **")
|
print("** One or more necessary files is missing from your InvokeAI root directory **")
|
||||||
print("** Please rerun the configuration script to fix this problem. **")
|
print("** Please rerun the configuration script to fix this problem. **")
|
||||||
print("** From the launcher, selection option [7]. **")
|
print("** From the launcher, selection option [6]. **")
|
||||||
print(
|
print(
|
||||||
'** From the command line, activate the virtual environment and run "invokeai-configure --yes --skip-sd-weights" **'
|
'** From the command line, activate the virtual environment and run "invokeai-configure --yes --skip-sd-weights" **'
|
||||||
)
|
)
|
||||||
|
@ -113,7 +113,8 @@
|
|||||||
"orderBy": "Ordinato per",
|
"orderBy": "Ordinato per",
|
||||||
"nextPage": "Pagina successiva",
|
"nextPage": "Pagina successiva",
|
||||||
"saveAs": "Salva come",
|
"saveAs": "Salva come",
|
||||||
"unsaved": "Non salvato"
|
"unsaved": "Non salvato",
|
||||||
|
"direction": "Direzione"
|
||||||
},
|
},
|
||||||
"gallery": {
|
"gallery": {
|
||||||
"generations": "Generazioni",
|
"generations": "Generazioni",
|
||||||
@ -1112,7 +1113,8 @@
|
|||||||
"betaDesc": "Questa invocazione è in versione beta. Fino a quando non sarà stabile, potrebbe subire modifiche importanti durante gli aggiornamenti dell'app. Abbiamo intenzione di supportare questa invocazione a lungo termine.",
|
"betaDesc": "Questa invocazione è in versione beta. Fino a quando non sarà stabile, potrebbe subire modifiche importanti durante gli aggiornamenti dell'app. Abbiamo intenzione di supportare questa invocazione a lungo termine.",
|
||||||
"newWorkflow": "Nuovo flusso di lavoro",
|
"newWorkflow": "Nuovo flusso di lavoro",
|
||||||
"newWorkflowDesc": "Creare un nuovo flusso di lavoro?",
|
"newWorkflowDesc": "Creare un nuovo flusso di lavoro?",
|
||||||
"newWorkflowDesc2": "Il flusso di lavoro attuale presenta modifiche non salvate."
|
"newWorkflowDesc2": "Il flusso di lavoro attuale presenta modifiche non salvate.",
|
||||||
|
"unsupportedAnyOfLength": "unione di troppi elementi ({{count}})"
|
||||||
},
|
},
|
||||||
"boards": {
|
"boards": {
|
||||||
"autoAddBoard": "Aggiungi automaticamente bacheca",
|
"autoAddBoard": "Aggiungi automaticamente bacheca",
|
||||||
|
@ -15,7 +15,7 @@
|
|||||||
"langBrPortuguese": "Português do Brasil",
|
"langBrPortuguese": "Português do Brasil",
|
||||||
"langRussian": "Русский",
|
"langRussian": "Русский",
|
||||||
"langSpanish": "Español",
|
"langSpanish": "Español",
|
||||||
"nodes": "노드",
|
"nodes": "Workflow Editor",
|
||||||
"nodesDesc": "이미지 생성을 위한 노드 기반 시스템은 현재 개발 중입니다. 이 놀라운 기능에 대한 업데이트를 계속 지켜봐 주세요.",
|
"nodesDesc": "이미지 생성을 위한 노드 기반 시스템은 현재 개발 중입니다. 이 놀라운 기능에 대한 업데이트를 계속 지켜봐 주세요.",
|
||||||
"postProcessing": "후처리",
|
"postProcessing": "후처리",
|
||||||
"postProcessDesc2": "보다 진보된 후처리 작업을 위한 전용 UI가 곧 출시될 예정입니다.",
|
"postProcessDesc2": "보다 진보된 후처리 작업을 위한 전용 UI가 곧 출시될 예정입니다.",
|
||||||
@ -25,7 +25,7 @@
|
|||||||
"trainingDesc2": "InvokeAI는 이미 메인 스크립트를 사용한 Textual Inversion를 이용한 Custom embedding 학습을 지원하고 있습니다.",
|
"trainingDesc2": "InvokeAI는 이미 메인 스크립트를 사용한 Textual Inversion를 이용한 Custom embedding 학습을 지원하고 있습니다.",
|
||||||
"upload": "업로드",
|
"upload": "업로드",
|
||||||
"close": "닫기",
|
"close": "닫기",
|
||||||
"load": "로드",
|
"load": "불러오기",
|
||||||
"back": "뒤로 가기",
|
"back": "뒤로 가기",
|
||||||
"statusConnected": "연결됨",
|
"statusConnected": "연결됨",
|
||||||
"statusDisconnected": "연결 끊김",
|
"statusDisconnected": "연결 끊김",
|
||||||
@ -58,7 +58,69 @@
|
|||||||
"statusGeneratingImageToImage": "이미지->이미지 생성",
|
"statusGeneratingImageToImage": "이미지->이미지 생성",
|
||||||
"statusProcessingComplete": "처리 완료",
|
"statusProcessingComplete": "처리 완료",
|
||||||
"statusIterationComplete": "반복(Iteration) 완료",
|
"statusIterationComplete": "반복(Iteration) 완료",
|
||||||
"statusSavingImage": "이미지 저장"
|
"statusSavingImage": "이미지 저장",
|
||||||
|
"t2iAdapter": "T2I 어댑터",
|
||||||
|
"communityLabel": "커뮤니티",
|
||||||
|
"txt2img": "텍스트->이미지",
|
||||||
|
"dontAskMeAgain": "다시 묻지 마세요",
|
||||||
|
"loadingInvokeAI": "Invoke AI 불러오는 중",
|
||||||
|
"checkpoint": "체크포인트",
|
||||||
|
"format": "형식",
|
||||||
|
"unknown": "알려지지 않음",
|
||||||
|
"areYouSure": "확실하나요?",
|
||||||
|
"folder": "폴더",
|
||||||
|
"inpaint": "inpaint",
|
||||||
|
"updated": "업데이트 됨",
|
||||||
|
"on": "켜기",
|
||||||
|
"save": "저장",
|
||||||
|
"langPortuguese": "Português",
|
||||||
|
"created": "생성됨",
|
||||||
|
"nodeEditor": "Node Editor",
|
||||||
|
"error": "에러",
|
||||||
|
"prevPage": "이전 페이지",
|
||||||
|
"ipAdapter": "IP 어댑터",
|
||||||
|
"controlAdapter": "제어 어댑터",
|
||||||
|
"installed": "설치됨",
|
||||||
|
"accept": "수락",
|
||||||
|
"ai": "인공지능",
|
||||||
|
"auto": "자동",
|
||||||
|
"file": "파일",
|
||||||
|
"openInNewTab": "새 탭에서 열기",
|
||||||
|
"delete": "삭제",
|
||||||
|
"template": "템플릿",
|
||||||
|
"cancel": "취소",
|
||||||
|
"controlNet": "컨트롤넷",
|
||||||
|
"outputs": "결과물",
|
||||||
|
"unknownError": "알려지지 않은 에러",
|
||||||
|
"statusProcessing": "처리 중",
|
||||||
|
"linear": "선형",
|
||||||
|
"imageFailedToLoad": "이미지를 로드할 수 없음",
|
||||||
|
"direction": "방향",
|
||||||
|
"data": "데이터",
|
||||||
|
"somethingWentWrong": "뭔가 잘못됐어요",
|
||||||
|
"imagePrompt": "이미지 프롬프트",
|
||||||
|
"modelManager": "Model Manager",
|
||||||
|
"lightMode": "라이트 모드",
|
||||||
|
"safetensors": "Safetensors",
|
||||||
|
"outpaint": "outpaint",
|
||||||
|
"langKorean": "한국어",
|
||||||
|
"orderBy": "정렬 기준",
|
||||||
|
"generate": "생성",
|
||||||
|
"copyError": "$t(gallery.copy) 에러",
|
||||||
|
"learnMore": "더 알아보기",
|
||||||
|
"nextPage": "다음 페이지",
|
||||||
|
"saveAs": "다른 이름으로 저장",
|
||||||
|
"darkMode": "다크 모드",
|
||||||
|
"loading": "불러오는 중",
|
||||||
|
"random": "랜덤",
|
||||||
|
"langHebrew": "Hebrew",
|
||||||
|
"batch": "Batch 매니저",
|
||||||
|
"postprocessing": "후처리",
|
||||||
|
"advanced": "고급",
|
||||||
|
"unsaved": "저장되지 않음",
|
||||||
|
"input": "입력",
|
||||||
|
"details": "세부사항",
|
||||||
|
"notInstalled": "설치되지 않음"
|
||||||
},
|
},
|
||||||
"gallery": {
|
"gallery": {
|
||||||
"showGenerations": "생성된 이미지 보기",
|
"showGenerations": "생성된 이미지 보기",
|
||||||
@ -68,7 +130,35 @@
|
|||||||
"galleryImageSize": "이미지 크기",
|
"galleryImageSize": "이미지 크기",
|
||||||
"galleryImageResetSize": "사이즈 리셋",
|
"galleryImageResetSize": "사이즈 리셋",
|
||||||
"gallerySettings": "갤러리 설정",
|
"gallerySettings": "갤러리 설정",
|
||||||
"maintainAspectRatio": "종횡비 유지"
|
"maintainAspectRatio": "종횡비 유지",
|
||||||
|
"deleteSelection": "선택 항목 삭제",
|
||||||
|
"featuresWillReset": "이 이미지를 삭제하면 해당 기능이 즉시 재설정됩니다.",
|
||||||
|
"deleteImageBin": "삭제된 이미지는 운영 체제의 Bin으로 전송됩니다.",
|
||||||
|
"assets": "자산",
|
||||||
|
"problemDeletingImagesDesc": "하나 이상의 이미지를 삭제할 수 없습니다",
|
||||||
|
"noImagesInGallery": "보여줄 이미지가 없음",
|
||||||
|
"autoSwitchNewImages": "새로운 이미지로 자동 전환",
|
||||||
|
"loading": "불러오는 중",
|
||||||
|
"unableToLoad": "갤러리를 로드할 수 없음",
|
||||||
|
"preparingDownload": "다운로드 준비",
|
||||||
|
"preparingDownloadFailed": "다운로드 준비 중 발생한 문제",
|
||||||
|
"singleColumnLayout": "단일 열 레이아웃",
|
||||||
|
"image": "이미지",
|
||||||
|
"loadMore": "더 불러오기",
|
||||||
|
"drop": "드랍",
|
||||||
|
"problemDeletingImages": "이미지 삭제 중 발생한 문제",
|
||||||
|
"downloadSelection": "선택 항목 다운로드",
|
||||||
|
"deleteImage": "이미지 삭제",
|
||||||
|
"currentlyInUse": "이 이미지는 현재 다음 기능에서 사용되고 있습니다:",
|
||||||
|
"allImagesLoaded": "불러온 모든 이미지",
|
||||||
|
"dropOrUpload": "$t(gallery.drop) 또는 업로드",
|
||||||
|
"copy": "복사",
|
||||||
|
"download": "다운로드",
|
||||||
|
"deleteImagePermanent": "삭제된 이미지는 복원할 수 없습니다.",
|
||||||
|
"noImageSelected": "선택된 이미지 없음",
|
||||||
|
"autoAssignBoardOnClick": "클릭 시 Board로 자동 할당",
|
||||||
|
"setCurrentImage": "현재 이미지로 설정",
|
||||||
|
"dropToUpload": "업로드를 위해 $t(gallery.drop)"
|
||||||
},
|
},
|
||||||
"unifiedCanvas": {
|
"unifiedCanvas": {
|
||||||
"betaPreserveMasked": "마스크 레이어 유지"
|
"betaPreserveMasked": "마스크 레이어 유지"
|
||||||
@ -79,6 +169,752 @@
|
|||||||
"nextImage": "다음 이미지",
|
"nextImage": "다음 이미지",
|
||||||
"mode": "모드",
|
"mode": "모드",
|
||||||
"menu": "메뉴",
|
"menu": "메뉴",
|
||||||
"modelSelect": "모델 선택"
|
"modelSelect": "모델 선택",
|
||||||
|
"zoomIn": "확대하기",
|
||||||
|
"rotateClockwise": "시계방향으로 회전",
|
||||||
|
"uploadImage": "이미지 업로드",
|
||||||
|
"showGalleryPanel": "갤러리 패널 표시",
|
||||||
|
"useThisParameter": "해당 변수 사용",
|
||||||
|
"reset": "리셋",
|
||||||
|
"loadMore": "더 불러오기",
|
||||||
|
"zoomOut": "축소하기",
|
||||||
|
"rotateCounterClockwise": "반시계방향으로 회전",
|
||||||
|
"showOptionsPanel": "사이드 패널 표시",
|
||||||
|
"toggleAutoscroll": "자동 스크롤 전환",
|
||||||
|
"toggleLogViewer": "Log Viewer 전환"
|
||||||
|
},
|
||||||
|
"modelManager": {
|
||||||
|
"pathToCustomConfig": "사용자 지정 구성 경로",
|
||||||
|
"importModels": "모델 가져오기",
|
||||||
|
"availableModels": "사용 가능한 모델",
|
||||||
|
"conversionNotSupported": "변환이 지원되지 않음",
|
||||||
|
"noCustomLocationProvided": "사용자 지정 위치가 제공되지 않음",
|
||||||
|
"onnxModels": "Onnx",
|
||||||
|
"vaeRepoID": "VAE Repo ID",
|
||||||
|
"modelExists": "모델 존재",
|
||||||
|
"custom": "사용자 지정",
|
||||||
|
"addModel": "모델 추가",
|
||||||
|
"none": "없음",
|
||||||
|
"modelConverted": "변환된 모델",
|
||||||
|
"width": "너비",
|
||||||
|
"weightedSum": "가중합",
|
||||||
|
"inverseSigmoid": "Inverse Sigmoid",
|
||||||
|
"invokeAIFolder": "Invoke AI 폴더",
|
||||||
|
"syncModelsDesc": "모델이 백엔드와 동기화되지 않은 경우 이 옵션을 사용하여 새로 고침할 수 있습니다. 이는 일반적으로 응용 프로그램이 부팅된 후 수동으로 모델.yaml 파일을 업데이트하거나 InvokeAI root 폴더에 모델을 추가하는 경우에 유용합니다.",
|
||||||
|
"convert": "변환",
|
||||||
|
"vae": "VAE",
|
||||||
|
"noModels": "모델을 찾을 수 없음",
|
||||||
|
"statusConverting": "변환중",
|
||||||
|
"sigmoid": "Sigmoid",
|
||||||
|
"deleteModel": "모델 삭제",
|
||||||
|
"modelLocation": "모델 위치",
|
||||||
|
"merge": "병합",
|
||||||
|
"v1": "v1",
|
||||||
|
"description": "Description",
|
||||||
|
"modelMergeInterpAddDifferenceHelp": "이 모드에서 모델 3은 먼저 모델 2에서 차감됩니다. 결과 버전은 위에 설정된 Alpha 비율로 모델 1과 혼합됩니다.",
|
||||||
|
"customConfig": "사용자 지정 구성",
|
||||||
|
"cannotUseSpaces": "공백을 사용할 수 없음",
|
||||||
|
"formMessageDiffusersModelLocationDesc": "적어도 하나 이상 입력해 주세요.",
|
||||||
|
"addDiffuserModel": "Diffusers 추가",
|
||||||
|
"search": "검색",
|
||||||
|
"predictionType": "예측 유형(안정 확산 2.x 모델 및 간혹 안정 확산 1.x 모델의 경우)",
|
||||||
|
"widthValidationMsg": "모형의 기본 너비.",
|
||||||
|
"selectAll": "모두 선택",
|
||||||
|
"vaeLocation": "VAE 위치",
|
||||||
|
"selectModel": "모델 선택",
|
||||||
|
"modelAdded": "추가된 모델",
|
||||||
|
"repo_id": "Repo ID",
|
||||||
|
"modelSyncFailed": "모델 동기화 실패",
|
||||||
|
"convertToDiffusersHelpText6": "이 모델을 변환하시겠습니까?",
|
||||||
|
"config": "구성",
|
||||||
|
"quickAdd": "빠른 추가",
|
||||||
|
"selected": "선택된",
|
||||||
|
"modelTwo": "모델 2",
|
||||||
|
"simpleModelDesc": "로컬 Difffusers 모델, 로컬 체크포인트/안전 센서 모델 HuggingFace Repo ID 또는 체크포인트/Diffusers 모델 URL의 경로를 제공합니다.",
|
||||||
|
"customSaveLocation": "사용자 정의 저장 위치",
|
||||||
|
"advanced": "고급",
|
||||||
|
"modelsFound": "발견된 모델",
|
||||||
|
"load": "불러오기",
|
||||||
|
"height": "높이",
|
||||||
|
"modelDeleted": "삭제된 모델",
|
||||||
|
"inpainting": "v1 Inpainting",
|
||||||
|
"vaeLocationValidationMsg": "VAE가 있는 경로.",
|
||||||
|
"convertToDiffusersHelpText2": "이 프로세스는 모델 관리자 항목을 동일한 모델의 Diffusers 버전으로 대체합니다.",
|
||||||
|
"modelUpdateFailed": "모델 업데이트 실패",
|
||||||
|
"modelUpdated": "업데이트된 모델",
|
||||||
|
"noModelsFound": "모델을 찾을 수 없음",
|
||||||
|
"useCustomConfig": "사용자 지정 구성 사용",
|
||||||
|
"formMessageDiffusersVAELocationDesc": "제공되지 않은 경우 호출AIA 파일을 위의 모델 위치 내에서 VAE 파일을 찾습니다.",
|
||||||
|
"formMessageDiffusersVAELocation": "VAE 위치",
|
||||||
|
"checkpointModels": "Checkpoints",
|
||||||
|
"modelOne": "모델 1",
|
||||||
|
"settings": "설정",
|
||||||
|
"heightValidationMsg": "모델의 기본 높이입니다.",
|
||||||
|
"selectAndAdd": "아래 나열된 모델 선택 및 추가",
|
||||||
|
"convertToDiffusersHelpText5": "디스크 공간이 충분한지 확인해 주세요. 모델은 일반적으로 2GB에서 7GB 사이로 다양합니다.",
|
||||||
|
"deleteConfig": "구성 삭제",
|
||||||
|
"deselectAll": "모두 선택 취소",
|
||||||
|
"modelConversionFailed": "모델 변환 실패",
|
||||||
|
"clearCheckpointFolder": "Checkpoint Folder 지우기",
|
||||||
|
"modelEntryDeleted": "모델 항목 삭제",
|
||||||
|
"deleteMsg1": "InvokeAI에서 이 모델을 삭제하시겠습니까?",
|
||||||
|
"syncModels": "동기화 모델",
|
||||||
|
"mergedModelSaveLocation": "위치 저장",
|
||||||
|
"checkpointOrSafetensors": "$t(common.checkpoint) / $t(common.safetensors)",
|
||||||
|
"modelType": "모델 유형",
|
||||||
|
"nameValidationMsg": "모델 이름 입력",
|
||||||
|
"cached": "cached",
|
||||||
|
"modelsMerged": "병합된 모델",
|
||||||
|
"formMessageDiffusersModelLocation": "Diffusers 모델 위치",
|
||||||
|
"modelsMergeFailed": "모델 병합 실패",
|
||||||
|
"convertingModelBegin": "모델 변환 중입니다. 잠시만 기다려 주십시오.",
|
||||||
|
"v2_base": "v2 (512px)",
|
||||||
|
"scanForModels": "모델 검색",
|
||||||
|
"modelLocationValidationMsg": "Diffusers 모델이 저장된 로컬 폴더의 경로 제공",
|
||||||
|
"name": "이름",
|
||||||
|
"selectFolder": "폴더 선택",
|
||||||
|
"updateModel": "모델 업데이트",
|
||||||
|
"addNewModel": "새로운 모델 추가",
|
||||||
|
"customConfigFileLocation": "사용자 지정 구성 파일 위치",
|
||||||
|
"descriptionValidationMsg": "모델에 대한 description 추가",
|
||||||
|
"safetensorModels": "SafeTensors",
|
||||||
|
"convertToDiffusersHelpText1": "이 모델은 🧨 Diffusers 형식으로 변환됩니다.",
|
||||||
|
"modelsSynced": "동기화된 모델",
|
||||||
|
"vaePrecision": "VAE 정밀도",
|
||||||
|
"invokeRoot": "InvokeAI 폴더",
|
||||||
|
"checkpointFolder": "Checkpoint Folder",
|
||||||
|
"mergedModelCustomSaveLocation": "사용자 지정 경로",
|
||||||
|
"mergeModels": "모델 병합",
|
||||||
|
"interpolationType": "Interpolation 타입",
|
||||||
|
"modelMergeHeaderHelp2": "Diffusers만 병합이 가능합니다. 체크포인트 모델 병합을 원하신다면 먼저 Diffusers로 변환해주세요.",
|
||||||
|
"convertToDiffusersSaveLocation": "위치 저장",
|
||||||
|
"deleteMsg2": "모델이 InvokeAI root 폴더에 있으면 디스크에서 모델이 삭제됩니다. 사용자 지정 위치를 사용하는 경우 모델이 디스크에서 삭제되지 않습니다.",
|
||||||
|
"oliveModels": "Olives",
|
||||||
|
"repoIDValidationMsg": "모델의 온라인 저장소",
|
||||||
|
"baseModel": "기본 모델",
|
||||||
|
"scanAgain": "다시 검색",
|
||||||
|
"pickModelType": "모델 유형 선택",
|
||||||
|
"sameFolder": "같은 폴더",
|
||||||
|
"addNew": "New 추가",
|
||||||
|
"manual": "매뉴얼",
|
||||||
|
"convertToDiffusersHelpText3": "디스크의 체크포인트 파일이 InvokeAI root 폴더에 있으면 삭제됩니다. 사용자 지정 위치에 있으면 삭제되지 않습니다.",
|
||||||
|
"addCheckpointModel": "체크포인트 / 안전 센서 모델 추가",
|
||||||
|
"configValidationMsg": "모델의 구성 파일에 대한 경로.",
|
||||||
|
"modelManager": "모델 매니저",
|
||||||
|
"variant": "Variant",
|
||||||
|
"vaeRepoIDValidationMsg": "VAE의 온라인 저장소",
|
||||||
|
"loraModels": "LoRAs",
|
||||||
|
"modelDeleteFailed": "모델을 삭제하지 못했습니다",
|
||||||
|
"convertToDiffusers": "Diffusers로 변환",
|
||||||
|
"allModels": "모든 모델",
|
||||||
|
"modelThree": "모델 3",
|
||||||
|
"findModels": "모델 찾기",
|
||||||
|
"notLoaded": "로드되지 않음",
|
||||||
|
"alpha": "Alpha",
|
||||||
|
"diffusersModels": "Diffusers",
|
||||||
|
"modelMergeAlphaHelp": "Alpha는 모델의 혼합 강도를 제어합니다. Alpha 값이 낮을수록 두 번째 모델의 영향력이 줄어듭니다.",
|
||||||
|
"addDifference": "Difference 추가",
|
||||||
|
"noModelSelected": "선택한 모델 없음",
|
||||||
|
"modelMergeHeaderHelp1": "최대 3개의 다른 모델을 병합하여 필요에 맞는 혼합물을 만들 수 있습니다.",
|
||||||
|
"ignoreMismatch": "선택한 모델 간의 불일치 무시",
|
||||||
|
"v2_768": "v2 (768px)",
|
||||||
|
"convertToDiffusersHelpText4": "이것은 한 번의 과정일 뿐입니다. 컴퓨터 사양에 따라 30-60초 정도 소요될 수 있습니다.",
|
||||||
|
"model": "모델",
|
||||||
|
"addManually": "Manually 추가",
|
||||||
|
"addSelected": "Selected 추가",
|
||||||
|
"mergedModelName": "병합된 모델 이름",
|
||||||
|
"delete": "삭제"
|
||||||
|
},
|
||||||
|
"controlnet": {
|
||||||
|
"amult": "a_mult",
|
||||||
|
"resize": "크기 조정",
|
||||||
|
"showAdvanced": "고급 표시",
|
||||||
|
"contentShuffleDescription": "이미지에서 content 섞기",
|
||||||
|
"bgth": "bg_th",
|
||||||
|
"addT2IAdapter": "$t(common.t2iAdapter) 추가",
|
||||||
|
"pidi": "PIDI",
|
||||||
|
"importImageFromCanvas": "캔버스에서 이미지 가져오기",
|
||||||
|
"lineartDescription": "이미지->lineart 변환",
|
||||||
|
"normalBae": "Normal BAE",
|
||||||
|
"importMaskFromCanvas": "캔버스에서 Mask 가져오기",
|
||||||
|
"hed": "HED",
|
||||||
|
"contentShuffle": "Content Shuffle",
|
||||||
|
"controlNetEnabledT2IDisabled": "$t(common.controlNet) 사용 가능, $t(common.t2iAdapter) 사용 불가능",
|
||||||
|
"ipAdapterModel": "Adapter 모델",
|
||||||
|
"resetControlImage": "Control Image 재설정",
|
||||||
|
"beginEndStepPercent": "Begin / End Step Percentage",
|
||||||
|
"mlsdDescription": "Minimalist Line Segment Detector",
|
||||||
|
"duplicate": "복제",
|
||||||
|
"balanced": "Balanced",
|
||||||
|
"f": "F",
|
||||||
|
"h": "H",
|
||||||
|
"prompt": "프롬프트",
|
||||||
|
"depthMidasDescription": "Midas를 사용하여 Depth map 생성하기",
|
||||||
|
"openPoseDescription": "Openpose를 이용한 사람 포즈 추정",
|
||||||
|
"control": "Control",
|
||||||
|
"resizeMode": "크기 조정 모드",
|
||||||
|
"t2iEnabledControlNetDisabled": "$t(common.t2iAdapter) 사용 가능,$t(common.controlNet) 사용 불가능",
|
||||||
|
"coarse": "Coarse",
|
||||||
|
"weight": "Weight",
|
||||||
|
"selectModel": "모델 선택",
|
||||||
|
"crop": "Crop",
|
||||||
|
"depthMidas": "Depth (Midas)",
|
||||||
|
"w": "W",
|
||||||
|
"processor": "프로세서",
|
||||||
|
"addControlNet": "$t(common.controlNet) 추가",
|
||||||
|
"none": "해당없음",
|
||||||
|
"incompatibleBaseModel": "호환되지 않는 기본 모델:",
|
||||||
|
"enableControlnet": "사용 가능한 ControlNet",
|
||||||
|
"detectResolution": "해상도 탐지",
|
||||||
|
"controlNetT2IMutexDesc": "$t(common.controlNet)와 $t(common.t2iAdapter)는 현재 동시에 지원되지 않습니다.",
|
||||||
|
"pidiDescription": "PIDI image 처리",
|
||||||
|
"mediapipeFace": "Mediapipe Face",
|
||||||
|
"mlsd": "M-LSD",
|
||||||
|
"controlMode": "Control Mode",
|
||||||
|
"fill": "채우기",
|
||||||
|
"cannyDescription": "Canny 모서리 삭제",
|
||||||
|
"addIPAdapter": "$t(common.ipAdapter) 추가",
|
||||||
|
"lineart": "Lineart",
|
||||||
|
"colorMapDescription": "이미지에서 color map을 생성합니다",
|
||||||
|
"lineartAnimeDescription": "Anime-style lineart 처리",
|
||||||
|
"minConfidence": "Min Confidence",
|
||||||
|
"imageResolution": "이미지 해상도",
|
||||||
|
"megaControl": "Mega Control",
|
||||||
|
"depthZoe": "Depth (Zoe)",
|
||||||
|
"colorMap": "색",
|
||||||
|
"lowThreshold": "Low Threshold",
|
||||||
|
"autoConfigure": "프로세서 자동 구성",
|
||||||
|
"highThreshold": "High Threshold",
|
||||||
|
"normalBaeDescription": "Normal BAE 처리",
|
||||||
|
"noneDescription": "처리되지 않음",
|
||||||
|
"saveControlImage": "Control Image 저장",
|
||||||
|
"openPose": "Openpose",
|
||||||
|
"toggleControlNet": "해당 ControlNet으로 전환",
|
||||||
|
"delete": "삭제",
|
||||||
|
"controlAdapter_other": "Control Adapter(s)",
|
||||||
|
"safe": "Safe",
|
||||||
|
"colorMapTileSize": "타일 크기",
|
||||||
|
"lineartAnime": "Lineart Anime",
|
||||||
|
"ipAdapterImageFallback": "IP Adapter Image가 선택되지 않음",
|
||||||
|
"mediapipeFaceDescription": "Mediapipe를 사용하여 Face 탐지",
|
||||||
|
"canny": "Canny",
|
||||||
|
"depthZoeDescription": "Zoe를 사용하여 Depth map 생성하기",
|
||||||
|
"hedDescription": "Holistically-Nested 모서리 탐지",
|
||||||
|
"setControlImageDimensions": "Control Image Dimensions를 W/H로 설정",
|
||||||
|
"scribble": "scribble",
|
||||||
|
"resetIPAdapterImage": "IP Adapter Image 재설정",
|
||||||
|
"handAndFace": "Hand and Face",
|
||||||
|
"enableIPAdapter": "사용 가능한 IP Adapter",
|
||||||
|
"maxFaces": "Max Faces"
|
||||||
|
},
|
||||||
|
"hotkeys": {
|
||||||
|
"toggleGalleryPin": {
|
||||||
|
"title": "Gallery Pin 전환",
|
||||||
|
"desc": "갤러리를 UI에 고정했다가 풉니다"
|
||||||
|
},
|
||||||
|
"toggleSnap": {
|
||||||
|
"desc": "Snap을 Grid로 전환",
|
||||||
|
"title": "Snap 전환"
|
||||||
|
},
|
||||||
|
"setSeed": {
|
||||||
|
"title": "시드 설정",
|
||||||
|
"desc": "현재 이미지의 시드 사용"
|
||||||
|
},
|
||||||
|
"keyboardShortcuts": "키보드 바로 가기",
|
||||||
|
"decreaseGalleryThumbSize": {
|
||||||
|
"desc": "갤러리 미리 보기 크기 축소",
|
||||||
|
"title": "갤러리 이미지 크기 축소"
|
||||||
|
},
|
||||||
|
"previousStagingImage": {
|
||||||
|
"title": "이전 스테이징 이미지",
|
||||||
|
"desc": "이전 스테이징 영역 이미지"
|
||||||
|
},
|
||||||
|
"decreaseBrushSize": {
|
||||||
|
"title": "브러시 크기 줄이기",
|
||||||
|
"desc": "캔버스 브러시/지우개 크기 감소"
|
||||||
|
},
|
||||||
|
"consoleToggle": {
|
||||||
|
"desc": "콘솔 열고 닫기",
|
||||||
|
"title": "콘솔 전환"
|
||||||
|
},
|
||||||
|
"selectBrush": {
|
||||||
|
"desc": "캔버스 브러시를 선택",
|
||||||
|
"title": "브러시 선택"
|
||||||
|
},
|
||||||
|
"upscale": {
|
||||||
|
"desc": "현재 이미지를 업스케일",
|
||||||
|
"title": "업스케일"
|
||||||
|
},
|
||||||
|
"previousImage": {
|
||||||
|
"title": "이전 이미지",
|
||||||
|
"desc": "갤러리에 이전 이미지 표시"
|
||||||
|
},
|
||||||
|
"unifiedCanvasHotkeys": "Unified Canvas Hotkeys",
|
||||||
|
"toggleOptions": {
|
||||||
|
"desc": "옵션 패널을 열고 닫기",
|
||||||
|
"title": "옵션 전환"
|
||||||
|
},
|
||||||
|
"selectEraser": {
|
||||||
|
"title": "지우개 선택",
|
||||||
|
"desc": "캔버스 지우개를 선택"
|
||||||
|
},
|
||||||
|
"setPrompt": {
|
||||||
|
"title": "프롬프트 설정",
|
||||||
|
"desc": "현재 이미지의 프롬프트 사용"
|
||||||
|
},
|
||||||
|
"acceptStagingImage": {
|
||||||
|
"desc": "현재 준비 영역 이미지 허용",
|
||||||
|
"title": "준비 이미지 허용"
|
||||||
|
},
|
||||||
|
"resetView": {
|
||||||
|
"desc": "Canvas View 초기화",
|
||||||
|
"title": "View 초기화"
|
||||||
|
},
|
||||||
|
"hideMask": {
|
||||||
|
"title": "Mask 숨김",
|
||||||
|
"desc": "mask 숨김/숨김 해제"
|
||||||
|
},
|
||||||
|
"pinOptions": {
|
||||||
|
"title": "옵션 고정",
|
||||||
|
"desc": "옵션 패널을 고정"
|
||||||
|
},
|
||||||
|
"toggleGallery": {
|
||||||
|
"desc": "gallery drawer 열기 및 닫기",
|
||||||
|
"title": "Gallery 전환"
|
||||||
|
},
|
||||||
|
"quickToggleMove": {
|
||||||
|
"title": "빠른 토글 이동",
|
||||||
|
"desc": "일시적으로 이동 모드 전환"
|
||||||
|
},
|
||||||
|
"generalHotkeys": "General Hotkeys",
|
||||||
|
"showHideBoundingBox": {
|
||||||
|
"desc": "bounding box 표시 전환",
|
||||||
|
"title": "Bounding box 표시/숨김"
|
||||||
|
},
|
||||||
|
"showInfo": {
|
||||||
|
"desc": "현재 이미지의 metadata 정보 표시",
|
||||||
|
"title": "정보 표시"
|
||||||
|
},
|
||||||
|
"copyToClipboard": {
|
||||||
|
"title": "클립보드로 복사",
|
||||||
|
"desc": "현재 캔버스를 클립보드로 복사"
|
||||||
|
},
|
||||||
|
"restoreFaces": {
|
||||||
|
"title": "Faces 복원",
|
||||||
|
"desc": "현재 이미지 복원"
|
||||||
|
},
|
||||||
|
"fillBoundingBox": {
|
||||||
|
"title": "Bounding Box 채우기",
|
||||||
|
"desc": "bounding box를 브러시 색으로 채웁니다"
|
||||||
|
},
|
||||||
|
"closePanels": {
|
||||||
|
"desc": "열린 panels 닫기",
|
||||||
|
"title": "panels 닫기"
|
||||||
|
},
|
||||||
|
"downloadImage": {
|
||||||
|
"desc": "현재 캔버스 다운로드",
|
||||||
|
"title": "이미지 다운로드"
|
||||||
|
},
|
||||||
|
"setParameters": {
|
||||||
|
"title": "매개 변수 설정",
|
||||||
|
"desc": "현재 이미지의 모든 매개 변수 사용"
|
||||||
|
},
|
||||||
|
"maximizeWorkSpace": {
|
||||||
|
"desc": "패널을 닫고 작업 면적을 극대화",
|
||||||
|
"title": "작업 공간 극대화"
|
||||||
|
},
|
||||||
|
"galleryHotkeys": "Gallery Hotkeys",
|
||||||
|
"cancel": {
|
||||||
|
"desc": "이미지 생성 취소",
|
||||||
|
"title": "취소"
|
||||||
|
},
|
||||||
|
"saveToGallery": {
|
||||||
|
"title": "갤러리에 저장",
|
||||||
|
"desc": "현재 캔버스를 갤러리에 저장"
|
||||||
|
},
|
||||||
|
"eraseBoundingBox": {
|
||||||
|
"desc": "bounding box 영역을 지웁니다",
|
||||||
|
"title": "Bounding Box 지우기"
|
||||||
|
},
|
||||||
|
"nextImage": {
|
||||||
|
"title": "다음 이미지",
|
||||||
|
"desc": "갤러리에 다음 이미지 표시"
|
||||||
|
},
|
||||||
|
"colorPicker": {
|
||||||
|
"desc": "canvas color picker 선택",
|
||||||
|
"title": "Color Picker 선택"
|
||||||
|
},
|
||||||
|
"invoke": {
|
||||||
|
"desc": "이미지 생성",
|
||||||
|
"title": "불러오기"
|
||||||
|
},
|
||||||
|
"sendToImageToImage": {
|
||||||
|
"desc": "현재 이미지를 이미지로 보내기"
|
||||||
|
},
|
||||||
|
"toggleLayer": {
|
||||||
|
"desc": "mask/base layer 선택 전환",
|
||||||
|
"title": "Layer 전환"
|
||||||
|
},
|
||||||
|
"increaseBrushSize": {
|
||||||
|
"title": "브러시 크기 증가",
|
||||||
|
"desc": "캔버스 브러시/지우개 크기 증가"
|
||||||
|
},
|
||||||
|
"appHotkeys": "App Hotkeys",
|
||||||
|
"deleteImage": {
|
||||||
|
"title": "이미지 삭제",
|
||||||
|
"desc": "현재 이미지 삭제"
|
||||||
|
},
|
||||||
|
"moveTool": {
|
||||||
|
"desc": "캔버스 탐색 허용",
|
||||||
|
"title": "툴 옮기기"
|
||||||
|
},
|
||||||
|
"clearMask": {
|
||||||
|
"desc": "전체 mask 제거",
|
||||||
|
"title": "Mask 제거"
|
||||||
|
},
|
||||||
|
"increaseGalleryThumbSize": {
|
||||||
|
"title": "갤러리 이미지 크기 증가",
|
||||||
|
"desc": "갤러리 미리 보기 크기를 늘립니다"
|
||||||
|
},
|
||||||
|
"increaseBrushOpacity": {
|
||||||
|
"desc": "캔버스 브러시의 불투명도를 높입니다",
|
||||||
|
"title": "브러시 불투명도 증가"
|
||||||
|
},
|
||||||
|
"focusPrompt": {
|
||||||
|
"desc": "프롬프트 입력 영역에 초점을 맞춥니다",
|
||||||
|
"title": "프롬프트에 초점 맞추기"
|
||||||
|
},
|
||||||
|
"decreaseBrushOpacity": {
|
||||||
|
"desc": "캔버스 브러시의 불투명도를 줄입니다",
|
||||||
|
"title": "브러시 불투명도 감소"
|
||||||
|
},
|
||||||
|
"nextStagingImage": {
|
||||||
|
"desc": "다음 스테이징 영역 이미지",
|
||||||
|
"title": "다음 스테이징 이미지"
|
||||||
|
},
|
||||||
|
"redoStroke": {
|
||||||
|
"title": "Stroke 다시 실행",
|
||||||
|
"desc": "brush stroke 다시 실행"
|
||||||
|
},
|
||||||
|
"nodesHotkeys": "Nodes Hotkeys",
|
||||||
|
"addNodes": {
|
||||||
|
"desc": "노드 추가 메뉴 열기",
|
||||||
|
"title": "노드 추가"
|
||||||
|
},
|
||||||
|
"toggleViewer": {
|
||||||
|
"desc": "이미지 뷰어 열기 및 닫기",
|
||||||
|
"title": "Viewer 전환"
|
||||||
|
},
|
||||||
|
"undoStroke": {
|
||||||
|
"title": "Stroke 실행 취소",
|
||||||
|
"desc": "brush stroke 실행 취소"
|
||||||
|
},
|
||||||
|
"changeTabs": {
|
||||||
|
"desc": "다른 workspace으로 전환",
|
||||||
|
"title": "탭 바꾸기"
|
||||||
|
},
|
||||||
|
"mergeVisible": {
|
||||||
|
"desc": "캔버스의 보이는 모든 레이어 병합"
|
||||||
|
}
|
||||||
|
},
|
||||||
|
"nodes": {
|
||||||
|
"inputField": "입력 필드",
|
||||||
|
"controlFieldDescription": "노드 간에 전달된 Control 정보입니다.",
|
||||||
|
"latentsFieldDescription": "노드 사이에 Latents를 전달할 수 있습니다.",
|
||||||
|
"denoiseMaskFieldDescription": "노드 간에 Denoise Mask가 전달될 수 있음",
|
||||||
|
"floatCollectionDescription": "실수 컬렉션.",
|
||||||
|
"missingTemplate": "잘못된 노드: {{type}} 유형의 {{node}} 템플릿 누락(설치되지 않으셨나요?)",
|
||||||
|
"outputSchemaNotFound": "Output schema가 발견되지 않음",
|
||||||
|
"ipAdapterPolymorphicDescription": "IP-Adapters 컬렉션.",
|
||||||
|
"latentsPolymorphicDescription": "노드 사이에 Latents를 전달할 수 있습니다.",
|
||||||
|
"colorFieldDescription": "RGBA 색.",
|
||||||
|
"mainModelField": "모델",
|
||||||
|
"ipAdapterCollection": "IP-Adapters 컬렉션",
|
||||||
|
"conditioningCollection": "Conditioning 컬렉션",
|
||||||
|
"maybeIncompatible": "설치된 것과 호환되지 않을 수 있음",
|
||||||
|
"ipAdapterPolymorphic": "IP-Adapter 다형성",
|
||||||
|
"noNodeSelected": "선택한 노드 없음",
|
||||||
|
"addNode": "노드 추가",
|
||||||
|
"hideGraphNodes": "그래프 오버레이 숨기기",
|
||||||
|
"enum": "Enum",
|
||||||
|
"loadWorkflow": "Workflow 불러오기",
|
||||||
|
"integerPolymorphicDescription": "정수 컬렉션.",
|
||||||
|
"noOutputRecorded": "기록된 출력 없음",
|
||||||
|
"conditioningCollectionDescription": "노드 간에 Conditioning을 전달할 수 있습니다.",
|
||||||
|
"colorPolymorphic": "색상 다형성",
|
||||||
|
"colorCodeEdgesHelp": "연결된 필드에 따른 색상 코드 선",
|
||||||
|
"collectionDescription": "해야 할 일",
|
||||||
|
"hideLegendNodes": "필드 유형 범례 숨기기",
|
||||||
|
"addLinearView": "Linear View에 추가",
|
||||||
|
"float": "실수",
|
||||||
|
"targetNodeFieldDoesNotExist": "잘못된 모서리: 대상/입력 필드 {{node}}. {{field}}이(가) 없습니다",
|
||||||
|
"animatedEdges": "애니메이션 모서리",
|
||||||
|
"conditioningPolymorphic": "Conditioning 다형성",
|
||||||
|
"integer": "정수",
|
||||||
|
"colorField": "색",
|
||||||
|
"boardField": "Board",
|
||||||
|
"nodeTemplate": "노드 템플릿",
|
||||||
|
"latentsCollection": "Latents 컬렉션",
|
||||||
|
"nodeOpacity": "노드 불투명도",
|
||||||
|
"sourceNodeDoesNotExist": "잘못된 모서리: 소스/출력 노드 {{node}}이(가) 없습니다",
|
||||||
|
"pickOne": "하나 고르기",
|
||||||
|
"collectionItemDescription": "해야 할 일",
|
||||||
|
"integerDescription": "정수는 소수점이 없는 숫자입니다.",
|
||||||
|
"outputField": "출력 필드",
|
||||||
|
"conditioningPolymorphicDescription": "노드 간에 Conditioning을 전달할 수 있습니다.",
|
||||||
|
"noFieldsLinearview": "Linear View에 추가된 필드 없음",
|
||||||
|
"imagePolymorphic": "이미지 다형성",
|
||||||
|
"nodeSearch": "노드 검색",
|
||||||
|
"imagePolymorphicDescription": "이미지 컬렉션.",
|
||||||
|
"floatPolymorphic": "실수 다형성",
|
||||||
|
"outputFieldInInput": "입력 중 출력필드",
|
||||||
|
"doesNotExist": "존재하지 않음",
|
||||||
|
"ipAdapterCollectionDescription": "IP-Adapters 컬렉션.",
|
||||||
|
"controlCollection": "Control 컬렉션",
|
||||||
|
"inputMayOnlyHaveOneConnection": "입력에 하나의 연결만 있을 수 있습니다",
|
||||||
|
"notes": "메모",
|
||||||
|
"nodeOutputs": "노드 결과물",
|
||||||
|
"currentImageDescription": "Node Editor에 현재 이미지를 표시합니다",
|
||||||
|
"downloadWorkflow": "Workflow JSON 다운로드",
|
||||||
|
"ipAdapter": "IP-Adapter",
|
||||||
|
"integerCollection": "정수 컬렉션",
|
||||||
|
"collectionItem": "컬렉션 아이템",
|
||||||
|
"noConnectionInProgress": "진행중인 연결이 없습니다",
|
||||||
|
"controlCollectionDescription": "노드 간에 전달된 Control 정보입니다.",
|
||||||
|
"noConnectionData": "연결 데이터 없음",
|
||||||
|
"outputFields": "출력 필드",
|
||||||
|
"fieldTypesMustMatch": "필드 유형은 일치해야 합니다",
|
||||||
|
"edge": "Edge",
|
||||||
|
"inputNode": "입력 노드",
|
||||||
|
"enumDescription": "Enums은 여러 옵션 중 하나일 수 있는 값입니다.",
|
||||||
|
"sourceNodeFieldDoesNotExist": "잘못된 모서리: 소스/출력 필드 {{node}}. {{field}}이(가) 없습니다",
|
||||||
|
"loRAModelFieldDescription": "해야 할 일",
|
||||||
|
"imageField": "이미지",
|
||||||
|
"animatedEdgesHelp": "선택한 노드에 연결된 선택한 가장자리 및 가장자리를 애니메이션화합니다",
|
||||||
|
"cannotDuplicateConnection": "중복 연결을 만들 수 없습니다",
|
||||||
|
"booleanPolymorphic": "Boolean 다형성",
|
||||||
|
"noWorkflow": "Workflow 없음",
|
||||||
|
"colorCollectionDescription": "해야 할 일",
|
||||||
|
"integerCollectionDescription": "정수 컬렉션.",
|
||||||
|
"colorPolymorphicDescription": "색의 컬렉션.",
|
||||||
|
"denoiseMaskField": "Denoise Mask",
|
||||||
|
"missingCanvaInitImage": "캔버스 init 이미지 누락",
|
||||||
|
"conditioningFieldDescription": "노드 간에 Conditioning을 전달할 수 있습니다.",
|
||||||
|
"clipFieldDescription": "Tokenizer 및 text_encoder 서브모델.",
|
||||||
|
"fullyContainNodesHelp": "선택하려면 노드가 선택 상자 안에 완전히 있어야 합니다",
|
||||||
|
"noImageFoundState": "상태에서 초기 이미지를 찾을 수 없습니다",
|
||||||
|
"clipField": "Clip",
|
||||||
|
"nodePack": "Node pack",
|
||||||
|
"nodeType": "노드 유형",
|
||||||
|
"noMatchingNodes": "일치하는 노드 없음",
|
||||||
|
"fullyContainNodes": "선택할 노드 전체 포함",
|
||||||
|
"integerPolymorphic": "정수 다형성",
|
||||||
|
"executionStateInProgress": "진행중",
|
||||||
|
"noFieldType": "필드 유형 없음",
|
||||||
|
"colorCollection": "색의 컬렉션.",
|
||||||
|
"executionStateError": "에러",
|
||||||
|
"noOutputSchemaName": "ref 개체에 output schema 이름이 없습니다",
|
||||||
|
"ipAdapterModel": "IP-Adapter 모델",
|
||||||
|
"latentsPolymorphic": "Latents 다형성",
|
||||||
|
"ipAdapterDescription": "이미지 프롬프트 어댑터(IP-Adapter).",
|
||||||
|
"boolean": "Booleans",
|
||||||
|
"missingCanvaInitMaskImages": "캔버스 init 및 mask 이미지 누락",
|
||||||
|
"problemReadingMetadata": "이미지에서 metadata를 읽는 중 문제가 발생했습니다",
|
||||||
|
"hideMinimapnodes": "미니맵 숨기기",
|
||||||
|
"oNNXModelField": "ONNX 모델",
|
||||||
|
"executionStateCompleted": "완료된",
|
||||||
|
"node": "노드",
|
||||||
|
"currentImage": "현재 이미지",
|
||||||
|
"controlField": "Control",
|
||||||
|
"booleanDescription": "Booleans은 참 또는 거짓입니다.",
|
||||||
|
"collection": "컬렉션",
|
||||||
|
"ipAdapterModelDescription": "IP-Adapter 모델 필드",
|
||||||
|
"cannotConnectInputToInput": "입력을 입력에 연결할 수 없습니다",
|
||||||
|
"invalidOutputSchema": "잘못된 output schema",
|
||||||
|
"boardFieldDescription": "A gallery board",
|
||||||
|
"floatDescription": "실수는 소수점이 있는 숫자입니다.",
|
||||||
|
"floatPolymorphicDescription": "실수 컬렉션.",
|
||||||
|
"conditioningField": "Conditioning",
|
||||||
|
"collectionFieldType": "{{name}} 컬렉션",
|
||||||
|
"floatCollection": "실수 컬렉션",
|
||||||
|
"latentsField": "Latents",
|
||||||
|
"cannotConnectOutputToOutput": "출력을 출력에 연결할 수 없습니다",
|
||||||
|
"booleanCollection": "Boolean 컬렉션",
|
||||||
|
"connectionWouldCreateCycle": "연결하면 주기가 생성됩니다",
|
||||||
|
"cannotConnectToSelf": "자체에 연결할 수 없습니다",
|
||||||
|
"notesDescription": "Workflow에 대한 메모 추가",
|
||||||
|
"inputFields": "입력 필드",
|
||||||
|
"colorCodeEdges": "색상-코드 선",
|
||||||
|
"targetNodeDoesNotExist": "잘못된 모서리: 대상/입력 노드 {{node}}이(가) 없습니다",
|
||||||
|
"imageCollectionDescription": "이미지 컬렉션.",
|
||||||
|
"mismatchedVersion": "잘못된 노드: {{type}} 유형의 {{node}} 노드에 일치하지 않는 버전이 있습니다(업데이트 해보시겠습니까?)",
|
||||||
|
"imageFieldDescription": "노드 간에 이미지를 전달할 수 있습니다.",
|
||||||
|
"outputNode": "출력노드",
|
||||||
|
"addNodeToolTip": "노드 추가(Shift+A, Space)",
|
||||||
|
"collectionOrScalarFieldType": "{{name}} 컬렉션|Scalar",
|
||||||
|
"nodeVersion": "노드 버전",
|
||||||
|
"loadingNodes": "노드 로딩중...",
|
||||||
|
"mainModelFieldDescription": "해야 할 일",
|
||||||
|
"loRAModelField": "LoRA",
|
||||||
|
"deletedInvalidEdge": "잘못된 모서리 {{source}} -> {{target}} 삭제",
|
||||||
|
"latentsCollectionDescription": "노드 사이에 Latents를 전달할 수 있습니다.",
|
||||||
|
"oNNXModelFieldDescription": "ONNX 모델 필드.",
|
||||||
|
"imageCollection": "이미지 컬렉션"
|
||||||
|
},
|
||||||
|
"queue": {
|
||||||
|
"status": "상태",
|
||||||
|
"pruneSucceeded": "Queue로부터 {{item_count}} 완성된 항목 잘라내기",
|
||||||
|
"cancelTooltip": "현재 항목 취소",
|
||||||
|
"queueEmpty": "비어있는 Queue",
|
||||||
|
"pauseSucceeded": "중지된 프로세서",
|
||||||
|
"in_progress": "진행 중",
|
||||||
|
"queueFront": "Front of Queue에 추가",
|
||||||
|
"notReady": "Queue를 생성할 수 없음",
|
||||||
|
"batchFailedToQueue": "Queue Batch에 실패",
|
||||||
|
"completed": "완성된",
|
||||||
|
"queueBack": "Queue에 추가",
|
||||||
|
"batchValues": "Batch 값들",
|
||||||
|
"cancelFailed": "항목 취소 중 발생한 문제",
|
||||||
|
"queueCountPrediction": "Queue에 {{predicted}} 추가",
|
||||||
|
"batchQueued": "Batch Queued",
|
||||||
|
"pauseFailed": "프로세서 중지 중 발생한 문제",
|
||||||
|
"clearFailed": "Queue 제거 중 발생한 문제",
|
||||||
|
"queuedCount": "{{pending}} Pending",
|
||||||
|
"front": "front",
|
||||||
|
"clearSucceeded": "제거된 Queue",
|
||||||
|
"pause": "중지",
|
||||||
|
"pruneTooltip": "{{item_count}} 완성된 항목 잘라내기",
|
||||||
|
"cancelSucceeded": "취소된 항목",
|
||||||
|
"batchQueuedDesc_other": "queue의 {{direction}}에 추가된 {{count}}세션",
|
||||||
|
"queue": "Queue",
|
||||||
|
"batch": "Batch",
|
||||||
|
"clearQueueAlertDialog": "Queue를 지우면 처리 항목이 즉시 취소되고 Queue가 완전히 지워집니다.",
|
||||||
|
"resumeFailed": "프로세서 재개 중 발생한 문제",
|
||||||
|
"clear": "제거하다",
|
||||||
|
"prune": "잘라내다",
|
||||||
|
"total": "총 개수",
|
||||||
|
"canceled": "취소된",
|
||||||
|
"pruneFailed": "Queue 잘라내는 중 발생한 문제",
|
||||||
|
"cancelBatchSucceeded": "취소된 Batch",
|
||||||
|
"clearTooltip": "모든 항목을 취소하고 제거",
|
||||||
|
"current": "최근",
|
||||||
|
"pauseTooltip": "프로세서 중지",
|
||||||
|
"failed": "실패한",
|
||||||
|
"cancelItem": "항목 취소",
|
||||||
|
"next": "다음",
|
||||||
|
"cancelBatch": "Batch 취소",
|
||||||
|
"back": "back",
|
||||||
|
"batchFieldValues": "Batch 필드 값들",
|
||||||
|
"cancel": "취소",
|
||||||
|
"session": "세션",
|
||||||
|
"time": "시간",
|
||||||
|
"queueTotal": "{{total}} Total",
|
||||||
|
"resumeSucceeded": "재개된 프로세서",
|
||||||
|
"enqueueing": "Queueing Batch",
|
||||||
|
"resumeTooltip": "프로세서 재개",
|
||||||
|
"resume": "재개",
|
||||||
|
"cancelBatchFailed": "Batch 취소 중 발생한 문제",
|
||||||
|
"clearQueueAlertDialog2": "Queue를 지우시겠습니까?",
|
||||||
|
"item": "항목",
|
||||||
|
"graphFailedToQueue": "queue graph에 실패"
|
||||||
|
},
|
||||||
|
"metadata": {
|
||||||
|
"positivePrompt": "긍정적 프롬프트",
|
||||||
|
"negativePrompt": "부정적인 프롬프트",
|
||||||
|
"generationMode": "Generation Mode",
|
||||||
|
"Threshold": "Noise Threshold",
|
||||||
|
"metadata": "Metadata",
|
||||||
|
"seed": "시드",
|
||||||
|
"imageDetails": "이미지 세부 정보",
|
||||||
|
"perlin": "Perlin Noise",
|
||||||
|
"model": "모델",
|
||||||
|
"noImageDetails": "이미지 세부 정보를 찾을 수 없습니다",
|
||||||
|
"hiresFix": "고해상도 최적화",
|
||||||
|
"cfgScale": "CFG scale",
|
||||||
|
"initImage": "초기이미지",
|
||||||
|
"recallParameters": "매개변수 호출",
|
||||||
|
"height": "Height",
|
||||||
|
"variations": "Seed-weight 쌍",
|
||||||
|
"noMetaData": "metadata를 찾을 수 없습니다",
|
||||||
|
"cfgRescaleMultiplier": "$t(parameters.cfgRescaleMultiplier)",
|
||||||
|
"width": "너비",
|
||||||
|
"vae": "VAE",
|
||||||
|
"createdBy": "~에 의해 생성된",
|
||||||
|
"workflow": "작업의 흐름",
|
||||||
|
"steps": "단계",
|
||||||
|
"scheduler": "스케줄러",
|
||||||
|
"noRecallParameters": "호출할 매개 변수가 없습니다"
|
||||||
|
},
|
||||||
|
"invocationCache": {
|
||||||
|
"useCache": "캐시 사용",
|
||||||
|
"disable": "이용 불가능한",
|
||||||
|
"misses": "캐시 미스",
|
||||||
|
"enableFailed": "Invocation 캐시를 사용하도록 설정하는 중 발생한 문제",
|
||||||
|
"invocationCache": "Invocation 캐시",
|
||||||
|
"clearSucceeded": "제거된 Invocation 캐시",
|
||||||
|
"enableSucceeded": "이용 가능한 Invocation 캐시",
|
||||||
|
"clearFailed": "Invocation 캐시 제거 중 발생한 문제",
|
||||||
|
"hits": "캐시 적중",
|
||||||
|
"disableSucceeded": "이용 불가능한 Invocation 캐시",
|
||||||
|
"disableFailed": "Invocation 캐시를 이용하지 못하게 설정 중 발생한 문제",
|
||||||
|
"enable": "이용 가능한",
|
||||||
|
"clear": "제거",
|
||||||
|
"maxCacheSize": "최대 캐시 크기",
|
||||||
|
"cacheSize": "캐시 크기"
|
||||||
|
},
|
||||||
|
"embedding": {
|
||||||
|
"noEmbeddingsLoaded": "불러온 Embeddings이 없음",
|
||||||
|
"noMatchingEmbedding": "일치하는 Embeddings이 없음",
|
||||||
|
"addEmbedding": "Embedding 추가",
|
||||||
|
"incompatibleModel": "호환되지 않는 기본 모델:"
|
||||||
|
},
|
||||||
|
"hrf": {
|
||||||
|
"enableHrf": "이용 가능한 고해상도 고정",
|
||||||
|
"upscaleMethod": "업스케일 방법",
|
||||||
|
"enableHrfTooltip": "낮은 초기 해상도로 생성하고 기본 해상도로 업스케일한 다음 Image-to-Image를 실행합니다.",
|
||||||
|
"metadata": {
|
||||||
|
"strength": "고해상도 고정 강도",
|
||||||
|
"enabled": "고해상도 고정 사용",
|
||||||
|
"method": "고해상도 고정 방법"
|
||||||
|
},
|
||||||
|
"hrf": "고해상도 고정",
|
||||||
|
"hrfStrength": "고해상도 고정 강도"
|
||||||
|
},
|
||||||
|
"models": {
|
||||||
|
"noLoRAsLoaded": "로드된 LoRA 없음",
|
||||||
|
"noMatchingModels": "일치하는 모델 없음",
|
||||||
|
"esrganModel": "ESRGAN 모델",
|
||||||
|
"loading": "로딩중",
|
||||||
|
"noMatchingLoRAs": "일치하는 LoRA 없음",
|
||||||
|
"noLoRAsAvailable": "사용 가능한 LoRA 없음",
|
||||||
|
"noModelsAvailable": "사용 가능한 모델이 없음",
|
||||||
|
"addLora": "LoRA 추가",
|
||||||
|
"selectModel": "모델 선택",
|
||||||
|
"noRefinerModelsInstalled": "SDXL Refiner 모델이 설치되지 않음",
|
||||||
|
"noLoRAsInstalled": "설치된 LoRA 없음",
|
||||||
|
"selectLoRA": "LoRA 선택"
|
||||||
|
},
|
||||||
|
"boards": {
|
||||||
|
"autoAddBoard": "자동 추가 Board",
|
||||||
|
"topMessage": "이 보드에는 다음 기능에 사용되는 이미지가 포함되어 있습니다:",
|
||||||
|
"move": "이동",
|
||||||
|
"menuItemAutoAdd": "해당 Board에 자동 추가",
|
||||||
|
"myBoard": "나의 Board",
|
||||||
|
"searchBoard": "Board 찾는 중...",
|
||||||
|
"deleteBoardOnly": "Board만 삭제",
|
||||||
|
"noMatching": "일치하는 Board들이 없음",
|
||||||
|
"movingImagesToBoard_other": "{{count}}이미지를 Board로 이동시키기",
|
||||||
|
"selectBoard": "Board 선택",
|
||||||
|
"cancel": "취소",
|
||||||
|
"addBoard": "Board 추가",
|
||||||
|
"bottomMessage": "이 보드와 이미지를 삭제하면 현재 사용 중인 모든 기능이 재설정됩니다.",
|
||||||
|
"uncategorized": "미분류",
|
||||||
|
"downloadBoard": "Board 다운로드",
|
||||||
|
"changeBoard": "Board 바꾸기",
|
||||||
|
"loading": "불러오는 중...",
|
||||||
|
"clearSearch": "검색 지우기",
|
||||||
|
"deleteBoard": "Board 삭제",
|
||||||
|
"deleteBoardAndImages": "Board와 이미지 삭제",
|
||||||
|
"deletedBoardsCannotbeRestored": "삭제된 Board는 복원할 수 없습니다"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -120,7 +120,8 @@
|
|||||||
"orderBy": "排序方式:",
|
"orderBy": "排序方式:",
|
||||||
"nextPage": "下一页",
|
"nextPage": "下一页",
|
||||||
"saveAs": "保存为",
|
"saveAs": "保存为",
|
||||||
"unsaved": "未保存"
|
"unsaved": "未保存",
|
||||||
|
"ai": "ai"
|
||||||
},
|
},
|
||||||
"gallery": {
|
"gallery": {
|
||||||
"generations": "生成的图像",
|
"generations": "生成的图像",
|
||||||
@ -1122,7 +1123,8 @@
|
|||||||
"betaDesc": "此调用尚处于测试阶段。在稳定之前,它可能会在项目更新期间发生破坏性更改。本项目计划长期支持这种调用。",
|
"betaDesc": "此调用尚处于测试阶段。在稳定之前,它可能会在项目更新期间发生破坏性更改。本项目计划长期支持这种调用。",
|
||||||
"newWorkflow": "新建工作流",
|
"newWorkflow": "新建工作流",
|
||||||
"newWorkflowDesc": "是否创建一个新的工作流?",
|
"newWorkflowDesc": "是否创建一个新的工作流?",
|
||||||
"newWorkflowDesc2": "当前工作流有未保存的更改。"
|
"newWorkflowDesc2": "当前工作流有未保存的更改。",
|
||||||
|
"unsupportedAnyOfLength": "联合(union)数据类型数目过多 ({{count}})"
|
||||||
},
|
},
|
||||||
"controlnet": {
|
"controlnet": {
|
||||||
"resize": "直接缩放",
|
"resize": "直接缩放",
|
||||||
|
@ -172,6 +172,8 @@ nav:
|
|||||||
- Adding Tests: 'contributing/TESTS.md'
|
- Adding Tests: 'contributing/TESTS.md'
|
||||||
- Documentation: 'contributing/contribution_guides/documentation.md'
|
- Documentation: 'contributing/contribution_guides/documentation.md'
|
||||||
- Nodes: 'contributing/INVOCATIONS.md'
|
- Nodes: 'contributing/INVOCATIONS.md'
|
||||||
|
- Model Manager: 'contributing/MODEL_MANAGER.md'
|
||||||
|
- Download Queue: 'contributing/DOWNLOAD_QUEUE.md'
|
||||||
- Translation: 'contributing/contribution_guides/translation.md'
|
- Translation: 'contributing/contribution_guides/translation.md'
|
||||||
- Tutorials: 'contributing/contribution_guides/tutorials.md'
|
- Tutorials: 'contributing/contribution_guides/tutorials.md'
|
||||||
- Changelog: 'CHANGELOG.md'
|
- Changelog: 'CHANGELOG.md'
|
||||||
|
@ -105,6 +105,7 @@ dependencies = [
|
|||||||
"pytest>6.0.0",
|
"pytest>6.0.0",
|
||||||
"pytest-cov",
|
"pytest-cov",
|
||||||
"pytest-datadir",
|
"pytest-datadir",
|
||||||
|
"requests_testadapter",
|
||||||
]
|
]
|
||||||
"xformers" = [
|
"xformers" = [
|
||||||
"xformers==0.0.23; sys_platform!='darwin'",
|
"xformers==0.0.23; sys_platform!='darwin'",
|
||||||
|
@ -26,7 +26,6 @@ from invokeai.app.services.shared.graph import (
|
|||||||
Graph,
|
Graph,
|
||||||
GraphExecutionState,
|
GraphExecutionState,
|
||||||
IterateInvocation,
|
IterateInvocation,
|
||||||
LibraryGraph,
|
|
||||||
)
|
)
|
||||||
from invokeai.backend.util.logging import InvokeAILogger
|
from invokeai.backend.util.logging import InvokeAILogger
|
||||||
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
from tests.fixtures.sqlite_database import create_mock_sqlite_database
|
||||||
@ -61,7 +60,6 @@ def mock_services() -> InvocationServices:
|
|||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
events=TestEventService(),
|
events=TestEventService(),
|
||||||
graph_execution_manager=graph_execution_manager,
|
graph_execution_manager=graph_execution_manager,
|
||||||
graph_library=SqliteItemStorage[LibraryGraph](db=db, table_name="graphs"),
|
|
||||||
image_files=None, # type: ignore
|
image_files=None, # type: ignore
|
||||||
image_records=None, # type: ignore
|
image_records=None, # type: ignore
|
||||||
images=None, # type: ignore
|
images=None, # type: ignore
|
||||||
@ -70,6 +68,7 @@ def mock_services() -> InvocationServices:
|
|||||||
logger=logging, # type: ignore
|
logger=logging, # type: ignore
|
||||||
model_manager=None, # type: ignore
|
model_manager=None, # type: ignore
|
||||||
model_records=None, # type: ignore
|
model_records=None, # type: ignore
|
||||||
|
download_queue=None, # type: ignore
|
||||||
model_install=None, # type: ignore
|
model_install=None, # type: ignore
|
||||||
names=None, # type: ignore
|
names=None, # type: ignore
|
||||||
performance_statistics=InvocationStatsService(),
|
performance_statistics=InvocationStatsService(),
|
||||||
|
@ -24,7 +24,7 @@ from invokeai.app.services.invocation_stats.invocation_stats_default import Invo
|
|||||||
from invokeai.app.services.invoker import Invoker
|
from invokeai.app.services.invoker import Invoker
|
||||||
from invokeai.app.services.item_storage.item_storage_sqlite import SqliteItemStorage
|
from invokeai.app.services.item_storage.item_storage_sqlite import SqliteItemStorage
|
||||||
from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID
|
from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID
|
||||||
from invokeai.app.services.shared.graph import Graph, GraphExecutionState, GraphInvocation, LibraryGraph
|
from invokeai.app.services.shared.graph import Graph, GraphExecutionState, GraphInvocation
|
||||||
|
|
||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
@ -66,7 +66,6 @@ def mock_services() -> InvocationServices:
|
|||||||
configuration=configuration,
|
configuration=configuration,
|
||||||
events=TestEventService(),
|
events=TestEventService(),
|
||||||
graph_execution_manager=graph_execution_manager,
|
graph_execution_manager=graph_execution_manager,
|
||||||
graph_library=SqliteItemStorage[LibraryGraph](db=db, table_name="graphs"),
|
|
||||||
image_files=None, # type: ignore
|
image_files=None, # type: ignore
|
||||||
image_records=None, # type: ignore
|
image_records=None, # type: ignore
|
||||||
images=None, # type: ignore
|
images=None, # type: ignore
|
||||||
@ -75,6 +74,7 @@ def mock_services() -> InvocationServices:
|
|||||||
logger=logging, # type: ignore
|
logger=logging, # type: ignore
|
||||||
model_manager=None, # type: ignore
|
model_manager=None, # type: ignore
|
||||||
model_records=None, # type: ignore
|
model_records=None, # type: ignore
|
||||||
|
download_queue=None, # type: ignore
|
||||||
model_install=None, # type: ignore
|
model_install=None, # type: ignore
|
||||||
names=None, # type: ignore
|
names=None, # type: ignore
|
||||||
performance_statistics=InvocationStatsService(),
|
performance_statistics=InvocationStatsService(),
|
||||||
|
223
tests/app/services/download/test_download_queue.py
Normal file
223
tests/app/services/download/test_download_queue.py
Normal file
@ -0,0 +1,223 @@
|
|||||||
|
"""Test the queued download facility"""
|
||||||
|
import re
|
||||||
|
import time
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any, Dict, List
|
||||||
|
|
||||||
|
import pytest
|
||||||
|
import requests
|
||||||
|
from pydantic import BaseModel
|
||||||
|
from pydantic.networks import AnyHttpUrl
|
||||||
|
from requests.sessions import Session
|
||||||
|
from requests_testadapter import TestAdapter
|
||||||
|
|
||||||
|
from invokeai.app.services.download import DownloadJob, DownloadJobStatus, DownloadQueueService
|
||||||
|
from invokeai.app.services.events.events_base import EventServiceBase
|
||||||
|
|
||||||
|
# Prevent pytest deprecation warnings
|
||||||
|
TestAdapter.__test__ = False
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.fixture
|
||||||
|
def session() -> requests.sessions.Session:
|
||||||
|
sess = requests.Session()
|
||||||
|
for i in ["12345", "9999", "54321"]:
|
||||||
|
content = (
|
||||||
|
b"I am a safetensors file " + bytearray(i, "utf-8") + bytearray(32_000)
|
||||||
|
) # for pause tests, must make content large
|
||||||
|
sess.mount(
|
||||||
|
f"http://www.civitai.com/models/{i}",
|
||||||
|
TestAdapter(
|
||||||
|
content,
|
||||||
|
headers={
|
||||||
|
"Content-Length": len(content),
|
||||||
|
"Content-Disposition": f'filename="mock{i}.safetensors"',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
|
||||||
|
# here are some malformed URLs to test
|
||||||
|
# missing the content length
|
||||||
|
sess.mount(
|
||||||
|
"http://www.civitai.com/models/missing",
|
||||||
|
TestAdapter(
|
||||||
|
b"Missing content length",
|
||||||
|
headers={
|
||||||
|
"Content-Disposition": 'filename="missing.txt"',
|
||||||
|
},
|
||||||
|
),
|
||||||
|
)
|
||||||
|
# not found test
|
||||||
|
sess.mount("http://www.civitai.com/models/broken", TestAdapter(b"Not found", status=404))
|
||||||
|
|
||||||
|
return sess
|
||||||
|
|
||||||
|
|
||||||
|
class DummyEvent(BaseModel):
|
||||||
|
"""Dummy Event to use with Dummy Event service."""
|
||||||
|
|
||||||
|
event_name: str
|
||||||
|
payload: Dict[str, Any]
|
||||||
|
|
||||||
|
|
||||||
|
# A dummy event service for testing event issuing
|
||||||
|
class DummyEventService(EventServiceBase):
|
||||||
|
"""Dummy event service for testing."""
|
||||||
|
|
||||||
|
events: List[DummyEvent]
|
||||||
|
|
||||||
|
def __init__(self) -> None:
|
||||||
|
super().__init__()
|
||||||
|
self.events = []
|
||||||
|
|
||||||
|
def dispatch(self, event_name: str, payload: Any) -> None:
|
||||||
|
"""Dispatch an event by appending it to self.events."""
|
||||||
|
self.events.append(DummyEvent(event_name=payload["event"], payload=payload["data"]))
|
||||||
|
|
||||||
|
|
||||||
|
def test_basic_queue_download(tmp_path: Path, session: Session) -> None:
|
||||||
|
events = set()
|
||||||
|
|
||||||
|
def event_handler(job: DownloadJob) -> None:
|
||||||
|
events.add(job.status)
|
||||||
|
|
||||||
|
queue = DownloadQueueService(
|
||||||
|
requests_session=session,
|
||||||
|
)
|
||||||
|
queue.start()
|
||||||
|
job = queue.download(
|
||||||
|
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||||
|
dest=tmp_path,
|
||||||
|
on_start=event_handler,
|
||||||
|
on_progress=event_handler,
|
||||||
|
on_complete=event_handler,
|
||||||
|
on_error=event_handler,
|
||||||
|
)
|
||||||
|
assert isinstance(job, DownloadJob), "expected the job to be of type DownloadJobBase"
|
||||||
|
assert isinstance(job.id, int), "expected the job id to be numeric"
|
||||||
|
queue.join()
|
||||||
|
|
||||||
|
assert job.status == DownloadJobStatus("completed"), "expected job status to be completed"
|
||||||
|
assert Path(tmp_path, "mock12345.safetensors").exists(), f"expected {tmp_path}/mock12345.safetensors to exist"
|
||||||
|
|
||||||
|
assert events == {DownloadJobStatus.RUNNING, DownloadJobStatus.COMPLETED}
|
||||||
|
queue.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_errors(tmp_path: Path, session: Session) -> None:
|
||||||
|
queue = DownloadQueueService(
|
||||||
|
requests_session=session,
|
||||||
|
)
|
||||||
|
queue.start()
|
||||||
|
|
||||||
|
for bad_url in ["http://www.civitai.com/models/broken", "http://www.civitai.com/models/missing"]:
|
||||||
|
queue.download(AnyHttpUrl(bad_url), dest=tmp_path)
|
||||||
|
|
||||||
|
queue.join()
|
||||||
|
jobs = queue.list_jobs()
|
||||||
|
print(jobs)
|
||||||
|
assert len(jobs) == 2
|
||||||
|
jobs_dict = {str(x.source): x for x in jobs}
|
||||||
|
assert jobs_dict["http://www.civitai.com/models/broken"].status == DownloadJobStatus.ERROR
|
||||||
|
assert jobs_dict["http://www.civitai.com/models/broken"].error_type == "HTTPError(NOT FOUND)"
|
||||||
|
assert jobs_dict["http://www.civitai.com/models/missing"].status == DownloadJobStatus.COMPLETED
|
||||||
|
assert jobs_dict["http://www.civitai.com/models/missing"].total_bytes == 0
|
||||||
|
queue.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_event_bus(tmp_path: Path, session: Session) -> None:
|
||||||
|
event_bus = DummyEventService()
|
||||||
|
|
||||||
|
queue = DownloadQueueService(requests_session=session, event_bus=event_bus)
|
||||||
|
queue.start()
|
||||||
|
queue.download(
|
||||||
|
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||||
|
dest=tmp_path,
|
||||||
|
)
|
||||||
|
queue.join()
|
||||||
|
events = event_bus.events
|
||||||
|
assert len(events) == 3
|
||||||
|
assert events[0].payload["timestamp"] <= events[1].payload["timestamp"]
|
||||||
|
assert events[1].payload["timestamp"] <= events[2].payload["timestamp"]
|
||||||
|
assert events[0].event_name == "download_started"
|
||||||
|
assert events[1].event_name == "download_progress"
|
||||||
|
assert events[1].payload["total_bytes"] > 0
|
||||||
|
assert events[1].payload["current_bytes"] <= events[1].payload["total_bytes"]
|
||||||
|
assert events[2].event_name == "download_complete"
|
||||||
|
assert events[2].payload["total_bytes"] == 32029
|
||||||
|
|
||||||
|
# test a failure
|
||||||
|
event_bus.events = [] # reset our accumulator
|
||||||
|
queue.download(source=AnyHttpUrl("http://www.civitai.com/models/broken"), dest=tmp_path)
|
||||||
|
queue.join()
|
||||||
|
events = event_bus.events
|
||||||
|
print("\n".join([x.model_dump_json() for x in events]))
|
||||||
|
assert len(events) == 1
|
||||||
|
assert events[0].event_name == "download_error"
|
||||||
|
assert events[0].payload["error_type"] == "HTTPError(NOT FOUND)"
|
||||||
|
assert events[0].payload["error"] is not None
|
||||||
|
assert re.search(r"requests.exceptions.HTTPError: NOT FOUND", events[0].payload["error"])
|
||||||
|
queue.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_broken_callbacks(tmp_path: Path, session: requests.sessions.Session, capsys) -> None:
|
||||||
|
queue = DownloadQueueService(
|
||||||
|
requests_session=session,
|
||||||
|
)
|
||||||
|
queue.start()
|
||||||
|
|
||||||
|
callback_ran = False
|
||||||
|
|
||||||
|
def broken_callback(job: DownloadJob) -> None:
|
||||||
|
nonlocal callback_ran
|
||||||
|
callback_ran = True
|
||||||
|
print(1 / 0) # deliberate error here
|
||||||
|
|
||||||
|
job = queue.download(
|
||||||
|
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||||
|
dest=tmp_path,
|
||||||
|
on_progress=broken_callback,
|
||||||
|
)
|
||||||
|
|
||||||
|
queue.join()
|
||||||
|
assert job.status == DownloadJobStatus.COMPLETED # should complete even though the callback is borked
|
||||||
|
assert Path(tmp_path, "mock12345.safetensors").exists()
|
||||||
|
assert callback_ran
|
||||||
|
# LS: The pytest capsys fixture does not seem to be working. I can see the
|
||||||
|
# correct stderr message in the pytest log, but it is not appearing in
|
||||||
|
# capsys.readouterr().
|
||||||
|
# captured = capsys.readouterr()
|
||||||
|
# assert re.search("division by zero", captured.err)
|
||||||
|
queue.stop()
|
||||||
|
|
||||||
|
|
||||||
|
def test_cancel(tmp_path: Path, session: requests.sessions.Session) -> None:
|
||||||
|
event_bus = DummyEventService()
|
||||||
|
|
||||||
|
queue = DownloadQueueService(requests_session=session, event_bus=event_bus)
|
||||||
|
queue.start()
|
||||||
|
|
||||||
|
cancelled = False
|
||||||
|
|
||||||
|
def slow_callback(job: DownloadJob) -> None:
|
||||||
|
time.sleep(2)
|
||||||
|
|
||||||
|
def cancelled_callback(job: DownloadJob) -> None:
|
||||||
|
nonlocal cancelled
|
||||||
|
cancelled = True
|
||||||
|
|
||||||
|
job = queue.download(
|
||||||
|
source=AnyHttpUrl("http://www.civitai.com/models/12345"),
|
||||||
|
dest=tmp_path,
|
||||||
|
on_start=slow_callback,
|
||||||
|
on_cancelled=cancelled_callback,
|
||||||
|
)
|
||||||
|
queue.cancel_job(job)
|
||||||
|
queue.join()
|
||||||
|
|
||||||
|
assert job.status == DownloadJobStatus.CANCELLED
|
||||||
|
assert cancelled
|
||||||
|
events = event_bus.events
|
||||||
|
assert events[-1].event_name == "download_cancelled"
|
||||||
|
assert events[-1].payload["source"] == "http://www.civitai.com/models/12345"
|
||||||
|
queue.stop()
|
@ -48,11 +48,13 @@ def store(
|
|||||||
|
|
||||||
@pytest.fixture
|
@pytest.fixture
|
||||||
def installer(app_config: InvokeAIAppConfig, store: ModelRecordServiceBase) -> ModelInstallServiceBase:
|
def installer(app_config: InvokeAIAppConfig, store: ModelRecordServiceBase) -> ModelInstallServiceBase:
|
||||||
return ModelInstallService(
|
installer = ModelInstallService(
|
||||||
app_config=app_config,
|
app_config=app_config,
|
||||||
record_store=store,
|
record_store=store,
|
||||||
event_bus=DummyEventService(),
|
event_bus=DummyEventService(),
|
||||||
)
|
)
|
||||||
|
installer.start()
|
||||||
|
return installer
|
||||||
|
|
||||||
|
|
||||||
class DummyEvent(BaseModel):
|
class DummyEvent(BaseModel):
|
||||||
|
Loading…
Reference in New Issue
Block a user