From 14226a3b62cba8abcdf6f3c4ac8bc414a1bf8933 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Thu, 7 May 2026 19:12:55 -0500 Subject: [PATCH 01/12] Add crash-recoverable image move service --- invokeai/app/api/dependencies.py | 3 + .../services/image_files/image_files_base.py | 17 + .../services/image_files/image_files_disk.py | 12 + invokeai/app/services/image_moves/__init__.py | 3 + .../image_moves/image_moves_default.py | 522 ++++++++++++++++++ invokeai/app/services/invocation_services.py | 3 + .../app/services/shared/sqlite/sqlite_util.py | 2 + .../migrations/migration_32.py | 71 +++ .../image_moves/test_image_moves_default.py | 332 +++++++++++ 9 files changed, 965 insertions(+) create mode 100644 invokeai/app/services/image_moves/__init__.py create mode 100644 invokeai/app/services/image_moves/image_moves_default.py create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py create mode 100644 tests/app/services/image_moves/test_image_moves_default.py diff --git a/invokeai/app/api/dependencies.py b/invokeai/app/api/dependencies.py index e7468c1bca4..3092f5ab71a 100644 --- a/invokeai/app/api/dependencies.py +++ b/invokeai/app/api/dependencies.py @@ -25,6 +25,7 @@ ) from invokeai.app.services.external_generation.startup import sync_configured_external_starter_models from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage +from invokeai.app.services.image_moves.image_moves_default import ImageMoveService from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage from invokeai.app.services.images.images_default import ImageService from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache @@ -130,6 +131,7 @@ def initialize( events = FastAPIEventService(event_handler_id, loop=loop) bulk_download = BulkDownloadService() image_records = SqliteImageRecordStorage(db=db) + image_moves = ImageMoveService(db=db, image_files=image_files, config=configuration, logger=logger) images = ImageService() invocation_cache = MemoryInvocationCache(max_cache_size=config.node_cache_size) tensors = ObjectSerializerForwardCache( @@ -198,6 +200,7 @@ def initialize( configuration=configuration, events=events, image_files=image_files, + image_moves=image_moves, image_records=image_records, images=images, invocation_cache=invocation_cache, diff --git a/invokeai/app/services/image_files/image_files_base.py b/invokeai/app/services/image_files/image_files_base.py index 7464cd7941d..764efd8833c 100644 --- a/invokeai/app/services/image_files/image_files_base.py +++ b/invokeai/app/services/image_files/image_files_base.py @@ -18,6 +18,23 @@ def get_path(self, image_name: str, thumbnail: bool = False, image_subfolder: st """Gets the internal path to an image or thumbnail.""" pass + @property + @abstractmethod + def image_root(self) -> Path: + """Gets the root directory for full-size images.""" + pass + + @property + @abstractmethod + def thumbnail_root(self) -> Path: + """Gets the root directory for thumbnails.""" + pass + + @abstractmethod + def evict_cache_paths(self, paths: list[Path]) -> None: + """Evicts any cached image objects for the provided paths.""" + pass + # TODO: We need to validate paths before starlette makes the FileResponse, else we get a # 500 internal server error. I don't like having this method on the service. @abstractmethod diff --git a/invokeai/app/services/image_files/image_files_disk.py b/invokeai/app/services/image_files/image_files_disk.py index 12b737a7cf1..278a965b97e 100644 --- a/invokeai/app/services/image_files/image_files_disk.py +++ b/invokeai/app/services/image_files/image_files_disk.py @@ -32,6 +32,18 @@ def __init__(self, output_folder: Union[str, Path]): def start(self, invoker: Invoker) -> None: self.__invoker = invoker + @property + def image_root(self) -> Path: + return self.__output_folder.resolve() + + @property + def thumbnail_root(self) -> Path: + return self.__thumbnails_folder.resolve() + + def evict_cache_paths(self, paths: list[Path]) -> None: + for path in paths: + self.__cache.pop(path.resolve(), None) + def get(self, image_name: str, image_subfolder: str = "") -> PILImageType: try: image_path = self.get_path(image_name, image_subfolder=image_subfolder) diff --git a/invokeai/app/services/image_moves/__init__.py b/invokeai/app/services/image_moves/__init__.py new file mode 100644 index 00000000000..69defbd891e --- /dev/null +++ b/invokeai/app/services/image_moves/__init__.py @@ -0,0 +1,3 @@ +from invokeai.app.services.image_moves.image_moves_default import ImageMoveService + +__all__ = ["ImageMoveService"] diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py new file mode 100644 index 00000000000..e2f269025db --- /dev/null +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -0,0 +1,522 @@ +import os +import tempfile +from dataclasses import dataclass +from datetime import datetime +from pathlib import Path +from typing import Literal, Sequence, cast + +from PIL import Image + +from invokeai.app.services.config import InvokeAIAppConfig +from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase +from invokeai.app.services.image_records.image_records_common import ImageCategory +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.util.thumbnails import make_thumbnail + +MoveJobState = Literal["planned", "moving", "moved", "committed", "error"] +MoveItemState = Literal["planned", "moved", "committed", "error"] + + +@dataclass(frozen=True) +class PlannedImageMove: + image_name: str + old_subfolder: str + new_subfolder: str + old_path: Path + new_path: Path + old_thumbnail_path: Path + new_thumbnail_path: Path + + +@dataclass(frozen=True) +class ImageMoveJob: + id: int + state: MoveJobState + error_message: str | None + + +@dataclass(frozen=True) +class ImageMoveResult: + planned: int = 0 + committed: int = 0 + errors: int = 0 + + +class ImageMoveService: + def __init__( + self, + db: SqliteDatabase, + image_files: ImageFileStorageBase, + config: InvokeAIAppConfig, + logger, + ) -> None: + self._db = db + self.image_files = image_files + self._config = config + self._logger = logger + + def move_all_images(self) -> ImageMoveResult: + recovered = self.startup_recovery() + last_image_name = "" + planned = 0 + committed = recovered.committed + errors = recovered.errors + + while True: + moves = self.plan_batch(last_image_name=last_image_name, limit=100) + if not moves: + next_name = self._next_image_name(last_image_name) + if next_name is None: + break + last_image_name = next_name + continue + + job_id = self.create_move_job(moves) + planned += len(moves) + try: + self.perform_filesystem_moves(job_id) + self.commit_database_updates(job_id) + committed += len(moves) + except Exception as e: + errors += 1 + self.record_job_error_message(job_id, str(e)) + raise + last_image_name = moves[-1].image_name + + return ImageMoveResult(planned=planned, committed=committed, errors=errors) + + def startup_recovery(self) -> ImageMoveResult: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT id FROM image_subfolder_move_jobs + WHERE state IN ('planned', 'moving', 'moved') + ORDER BY id; + """ + ) + job_ids = [cast(int, row[0]) for row in cursor.fetchall()] + + committed = 0 + errors = 0 + for job_id in job_ids: + try: + self.complete_partial_filesystem_moves(job_id) + self.commit_database_updates(job_id) + committed += len(self._get_items(job_id)) + except Exception as e: + errors += 1 + if self._is_unrecoverable_error(e): + self.mark_job_unrecoverable(job_id, str(e)) + else: + self.record_job_error_message(job_id, str(e)) + return ImageMoveResult(committed=committed, errors=errors) + + def plan_batch(self, last_image_name: str, limit: int) -> list[PlannedImageMove]: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT image_name, image_subfolder, image_category, is_intermediate, created_at + FROM images + WHERE image_name > ? + AND deleted_at IS NULL + ORDER BY image_name + LIMIT ?; + """, + (last_image_name, limit), + ) + rows = cursor.fetchall() + + moves: list[PlannedImageMove] = [] + for row in rows: + image_name = cast(str, row["image_name"]) + old_subfolder = cast(str, row["image_subfolder"] or "") + new_subfolder = self._get_new_subfolder( + image_name=image_name, + image_category=ImageCategory(row["image_category"]), + is_intermediate=bool(row["is_intermediate"]), + created_at=row["created_at"], + ) + if new_subfolder == old_subfolder: + continue + moves.append( + PlannedImageMove( + image_name=image_name, + old_subfolder=old_subfolder, + new_subfolder=new_subfolder, + old_path=self.image_files.get_path(image_name, image_subfolder=old_subfolder), + new_path=self.image_files.get_path(image_name, image_subfolder=new_subfolder), + old_thumbnail_path=self.image_files.get_path( + image_name, thumbnail=True, image_subfolder=old_subfolder + ), + new_thumbnail_path=self.image_files.get_path( + image_name, thumbnail=True, image_subfolder=new_subfolder + ), + ) + ) + self.preflight_moves(moves) + return moves + + def create_move_job(self, moves: Sequence[PlannedImageMove]) -> int: + if not moves: + raise ValueError("Cannot create an image move job with no items") + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT 1 + FROM image_subfolder_move_jobs + WHERE state NOT IN ('committed', 'error') + LIMIT 1; + """ + ) + if cursor.fetchone() is not None: + raise ValueError("Cannot create image move job while another active image move job exists") + cursor.execute("INSERT INTO image_subfolder_move_jobs (state) VALUES ('planned');") + job_id = cast(int, cursor.lastrowid) + cursor.executemany( + """--sql + INSERT INTO image_subfolder_move_items ( + job_id, + image_name, + old_subfolder, + new_subfolder, + old_path, + new_path, + old_thumbnail_path, + new_thumbnail_path, + state + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'planned'); + """, + [ + ( + job_id, + move.image_name, + move.old_subfolder, + move.new_subfolder, + str(move.old_path), + str(move.new_path), + str(move.old_thumbnail_path), + str(move.new_thumbnail_path), + ) + for move in moves + ], + ) + return job_id + + def preflight_moves(self, moves: Sequence[PlannedImageMove]) -> None: + destinations: set[Path] = set() + thumbnail_destinations: set[Path] = set() + for move in moves: + if not move.old_path.exists(): + raise FileNotFoundError(f"Source image does not exist: {move.old_path}") + if move.new_path.exists(): + raise FileExistsError(f"Destination image already exists: {move.new_path}") + if move.old_path == move.new_path: + raise ValueError(f"Old and new paths are identical for {move.image_name}") + if move.new_path in destinations: + raise ValueError(f"Duplicate destination path: {move.new_path}") + destinations.add(move.new_path) + if move.new_thumbnail_path in thumbnail_destinations: + raise ValueError(f"Duplicate destination thumbnail path: {move.new_thumbnail_path}") + thumbnail_destinations.add(move.new_thumbnail_path) + if self._has_active_job_for_image(move.image_name): + raise ValueError(f"Image {move.image_name} already has an active image move job") + self._assert_same_filesystem(move.old_path, move.new_path) + if move.old_thumbnail_path.exists(): + if move.new_thumbnail_path.exists(): + raise FileExistsError(f"Destination thumbnail already exists: {move.new_thumbnail_path}") + self._assert_same_filesystem(move.old_thumbnail_path, move.new_thumbnail_path) + + def perform_filesystem_moves(self, job_id: int) -> None: + self._set_job_state(job_id, "moving") + self.complete_partial_filesystem_moves(job_id) + self.cleanup_empty_source_dirs(job_id) + self._set_job_state(job_id, "moved") + + def complete_partial_filesystem_moves(self, job_id: int) -> None: + items = self._get_items(job_id) + if not items: + raise ValueError(f"Image move job {job_id} has no items") + for item in items: + old_path = self.image_files.get_path(item.image_name, image_subfolder=item.old_subfolder) + new_path = self.image_files.get_path(item.image_name, image_subfolder=item.new_subfolder) + old_thumbnail_path = self.image_files.get_path( + item.image_name, thumbnail=True, image_subfolder=item.old_subfolder + ) + new_thumbnail_path = self.image_files.get_path( + item.image_name, thumbnail=True, image_subfolder=item.new_subfolder + ) + old_exists = old_path.exists() + new_exists = new_path.exists() + if old_exists and new_exists: + raise RuntimeError(f"Both old and new image files exist for {item.image_name}") + if not old_exists and not new_exists: + raise RuntimeError(f"Neither old nor new image file exists for {item.image_name}") + if old_exists: + new_path.parent.mkdir(parents=True, exist_ok=True) + os.replace(old_path, new_path) + self._fsync_file(new_path) + self._fsync_dir(new_path.parent) + self._fsync_dir(old_path.parent) + + old_thumbnail_exists = old_thumbnail_path.exists() + new_thumbnail_exists = new_thumbnail_path.exists() + if old_thumbnail_exists and new_thumbnail_exists: + self._regenerate_thumbnail(new_path, new_thumbnail_path) + old_thumbnail_path.unlink() + self._fsync_dir(old_thumbnail_path.parent) + elif old_thumbnail_exists and not new_thumbnail_exists: + new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True) + os.replace(old_thumbnail_path, new_thumbnail_path) + self._fsync_file(new_thumbnail_path) + self._fsync_dir(new_thumbnail_path.parent) + self._fsync_dir(old_thumbnail_path.parent) + elif not new_thumbnail_exists: + self._regenerate_thumbnail(new_path, new_thumbnail_path) + + self.image_files.evict_cache_paths([old_path, new_path, old_thumbnail_path, new_thumbnail_path]) + self.mark_item_moved(job_id, item.image_name) + + def cleanup_empty_source_dirs(self, job_id: int) -> None: + for item in self._get_items(job_id): + self._remove_empty_parents( + self.image_files.get_path(item.image_name, image_subfolder=item.old_subfolder).parent, + self.image_files.image_root, + ) + self._remove_empty_parents( + self.image_files.get_path(item.image_name, thumbnail=True, image_subfolder=item.old_subfolder).parent, + self.image_files.thumbnail_root, + ) + + def commit_database_updates(self, job_id: int) -> None: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + UPDATE images + SET image_subfolder = ( + SELECT item.new_subfolder + FROM image_subfolder_move_items AS item + WHERE item.job_id = ? + AND item.image_name = images.image_name + ) + WHERE image_name IN ( + SELECT image_name + FROM image_subfolder_move_items + WHERE job_id = ? + AND state = 'moved' + ) + AND image_subfolder = ( + SELECT item.old_subfolder + FROM image_subfolder_move_items AS item + WHERE item.job_id = ? + AND item.image_name = images.image_name + ); + """, + (job_id, job_id, job_id), + ) + cursor.execute( + """--sql + SELECT COUNT(*) + FROM image_subfolder_move_items AS item + LEFT JOIN images ON images.image_name = item.image_name + WHERE item.job_id = ? + AND ( + images.image_name IS NULL + OR images.deleted_at IS NOT NULL + OR images.image_subfolder != item.new_subfolder + ); + """, + (job_id,), + ) + invalid_count = cast(int, cursor.fetchone()[0]) + if invalid_count: + raise RuntimeError(f"Image move job {job_id} failed commit validation") + cursor.execute( + "UPDATE image_subfolder_move_items SET state = 'committed' WHERE job_id = ?;", + (job_id,), + ) + cursor.execute( + "UPDATE image_subfolder_move_jobs SET state = 'committed', error_message = NULL WHERE id = ?;", + (job_id,), + ) + + def mark_item_moved(self, job_id: int, image_name: str) -> None: + with self._db.transaction() as cursor: + cursor.execute( + "UPDATE image_subfolder_move_items SET state = 'moved' WHERE job_id = ? AND image_name = ?;", + (job_id, image_name), + ) + + def record_job_error_message(self, job_id: int, message: str) -> None: + with self._db.transaction() as cursor: + cursor.execute( + "UPDATE image_subfolder_move_jobs SET error_message = ? WHERE id = ?;", + (message, job_id), + ) + + def mark_job_unrecoverable(self, job_id: int, message: str) -> None: + with self._db.transaction() as cursor: + cursor.execute( + "UPDATE image_subfolder_move_jobs SET state = 'error', error_message = ? WHERE id = ?;", + (message, job_id), + ) + cursor.execute( + "UPDATE image_subfolder_move_items SET state = 'error', error_message = ? WHERE job_id = ?;", + (message, job_id), + ) + + def get_job(self, job_id: int) -> ImageMoveJob: + with self._db.transaction() as cursor: + cursor.execute( + "SELECT id, state, error_message FROM image_subfolder_move_jobs WHERE id = ?;", + (job_id,), + ) + row = cursor.fetchone() + if row is None: + raise ValueError(f"Image move job not found: {job_id}") + return ImageMoveJob( + id=cast(int, row["id"]), state=cast(MoveJobState, row["state"]), error_message=row["error_message"] + ) + + def _get_new_subfolder( + self, image_name: str, image_category: ImageCategory, is_intermediate: bool, created_at: str | datetime + ) -> str: + strategy = self._config.image_subfolder_strategy + if strategy == "flat": + return "" + if strategy == "type": + return "intermediate" if is_intermediate else image_category.value + if strategy == "hash": + return image_name[:2] + if strategy == "date": + timestamp = created_at if isinstance(created_at, datetime) else datetime.fromisoformat(created_at) + return f"{timestamp.year}/{timestamp.month:02d}/{timestamp.day:02d}" + raise ValueError(f"Unknown image subfolder strategy: {strategy}") + + def _get_items(self, job_id: int) -> list[PlannedImageMove]: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT image_name, old_subfolder, new_subfolder + FROM image_subfolder_move_items + WHERE job_id = ? + ORDER BY image_name; + """, + (job_id,), + ) + rows = cursor.fetchall() + return [ + PlannedImageMove( + image_name=row["image_name"], + old_subfolder=row["old_subfolder"], + new_subfolder=row["new_subfolder"], + old_path=self.image_files.get_path(row["image_name"], image_subfolder=row["old_subfolder"]), + new_path=self.image_files.get_path(row["image_name"], image_subfolder=row["new_subfolder"]), + old_thumbnail_path=self.image_files.get_path( + row["image_name"], thumbnail=True, image_subfolder=row["old_subfolder"] + ), + new_thumbnail_path=self.image_files.get_path( + row["image_name"], thumbnail=True, image_subfolder=row["new_subfolder"] + ), + ) + for row in rows + ] + + def _set_job_state(self, job_id: int, state: MoveJobState) -> None: + with self._db.transaction() as cursor: + cursor.execute("UPDATE image_subfolder_move_jobs SET state = ? WHERE id = ?;", (state, job_id)) + + def _has_active_job_for_image(self, image_name: str) -> bool: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT 1 + FROM image_subfolder_move_items AS item + JOIN image_subfolder_move_jobs AS job ON job.id = item.job_id + WHERE item.image_name = ? + AND job.state NOT IN ('committed', 'error') + LIMIT 1; + """, + (image_name,), + ) + return cursor.fetchone() is not None + + def _next_image_name(self, last_image_name: str) -> str | None: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT image_name FROM images + WHERE image_name > ? + AND deleted_at IS NULL + ORDER BY image_name + LIMIT 1; + """, + (last_image_name,), + ) + row = cursor.fetchone() + return None if row is None else cast(str, row[0]) + + def _regenerate_thumbnail(self, image_path: Path, thumbnail_path: Path) -> None: + thumbnail_path.parent.mkdir(parents=True, exist_ok=True) + with Image.open(image_path) as image: + thumbnail = make_thumbnail(image) + with tempfile.NamedTemporaryFile( + dir=thumbnail_path.parent, prefix=f".{thumbnail_path.name}.", suffix=".tmp", delete=False + ) as temp_file: + temp_path = Path(temp_file.name) + try: + thumbnail.save(temp_path, format="WEBP") + self._fsync_file(temp_path) + os.replace(temp_path, thumbnail_path) + self._fsync_file(thumbnail_path) + self._fsync_dir(thumbnail_path.parent) + finally: + temp_path.unlink(missing_ok=True) + + def _remove_empty_parents(self, start: Path, root: Path) -> None: + root = root.resolve() + current = start.resolve() + while current != root and current.is_relative_to(root): + try: + current.rmdir() + except OSError: + return + current = current.parent + + def _assert_same_filesystem(self, source: Path, destination: Path) -> None: + source_parent = source.parent + destination_parent = self._nearest_existing_parent(destination.parent) + if source_parent.stat().st_dev != destination_parent.stat().st_dev: + raise ValueError(f"Cross-filesystem image move is not supported: {source} -> {destination}") + + def _nearest_existing_parent(self, path: Path) -> Path: + current = path + while not current.exists(): + if current.parent == current: + raise FileNotFoundError(f"No existing parent found for {path}") + current = current.parent + return current + + def _fsync_file(self, path: Path) -> None: + with path.open("rb") as file: + os.fsync(file.fileno()) + + def _fsync_dir(self, path: Path) -> None: + try: + dir_fd = os.open(path, os.O_RDONLY) + except OSError as e: + self._logger.debug("Unable to open directory for fsync: %s: %s", path, e) + return + try: + os.fsync(dir_fd) + except OSError as e: + self._logger.debug("Unable to fsync directory: %s: %s", path, e) + finally: + os.close(dir_fd) + + def _is_unrecoverable_error(self, error: Exception) -> bool: + return isinstance(error, RuntimeError) and ( + str(error).startswith("Both old and new image files exist") + or str(error).startswith("Neither old nor new image file exists") + or str(error).startswith("Image move job") + and "has no items" in str(error) + ) diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 2c95f87b41d..0b36705ab3f 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -23,6 +23,7 @@ from invokeai.app.services.events.events_base import EventServiceBase from invokeai.app.services.external_generation.external_generation_base import ExternalGenerationServiceBase from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase + from invokeai.app.services.image_moves.image_moves_default import ImageMoveService from invokeai.app.services.image_records.image_records_base import ImageRecordStorageBase from invokeai.app.services.images.images_base import ImageServiceABC from invokeai.app.services.invocation_cache.invocation_cache_base import InvocationCacheBase @@ -79,6 +80,7 @@ def __init__( workflow_thumbnails: "WorkflowThumbnailServiceBase", client_state_persistence: "ClientStatePersistenceABC", users: "UserServiceBase", + image_moves: "ImageMoveService | None" = None, ): self.board_images = board_images self.board_image_records = board_image_records @@ -111,3 +113,4 @@ def __init__( self.workflow_thumbnails = workflow_thumbnails self.client_state_persistence = client_state_persistence self.users = users + self.image_moves = image_moves diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 12642610c8c..3e1d5c53f3e 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -34,6 +34,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_29 import build_migration_29 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_30 import build_migration_30 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_31 import build_migration_31 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_32 import build_migration_32 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator @@ -85,6 +86,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_29()) migrator.register_migration(build_migration_30()) migrator.register_migration(build_migration_31()) + migrator.register_migration(build_migration_32()) migrator.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py new file mode 100644 index 00000000000..09e09e8f783 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py @@ -0,0 +1,71 @@ +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration32Callback: + def __call__(self, cursor: sqlite3.Cursor) -> None: + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS image_subfolder_move_jobs ( + id INTEGER PRIMARY KEY, + state TEXT NOT NULL CHECK ( + state IN ('planned', 'moving', 'moved', 'committed', 'error') + ), + created_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + updated_at DATETIME NOT NULL DEFAULT(STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW')), + error_message TEXT + ); + """ + ) + cursor.execute( + """--sql + CREATE TABLE IF NOT EXISTS image_subfolder_move_items ( + job_id INTEGER NOT NULL REFERENCES image_subfolder_move_jobs(id), + image_name TEXT NOT NULL REFERENCES images(image_name), + old_subfolder TEXT NOT NULL, + new_subfolder TEXT NOT NULL, + old_path TEXT, + new_path TEXT, + old_thumbnail_path TEXT, + new_thumbnail_path TEXT, + state TEXT NOT NULL CHECK ( + state IN ('planned', 'moved', 'committed', 'error') + ), + error_message TEXT, + PRIMARY KEY (job_id, image_name) + ); + """ + ) + cursor.execute( + """--sql + CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_job_state + ON image_subfolder_move_items(job_id, state); + """ + ) + cursor.execute( + """--sql + CREATE INDEX IF NOT EXISTS idx_image_subfolder_move_items_image_name + ON image_subfolder_move_items(image_name); + """ + ) + cursor.execute( + """--sql + CREATE TRIGGER IF NOT EXISTS tg_image_subfolder_move_jobs_updated_at + AFTER UPDATE + ON image_subfolder_move_jobs FOR EACH ROW + BEGIN + UPDATE image_subfolder_move_jobs + SET updated_at = STRFTIME('%Y-%m-%d %H:%M:%f', 'NOW') + WHERE id = old.id; + END; + """ + ) + + +def build_migration_32() -> Migration: + return Migration( + from_version=31, + to_version=32, + callback=Migration32Callback(), + ) diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py new file mode 100644 index 00000000000..00541e68a91 --- /dev/null +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -0,0 +1,332 @@ +from pathlib import Path +from shutil import copy2 +from unittest.mock import MagicMock, patch + +import pytest +from PIL import Image + +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage +from invokeai.app.services.image_moves.image_moves_default import ImageMoveService +from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin +from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage +from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase +from invokeai.app.services.shared.sqlite.sqlite_util import init_db +from invokeai.backend.util.logging import InvokeAILogger + + +def _build_db(tmp_path: Path) -> SqliteDatabase: + logger = InvokeAILogger.get_logger() + config = InvokeAIAppConfig(use_memory_db=False) + config._root = tmp_path + image_files = DiskImageFileStorage(tmp_path / "images") + return init_db(config=config, logger=logger, image_files=image_files) + + +def _save_record(records: SqliteImageRecordStorage, image_name: str, subfolder: str, created_at: str) -> None: + records.save( + image_name=image_name, + image_origin=ResourceOrigin.INTERNAL, + image_category=ImageCategory.GENERAL, + width=16, + height=16, + has_workflow=False, + image_subfolder=subfolder, + ) + with records._db.transaction() as cursor: + cursor.execute("UPDATE images SET created_at = ? WHERE image_name = ?;", (created_at, image_name)) + + +def _save_image( + service: ImageMoveService, + records: SqliteImageRecordStorage, + image_name: str, + subfolder: str, + created_at: str, + color: str, +) -> None: + _save_record(records, image_name=image_name, subfolder=subfolder, created_at=created_at) + service.image_files.save(Image.new("RGB", (16, 16), color), image_name=image_name, image_subfolder=subfolder) + + +def _service(tmp_path: Path, strategy: str = "date") -> tuple[ImageMoveService, SqliteImageRecordStorage]: + db = _build_db(tmp_path) + records = SqliteImageRecordStorage(db=db) + storage = DiskImageFileStorage(tmp_path / "images") + invoker = MagicMock() + invoker.services.configuration.pil_compress_level = 6 + storage.start(invoker) + config = InvokeAIAppConfig(use_memory_db=True, image_subfolder_strategy=strategy) + config._root = tmp_path + service = ImageMoveService(db=db, image_files=storage, config=config, logger=InvokeAILogger.get_logger()) + return service, records + + +def _job_item_states(service: ImageMoveService, job_id: int) -> dict[str, str]: + with service._db.transaction() as cursor: + cursor.execute( + "SELECT image_name, state FROM image_subfolder_move_items WHERE job_id = ? ORDER BY image_name;", + (job_id,), + ) + return {row["image_name"]: row["state"] for row in cursor.fetchall()} + + +def test_move_all_images_uses_created_at_for_date_strategy(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-a.png" + _save_record(records, image_name=image_name, subfolder="", created_at="2024-02-03 04:05:06.000") + service.image_files.save(Image.new("RGB", (16, 16), "red"), image_name=image_name) + + result = service.move_all_images() + + assert result.planned == 1 + assert result.committed == 1 + record = records.get(image_name) + assert record.image_subfolder == "2024/02/03" + assert service.image_files.get_path(image_name, image_subfolder="2024/02/03").exists() + assert not service.image_files.get_path(image_name, image_subfolder="").exists() + + +def test_startup_recovery_commits_after_files_moved_but_db_not_updated(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-b.png" + _save_record(records, image_name=image_name, subfolder="", created_at="2025-06-07 08:09:10.000") + service.image_files.save(Image.new("RGB", (16, 16), "blue"), image_name=image_name) + + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + service.perform_filesystem_moves(job_id) + + assert records.get(image_name).image_subfolder == "" + + recovered = service.startup_recovery() + + assert recovered.committed == 1 + assert records.get(image_name).image_subfolder == "2025/06/07" + assert service.get_job(job_id).state == "committed" + + +def test_cleanup_empty_source_directories_after_move(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-c.png" + old_subfolder = "old/nested" + _save_record(records, image_name=image_name, subfolder=old_subfolder, created_at="2024-11-12 01:02:03.000") + service.image_files.save(Image.new("RGB", (16, 16), "green"), image_name=image_name, image_subfolder=old_subfolder) + old_parent = service.image_files.get_path(image_name, image_subfolder=old_subfolder).parent + old_thumb_parent = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder).parent + + service.move_all_images() + + assert not old_parent.exists() + assert not old_thumb_parent.exists() + assert service.image_files.image_root.exists() + assert service.image_files.thumbnail_root.exists() + + +def test_preflight_rejects_active_uncommitted_job_for_same_image(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-d.png" + _save_record(records, image_name=image_name, subfolder="", created_at="2024-01-02 03:04:05.000") + service.image_files.save(Image.new("RGB", (16, 16), "yellow"), image_name=image_name) + + moves = service.plan_batch(last_image_name="", limit=100) + service.create_move_job(moves) + + with pytest.raises(ValueError, match="active image move job"): + service.plan_batch(last_image_name="", limit=100) + + +def test_create_move_job_rejects_second_active_job_from_stale_plan(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-active-race.png" + _save_image(service, records, image_name, "", "2024-01-03 03:04:05.000", "yellow") + + stale_plan_a = service.plan_batch(last_image_name="", limit=100) + stale_plan_b = service.plan_batch(last_image_name="", limit=100) + service.create_move_job(stale_plan_a) + + with pytest.raises(ValueError, match="active image move job"): + service.create_move_job(stale_plan_b) + + +def test_startup_recovery_completes_planned_job_before_any_file_move(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-e.png" + _save_image(service, records, image_name, "", "2024-03-04 05:06:07.000", "purple") + + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + + recovered_once = service.startup_recovery() + recovered_twice = service.startup_recovery() + + assert recovered_once.committed == 1 + assert recovered_once.errors == 0 + assert recovered_twice.committed == 0 + assert recovered_twice.errors == 0 + assert records.get(image_name).image_subfolder == "2024/03/04" + assert service.get_job(job_id).state == "committed" + assert _job_item_states(service, job_id) == {image_name: "committed"} + + +def test_startup_recovery_completes_partial_multi_image_move(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + _save_image(service, records, "image-f.png", "", "2024-04-05 06:07:08.000", "orange") + _save_image(service, records, "image-g.png", "", "2024-04-06 06:07:08.000", "cyan") + + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + first_move = moves[0] + first_move.new_path.parent.mkdir(parents=True, exist_ok=True) + first_move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True) + first_move.old_path.replace(first_move.new_path) + first_move.old_thumbnail_path.replace(first_move.new_thumbnail_path) + + recovered_once = service.startup_recovery() + recovered_twice = service.startup_recovery() + + assert recovered_once.committed == 2 + assert recovered_once.errors == 0 + assert recovered_twice.committed == 0 + assert recovered_twice.errors == 0 + assert records.get("image-f.png").image_subfolder == "2024/04/05" + assert records.get("image-g.png").image_subfolder == "2024/04/06" + assert service.get_job(job_id).state == "committed" + assert _job_item_states(service, job_id) == {"image-f.png": "committed", "image-g.png": "committed"} + + +def test_startup_recovery_marks_committed_after_db_update_but_before_journal_commit(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-h.png" + _save_image(service, records, image_name, "", "2024-05-06 07:08:09.000", "pink") + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + service.perform_filesystem_moves(job_id) + + with service._db.transaction() as cursor: + cursor.execute( + "UPDATE images SET image_subfolder = ? WHERE image_name = ?;", + ("2024/05/06", image_name), + ) + + recovered_once = service.startup_recovery() + recovered_twice = service.startup_recovery() + + assert recovered_once.committed == 1 + assert recovered_once.errors == 0 + assert recovered_twice.committed == 0 + assert recovered_twice.errors == 0 + assert records.get(image_name).image_subfolder == "2024/05/06" + assert service.get_job(job_id).state == "committed" + assert _job_item_states(service, job_id) == {image_name: "committed"} + + +def test_startup_recovery_marks_error_when_both_old_and_new_full_size_files_exist(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-i.png" + _save_image(service, records, image_name, "", "2024-07-08 09:10:11.000", "red") + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + move = moves[0] + move.new_path.parent.mkdir(parents=True, exist_ok=True) + copy2(move.old_path, move.new_path) + + recovered = service.startup_recovery() + + assert recovered.committed == 0 + assert recovered.errors == 1 + assert records.get(image_name).image_subfolder == "" + assert service.get_job(job_id).state == "error" + assert _job_item_states(service, job_id) == {image_name: "error"} + + +def test_startup_recovery_marks_error_when_neither_old_nor_new_full_size_file_exists(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-j.png" + _save_image(service, records, image_name, "", "2024-08-09 10:11:12.000", "blue") + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + moves[0].old_path.unlink() + + recovered = service.startup_recovery() + + assert recovered.committed == 0 + assert recovered.errors == 1 + assert records.get(image_name).image_subfolder == "" + assert service.get_job(job_id).state == "error" + assert _job_item_states(service, job_id) == {image_name: "error"} + + +def test_startup_recovery_keeps_job_recoverable_after_ordinary_exception(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-k.png" + _save_image(service, records, image_name, "", "2024-09-10 11:12:13.000", "white") + job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100)) + + with patch.object(service, "complete_partial_filesystem_moves", side_effect=OSError("temporary failure")): + recovered = service.startup_recovery() + + assert recovered.committed == 0 + assert recovered.errors == 1 + job = service.get_job(job_id) + assert job.state == "planned" + assert job.error_message == "temporary failure" + + recovered_retry = service.startup_recovery() + + assert recovered_retry.committed == 1 + assert recovered_retry.errors == 0 + assert records.get(image_name).image_subfolder == "2024/09/10" + assert service.get_job(job_id).state == "committed" + + +def test_startup_recovery_regenerates_thumbnail_when_old_and_new_thumbnails_exist(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-l.png" + _save_image(service, records, image_name, "", "2024-10-11 12:13:14.000", "black") + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + move = moves[0] + move.new_path.parent.mkdir(parents=True, exist_ok=True) + move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True) + move.old_path.replace(move.new_path) + copy2(move.old_thumbnail_path, move.new_thumbnail_path) + + recovered = service.startup_recovery() + + assert recovered.committed == 1 + assert recovered.errors == 0 + assert records.get(image_name).image_subfolder == "2024/10/11" + assert move.new_thumbnail_path.exists() + assert not move.old_thumbnail_path.exists() + assert service.get_job(job_id).state == "committed" + + +def test_preflight_rejects_duplicate_thumbnail_destination_paths(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + _save_image(service, records, "same-name.jpg", "", "2024-12-13 14:15:16.000", "red") + _save_image(service, records, "same-name.png", "", "2024-12-13 14:15:16.000", "green") + + with pytest.raises(ValueError, match="Duplicate destination thumbnail path"): + service.plan_batch(last_image_name="", limit=100) + + +def test_successful_filesystem_move_fsyncs_files_and_directories(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-m.png" + _save_image(service, records, image_name, "", "2025-01-02 03:04:05.000", "blue") + job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100)) + + with ( + patch.object(service, "_fsync_file") as fsync_file, + patch.object(service, "_fsync_dir") as fsync_dir, + ): + service.perform_filesystem_moves(job_id) + + moved = service._get_items(job_id)[0] + fsync_file.assert_any_call(moved.new_path) + fsync_file.assert_any_call(moved.new_thumbnail_path) + fsync_dir.assert_any_call(moved.new_path.parent) + fsync_dir.assert_any_call(moved.old_path.parent) + fsync_dir.assert_any_call(moved.new_thumbnail_path.parent) + fsync_dir.assert_any_call(moved.old_thumbnail_path.parent) From ec8e62a85654341c96d41b93cf9a914b85b3ccbc Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Sat, 9 May 2026 16:11:20 -0500 Subject: [PATCH 02/12] Add image move background API --- invokeai/app/api/routers/image_moves.py | 91 + invokeai/app/api_app.py | 2 + .../image_moves/image_moves_default.py | 119 + invokeai/frontend/web/openapi.json | 7310 ++++++++++++++--- .../frontend/web/src/services/api/schema.ts | 155 + tests/app/routers/test_image_moves.py | 187 + .../image_moves/test_image_moves_default.py | 61 +- 7 files changed, 6903 insertions(+), 1022 deletions(-) create mode 100644 invokeai/app/api/routers/image_moves.py create mode 100644 tests/app/routers/test_image_moves.py diff --git a/invokeai/app/api/routers/image_moves.py b/invokeai/app/api/routers/image_moves.py new file mode 100644 index 00000000000..d2b92699fd7 --- /dev/null +++ b/invokeai/app/api/routers/image_moves.py @@ -0,0 +1,91 @@ +from fastapi import HTTPException, status +from fastapi.routing import APIRouter +from pydantic import BaseModel, Field + +from invokeai.app.api.auth_dependencies import AdminUserOrDefault +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.services.image_moves.image_moves_default import ( + ImageMoveBackgroundOperation, + ImageMoveBackgroundStatus, + ImageMoveJob, + ImageMoveJobAlreadyRunning, + MoveJobState, +) + +image_moves_router = APIRouter(prefix="/v1/image_moves", tags=["image_moves"]) + + +class ImageMoveJobResponse(BaseModel): + id: int = Field(description="The image move job id.") + state: MoveJobState = Field(description="The image move job state.") + error_message: str | None = Field(default=None, description="The last error recorded for the job, if any.") + + +class ImageMoveStatusResponse(BaseModel): + is_running: bool = Field(description="Whether an image move background operation is currently running.") + operation: ImageMoveBackgroundOperation | None = Field( + default=None, description="The active background operation, if any." + ) + active_job_id: int | None = Field(default=None, description="The active journal job id, if any.") + latest_job: ImageMoveJobResponse | None = Field(default=None, description="The latest journal job, if any.") + last_error: str | None = Field(default=None, description="The last background worker error, if any.") + + +def _get_image_move_service(): + image_moves = getattr(ApiDependencies.invoker.services, "image_moves", None) + if image_moves is None: + raise HTTPException(status_code=status.HTTP_503_SERVICE_UNAVAILABLE, detail="Image move service unavailable") + return image_moves + + +def _job_to_response(job: ImageMoveJob | None) -> ImageMoveJobResponse | None: + if job is None: + return None + return ImageMoveJobResponse(id=job.id, state=job.state, error_message=job.error_message) + + +def _status_to_response(service_status: ImageMoveBackgroundStatus | dict) -> ImageMoveStatusResponse: + if isinstance(service_status, dict): + return ImageMoveStatusResponse(**service_status) + return ImageMoveStatusResponse( + is_running=service_status.is_running, + operation=service_status.operation, + active_job_id=service_status.active_job_id, + latest_job=_job_to_response(service_status.latest_job), + last_error=service_status.last_error, + ) + + +@image_moves_router.post( + "/start", + operation_id="start_image_move", + response_model=ImageMoveStatusResponse, + status_code=status.HTTP_202_ACCEPTED, +) +async def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: + try: + return _status_to_response(_get_image_move_service().start_background_move_all()) + except ImageMoveJobAlreadyRunning as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e + + +@image_moves_router.post( + "/recover", + operation_id="start_image_move_recovery", + response_model=ImageMoveStatusResponse, + status_code=status.HTTP_202_ACCEPTED, +) +async def start_image_move_recovery(_: AdminUserOrDefault) -> ImageMoveStatusResponse: + try: + return _status_to_response(_get_image_move_service().start_background_recovery()) + except ImageMoveJobAlreadyRunning as e: + raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e + + +@image_moves_router.get( + "/status", + operation_id="get_image_move_status", + response_model=ImageMoveStatusResponse, +) +async def get_image_move_status(_: AdminUserOrDefault) -> ImageMoveStatusResponse: + return _status_to_response(_get_image_move_service().get_background_status()) diff --git a/invokeai/app/api_app.py b/invokeai/app/api_app.py index 4b79e1eeb0c..ed02d75eae3 100644 --- a/invokeai/app/api_app.py +++ b/invokeai/app/api_app.py @@ -23,6 +23,7 @@ client_state, custom_nodes, download_queue, + image_moves, images, model_manager, model_relationships, @@ -176,6 +177,7 @@ async def dispatch(self, request: Request, call_next: RequestResponseEndpoint): app.include_router(utilities.utilities_router, prefix="/api") app.include_router(model_manager.model_manager_router, prefix="/api") app.include_router(download_queue.download_queue_router, prefix="/api") +app.include_router(image_moves.image_moves_router, prefix="/api") app.include_router(images.images_router, prefix="/api") app.include_router(boards.boards_router, prefix="/api") app.include_router(board_images.board_images_router, prefix="/api") diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index e2f269025db..f5ac2b2f947 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -1,5 +1,7 @@ import os import tempfile +import threading +from concurrent.futures import Future, ThreadPoolExecutor from dataclasses import dataclass from datetime import datetime from pathlib import Path @@ -15,6 +17,7 @@ MoveJobState = Literal["planned", "moving", "moved", "committed", "error"] MoveItemState = Literal["planned", "moved", "committed", "error"] +ImageMoveBackgroundOperation = Literal["move_all", "recovery"] @dataclass(frozen=True) @@ -42,6 +45,22 @@ class ImageMoveResult: errors: int = 0 +@dataclass(frozen=True) +class ImageMoveBackgroundStatus: + is_running: bool + operation: ImageMoveBackgroundOperation | None + active_job_id: int | None + latest_job: ImageMoveJob | None + last_error: str | None + + +class ImageMoveJobAlreadyRunning(Exception): + pass + + +BACKGROUND_SHUTDOWN_ERROR = "Image move service stopped while background operation was running" + + class ImageMoveService: def __init__( self, @@ -54,6 +73,75 @@ def __init__( self.image_files = image_files self._config = config self._logger = logger + self._executor = ThreadPoolExecutor(max_workers=1, thread_name_prefix="image-move") + self._future_lock = threading.Lock() + self._future: Future | None = None + self._future_operation: ImageMoveBackgroundOperation | None = None + self._last_background_error: str | None = None + + def stop(self, *args, **kwargs) -> None: + with self._future_lock: + is_running = self._future is not None and not self._future.done() + if is_running: + self._record_background_error(BACKGROUND_SHUTDOWN_ERROR) + self._executor.shutdown(wait=False, cancel_futures=False) + + def start_background_move_all(self) -> ImageMoveBackgroundStatus: + return self._start_background_operation("move_all", self.move_all_images) + + def start_background_recovery(self) -> ImageMoveBackgroundStatus: + return self._start_background_operation("recovery", self.startup_recovery) + + def get_background_status(self) -> ImageMoveBackgroundStatus: + with self._future_lock: + self._refresh_finished_future_locked() + return self._build_background_status_locked() + + def _start_background_operation(self, operation: ImageMoveBackgroundOperation, target) -> ImageMoveBackgroundStatus: + with self._future_lock: + self._refresh_finished_future_locked() + if self._future is not None and not self._future.done(): + raise ImageMoveJobAlreadyRunning("An image move job is already running") + active_job_id = self._get_active_job_id() + if operation != "recovery" and active_job_id is not None: + raise ImageMoveJobAlreadyRunning("An image move job is already active") + self._last_background_error = None + self._future_operation = operation + self._future = self._executor.submit(self._run_background_operation, operation, target) + return self._build_background_status_locked() + + def _run_background_operation(self, operation: ImageMoveBackgroundOperation, target) -> None: + try: + target() + except Exception as e: + self._record_background_error(str(e)) + self._logger.exception("Image move background operation failed: %s", operation) + + def _record_background_error(self, message: str) -> None: + with self._future_lock: + self._last_background_error = message + active_job_id = self._get_active_job_id() + if active_job_id is not None: + try: + self.record_job_error_message(active_job_id, message) + except Exception: + self._logger.exception("Failed to record image move background error on active job") + + def _refresh_finished_future_locked(self) -> None: + if self._future is None or not self._future.done(): + return + self._future = None + self._future_operation = None + + def _build_background_status_locked(self) -> ImageMoveBackgroundStatus: + latest_job = self.get_latest_job() + return ImageMoveBackgroundStatus( + is_running=self._future is not None and not self._future.done(), + operation=self._future_operation, + active_job_id=self._get_active_job_id(), + latest_job=latest_job, + last_error=self._last_background_error, + ) def move_all_images(self) -> ImageMoveResult: recovered = self.startup_recovery() @@ -377,6 +465,23 @@ def get_job(self, job_id: int) -> ImageMoveJob: id=cast(int, row["id"]), state=cast(MoveJobState, row["state"]), error_message=row["error_message"] ) + def get_latest_job(self) -> ImageMoveJob | None: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT id, state, error_message + FROM image_subfolder_move_jobs + ORDER BY id DESC + LIMIT 1; + """ + ) + row = cursor.fetchone() + if row is None: + return None + return ImageMoveJob( + id=cast(int, row["id"]), state=cast(MoveJobState, row["state"]), error_message=row["error_message"] + ) + def _get_new_subfolder( self, image_name: str, image_category: ImageCategory, is_intermediate: bool, created_at: str | datetime ) -> str: @@ -440,6 +545,20 @@ def _has_active_job_for_image(self, image_name: str) -> bool: ) return cursor.fetchone() is not None + def _get_active_job_id(self) -> int | None: + with self._db.transaction() as cursor: + cursor.execute( + """--sql + SELECT id + FROM image_subfolder_move_jobs + WHERE state NOT IN ('committed', 'error') + ORDER BY id + LIMIT 1; + """ + ) + row = cursor.fetchone() + return None if row is None else cast(int, row["id"]) + def _next_image_name(self, last_image_name: str) -> str | None: with self._db.transaction() as cursor: cursor.execute( diff --git a/invokeai/frontend/web/openapi.json b/invokeai/frontend/web/openapi.json index 76e96cfd870..954f59f83f9 100644 --- a/invokeai/frontend/web/openapi.json +++ b/invokeai/frontend/web/openapi.json @@ -484,6 +484,86 @@ } } }, + "/api/v1/utilities/expand-prompt": { + "post": { + "tags": ["utilities"], + "summary": "Expand Prompt", + "description": "Expand a brief prompt into a detailed image generation prompt using a text LLM.", + "operationId": "expand_prompt", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpandPromptRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExpandPromptResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/utilities/image-to-prompt": { + "post": { + "tags": ["utilities"], + "summary": "Image To Prompt", + "description": "Generate a descriptive prompt from an image using a vision-language model.", + "operationId": "image_to_prompt", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageToPromptRequest" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageToPromptResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v2/models/": { "get": { "tags": ["model_manager"], @@ -565,6 +645,28 @@ "title": "Model Format" }, "description": "Exact match on the format of the model (e.g. 'diffusers')" + }, + { + "name": "order_by", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/ModelRecordOrderBy", + "description": "The field to order by", + "default": "name" + }, + "description": "The field to order by" + }, + { + "name": "direction", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/SQLiteDirection", + "description": "The direction to order by", + "default": "ASC" + }, + "description": "The direction to order by" } ], "responses": { @@ -741,6 +843,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -846,6 +951,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -912,6 +1023,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -1044,6 +1161,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -1149,6 +1269,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -1215,6 +1341,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -1347,6 +1479,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -1452,6 +1587,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -1518,6 +1659,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -1700,6 +1847,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -1805,6 +1955,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -1871,6 +2027,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -2077,6 +2239,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -2182,6 +2347,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -2248,6 +2419,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -3274,6 +3451,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -3379,6 +3559,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -3445,6 +3631,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -3917,6 +4109,78 @@ } } }, + "/api/v1/image_moves/start": { + "post": { + "tags": ["image_moves"], + "summary": "Start Image Move", + "operationId": "start_image_move", + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageMoveStatusResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/image_moves/recover": { + "post": { + "tags": ["image_moves"], + "summary": "Start Image Move Recovery", + "operationId": "start_image_move_recovery", + "responses": { + "202": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageMoveStatusResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/image_moves/status": { + "get": { + "tags": ["image_moves"], + "summary": "Get Image Move Status", + "operationId": "get_image_move_status", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageMoveStatusResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, "/api/v1/images/upload": { "post": { "tags": ["images"], @@ -5805,6 +6069,145 @@ ] } }, + "/api/v1/virtual_boards/by_date": { + "get": { + "tags": ["virtual_boards"], + "summary": "List Virtual Boards By Date", + "description": "Gets a list of virtual sub-boards grouped by date.", + "operationId": "list_virtual_boards_by_date", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/VirtualSubBoardDTO" + }, + "type": "array", + "title": "Response List Virtual Boards By Date" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/virtual_boards/by_date/{date}/image_names": { + "get": { + "tags": ["virtual_boards"], + "summary": "List Virtual Board Image Names By Date", + "description": "Gets ordered image names for a specific date.", + "operationId": "list_virtual_board_image_names_by_date", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "date", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The ISO date string, e.g. '2026-03-18'", + "title": "Date" + }, + "description": "The ISO date string, e.g. '2026-03-18'" + }, + { + "name": "starred_first", + "in": "query", + "required": false, + "schema": { + "type": "boolean", + "description": "Whether to sort starred images first", + "default": true, + "title": "Starred First" + }, + "description": "Whether to sort starred images first" + }, + { + "name": "order_dir", + "in": "query", + "required": false, + "schema": { + "$ref": "#/components/schemas/SQLiteDirection", + "description": "The sort direction", + "default": "DESC" + }, + "description": "The sort direction" + }, + { + "name": "categories", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "array", + "items": { + "$ref": "#/components/schemas/ImageCategory" + } + }, + { + "type": "null" + } + ], + "description": "The categories of images to include", + "title": "Categories" + }, + "description": "The categories of images to include" + }, + { + "name": "search_term", + "in": "query", + "required": false, + "schema": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "description": "Search term to filter images", + "title": "Search Term" + }, + "description": "Search term to filter images" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ImageNamesResult" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/model_relationships/i/{model_key}": { "get": { "tags": ["model_relationships"], @@ -6052,6 +6455,188 @@ } } } + }, + "patch": { + "tags": ["app"], + "summary": "Update Runtime Config", + "operationId": "update_runtime_config", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UpdateAppGenerationSettingsRequest", + "description": "Writable runtime configuration changes" + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InvokeAIAppConfigWithSetFields" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v1/app/external_providers/status": { + "get": { + "tags": ["app"], + "summary": "Get External Provider Statuses", + "operationId": "get_external_provider_statuses", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ExternalProviderStatusModel" + }, + "type": "array", + "title": "Response Get External Provider Statuses" + } + } + } + } + } + } + }, + "/api/v1/app/external_providers/config": { + "get": { + "tags": ["app"], + "summary": "Get External Provider Configs", + "operationId": "get_external_provider_configs", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "items": { + "$ref": "#/components/schemas/ExternalProviderConfigModel" + }, + "type": "array", + "title": "Response Get External Provider Configs" + } + } + } + } + } + } + }, + "/api/v1/app/external_providers/config/{provider_id}": { + "post": { + "tags": ["app"], + "summary": "Set External Provider Config", + "operationId": "set_external_provider_config", + "parameters": [ + { + "name": "provider_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The external provider identifier", + "title": "Provider Id" + }, + "description": "The external provider identifier" + } + ], + "requestBody": { + "required": true, + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalProviderConfigUpdate", + "description": "External provider configuration settings" + } + } + } + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalProviderConfigModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + }, + "delete": { + "tags": ["app"], + "summary": "Reset External Provider Config", + "operationId": "reset_external_provider_config", + "parameters": [ + { + "name": "provider_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The external provider identifier", + "title": "Provider Id" + }, + "description": "The external provider identifier" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/ExternalProviderConfigModel" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } } }, "/api/v1/app/logging": { @@ -8771,6 +9356,129 @@ } } }, + "/api/v1/client_state/{queue_id}/get_keys_by_prefix": { + "get": { + "tags": ["client_state"], + "summary": "Get Client State Keys By Prefix", + "description": "Gets client state keys matching a prefix for the current user", + "operationId": "get_client_state_keys_by_prefix", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The queue id (ignored, kept for backwards compatibility)", + "title": "Queue Id" + }, + "description": "The queue id (ignored, kept for backwards compatibility)" + }, + { + "name": "prefix", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Prefix to filter keys by", + "title": "Prefix" + }, + "description": "Prefix to filter keys by" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "type": "array", + "items": { + "type": "string" + }, + "title": "Response Get Client State Keys By Prefix" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v1/client_state/{queue_id}/delete_by_key": { + "post": { + "tags": ["client_state"], + "summary": "Delete Client State By Key", + "description": "Deletes a specific client state key for the current user", + "operationId": "delete_client_state_by_key", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "queue_id", + "in": "path", + "required": true, + "schema": { + "type": "string", + "description": "The queue id (ignored, kept for backwards compatibility)", + "title": "Queue Id" + }, + "description": "The queue id (ignored, kept for backwards compatibility)" + }, + { + "name": "key", + "in": "query", + "required": true, + "schema": { + "type": "string", + "description": "Key to delete", + "title": "Key" + }, + "description": "Key to delete" + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": {} + } + } + }, + "204": { + "description": "Client state key deleted" + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, "/api/v1/client_state/{queue_id}/delete": { "post": { "tags": ["client_state"], @@ -8940,6 +9648,152 @@ } } } + }, + "/api/v2/custom_nodes/": { + "get": { + "tags": ["custom_nodes"], + "summary": "List Custom Node Packs", + "description": "Lists all installed custom node packs.\n\nAdmin-only: the response includes absolute filesystem paths, and non-admins have no\nlegitimate use for pack management data (install/uninstall/reload are also admin-only).", + "operationId": "list_custom_node_packs", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/NodePackListResponse" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v2/custom_nodes/install": { + "post": { + "tags": ["custom_nodes"], + "summary": "Install Custom Node Pack", + "description": "Installs a custom node pack from a git URL by cloning it into the nodes directory.", + "operationId": "install_custom_node_pack", + "requestBody": { + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallNodePackRequest", + "description": "The source URL to install from." + } + } + }, + "required": true + }, + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/InstallNodePackResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } + }, + "/api/v2/custom_nodes/{pack_name}": { + "delete": { + "tags": ["custom_nodes"], + "summary": "Uninstall Custom Node Pack", + "description": "Uninstalls a custom node pack by removing its directory.\n\nNote: A restart is required for the node removal to take full effect.\nInstalled nodes from the pack will remain registered until restart.", + "operationId": "uninstall_custom_node_pack", + "security": [ + { + "HTTPBearer": [] + } + ], + "parameters": [ + { + "name": "pack_name", + "in": "path", + "required": true, + "schema": { + "type": "string", + "title": "Pack Name" + } + } + ], + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/UninstallNodePackResponse" + } + } + } + }, + "422": { + "description": "Validation Error", + "content": { + "application/json": { + "schema": { + "$ref": "#/components/schemas/HTTPValidationError" + } + } + } + } + } + } + }, + "/api/v2/custom_nodes/reload": { + "post": { + "tags": ["custom_nodes"], + "summary": "Reload Custom Nodes", + "description": "Triggers a reload of all custom nodes.\n\nThis re-scans the nodes directory and loads any new node packs.\nAlready loaded packs are skipped.", + "operationId": "reload_custom_nodes", + "responses": { + "200": { + "description": "Successful Response", + "content": { + "application/json": { + "schema": { + "additionalProperties": { + "type": "string" + }, + "type": "object", + "title": "Response Reload Custom Nodes" + } + } + } + } + }, + "security": [ + { + "HTTPBearer": [] + } + ] + } } }, "components": { @@ -9126,8 +9980,246 @@ "title": "AdminUserUpdateRequest", "description": "Request body for admin to update any user." }, + "AlibabaCloudImageGenerationInvocation": { + "category": "image", + "class": "invocation", + "classification": "stable", + "description": "Generate images using an Alibaba Cloud DashScope external model.", + "node_pack": "invokeai", + "properties": { + "board": { + "anyOf": [ + { + "$ref": "#/components/schemas/BoardField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The board to save the image to", + "field_kind": "internal", + "input": "direct", + "orig_required": false, + "ui_hidden": false + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional metadata to be saved with the image", + "field_kind": "internal", + "input": "connection", + "orig_required": false, + "ui_hidden": false + }, + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelIdentifierField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Main model (UNet, VAE, CLIP) to load", + "field_kind": "input", + "input": "any", + "orig_required": true, + "ui_model_base": ["external"], + "ui_model_format": ["external_api"], + "ui_model_provider_id": ["alibabacloud"], + "ui_model_type": ["external_image_generator"] + }, + "mode": { + "default": "txt2img", + "description": "Generation mode. Not all modes are supported by every model; unsupported modes raise at runtime.", + "enum": ["txt2img", "img2img", "inpaint"], + "field_kind": "input", + "input": "any", + "orig_default": "txt2img", + "orig_required": false, + "title": "Mode", + "type": "string" + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Prompt", + "field_kind": "input", + "input": "any", + "orig_required": true, + "title": "Prompt" + }, + "seed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Seed for random number generation", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Seed" + }, + "num_images": { + "default": 1, + "description": "Number of images to generate", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1, + "orig_required": false, + "title": "Num Images", + "type": "integer" + }, + "width": { + "default": 1024, + "description": "Width of output (px)", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1024, + "orig_required": false, + "title": "Width", + "type": "integer" + }, + "height": { + "default": 1024, + "description": "Height of output (px)", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1024, + "orig_required": false, + "title": "Height", + "type": "integer" + }, + "image_size": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Image size preset (e.g. 1K, 2K, 4K)", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Image Size" + }, + "init_image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Init image for img2img/inpaint", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "mask_image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Mask image for inpaint", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "reference_images": { + "default": [], + "description": "Reference images", + "field_kind": "input", + "input": "any", + "items": { + "$ref": "#/components/schemas/ImageField" + }, + "orig_default": [], + "orig_required": false, + "title": "Reference Images", + "type": "array" + }, + "type": { + "const": "alibabacloud_image_generation", + "default": "alibabacloud_image_generation", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["external", "generation", "alibabacloud", "dashscope"], + "title": "Alibaba Cloud DashScope Image Generation", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/ImageCollectionOutput" + } + }, "AlphaMaskToTensorInvocation": { - "category": "conditioning", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Convert a mask image to a tensor. Opaque regions are 1 and transparent regions are 0.", @@ -9469,11 +10561,11 @@ "title": "Scheduler", "type": "string", "ui_choice_labels": { - "euler": "Euler", - "heun": "Heun (2nd order)", "dpmpp_2m": "DPM++ 2M", "dpmpp_2m_sde": "DPM++ 2M SDE", "er_sde": "ER-SDE", + "euler": "Euler", + "heun": "Heun (2nd order)", "lcm": "LCM" } }, @@ -9489,7 +10581,7 @@ "tags": ["image", "anima"], "title": "Denoise - Anima", "type": "object", - "version": "1.4.0", + "version": "1.5.0", "output": { "$ref": "#/components/schemas/LatentsOutput" } @@ -10303,6 +11395,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -10408,6 +11503,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -10474,6 +11575,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -10613,7 +11720,7 @@ } }, "ApplyMaskToImageInvocation": { - "category": "image", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Extracts a region from a generated image using a mask and blends it seamlessly onto a source image.\nThe mask uses black to indicate areas to keep from the generated image and white for areas to discard.", @@ -10764,6 +11871,7 @@ "flux2", "cogview4", "z-image", + "external", "qwen-image", "anima", "unknown" @@ -12540,6 +13648,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -12604,6 +13724,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -12674,6 +13795,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -12738,6 +13871,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -12800,7 +13934,7 @@ "type": "object" }, "CLIPSkipInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "stable", "description": "Skip layers in clip text_encoder model.", @@ -12965,6 +14099,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -13023,6 +14169,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -13546,7 +14693,7 @@ "description": "Result of canceling by a destination" }, "CannyEdgeDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Geneartes an edge map using a cv2's Canny algorithm.", @@ -13728,7 +14875,7 @@ } }, "CanvasPasteBackInvocation": { - "category": "image", + "category": "canvas", "class": "invocation", "classification": "stable", "description": "Combines two images by using the mask provided. Intended for use on the Unified Canvas.", @@ -13864,7 +15011,7 @@ } }, "CanvasV2MaskAndCropInvocation": { - "category": "image", + "category": "canvas", "class": "invocation", "classification": "deprecated", "description": "Handles Canvas V2 image output masking and cropping", @@ -14164,7 +15311,7 @@ "type": "object" }, "CogView4DenoiseInvocation": { - "category": "image", + "category": "latents", "class": "invocation", "classification": "prototype", "description": "Run the denoising process with a CogView4 model.", @@ -14409,7 +15556,7 @@ } }, "CogView4ImageToLatentsInvocation": { - "category": "image", + "category": "latents", "class": "invocation", "classification": "prototype", "description": "Generates latents from an image.", @@ -14723,7 +15870,7 @@ "type": "object" }, "CogView4TextEncoderInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "prototype", "description": "Encodes and preps a prompt for a cogview4 image.", @@ -15168,7 +16315,7 @@ } }, "ColorMapInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generates a color map from the provided image.", @@ -15296,7 +16443,7 @@ "type": "object" }, "CompelInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "stable", "description": "Parse prompt using compel package to conditioning.", @@ -15584,7 +16731,7 @@ "type": "object" }, "ContentShuffleInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Shuffles the image, similar to a 'liquify' filter.", @@ -15849,6 +16996,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -15916,6 +17075,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "default_settings", "base", @@ -15927,7 +17087,7 @@ "description": "Model config for Control LoRA models." }, "ControlNetInvocation": { - "category": "controlnet", + "category": "conditioning", "class": "invocation", "classification": "stable", "description": "Collects ControlNet info to pass to other nodes", @@ -16281,6 +17441,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -16345,6 +17517,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -16414,6 +17587,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -16478,6 +17663,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -16547,6 +17733,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -16611,6 +17809,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -16680,6 +17879,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -16744,6 +17955,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -16813,6 +18025,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -16877,6 +18101,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -16947,6 +18172,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -17003,6 +18240,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -17072,6 +18310,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -17128,6 +18378,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -17197,6 +18448,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -17253,6 +18516,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -17322,6 +18586,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -17378,6 +18654,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -18100,7 +19377,7 @@ } }, "CreateDenoiseMaskInvocation": { - "category": "latents", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Creates mask for denoising model run.", @@ -18219,7 +19496,7 @@ } }, "CreateGradientMaskInvocation": { - "category": "latents", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Creates mask for denoising.", @@ -18751,7 +20028,7 @@ } }, "DWOpenposeDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generates an openpose pose from an image using DWPose", @@ -19241,6 +20518,7 @@ "dpmpp_3m_k", "dpmpp_sde", "dpmpp_sde_k", + "er_sde", "unipc", "unipc_k", "lcm", @@ -19406,7 +20684,7 @@ } }, "DenoiseLatentsMetaInvocation": { - "category": "latents", + "category": "metadata", "class": "invocation", "classification": "stable", "node_pack": "invokeai", @@ -19599,6 +20877,7 @@ "dpmpp_3m_k", "dpmpp_sde", "dpmpp_sde_k", + "er_sde", "unipc", "unipc_k", "lcm", @@ -19818,7 +21097,7 @@ "type": "object" }, "DepthAnythingDepthEstimationInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generates a depth map using a Depth Anything model.", @@ -20475,7 +21754,7 @@ "title": "DynamicPromptsResponse" }, "ESRGANInvocation": { - "category": "esrgan", + "category": "upscale", "class": "invocation", "classification": "stable", "description": "Upscales an image using RealESRGAN.", @@ -20668,7 +21947,7 @@ "title": "EnqueueBatchResult" }, "ExpandMaskWithFadeInvocation": { - "category": "image", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Expands a mask with a fade effect. The mask uses black to indicate areas to keep from the generated image and white for areas to discard.\nThe mask is thresholded to create a binary mask, and then a distance transform is applied to create a fade effect.\nThe fade size is specified in pixels, and the mask is expanded by that amount. The result is a mask with a smooth transition from black to white.\nIf the fade size is 0, the mask is returned as-is.", @@ -20785,144 +22064,77 @@ "$ref": "#/components/schemas/ImageOutput" } }, - "ExposedField": { + "ExpandPromptRequest": { "properties": { - "nodeId": { + "prompt": { "type": "string", - "title": "Nodeid" + "title": "Prompt" }, - "fieldName": { + "model_key": { "type": "string", - "title": "Fieldname" - } - }, - "type": "object", - "required": ["nodeId", "fieldName"], - "title": "ExposedField" - }, - "FLUXLoRACollectionLoader": { - "category": "model", - "class": "invocation", - "classification": "stable", - "description": "Applies a collection of LoRAs to a FLUX transformer.", - "node_pack": "invokeai", - "properties": { - "id": { - "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", - "field_kind": "node_attribute", - "title": "Id", - "type": "string" + "title": "Model Key" }, - "is_intermediate": { - "default": false, - "description": "Whether or not this is an intermediate invocation.", - "field_kind": "node_attribute", - "input": "direct", - "orig_required": true, - "title": "Is Intermediate", - "type": "boolean", - "ui_hidden": false, - "ui_type": "IsIntermediate" - }, - "use_cache": { - "default": true, - "description": "Whether or not to use the cache", - "field_kind": "node_attribute", - "title": "Use Cache", - "type": "boolean" - }, - "loras": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoRAField" - }, - { - "items": { - "$ref": "#/components/schemas/LoRAField" - }, - "type": "array" - }, - { - "type": "null" - } - ], - "default": null, - "description": "LoRA models and weights. May be a single LoRA or collection.", - "field_kind": "input", - "input": "any", - "orig_default": null, - "orig_required": false, - "title": "LoRAs" - }, - "transformer": { - "anyOf": [ - { - "$ref": "#/components/schemas/TransformerField" - }, - { - "type": "null" - } - ], - "default": null, - "description": "Transformer", - "field_kind": "input", - "input": "connection", - "orig_default": null, - "orig_required": false, - "title": "Transformer" + "max_tokens": { + "type": "integer", + "maximum": 2048.0, + "minimum": 1.0, + "title": "Max Tokens", + "default": 300 }, - "clip": { + "system_prompt": { "anyOf": [ { - "$ref": "#/components/schemas/CLIPField" + "type": "string" }, { "type": "null" } ], - "default": null, - "description": "CLIP (tokenizer, text encoder, LoRAs) and skipped layer count", - "field_kind": "input", - "input": "connection", - "orig_default": null, - "orig_required": false, - "title": "CLIP" + "title": "System Prompt" + } + }, + "type": "object", + "required": ["prompt", "model_key"], + "title": "ExpandPromptRequest" + }, + "ExpandPromptResponse": { + "properties": { + "expanded_prompt": { + "type": "string", + "title": "Expanded Prompt" }, - "t5_encoder": { + "error": { "anyOf": [ { - "$ref": "#/components/schemas/T5EncoderField" + "type": "string" }, { "type": "null" } ], - "default": null, - "description": "T5 tokenizer and text encoder", - "field_kind": "input", - "input": "connection", - "orig_default": null, - "orig_required": false, - "title": "T5 Encoder" + "title": "Error" + } + }, + "type": "object", + "required": ["expanded_prompt"], + "title": "ExpandPromptResponse" + }, + "ExposedField": { + "properties": { + "nodeId": { + "type": "string", + "title": "Nodeid" }, - "type": { - "const": "flux_lora_collection_loader", - "default": "flux_lora_collection_loader", - "field_kind": "node_attribute", - "title": "type", - "type": "string" + "fieldName": { + "type": "string", + "title": "Fieldname" } }, - "required": ["type", "id"], - "tags": ["lora", "model", "flux"], - "title": "Apply LoRA Collection - FLUX", "type": "object", - "version": "1.3.1", - "output": { - "$ref": "#/components/schemas/FluxLoRALoaderOutput" - } + "required": ["nodeId", "fieldName"], + "title": "ExposedField" }, - "FLUXRedux_Checkpoint_Config": { + "ExternalApiModelConfig": { "properties": { "key": { "type": "string", @@ -20932,17 +22144,790 @@ "hash": { "type": "string", "title": "Hash", - "description": "The hash of the model file(s)." + "default": "" }, "path": { "type": "string", "title": "Path", - "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + "default": "" }, "file_size": { "type": "integer", + "minimum": 0.0, "title": "File Size", - "description": "The size of the model in bytes." + "default": 0 + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the model." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Model description" + }, + "source": { + "type": "string", + "title": "Source", + "default": "" + }, + "source_type": { + "$ref": "#/components/schemas/ModelSourceType", + "default": "external" + }, + "source_api_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Api Response", + "description": "The original API response from the source, as stringified JSON." + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, + "cover_image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image", + "description": "Url for image to preview model" + }, + "base": { + "type": "string", + "const": "external", + "title": "Base", + "default": "external" + }, + "type": { + "type": "string", + "const": "external_image_generator", + "title": "Type", + "default": "external_image_generator" + }, + "format": { + "type": "string", + "const": "external_api", + "title": "Format", + "default": "external_api" + }, + "provider_id": { + "type": "string", + "minLength": 1, + "title": "Provider Id", + "description": "External provider ID" + }, + "provider_model_id": { + "type": "string", + "minLength": 1, + "title": "Provider Model Id", + "description": "Provider-specific model ID" + }, + "capabilities": { + "$ref": "#/components/schemas/ExternalModelCapabilities", + "description": "Provider capability matrix" + }, + "default_settings": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalApiModelDefaultSettings" + }, + { + "type": "null" + } + ] + }, + "panel_schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalModelPanelSchema" + }, + { + "type": "null" + } + ] + }, + "tags": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Tags" + }, + "is_default": { + "type": "boolean", + "title": "Is Default", + "default": false + } + }, + "additionalProperties": false, + "type": "object", + "required": [ + "key", + "hash", + "path", + "file_size", + "name", + "description", + "source", + "source_type", + "source_api_response", + "source_url", + "cover_image", + "base", + "type", + "format", + "provider_id", + "provider_model_id", + "capabilities", + "default_settings", + "panel_schema", + "tags", + "is_default" + ], + "title": "ExternalApiModelConfig" + }, + "ExternalApiModelDefaultSettings": { + "properties": { + "width": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Width" + }, + "height": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Height" + }, + "num_images": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Num Images" + } + }, + "additionalProperties": false, + "type": "object", + "title": "ExternalApiModelDefaultSettings" + }, + "ExternalImageSize": { + "properties": { + "width": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Width" + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Height" + } + }, + "additionalProperties": false, + "type": "object", + "required": ["width", "height"], + "title": "ExternalImageSize" + }, + "ExternalModelCapabilities": { + "properties": { + "modes": { + "items": { + "type": "string", + "enum": ["txt2img", "img2img", "inpaint"] + }, + "type": "array", + "title": "Modes" + }, + "supports_reference_images": { + "type": "boolean", + "title": "Supports Reference Images", + "default": false + }, + "supports_negative_prompt": { + "type": "boolean", + "title": "Supports Negative Prompt", + "default": true + }, + "supports_seed": { + "type": "boolean", + "title": "Supports Seed", + "default": false + }, + "supports_guidance": { + "type": "boolean", + "title": "Supports Guidance", + "default": false + }, + "supports_steps": { + "type": "boolean", + "title": "Supports Steps", + "default": false + }, + "max_images_per_request": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Images Per Request" + }, + "max_image_size": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalImageSize" + }, + { + "type": "null" + } + ] + }, + "allowed_aspect_ratios": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Allowed Aspect Ratios" + }, + "aspect_ratio_sizes": { + "anyOf": [ + { + "additionalProperties": { + "$ref": "#/components/schemas/ExternalImageSize" + }, + "type": "object" + }, + { + "type": "null" + } + ], + "title": "Aspect Ratio Sizes" + }, + "resolution_presets": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/ExternalResolutionPreset" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Resolution Presets" + }, + "max_reference_images": { + "anyOf": [ + { + "type": "integer", + "exclusiveMinimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Reference Images" + }, + "mask_format": { + "type": "string", + "enum": ["alpha", "binary", "none"], + "title": "Mask Format", + "default": "none" + }, + "input_image_required_for": { + "anyOf": [ + { + "items": { + "type": "string", + "enum": ["txt2img", "img2img", "inpaint"] + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Input Image Required For" + } + }, + "additionalProperties": false, + "type": "object", + "title": "ExternalModelCapabilities" + }, + "ExternalModelPanelControl": { + "properties": { + "name": { + "type": "string", + "enum": ["reference_images", "dimensions", "seed"], + "title": "Name" + }, + "slider_min": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Slider Min" + }, + "slider_max": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Slider Max" + }, + "number_input_min": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Number Input Min" + }, + "number_input_max": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Number Input Max" + }, + "fine_step": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Fine Step" + }, + "coarse_step": { + "anyOf": [ + { + "type": "number" + }, + { + "type": "null" + } + ], + "title": "Coarse Step" + }, + "marks": { + "anyOf": [ + { + "items": { + "type": "number" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Marks" + } + }, + "additionalProperties": false, + "type": "object", + "required": ["name"], + "title": "ExternalModelPanelControl" + }, + "ExternalModelPanelSchema": { + "properties": { + "prompts": { + "items": { + "$ref": "#/components/schemas/ExternalModelPanelControl" + }, + "type": "array", + "title": "Prompts" + }, + "image": { + "items": { + "$ref": "#/components/schemas/ExternalModelPanelControl" + }, + "type": "array", + "title": "Image" + }, + "generation": { + "items": { + "$ref": "#/components/schemas/ExternalModelPanelControl" + }, + "type": "array", + "title": "Generation" + } + }, + "additionalProperties": false, + "type": "object", + "title": "ExternalModelPanelSchema" + }, + "ExternalModelSource": { + "properties": { + "provider_id": { + "type": "string", + "title": "Provider Id" + }, + "provider_model_id": { + "type": "string", + "title": "Provider Model Id" + }, + "type": { + "type": "string", + "const": "external", + "title": "Type", + "default": "external" + } + }, + "type": "object", + "required": ["provider_id", "provider_model_id"], + "title": "ExternalModelSource", + "description": "An external provider model identifier." + }, + "ExternalProviderConfigModel": { + "properties": { + "provider_id": { + "type": "string", + "title": "Provider Id", + "description": "The external provider identifier" + }, + "api_key_configured": { + "type": "boolean", + "title": "Api Key Configured", + "description": "Whether an API key is configured" + }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Url", + "description": "Optional base URL override" + } + }, + "type": "object", + "required": ["provider_id", "api_key_configured"], + "title": "ExternalProviderConfigModel" + }, + "ExternalProviderConfigUpdate": { + "properties": { + "api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Api Key", + "description": "API key for the external provider" + }, + "base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Base Url", + "description": "Optional base URL override for the provider" + } + }, + "type": "object", + "title": "ExternalProviderConfigUpdate" + }, + "ExternalProviderStatusModel": { + "properties": { + "provider_id": { + "type": "string", + "title": "Provider Id", + "description": "The external provider identifier" + }, + "configured": { + "type": "boolean", + "title": "Configured", + "description": "Whether credentials are configured for the provider" + }, + "message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Message", + "description": "Optional provider status detail" + } + }, + "type": "object", + "required": ["provider_id", "configured"], + "title": "ExternalProviderStatusModel" + }, + "ExternalResolutionPreset": { + "properties": { + "label": { + "type": "string", + "minLength": 1, + "title": "Label", + "description": "Display label, e.g. '1:1 (1K)'" + }, + "aspect_ratio": { + "type": "string", + "minLength": 1, + "title": "Aspect Ratio", + "description": "Aspect ratio string, e.g. '1:1'" + }, + "image_size": { + "type": "string", + "minLength": 1, + "title": "Image Size", + "description": "Image size preset, e.g. '1K'" + }, + "width": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Width" + }, + "height": { + "type": "integer", + "exclusiveMinimum": 0.0, + "title": "Height" + } + }, + "additionalProperties": false, + "type": "object", + "required": ["label", "aspect_ratio", "image_size", "width", "height"], + "title": "ExternalResolutionPreset" + }, + "FLUXLoRACollectionLoader": { + "category": "model", + "class": "invocation", + "classification": "stable", + "description": "Applies a collection of LoRAs to a FLUX transformer.", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "loras": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoRAField" + }, + { + "items": { + "$ref": "#/components/schemas/LoRAField" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "description": "LoRA models and weights. May be a single LoRA or collection.", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "LoRAs" + }, + "transformer": { + "anyOf": [ + { + "$ref": "#/components/schemas/TransformerField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Transformer", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "Transformer" + }, + "clip": { + "anyOf": [ + { + "$ref": "#/components/schemas/CLIPField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "CLIP (tokenizer, text encoder, LoRAs) and skipped layer count", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "CLIP" + }, + "t5_encoder": { + "anyOf": [ + { + "$ref": "#/components/schemas/T5EncoderField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "T5 tokenizer and text encoder", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "T5 Encoder" + }, + "type": { + "const": "flux_lora_collection_loader", + "default": "flux_lora_collection_loader", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["lora", "model", "flux"], + "title": "Apply LoRA Collection - FLUX", + "type": "object", + "version": "1.3.1", + "output": { + "$ref": "#/components/schemas/FluxLoRALoaderOutput" + } + }, + "FLUXRedux_Checkpoint_Config": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "A unique key for this model." + }, + "hash": { + "type": "string", + "title": "Hash", + "description": "The hash of the model file(s)." + }, + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + }, + "file_size": { + "type": "integer", + "title": "File Size", + "description": "The size of the model in bytes." }, "name": { "type": "string", @@ -20982,6 +22967,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -21024,6 +23021,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -21033,7 +23031,7 @@ "description": "Model config for FLUX Tools Redux model." }, "FaceIdentifierInvocation": { - "category": "image", + "category": "segmentation", "class": "invocation", "classification": "stable", "description": "Outputs an image with detected face IDs printed on each face. For use with other FaceTools.", @@ -21148,7 +23146,7 @@ } }, "FaceMaskInvocation": { - "category": "image", + "category": "segmentation", "class": "invocation", "classification": "stable", "description": "Face mask creation using mediapipe face detection", @@ -21329,7 +23327,7 @@ "type": "object" }, "FaceOffInvocation": { - "category": "image", + "category": "segmentation", "class": "invocation", "classification": "stable", "description": "Bound, extract, and mask a face from an image using MediaPipe detection", @@ -21531,7 +23529,7 @@ "type": "string" }, "FloatBatchInvocation": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Create a batched generation, where the workflow is executed once for each float in the batch.", @@ -21697,7 +23695,7 @@ "type": "object" }, "FloatGenerator": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Generated a range of floats for use in a batched generation", @@ -22268,6 +24266,18 @@ "orig_default": null, "orig_required": false }, + "guidance": { + "default": 4.0, + "description": "Guidance strength for distilled guidance-embedding models. Inert for all current FLUX.2 Klein variants (their guidance_embeds weights are absent/zero); kept for node-graph compatibility and future guidance-embedded models.", + "field_kind": "input", + "input": "any", + "maximum": 20, + "minimum": 0, + "orig_default": 4.0, + "orig_required": false, + "title": "Guidance", + "type": "number" + }, "cfg_scale": { "default": 1.0, "description": "Classifier-Free Guidance scale", @@ -22386,7 +24396,7 @@ "tags": ["image", "flux", "flux2", "klein", "denoise"], "title": "FLUX2 Denoise", "type": "object", - "version": "1.4.0", + "version": "1.5.0", "output": { "$ref": "#/components/schemas/LatentsOutput" } @@ -22824,7 +24834,7 @@ "type": "object" }, "Flux2KleinTextEncoderInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "prototype", "description": "Encodes and preps a prompt for Flux2 Klein image generation.\n\nFlux2 Klein uses Qwen3 as the text encoder, extracting hidden states from\nlayers (9, 18, 27) and stacking them for richer text representations.\nThis matches the diffusers Flux2KleinPipeline implementation exactly.", @@ -23381,7 +25391,7 @@ "type": "object" }, "FluxControlNetInvocation": { - "category": "controlnet", + "category": "conditioning", "class": "invocation", "classification": "stable", "description": "Collect FLUX ControlNet info to pass to other nodes.", @@ -23557,7 +25567,7 @@ "type": "object" }, "FluxDenoiseInvocation": { - "category": "image", + "category": "latents", "class": "invocation", "classification": "stable", "description": "Run denoising process with a FLUX transformer model.", @@ -24040,7 +26050,7 @@ } }, "FluxDenoiseLatentsMetaInvocation": { - "category": "latents", + "category": "metadata", "class": "invocation", "classification": "stable", "description": "Run denoising process with a FLUX transformer model + metadata.", @@ -24555,7 +26565,7 @@ "type": "object" }, "FluxFillInvocation": { - "category": "inpaint", + "category": "conditioning", "class": "invocation", "classification": "beta", "description": "Prepare the FLUX Fill conditioning data.", @@ -24656,7 +26666,7 @@ "type": "object" }, "FluxIPAdapterInvocation": { - "category": "ip_adapter", + "category": "conditioning", "class": "invocation", "classification": "stable", "description": "Collects FLUX IP-Adapter info to pass to other nodes.", @@ -24792,7 +26802,7 @@ } }, "FluxKontextConcatenateImagesInvocation": { - "category": "image", + "category": "conditioning", "class": "invocation", "classification": "stable", "description": "Prepares an image or images for use with FLUX Kontext. The first/single image is resized to the nearest\npreferred Kontext resolution. All other images are concatenated horizontally, maintaining their aspect ratio.", @@ -25382,7 +27392,7 @@ "type": "object" }, "FluxReduxInvocation": { - "category": "ip_adapter", + "category": "conditioning", "class": "invocation", "classification": "beta", "description": "Runs a FLUX Redux model to generate a conditioning tensor.", @@ -25537,7 +27547,7 @@ "type": "object" }, "FluxTextEncoderInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "stable", "description": "Encodes and preps a prompt for a flux image.", @@ -25914,7 +27924,7 @@ "type": "object" }, "FreeUInvocation": { - "category": "unet", + "category": "model", "class": "invocation", "classification": "stable", "description": "Applies FreeU to the UNet. Suggested values (b1/b2/s1/s2):\n\nSD1.5: 1.2/1.4/0.9/0.2,\nSD2: 1.1/1.2/0.9/0.2,\nSDXL: 1.1/1.2/0.6/0.4,", @@ -26025,6 +28035,284 @@ "$ref": "#/components/schemas/UNetOutput" } }, + "GeminiImageGenerationInvocation": { + "category": "image", + "class": "invocation", + "classification": "stable", + "description": "Generate images using a Gemini-hosted external model.", + "node_pack": "invokeai", + "properties": { + "board": { + "anyOf": [ + { + "$ref": "#/components/schemas/BoardField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The board to save the image to", + "field_kind": "internal", + "input": "direct", + "orig_required": false, + "ui_hidden": false + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional metadata to be saved with the image", + "field_kind": "internal", + "input": "connection", + "orig_required": false, + "ui_hidden": false + }, + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelIdentifierField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Main model (UNet, VAE, CLIP) to load", + "field_kind": "input", + "input": "any", + "orig_required": true, + "ui_model_base": ["external"], + "ui_model_format": ["external_api"], + "ui_model_provider_id": ["gemini"], + "ui_model_type": ["external_image_generator"] + }, + "mode": { + "default": "txt2img", + "description": "Generation mode.", + "enum": ["txt2img", "img2img", "inpaint"], + "field_kind": "input", + "input": "any", + "orig_default": "txt2img", + "orig_required": false, + "title": "Mode", + "type": "string", + "ui_hidden": true + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Prompt", + "field_kind": "input", + "input": "any", + "orig_required": true, + "title": "Prompt" + }, + "seed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Seed for random number generation", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Seed" + }, + "num_images": { + "default": 1, + "description": "Number of images to generate", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1, + "orig_required": false, + "title": "Num Images", + "type": "integer" + }, + "width": { + "default": 1024, + "description": "Width of output (px)", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1024, + "orig_required": false, + "title": "Width", + "type": "integer" + }, + "height": { + "default": 1024, + "description": "Height of output (px)", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1024, + "orig_required": false, + "title": "Height", + "type": "integer" + }, + "image_size": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Image size preset (e.g. 1K, 2K, 4K)", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Image Size" + }, + "init_image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Init image for img2img/inpaint", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "ui_hidden": true + }, + "mask_image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Mask image for inpaint", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "ui_hidden": true + }, + "reference_images": { + "default": [], + "description": "Reference images", + "field_kind": "input", + "input": "any", + "items": { + "$ref": "#/components/schemas/ImageField" + }, + "orig_default": [], + "orig_required": false, + "title": "Reference Images", + "type": "array" + }, + "temperature": { + "anyOf": [ + { + "maximum": 2.0, + "minimum": 0.0, + "type": "number" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Sampling temperature", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Temperature" + }, + "thinking_level": { + "anyOf": [ + { + "enum": ["minimal", "high"], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Thinking level for image generation", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Thinking Level" + }, + "type": { + "const": "gemini_image_generation", + "default": "gemini_image_generation", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["external", "generation", "gemini"], + "title": "Gemini Image Generation", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/ImageCollectionOutput" + } + }, "GeneratePasswordResponse": { "properties": { "password": { @@ -26186,6 +28474,9 @@ { "$ref": "#/components/schemas/AddInvocation" }, + { + "$ref": "#/components/schemas/AlibabaCloudImageGenerationInvocation" + }, { "$ref": "#/components/schemas/AlphaMaskToTensorInvocation" }, @@ -26447,6 +28738,9 @@ { "$ref": "#/components/schemas/FreeUInvocation" }, + { + "$ref": "#/components/schemas/GeminiImageGenerationInvocation" + }, { "$ref": "#/components/schemas/GetMaskBoundingBoxInvocation" }, @@ -26729,6 +29023,9 @@ { "$ref": "#/components/schemas/NormalMapInvocation" }, + { + "$ref": "#/components/schemas/OpenAIImageGenerationInvocation" + }, { "$ref": "#/components/schemas/PBRMapsInvocation" }, @@ -26837,6 +29134,9 @@ { "$ref": "#/components/schemas/SeamlessModeInvocation" }, + { + "$ref": "#/components/schemas/SeedreamImageGenerationInvocation" + }, { "$ref": "#/components/schemas/SegmentAnythingInvocation" }, @@ -26882,6 +29182,9 @@ { "$ref": "#/components/schemas/T2IAdapterInvocation" }, + { + "$ref": "#/components/schemas/TextLLMInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -27314,7 +29617,7 @@ "description": "Tracks source-graph expansion, execution progress, and runtime results." }, "GroundingDinoInvocation": { - "category": "image", + "category": "segmentation", "class": "invocation", "classification": "stable", "description": "Runs a Grounding DINO model. Performs zero-shot bounding-box object detection from a text prompt.", @@ -27422,7 +29725,7 @@ } }, "HEDEdgeDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Geneartes an edge map using the HED (softedge) model.", @@ -27597,7 +29900,7 @@ "title": "HTTPValidationError" }, "HeuristicResizeInvocation": { - "category": "image", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "prototype", "description": "Resize an image using a heuristic method. Preserves edge maps.", @@ -27867,7 +30170,7 @@ "type": "object" }, "IPAdapterInvocation": { - "category": "ip_adapter", + "category": "conditioning", "class": "invocation", "classification": "stable", "description": "Collects IP-Adapter info to pass to other nodes.", @@ -28269,6 +30572,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -28311,6 +30626,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -28378,6 +30694,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -28420,6 +30748,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -28487,6 +30816,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -28529,6 +30870,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -28596,6 +30938,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -28638,6 +30992,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -28705,6 +31060,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -28751,6 +31118,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -28819,6 +31187,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -28865,6 +31245,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -28933,6 +31314,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -28979,6 +31372,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", @@ -28988,6 +31382,7 @@ "title": "IPAdapter_InvokeAI_SDXL_Config" }, "IdealSizeInvocation": { + "category": "latents", "class": "invocation", "classification": "stable", "description": "Calculates the ideal size for generation to avoid duplication", @@ -29110,7 +31505,7 @@ "type": "object" }, "IfInvocation": { - "category": "logic", + "category": "math", "class": "invocation", "classification": "stable", "description": "Selects between two optional inputs based on a boolean condition.", @@ -29229,7 +31624,7 @@ "type": "object" }, "ImageBatchInvocation": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Create a batched generation, where the workflow is executed once for each image in the batch.", @@ -30313,6 +32708,12 @@ "title": "Has Workflow", "description": "Whether this image has a workflow." }, + "image_subfolder": { + "type": "string", + "title": "Image Subfolder", + "description": "The subfolder where the image is stored on disk.", + "default": "" + }, "board_id": { "anyOf": [ { @@ -30358,7 +32759,7 @@ "description": "An image primitive field" }, "ImageGenerator": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Generated a collection of images for use in a batched generation", @@ -30851,7 +33252,7 @@ } }, "ImageMaskToTensorInvocation": { - "category": "conditioning", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Convert a mask image to a tensor. Converts the image to grayscale and uses thresholding at the specified value.", @@ -30951,6 +33352,96 @@ "$ref": "#/components/schemas/MaskOutput" } }, + "ImageMoveJobResponse": { + "properties": { + "id": { + "type": "integer", + "title": "Id", + "description": "The image move job id." + }, + "state": { + "type": "string", + "enum": ["planned", "moving", "moved", "committed", "error"], + "title": "State", + "description": "The image move job state." + }, + "error_message": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error Message", + "description": "The last error recorded for the job, if any." + } + }, + "type": "object", + "required": ["id", "state"], + "title": "ImageMoveJobResponse" + }, + "ImageMoveStatusResponse": { + "properties": { + "is_running": { + "type": "boolean", + "title": "Is Running", + "description": "Whether an image move background operation is currently running." + }, + "operation": { + "anyOf": [ + { + "type": "string", + "enum": ["move_all", "recovery"] + }, + { + "type": "null" + } + ], + "title": "Operation", + "description": "The active background operation, if any." + }, + "active_job_id": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Active Job Id", + "description": "The active journal job id, if any." + }, + "latest_job": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageMoveJobResponse" + }, + { + "type": "null" + } + ], + "description": "The latest journal job, if any." + }, + "last_error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Last Error", + "description": "The last background worker error, if any." + } + }, + "type": "object", + "required": ["is_running"], + "title": "ImageMoveStatusResponse" + }, "ImageMultiplyInvocation": { "category": "image", "class": "invocation", @@ -31429,7 +33920,7 @@ "type": "object" }, "ImagePanelLayoutInvocation": { - "category": "image", + "category": "canvas", "class": "invocation", "classification": "prototype", "description": "Get the coordinates of a single panel in a grid. (If the full image shape cannot be divided evenly into panels,\nthen the grid may not cover the entire image.)", @@ -32128,6 +34619,48 @@ "$ref": "#/components/schemas/LatentsOutput" } }, + "ImageToPromptRequest": { + "properties": { + "image_name": { + "type": "string", + "title": "Image Name" + }, + "model_key": { + "type": "string", + "title": "Model Key" + }, + "instruction": { + "type": "string", + "title": "Instruction", + "default": "Describe this image in detail for use as an AI image generation prompt." + } + }, + "type": "object", + "required": ["image_name", "model_key"], + "title": "ImageToPromptRequest" + }, + "ImageToPromptResponse": { + "properties": { + "prompt": { + "type": "string", + "title": "Prompt" + }, + "error": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Error" + } + }, + "type": "object", + "required": ["prompt"], + "title": "ImageToPromptResponse" + }, "ImageUploadEntry": { "properties": { "image_dto": { @@ -32811,6 +35344,21 @@ ], "default": null, "title": "Ui Model Format" + }, + "ui_model_provider_id": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "default": null, + "title": "Ui Model Provider Id" } }, "required": [ @@ -32827,11 +35375,72 @@ "ui_model_base", "ui_model_type", "ui_model_variant", - "ui_model_format" + "ui_model_format", + "ui_model_provider_id" ], "title": "InputFieldJSONSchemaExtra", "type": "object" }, + "InstallNodePackRequest": { + "properties": { + "source": { + "type": "string", + "title": "Source", + "description": "Git URL of the node pack to install." + } + }, + "type": "object", + "required": ["source"], + "title": "InstallNodePackRequest", + "description": "Request to install a node pack from a git URL." + }, + "InstallNodePackResponse": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the installed node pack." + }, + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether the installation was successful." + }, + "message": { + "type": "string", + "title": "Message", + "description": "Status message." + }, + "workflows_imported": { + "type": "integer", + "title": "Workflows Imported", + "description": "Number of workflows imported from the pack.", + "default": 0 + }, + "requires_dependencies": { + "type": "boolean", + "title": "Requires Dependencies", + "description": "Whether the pack ships a dependency manifest (requirements.txt or pyproject.toml) that the user must install manually following the pack's documentation.", + "default": false + }, + "dependency_file": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Dependency File", + "description": "Name of the detected dependency manifest file, if any." + } + }, + "type": "object", + "required": ["name", "success", "message"], + "title": "InstallNodePackResponse", + "description": "Response after installing a node pack." + }, "InstallStatus": { "type": "string", "enum": ["waiting", "downloading", "downloads_done", "running", "paused", "completed", "error", "cancelled"], @@ -32839,7 +35448,7 @@ "description": "State of an install job running in the background." }, "IntegerBatchInvocation": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Create a batched generation, where the workflow is executed once for each integer in the batch.", @@ -33005,7 +35614,7 @@ "type": "object" }, "IntegerGenerator": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Generated a range of integers for use in a batched generation", @@ -33274,7 +35883,7 @@ "type": "object" }, "InvertTensorMaskInvocation": { - "category": "conditioning", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Inverts a tensor mask.", @@ -33434,6 +36043,9 @@ { "$ref": "#/components/schemas/AddInvocation" }, + { + "$ref": "#/components/schemas/AlibabaCloudImageGenerationInvocation" + }, { "$ref": "#/components/schemas/AlphaMaskToTensorInvocation" }, @@ -33695,6 +36307,9 @@ { "$ref": "#/components/schemas/FreeUInvocation" }, + { + "$ref": "#/components/schemas/GeminiImageGenerationInvocation" + }, { "$ref": "#/components/schemas/GetMaskBoundingBoxInvocation" }, @@ -33977,6 +36592,9 @@ { "$ref": "#/components/schemas/NormalMapInvocation" }, + { + "$ref": "#/components/schemas/OpenAIImageGenerationInvocation" + }, { "$ref": "#/components/schemas/PBRMapsInvocation" }, @@ -34085,6 +36703,9 @@ { "$ref": "#/components/schemas/SeamlessModeInvocation" }, + { + "$ref": "#/components/schemas/SeedreamImageGenerationInvocation" + }, { "$ref": "#/components/schemas/SegmentAnythingInvocation" }, @@ -34130,6 +36751,9 @@ { "$ref": "#/components/schemas/T2IAdapterInvocation" }, + { + "$ref": "#/components/schemas/TextLLMInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -34539,6 +37163,9 @@ { "$ref": "#/components/schemas/AddInvocation" }, + { + "$ref": "#/components/schemas/AlibabaCloudImageGenerationInvocation" + }, { "$ref": "#/components/schemas/AlphaMaskToTensorInvocation" }, @@ -34800,6 +37427,9 @@ { "$ref": "#/components/schemas/FreeUInvocation" }, + { + "$ref": "#/components/schemas/GeminiImageGenerationInvocation" + }, { "$ref": "#/components/schemas/GetMaskBoundingBoxInvocation" }, @@ -35082,6 +37712,9 @@ { "$ref": "#/components/schemas/NormalMapInvocation" }, + { + "$ref": "#/components/schemas/OpenAIImageGenerationInvocation" + }, { "$ref": "#/components/schemas/PBRMapsInvocation" }, @@ -35190,6 +37823,9 @@ { "$ref": "#/components/schemas/SeamlessModeInvocation" }, + { + "$ref": "#/components/schemas/SeedreamImageGenerationInvocation" + }, { "$ref": "#/components/schemas/SegmentAnythingInvocation" }, @@ -35235,6 +37871,9 @@ { "$ref": "#/components/schemas/T2IAdapterInvocation" }, + { + "$ref": "#/components/schemas/TextLLMInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -35325,6 +37964,9 @@ "add": { "$ref": "#/components/schemas/IntegerOutput" }, + "alibabacloud_image_generation": { + "$ref": "#/components/schemas/ImageCollectionOutput" + }, "alpha_mask_to_tensor": { "$ref": "#/components/schemas/MaskOutput" }, @@ -35577,6 +38219,9 @@ "freeu": { "$ref": "#/components/schemas/UNetOutput" }, + "gemini_image_generation": { + "$ref": "#/components/schemas/ImageCollectionOutput" + }, "get_image_mask_bounding_box": { "$ref": "#/components/schemas/BoundingBoxOutput" }, @@ -35868,6 +38513,9 @@ "normal_map": { "$ref": "#/components/schemas/ImageOutput" }, + "openai_image_generation": { + "$ref": "#/components/schemas/ImageCollectionOutput" + }, "pair_tile_image": { "$ref": "#/components/schemas/PairTileImageOutput" }, @@ -35970,6 +38618,9 @@ "seamless": { "$ref": "#/components/schemas/SeamlessModeOutput" }, + "seedream_image_generation": { + "$ref": "#/components/schemas/ImageCollectionOutput" + }, "segment_anything": { "$ref": "#/components/schemas/MaskOutput" }, @@ -36018,6 +38669,9 @@ "tensor_mask_to_image": { "$ref": "#/components/schemas/ImageOutput" }, + "text_llm": { + "$ref": "#/components/schemas/StringOutput" + }, "tile_to_properties": { "$ref": "#/components/schemas/TileToPropertiesOutput" }, @@ -36065,253 +38719,258 @@ } }, "required": [ - "z_image_denoise", - "dynamic_prompt", + "add", + "alibabacloud_image_generation", + "alpha_mask_to_tensor", + "anima_denoise", + "anima_i2l", + "anima_l2i", + "anima_lora_collection_loader", + "anima_lora_loader", + "anima_model_loader", + "anima_text_encoder", + "apply_mask_to_image", + "apply_tensor_mask_to_image", + "blank_image", "boolean", - "sdxl_lora_collection_loader", + "boolean_collection", + "bounding_box", + "calculate_image_tiles", + "calculate_image_tiles_even_split", + "calculate_image_tiles_min_overlap", + "canny_edge_detection", + "canvas_output", + "canvas_paste_back", + "canvas_v2_mask_and_crop", + "clip_skip", + "cogview4_denoise", + "cogview4_i2l", + "cogview4_l2i", + "cogview4_model_loader", + "cogview4_text_encoder", "collect", - "conditioning", - "mediapipe_face_detection", - "color_map", - "image_panel_layout", - "img_paste", - "z_image_control", - "z_image_text_encoder", - "sd3_text_encoder", - "metadata_to_loras", - "img_pad_crop", "color", - "pbr_maps", - "sd3_i2l", - "metadata_to_sdxl_model", - "cv_inpaint", - "float_to_int", - "heuristic_resize", - "latents_collection", - "scheduler", - "z_image_i2l", + "color_correct", + "color_map", "compel", - "flux_lora_collection_loader", - "flux2_klein_text_encoder", - "flux_denoise", - "l2i", - "z_image_l2i", + "conditioning", + "conditioning_collection", + "content_shuffle", + "controlnet", + "core_metadata", + "create_denoise_mask", + "create_gradient_mask", + "crop_image_to_bounding_box", "crop_latents", - "image_batch", - "flux2_klein_model_loader", - "metadata_to_integer_collection", + "cv_inpaint", + "decode_watermark", + "denoise_latents", + "denoise_latents_meta", "depth_anything_depth_estimation", - "metadata_field_extractor", - "mask_edge", - "float_batch", - "img_mul", - "metadata", - "llava_onevision_vllm", - "metadata_to_string_collection", - "random_range", - "infill_cv2", + "div", + "dw_openpose_detection", + "dynamic_prompt", "esrgan", - "tomask", - "anima_model_loader", + "expand_mask_with_fade", + "face_identifier", + "face_mask_detection", + "face_off", + "float", + "float_batch", + "float_collection", + "float_generator", + "float_math", + "float_range", + "float_to_int", + "flux2_denoise", + "flux2_klein_lora_collection_loader", + "flux2_klein_lora_loader", + "flux2_klein_model_loader", + "flux2_klein_text_encoder", + "flux2_vae_decode", + "flux2_vae_encode", + "flux_control_lora_loader", + "flux_controlnet", + "flux_denoise", + "flux_denoise_meta", + "flux_fill", + "flux_ip_adapter", + "flux_kontext", + "flux_kontext_image_prep", + "flux_lora_collection_loader", + "flux_lora_loader", + "flux_model_loader", + "flux_redux", "flux_text_encoder", - "content_shuffle", + "flux_vae_decode", + "flux_vae_encode", + "freeu", + "gemini_image_generation", + "get_image_mask_bounding_box", "grounding_dino", - "spandrel_image_to_image", - "sdxl_compel_prompt", - "prompt_template", - "infill_rgba", - "img_noise", - "lineart_edge_detection", - "string_collection", - "anima_lora_loader", - "apply_mask_to_image", + "hed_edge_detection", + "heuristic_resize", + "i2l", + "ideal_size", + "if", + "image", + "image_batch", + "image_collection", + "image_generator", + "image_mask_to_tensor", + "image_panel_layout", "img_blur", - "create_denoise_mask", - "metadata_item_linked", + "img_chan", + "img_channel_multiply", + "img_channel_offset", + "img_conv", + "img_crop", + "img_hue_adjust", + "img_ilerp", "img_lerp", - "qwen_image_lora_collection_loader", - "i2l", - "integer", - "decode_watermark", - "infill_tile", - "lora_loader", - "metadata_to_lora_collection", - "flux2_vae_decode", - "denoise_latents_meta", - "mlsd_detection", - "integer_generator", - "flux2_vae_encode", - "rand_float", + "img_mul", + "img_noise", + "img_nsfw", + "img_pad_crop", + "img_paste", "img_resize", - "metadata_to_float_collection", - "pair_tile_image", - "anima_l2i", - "clip_skip", - "range_of_size", - "create_gradient_mask", - "paste_image_into_bounding_box", - "float", - "canny_edge_detection", - "calculate_image_tiles_min_overlap", - "cogview4_model_loader", "img_scale", - "dw_openpose_detection", - "flux_control_lora_loader", - "string_split_neg", - "sdxl_refiner_model_loader", - "qwen_image_lora_loader", - "z_image_lora_loader", - "string", - "tensor_mask_to_image", + "img_watermark", + "infill_cv2", + "infill_lama", + "infill_patchmatch", + "infill_rgba", + "infill_tile", + "integer", + "integer_batch", + "integer_collection", + "integer_generator", + "integer_math", + "invert_tensor_mask", + "invokeai_ealightness", + "invokeai_img_blend", + "invokeai_img_composite", + "invokeai_img_dilate_erode", + "invokeai_img_enhance", + "invokeai_img_hue_adjust_plus", + "invokeai_img_val_thresholds", + "ip_adapter", + "iterate", + "l2i", "latents", - "denoise_latents", - "z_image_denoise_meta", - "metadata_to_vae", - "metadata_to_controlnets", - "cogview4_denoise", - "flux2_klein_lora_collection_loader", - "qwen_image_model_loader", - "flux2_denoise", - "sdxl_lora_loader", + "latents_collection", + "lblend", + "lineart_anime_edge_detection", + "lineart_edge_detection", + "llava_onevision_vllm", + "lora_collection_loader", + "lora_loader", + "lora_selector", + "lresize", + "lscale", + "main_model_loader", + "mask_combine", + "mask_edge", + "mask_from_id", + "mediapipe_face_detection", + "merge_metadata", + "merge_tiles_to_image", + "metadata", + "metadata_field_extractor", + "metadata_from_image", + "metadata_item", + "metadata_item_linked", "metadata_to_bool", - "flux_lora_loader", - "rectangle_mask", - "img_ilerp", - "add", - "sd3_denoise", - "img_channel_offset", - "t2i_adapter", - "string_join", - "boolean_collection", + "metadata_to_bool_collection", + "metadata_to_controlnets", + "metadata_to_float", + "metadata_to_float_collection", + "metadata_to_integer", + "metadata_to_integer_collection", + "metadata_to_ip_adapters", + "metadata_to_lora_collection", + "metadata_to_loras", + "metadata_to_model", "metadata_to_scheduler", - "show_image", - "integer_collection", - "string_join_three", - "alpha_mask_to_tensor", + "metadata_to_sdlx_loras", + "metadata_to_sdxl_model", "metadata_to_string", - "img_watermark", - "metadata_to_ip_adapters", - "flux2_klein_lora_loader", + "metadata_to_string_collection", + "metadata_to_t2i_adapters", + "metadata_to_vae", + "mlsd_detection", + "model_identifier", "mul", - "blank_image", + "noise", + "normal_map", + "openai_image_generation", + "pair_tile_image", + "paste_image_into_bounding_box", + "pbr_maps", + "pidi_edge_detection", "prompt_from_file", - "float_range", - "flux_redux", - "invokeai_img_enhance", - "freeu", - "anima_denoise", - "invokeai_img_blend", - "canvas_paste_back", - "mask_from_id", - "canvas_v2_mask_and_crop", - "crop_image_to_bounding_box", - "main_model_loader", - "canvas_output", - "ideal_size", - "merge_metadata", - "lineart_anime_edge_detection", - "mask_combine", - "iterate", - "metadata_to_t2i_adapters", - "unsharp_mask", + "prompt_template", + "qwen_image_denoise", "qwen_image_i2l", + "qwen_image_l2i", + "qwen_image_lora_collection_loader", + "qwen_image_lora_loader", + "qwen_image_model_loader", "qwen_image_text_encoder", - "flux_controlnet", - "string_generator", - "metadata_from_image", - "z_image_seed_variance_enhancer", - "invokeai_img_composite", - "metadata_item", - "model_identifier", - "integer_math", - "lora_selector", - "string_batch", - "lresize", - "string_replace", - "invokeai_img_dilate_erode", - "color_correct", - "round_float", - "core_metadata", - "img_channel_multiply", - "image_collection", - "lscale", - "conditioning_collection", - "flux_vae_encode", - "invokeai_ealightness", - "lblend", + "rand_float", "rand_int", - "flux_denoise_meta", - "img_hue_adjust", + "random_range", "range", - "float_math", - "calculate_image_tiles_even_split", - "segment_anything", - "flux_ip_adapter", + "range_of_size", + "rectangle_mask", + "round_float", "save_image", - "metadata_to_model", - "z_image_model_loader", - "flux_kontext", - "flux_fill", - "img_chan", + "scheduler", + "sd3_denoise", + "sd3_i2l", + "sd3_l2i", + "sd3_model_loader", + "sd3_text_encoder", + "sdxl_compel_prompt", + "sdxl_lora_collection_loader", + "sdxl_lora_loader", + "sdxl_model_loader", + "sdxl_refiner_compel_prompt", + "sdxl_refiner_model_loader", "seamless", - "metadata_to_sdlx_loras", - "div", - "image_generator", - "anima_text_encoder", + "seedream_image_generation", + "segment_anything", + "show_image", + "spandrel_image_to_image", + "spandrel_image_to_image_autoscale", + "string", + "string_batch", + "string_collection", + "string_generator", + "string_join", + "string_join_three", + "string_replace", + "string_split", + "string_split_neg", + "sub", + "t2i_adapter", + "tensor_mask_to_image", + "text_llm", "tile_to_properties", - "flux_kontext_image_prep", - "face_mask_detection", - "invokeai_img_val_thresholds", - "float_collection", - "controlnet", - "face_off", - "get_image_mask_bounding_box", - "float_generator", - "ip_adapter", - "cogview4_i2l", "tiled_multi_diffusion_denoise_latents", - "metadata_to_bool_collection", - "qwen_image_denoise", - "sdxl_model_loader", - "calculate_image_tiles", - "invert_tensor_mask", - "infill_patchmatch", - "face_identifier", - "img_crop", + "tomask", + "unsharp_mask", "vae_loader", - "hed_edge_detection", - "infill_lama", - "noise", - "anima_i2l", - "flux_vae_decode", - "cogview4_text_encoder", - "sd3_model_loader", - "flux_model_loader", - "bounding_box", - "pidi_edge_detection", - "integer_batch", - "apply_tensor_mask_to_image", - "image", - "expand_mask_with_fade", - "sub", - "spandrel_image_to_image_autoscale", - "normal_map", - "invokeai_img_hue_adjust_plus", - "metadata_to_integer", - "merge_tiles_to_image", - "img_nsfw", - "string_split", - "metadata_to_float", - "lora_collection_loader", - "image_mask_to_tensor", - "qwen_image_l2i", + "z_image_control", + "z_image_denoise", + "z_image_denoise_meta", + "z_image_i2l", + "z_image_l2i", "z_image_lora_collection_loader", - "img_conv", - "sdxl_refiner_compel_prompt", - "cogview4_l2i", - "if", - "sd3_l2i", - "anima_lora_collection_loader" + "z_image_lora_loader", + "z_image_model_loader", + "z_image_seed_variance_enhancer", + "z_image_text_encoder" ] }, "InvocationProgressEvent": { @@ -36380,6 +39039,9 @@ { "$ref": "#/components/schemas/AddInvocation" }, + { + "$ref": "#/components/schemas/AlibabaCloudImageGenerationInvocation" + }, { "$ref": "#/components/schemas/AlphaMaskToTensorInvocation" }, @@ -36641,6 +39303,9 @@ { "$ref": "#/components/schemas/FreeUInvocation" }, + { + "$ref": "#/components/schemas/GeminiImageGenerationInvocation" + }, { "$ref": "#/components/schemas/GetMaskBoundingBoxInvocation" }, @@ -36923,6 +39588,9 @@ { "$ref": "#/components/schemas/NormalMapInvocation" }, + { + "$ref": "#/components/schemas/OpenAIImageGenerationInvocation" + }, { "$ref": "#/components/schemas/PBRMapsInvocation" }, @@ -37031,6 +39699,9 @@ { "$ref": "#/components/schemas/SeamlessModeInvocation" }, + { + "$ref": "#/components/schemas/SeedreamImageGenerationInvocation" + }, { "$ref": "#/components/schemas/SegmentAnythingInvocation" }, @@ -37076,6 +39747,9 @@ { "$ref": "#/components/schemas/T2IAdapterInvocation" }, + { + "$ref": "#/components/schemas/TextLLMInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -37243,6 +39917,9 @@ { "$ref": "#/components/schemas/AddInvocation" }, + { + "$ref": "#/components/schemas/AlibabaCloudImageGenerationInvocation" + }, { "$ref": "#/components/schemas/AlphaMaskToTensorInvocation" }, @@ -37504,6 +40181,9 @@ { "$ref": "#/components/schemas/FreeUInvocation" }, + { + "$ref": "#/components/schemas/GeminiImageGenerationInvocation" + }, { "$ref": "#/components/schemas/GetMaskBoundingBoxInvocation" }, @@ -37786,6 +40466,9 @@ { "$ref": "#/components/schemas/NormalMapInvocation" }, + { + "$ref": "#/components/schemas/OpenAIImageGenerationInvocation" + }, { "$ref": "#/components/schemas/PBRMapsInvocation" }, @@ -37894,6 +40577,9 @@ { "$ref": "#/components/schemas/SeamlessModeInvocation" }, + { + "$ref": "#/components/schemas/SeedreamImageGenerationInvocation" + }, { "$ref": "#/components/schemas/SegmentAnythingInvocation" }, @@ -37939,6 +40625,9 @@ { "$ref": "#/components/schemas/T2IAdapterInvocation" }, + { + "$ref": "#/components/schemas/TextLLMInvocation" + }, { "$ref": "#/components/schemas/TileToPropertiesInvocation" }, @@ -38011,7 +40700,7 @@ "type": "string", "title": "Schema Version", "description": "Schema version of the config file. This is not a user-configurable setting.", - "default": "4.0.2" + "default": "4.0.3" }, "legacy_models_yaml_path": { "anyOf": [ @@ -38151,6 +40840,13 @@ "description": "Path to directory for outputs.", "default": "outputs" }, + "image_subfolder_strategy": { + "type": "string", + "enum": ["flat", "date", "type", "hash"], + "title": "Image Subfolder Strategy", + "description": "Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.", + "default": "flat" + }, "custom_nodes_dir": { "type": "string", "format": "path", @@ -38294,7 +40990,7 @@ "type": "boolean", "title": "Enable Partial Loading", "description": "Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.", - "default": false + "default": true }, "keep_ram_copy_of_weights": { "type": "boolean", @@ -38401,9 +41097,22 @@ "clear_queue_on_startup": { "type": "boolean", "title": "Clear Queue On Startup", - "description": "Empties session queue on startup.", + "description": "Empties session queue on startup. If true, disables `max_queue_history`.", "default": false }, + "max_queue_history": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Queue History", + "description": "Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true." + }, "allow_nodes": { "anyOf": [ { @@ -38509,12 +41218,108 @@ "title": "Strict Password Checking", "description": "Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.", "default": false + }, + "external_alibabacloud_api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Alibabacloud Api Key", + "description": "API key for Alibaba Cloud DashScope image generation." + }, + "external_alibabacloud_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Alibabacloud Base Url", + "description": "Base URL override for Alibaba Cloud DashScope image generation." + }, + "external_gemini_api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Gemini Api Key", + "description": "API key for Gemini image generation." + }, + "external_openai_api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Openai Api Key", + "description": "API key for OpenAI image generation." + }, + "external_gemini_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Gemini Base Url", + "description": "Base URL override for Gemini image generation." + }, + "external_openai_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Openai Base Url", + "description": "Base URL override for OpenAI image generation." + }, + "external_seedream_api_key": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Seedream Api Key", + "description": "API key for Seedream image generation." + }, + "external_seedream_base_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "External Seedream Base Url", + "description": "Base URL override for Seedream image generation." } }, "additionalProperties": false, "type": "object", "title": "InvokeAIAppConfig", - "description": "Invoke's global app configuration.\n\nTypically, you won't need to interact with this class directly. Instead, use the `get_config` function from `invokeai.app.services.config` to get a singleton config object.\n\nAttributes:\n host: IP address to bind to. Use `0.0.0.0` to serve to your local network.\n port: Port to bind to.\n allow_origins: Allowed CORS origins.\n allow_credentials: Allow CORS credentials.\n allow_methods: Methods allowed for CORS.\n allow_headers: Headers allowed for CORS.\n ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.org/settings/#https.\n ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.org/settings/#https.\n log_tokenization: Enable logging of parsed prompt tokens.\n patchmatch: Enable patchmatch inpaint code.\n models_dir: Path to the models directory.\n convert_cache_dir: Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).\n download_cache_dir: Path to the directory that contains dynamically downloaded models.\n legacy_conf_dir: Path to directory of legacy checkpoint config files.\n db_dir: Path to InvokeAI databases directory.\n outputs_dir: Path to directory for outputs.\n custom_nodes_dir: Path to directory for custom nodes.\n style_presets_dir: Path to directory for style presets.\n workflow_thumbnails_dir: Path to directory for workflow thumbnails.\n log_handlers: Log handler. Valid options are \"console\", \"file=\", \"syslog=path|address:host:port\", \"http=\".\n log_format: Log format. Use \"plain\" for text-only, \"color\" for colorized output, \"legacy\" for 2.3-style logging and \"syslog\" for syslog-style.
Valid values: `plain`, `color`, `syslog`, `legacy`\n log_level: Emit logging messages at this level or higher.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n log_sql: Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.\n log_level_network: Log level for network-related messages. 'info' and 'debug' are very verbose.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n use_memory_db: Use in-memory database. Useful for development.\n dev_reload: Automatically reload when Python sources are changed. Does not reload node definitions.\n profile_graphs: Enable graph profiling using `cProfile`.\n profile_prefix: An optional prefix for profile output files.\n profiles_dir: Path to profiles output directory.\n max_cache_ram_gb: The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.\n max_cache_vram_gb: The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.\n log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.\n model_cache_keep_alive_min: How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.\n device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.\n enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.\n keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.\n ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.\n pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.\n device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.
Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)\n precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.
Valid values: `auto`, `float16`, `bfloat16`, `float32`\n sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.\n attention_type: Attention type.
Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`\n attention_slice_size: Slice size, valid when attention_type==\"sliced\".
Valid values: `auto`, `balanced`, `max`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`\n force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).\n pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.\n max_queue_size: Maximum number of items in the session queue.\n clear_queue_on_startup: Empties session queue on startup.\n allow_nodes: List of nodes to allow. Omit to allow all.\n deny_nodes: List of nodes to deny. Omit to deny none.\n node_cache_size: How many cached nodes to keep in memory.\n hashing_algorithm: Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.
Valid values: `blake3_multi`, `blake3_single`, `random`, `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `blake2b`, `blake2s`, `sha3_224`, `sha3_256`, `sha3_384`, `sha3_512`, `shake_128`, `shake_256`\n remote_api_tokens: List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.\n scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.\n unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.\n allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.\n multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.\n strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user." + "description": "Invoke's global app configuration.\n\nTypically, you won't need to interact with this class directly. Instead, use the `get_config` function from `invokeai.app.services.config` to get a singleton config object.\n\nAttributes:\n host: IP address to bind to. Use `0.0.0.0` to serve to your local network.\n port: Port to bind to.\n allow_origins: Allowed CORS origins.\n allow_credentials: Allow CORS credentials.\n allow_methods: Methods allowed for CORS.\n allow_headers: Headers allowed for CORS.\n ssl_certfile: SSL certificate file for HTTPS. See https://www.uvicorn.org/settings/#https.\n ssl_keyfile: SSL key file for HTTPS. See https://www.uvicorn.org/settings/#https.\n log_tokenization: Enable logging of parsed prompt tokens.\n patchmatch: Enable patchmatch inpaint code.\n models_dir: Path to the models directory.\n convert_cache_dir: Path to the converted models cache directory (DEPRECATED, but do not delete because it is needed for migration from previous versions).\n download_cache_dir: Path to the directory that contains dynamically downloaded models.\n legacy_conf_dir: Path to directory of legacy checkpoint config files.\n db_dir: Path to InvokeAI databases directory.\n outputs_dir: Path to directory for outputs.\n image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder. 'date' organizes by YYYY/MM/DD. 'type' organizes by image category. 'hash' uses first 2 characters of UUID for filesystem performance.
Valid values: `flat`, `date`, `type`, `hash`\n custom_nodes_dir: Path to directory for custom nodes.\n style_presets_dir: Path to directory for style presets.\n workflow_thumbnails_dir: Path to directory for workflow thumbnails.\n log_handlers: Log handler. Valid options are \"console\", \"file=\", \"syslog=path|address:host:port\", \"http=\".\n log_format: Log format. Use \"plain\" for text-only, \"color\" for colorized output, \"legacy\" for 2.3-style logging and \"syslog\" for syslog-style.
Valid values: `plain`, `color`, `syslog`, `legacy`\n log_level: Emit logging messages at this level or higher.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n log_sql: Log SQL queries. `log_level` must be `debug` for this to do anything. Extremely verbose.\n log_level_network: Log level for network-related messages. 'info' and 'debug' are very verbose.
Valid values: `debug`, `info`, `warning`, `error`, `critical`\n use_memory_db: Use in-memory database. Useful for development.\n dev_reload: Automatically reload when Python sources are changed. Does not reload node definitions.\n profile_graphs: Enable graph profiling using `cProfile`.\n profile_prefix: An optional prefix for profile output files.\n profiles_dir: Path to profiles output directory.\n max_cache_ram_gb: The maximum amount of CPU RAM to use for model caching in GB. If unset, the limit will be configured based on the available RAM. In most cases, it is recommended to leave this unset.\n max_cache_vram_gb: The amount of VRAM to use for model caching in GB. If unset, the limit will be configured based on the available VRAM and the device_working_mem_gb. In most cases, it is recommended to leave this unset.\n log_memory_usage: If True, a memory snapshot will be captured before and after every model cache operation, and the result will be logged (at debug level). There is a time cost to capturing the memory snapshots, so it is recommended to only enable this feature if you are actively inspecting the model cache's behaviour.\n model_cache_keep_alive_min: How long to keep models in cache after last use, in minutes. A value of 0 (the default) means models are kept in cache indefinitely. If no model generations occur within the timeout period, the model cache is cleared using the same logic as the 'Clear Model Cache' button.\n device_working_mem_gb: The amount of working memory to keep available on the compute device (in GB). Has no effect if running on CPU. If you are experiencing OOM errors, try increasing this value.\n enable_partial_loading: Enable partial loading of models. This enables models to run with reduced VRAM requirements (at the cost of slower speed) by streaming the model from RAM to VRAM as its used. In some edge cases, partial loading can cause models to run more slowly if they were previously being fully loaded into VRAM.\n keep_ram_copy_of_weights: Whether to keep a full RAM copy of a model's weights when the model is loaded in VRAM. Keeping a RAM copy increases average RAM usage, but speeds up model switching and LoRA patching (assuming there is sufficient RAM). Set this to False if RAM pressure is consistently high.\n ram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_ram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n vram: DEPRECATED: This setting is no longer used. It has been replaced by `max_cache_vram_gb`, but most users will not need to use this config since automatic cache size limits should work well in most cases. This config setting will be removed once the new model cache behavior is stable.\n lazy_offload: DEPRECATED: This setting is no longer used. Lazy-offloading is enabled by default. This config setting will be removed once the new model cache behavior is stable.\n pytorch_cuda_alloc_conf: Configure the Torch CUDA memory allocator. This will impact peak reserved VRAM usage and performance. Setting to \"backend:cudaMallocAsync\" works well on many systems. The optimal configuration is highly dependent on the system configuration (device type, VRAM, CUDA driver version, etc.), so must be tuned experimentally.\n device: Preferred execution device. `auto` will choose the device depending on the hardware platform and the installed torch capabilities.
Valid values: `auto`, `cpu`, `cuda`, `mps`, `cuda:N` (where N is a device number)\n precision: Floating point precision. `float16` will consume half the memory of `float32` but produce slightly lower-quality images. The `auto` setting will guess the proper precision based on your video card and operating system.
Valid values: `auto`, `float16`, `bfloat16`, `float32`\n sequential_guidance: Whether to calculate guidance in serial instead of in parallel, lowering memory requirements.\n attention_type: Attention type.
Valid values: `auto`, `normal`, `xformers`, `sliced`, `torch-sdp`\n attention_slice_size: Slice size, valid when attention_type==\"sliced\".
Valid values: `auto`, `balanced`, `max`, `1`, `2`, `3`, `4`, `5`, `6`, `7`, `8`\n force_tiled_decode: Whether to enable tiled VAE decode (reduces memory consumption with some performance penalty).\n pil_compress_level: The compress_level setting of PIL.Image.save(), used for PNG encoding. All settings are lossless. 0 = no compression, 1 = fastest with slightly larger filesize, 9 = slowest with smallest filesize. 1 is typically the best setting.\n max_queue_size: Maximum number of items in the session queue.\n clear_queue_on_startup: Empties session queue on startup. If true, disables `max_queue_history`.\n max_queue_history: Keep the last N completed, failed, and canceled queue items. Older items are deleted on startup. Set to 0 to prune all terminal items. Ignored if `clear_queue_on_startup` is true.\n allow_nodes: List of nodes to allow. Omit to allow all.\n deny_nodes: List of nodes to deny. Omit to deny none.\n node_cache_size: How many cached nodes to keep in memory.\n hashing_algorithm: Model hashing algorthim for model installs. 'blake3_multi' is best for SSDs. 'blake3_single' is best for spinning disk HDDs. 'random' disables hashing, instead assigning a UUID to models. Useful when using a memory db to reduce model installation time, or if you don't care about storing stable hashes for models. Alternatively, any other hashlib algorithm is accepted, though these are not nearly as performant as blake3.
Valid values: `blake3_multi`, `blake3_single`, `random`, `md5`, `sha1`, `sha224`, `sha256`, `sha384`, `sha512`, `blake2b`, `blake2s`, `sha3_224`, `sha3_256`, `sha3_384`, `sha3_512`, `shake_128`, `shake_256`\n remote_api_tokens: List of regular expression and token pairs used when downloading models from URLs. The download URL is tested against the regex, and if it matches, the token is provided in as a Bearer token.\n scan_models_on_startup: Scan the models directory on startup, registering orphaned models. This is typically only used in conjunction with `use_memory_db` for testing purposes.\n unsafe_disable_picklescan: UNSAFE. Disable the picklescan security check during model installation. Recommended only for development and testing purposes. This will allow arbitrary code execution during model installation, so should never be used in production.\n allow_unknown_models: Allow installation of models that we are unable to identify. If enabled, models will be marked as `unknown` in the database, and will not have any metadata associated with them. If disabled, unknown models will be rejected during installation.\n multiuser: Enable multiuser support. When disabled, the application runs in single-user mode using a default system account with administrator privileges. When enabled, requires user authentication and authorization.\n strict_password_checking: Enforce strict password requirements. When True, passwords must contain uppercase, lowercase, and numbers. When False (default), any password is accepted but its strength (weak/moderate/strong) is reported to the user.\n external_alibabacloud_api_key: API key for Alibaba Cloud DashScope image generation.\n external_alibabacloud_base_url: Base URL override for Alibaba Cloud DashScope image generation.\n external_gemini_api_key: API key for Gemini image generation.\n external_openai_api_key: API key for OpenAI image generation.\n external_gemini_base_url: Base URL override for Gemini image generation.\n external_openai_base_url: Base URL override for OpenAI image generation.\n external_seedream_api_key: API key for Seedream image generation.\n external_seedream_base_url: Base URL override for Seedream image generation." }, "InvokeAIAppConfigWithSetFields": { "properties": { @@ -40232,7 +43037,7 @@ } }, "LineartAnimeEdgeDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Geneartes an edge map using the Lineart model.", @@ -40327,7 +43132,7 @@ } }, "LineartEdgeDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generates an edge map using the Lineart model.", @@ -40432,7 +43237,7 @@ } }, "LlavaOnevisionVllmInvocation": { - "category": "vllm", + "category": "multimodal", "class": "invocation", "classification": "beta", "description": "Run a LLaVA OneVision VLLM model.", @@ -40595,6 +43400,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -40653,6 +43470,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -41142,6 +43960,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -41211,6 +44041,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -41280,6 +44111,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -41359,6 +44202,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -41430,6 +44274,169 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, + "cover_image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image", + "description": "Url for image to preview model" + }, + "type": { + "type": "string", + "const": "lora", + "title": "Type", + "default": "lora" + }, + "trigger_phrases": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + { + "type": "null" + } + ], + "title": "Trigger Phrases", + "description": "Set of trigger phrases for this model" + }, + "default_settings": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoraModelDefaultSettings" + }, + { + "type": "null" + } + ], + "description": "Default settings for this model" + }, + "format": { + "type": "string", + "const": "diffusers", + "title": "Format", + "default": "diffusers" + }, + "base": { + "type": "string", + "const": "sd-1", + "title": "Base", + "default": "sd-1" + } + }, + "type": "object", + "required": [ + "key", + "hash", + "path", + "file_size", + "name", + "description", + "source", + "source_type", + "source_api_response", + "source_url", + "cover_image", + "type", + "trigger_phrases", + "default_settings", + "format", + "base" + ], + "title": "LoRA_Diffusers_SD1_Config" + }, + "LoRA_Diffusers_SD2_Config": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "A unique key for this model." + }, + "hash": { + "type": "string", + "title": "Hash", + "description": "The hash of the model file(s)." + }, + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + }, + "file_size": { + "type": "integer", + "title": "File Size", + "description": "The size of the model in bytes." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the model." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Model description" + }, + "source": { + "type": "string", + "title": "Source", + "description": "The original source of the model (path, URL or repo_id)." + }, + "source_type": { + "$ref": "#/components/schemas/ModelSourceType", + "description": "The type of source" + }, + "source_api_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Api Response", + "description": "The original API response from the source, as stringified JSON." + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -41483,9 +44490,9 @@ }, "base": { "type": "string", - "const": "sd-1", + "const": "sd-2", "title": "Base", - "default": "sd-1" + "default": "sd-2" } }, "type": "object", @@ -41499,6 +44506,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -41506,9 +44514,9 @@ "format", "base" ], - "title": "LoRA_Diffusers_SD1_Config" + "title": "LoRA_Diffusers_SD2_Config" }, - "LoRA_Diffusers_SD2_Config": { + "LoRA_Diffusers_SDXL_Config": { "properties": { "key": { "type": "string", @@ -41568,6 +44576,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -41621,9 +44641,9 @@ }, "base": { "type": "string", - "const": "sd-2", + "const": "sdxl", "title": "Base", - "default": "sd-2" + "default": "sdxl" } }, "type": "object", @@ -41637,6 +44657,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -41644,9 +44665,9 @@ "format", "base" ], - "title": "LoRA_Diffusers_SD2_Config" + "title": "LoRA_Diffusers_SDXL_Config" }, - "LoRA_Diffusers_SDXL_Config": { + "LoRA_Diffusers_ZImage_Config": { "properties": { "key": { "type": "string", @@ -41706,6 +44727,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -41759,9 +44792,19 @@ }, "base": { "type": "string", - "const": "sdxl", + "const": "z-image", "title": "Base", - "default": "sdxl" + "default": "z-image" + }, + "variant": { + "anyOf": [ + { + "$ref": "#/components/schemas/ZImageVariantType" + }, + { + "type": "null" + } + ] } }, "type": "object", @@ -41775,16 +44818,19 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", "default_settings", "format", - "base" + "base", + "variant" ], - "title": "LoRA_Diffusers_SDXL_Config" + "title": "LoRA_Diffusers_ZImage_Config", + "description": "Model config for Z-Image LoRA models in Diffusers format." }, - "LoRA_Diffusers_ZImage_Config": { + "LoRA_LyCORIS_Anima_Config": { "properties": { "key": { "type": "string", @@ -41844,6 +44890,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -41891,25 +44949,167 @@ }, "format": { "type": "string", - "const": "diffusers", + "const": "lycoris", "title": "Format", - "default": "diffusers" + "default": "lycoris" }, "base": { "type": "string", - "const": "z-image", + "const": "anima", "title": "Base", - "default": "z-image" + "default": "anima" + } + }, + "type": "object", + "required": [ + "key", + "hash", + "path", + "file_size", + "name", + "description", + "source", + "source_type", + "source_api_response", + "source_url", + "cover_image", + "type", + "trigger_phrases", + "default_settings", + "format", + "base" + ], + "title": "LoRA_LyCORIS_Anima_Config", + "description": "Model config for Anima LoRA models in LyCORIS format." + }, + "LoRA_LyCORIS_FLUX_Config": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "A unique key for this model." }, - "variant": { + "hash": { + "type": "string", + "title": "Hash", + "description": "The hash of the model file(s)." + }, + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + }, + "file_size": { + "type": "integer", + "title": "File Size", + "description": "The size of the model in bytes." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the model." + }, + "description": { "anyOf": [ { - "$ref": "#/components/schemas/ZImageVariantType" + "type": "string" }, { "type": "null" } - ] + ], + "title": "Description", + "description": "Model description" + }, + "source": { + "type": "string", + "title": "Source", + "description": "The original source of the model (path, URL or repo_id)." + }, + "source_type": { + "$ref": "#/components/schemas/ModelSourceType", + "description": "The type of source" + }, + "source_api_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Api Response", + "description": "The original API response from the source, as stringified JSON." + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, + "cover_image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image", + "description": "Url for image to preview model" + }, + "type": { + "type": "string", + "const": "lora", + "title": "Type", + "default": "lora" + }, + "trigger_phrases": { + "anyOf": [ + { + "items": { + "type": "string" + }, + "type": "array", + "uniqueItems": true + }, + { + "type": "null" + } + ], + "title": "Trigger Phrases", + "description": "Set of trigger phrases for this model" + }, + "default_settings": { + "anyOf": [ + { + "$ref": "#/components/schemas/LoraModelDefaultSettings" + }, + { + "type": "null" + } + ], + "description": "Default settings for this model" + }, + "format": { + "type": "string", + "const": "lycoris", + "title": "Format", + "default": "lycoris" + }, + "base": { + "type": "string", + "const": "flux", + "title": "Base", + "default": "flux" } }, "type": "object", @@ -41923,18 +45123,17 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", "default_settings", "format", - "base", - "variant" + "base" ], - "title": "LoRA_Diffusers_ZImage_Config", - "description": "Model config for Z-Image LoRA models in Diffusers format." + "title": "LoRA_LyCORIS_FLUX_Config" }, - "LoRA_LyCORIS_Anima_Config": { + "LoRA_LyCORIS_Flux2_Config": { "properties": { "key": { "type": "string", @@ -41994,6 +45193,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -42047,9 +45258,19 @@ }, "base": { "type": "string", - "const": "anima", + "const": "flux2", "title": "Base", - "default": "anima" + "default": "flux2" + }, + "variant": { + "anyOf": [ + { + "$ref": "#/components/schemas/Flux2VariantType" + }, + { + "type": "null" + } + ] } }, "type": "object", @@ -42063,17 +45284,19 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", "default_settings", "format", - "base" + "base", + "variant" ], - "title": "LoRA_LyCORIS_Anima_Config", - "description": "Model config for Anima LoRA models in LyCORIS format." + "title": "LoRA_LyCORIS_Flux2_Config", + "description": "Model config for FLUX.2 (Klein) LoRA models in LyCORIS format." }, - "LoRA_LyCORIS_FLUX_Config": { + "LoRA_LyCORIS_QwenImage_Config": { "properties": { "key": { "type": "string", @@ -42133,7 +45356,7 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, - "cover_image": { + "source_url": { "anyOf": [ { "type": "string" @@ -42142,134 +45365,8 @@ "type": "null" } ], - "title": "Cover Image", - "description": "Url for image to preview model" - }, - "type": { - "type": "string", - "const": "lora", - "title": "Type", - "default": "lora" - }, - "trigger_phrases": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - }, - { - "type": "null" - } - ], - "title": "Trigger Phrases", - "description": "Set of trigger phrases for this model" - }, - "default_settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoraModelDefaultSettings" - }, - { - "type": "null" - } - ], - "description": "Default settings for this model" - }, - "format": { - "type": "string", - "const": "lycoris", - "title": "Format", - "default": "lycoris" - }, - "base": { - "type": "string", - "const": "flux", - "title": "Base", - "default": "flux" - } - }, - "type": "object", - "required": [ - "key", - "hash", - "path", - "file_size", - "name", - "description", - "source", - "source_type", - "source_api_response", - "cover_image", - "type", - "trigger_phrases", - "default_settings", - "format", - "base" - ], - "title": "LoRA_LyCORIS_FLUX_Config" - }, - "LoRA_LyCORIS_Flux2_Config": { - "properties": { - "key": { - "type": "string", - "title": "Key", - "description": "A unique key for this model." - }, - "hash": { - "type": "string", - "title": "Hash", - "description": "The hash of the model file(s)." - }, - "path": { - "type": "string", - "title": "Path", - "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." - }, - "file_size": { - "type": "integer", - "title": "File Size", - "description": "The size of the model in bytes." - }, - "name": { - "type": "string", - "title": "Name", - "description": "Name of the model." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description", - "description": "Model description" - }, - "source": { - "type": "string", - "title": "Source", - "description": "The original source of the model (path, URL or repo_id)." - }, - "source_type": { - "$ref": "#/components/schemas/ModelSourceType", - "description": "The type of source" - }, - "source_api_response": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Source Api Response", - "description": "The original API response from the source, as stringified JSON." + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." }, "cover_image": { "anyOf": [ @@ -42324,19 +45421,9 @@ }, "base": { "type": "string", - "const": "flux2", + "const": "qwen-image", "title": "Base", - "default": "flux2" - }, - "variant": { - "anyOf": [ - { - "$ref": "#/components/schemas/Flux2VariantType" - }, - { - "type": "null" - } - ] + "default": "qwen-image" } }, "type": "object", @@ -42350,18 +45437,18 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", "default_settings", "format", - "base", - "variant" + "base" ], - "title": "LoRA_LyCORIS_Flux2_Config", - "description": "Model config for FLUX.2 (Klein) LoRA models in LyCORIS format." + "title": "LoRA_LyCORIS_QwenImage_Config", + "description": "Model config for Qwen Image Edit LoRA models in LyCORIS format." }, - "LoRA_LyCORIS_QwenImage_Config": { + "LoRA_LyCORIS_SD1_Config": { "properties": { "key": { "type": "string", @@ -42421,7 +45508,7 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, - "cover_image": { + "source_url": { "anyOf": [ { "type": "string" @@ -42430,135 +45517,8 @@ "type": "null" } ], - "title": "Cover Image", - "description": "Url for image to preview model" - }, - "type": { - "type": "string", - "const": "lora", - "title": "Type", - "default": "lora" - }, - "trigger_phrases": { - "anyOf": [ - { - "items": { - "type": "string" - }, - "type": "array", - "uniqueItems": true - }, - { - "type": "null" - } - ], - "title": "Trigger Phrases", - "description": "Set of trigger phrases for this model" - }, - "default_settings": { - "anyOf": [ - { - "$ref": "#/components/schemas/LoraModelDefaultSettings" - }, - { - "type": "null" - } - ], - "description": "Default settings for this model" - }, - "format": { - "type": "string", - "const": "lycoris", - "title": "Format", - "default": "lycoris" - }, - "base": { - "type": "string", - "const": "qwen-image", - "title": "Base", - "default": "qwen-image" - } - }, - "type": "object", - "required": [ - "key", - "hash", - "path", - "file_size", - "name", - "description", - "source", - "source_type", - "source_api_response", - "cover_image", - "type", - "trigger_phrases", - "default_settings", - "format", - "base" - ], - "title": "LoRA_LyCORIS_QwenImage_Config", - "description": "Model config for Qwen Image Edit LoRA models in LyCORIS format." - }, - "LoRA_LyCORIS_SD1_Config": { - "properties": { - "key": { - "type": "string", - "title": "Key", - "description": "A unique key for this model." - }, - "hash": { - "type": "string", - "title": "Hash", - "description": "The hash of the model file(s)." - }, - "path": { - "type": "string", - "title": "Path", - "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." - }, - "file_size": { - "type": "integer", - "title": "File Size", - "description": "The size of the model in bytes." - }, - "name": { - "type": "string", - "title": "Name", - "description": "Name of the model." - }, - "description": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Description", - "description": "Model description" - }, - "source": { - "type": "string", - "title": "Source", - "description": "The original source of the model (path, URL or repo_id)." - }, - "source_type": { - "$ref": "#/components/schemas/ModelSourceType", - "description": "The type of source" - }, - "source_api_response": { - "anyOf": [ - { - "type": "string" - }, - { - "type": "null" - } - ], - "title": "Source Api Response", - "description": "The original API response from the source, as stringified JSON." + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." }, "cover_image": { "anyOf": [ @@ -42629,6 +45589,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -42698,6 +45659,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -42767,6 +45740,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -42836,6 +45810,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -42905,6 +45891,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -42974,6 +45961,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -43053,6 +46052,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -43124,6 +46124,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -43193,6 +46205,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -43262,6 +46275,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -43331,6 +46356,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -43569,7 +46595,7 @@ "type": "object" }, "MLSDDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generates an line segment map using MLSD.", @@ -43743,6 +46769,7 @@ "dpmpp_3m_k", "dpmpp_sde", "dpmpp_sde_k", + "er_sde", "unipc", "unipc_k", "lcm", @@ -43979,6 +47006,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -44063,6 +47102,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -44135,6 +47175,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -44216,6 +47268,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -44287,6 +47340,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -44371,6 +47436,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -44443,6 +47509,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -44527,6 +47605,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -44599,6 +47678,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -44686,6 +47777,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -44758,6 +47850,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -44845,6 +47949,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -44917,6 +48022,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -45004,6 +48121,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -45076,6 +48194,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -45163,6 +48293,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -45235,6 +48366,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -45319,6 +48462,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -45391,6 +48535,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -45464,6 +48620,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -45534,6 +48691,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -45610,6 +48779,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -45682,6 +48852,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -45758,6 +48940,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -45830,6 +49013,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -45913,6 +49108,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -45985,6 +49181,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -46064,6 +49272,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -46136,6 +49345,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -46215,6 +49436,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -46287,6 +49509,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -46378,6 +49612,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -46449,6 +49684,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -46528,6 +49775,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -46600,6 +49848,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -46679,6 +49939,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -46751,6 +50012,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -46827,6 +50100,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -46899,6 +50173,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -46983,6 +50269,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -47055,6 +50342,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -47139,6 +50438,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -47211,6 +50511,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -47302,6 +50614,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -47374,6 +50687,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -47458,6 +50783,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "trigger_phrases", @@ -47471,7 +50797,7 @@ "description": "Model config for GGUF-quantized Z-Image transformer models." }, "MaskCombineInvocation": { - "category": "image", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Combine two masks together by multiplying them using `PIL.ImageChops.multiply()`.", @@ -47581,7 +50907,7 @@ } }, "MaskEdgeInvocation": { - "category": "image", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Applies an edge mask to an image", @@ -47740,7 +51066,7 @@ } }, "MaskFromAlphaInvocation": { - "category": "image", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Extracts the alpha channel of an image as a mask.", @@ -47845,7 +51171,7 @@ } }, "MaskFromIDInvocation": { - "category": "image", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Generate a mask for a particular color in an ID Map", @@ -48106,7 +51432,7 @@ } }, "MediaPipeFaceDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Detects faces using MediaPipe.", @@ -50510,6 +53836,7 @@ "dpmpp_3m_k", "dpmpp_sde", "dpmpp_sde_k", + "er_sde", "unipc", "unipc_k", "lcm", @@ -50979,9 +54306,11 @@ "invokeai", "t5_encoder", "qwen3_encoder", + "qwen_vl_encoder", "bnb_quantized_int8b", "bnb_quantized_nf4b", "gguf_quantized", + "external_api", "unknown" ], "title": "ModelFormat", @@ -51133,6 +54462,7 @@ "description": "Source of the model; local path, repo_id or url", "discriminator": { "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -51148,6 +54478,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source" @@ -51174,6 +54507,7 @@ "description": "Source of the model; local path, repo_id or url", "discriminator": { "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -51189,6 +54523,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source" @@ -51297,6 +54634,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -51402,6 +54742,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -51468,6 +54814,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -51496,6 +54848,7 @@ "description": "Source of the model; local path, repo_id or url", "discriminator": { "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -51511,6 +54864,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source" @@ -51570,6 +54926,7 @@ "description": "Source of the model; local path, repo_id or url", "discriminator": { "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -51585,6 +54942,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source" @@ -51644,6 +55004,7 @@ "description": "Source of the model; local path, repo_id or url", "discriminator": { "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -51659,6 +55020,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source" @@ -51685,6 +55049,7 @@ "description": "Source of the model; local path, repo_id or url", "discriminator": { "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -51700,6 +55065,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source" @@ -51835,6 +55203,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -51940,6 +55311,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -52006,6 +55383,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -52034,6 +55417,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source", @@ -52041,6 +55427,7 @@ "discriminator": { "propertyName": "type", "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -52147,6 +55534,7 @@ "description": "Source of the model; local path, repo_id or url", "discriminator": { "mapping": { + "external": "#/components/schemas/ExternalModelSource", "hf": "#/components/schemas/HFModelSource", "local": "#/components/schemas/LocalModelSource", "url": "#/components/schemas/URLModelSource" @@ -52162,6 +55550,9 @@ }, { "$ref": "#/components/schemas/URLModelSource" + }, + { + "$ref": "#/components/schemas/ExternalModelSource" } ], "title": "Source" @@ -52266,6 +55657,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -52371,6 +55765,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -52437,6 +55837,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -52555,6 +55961,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -52660,6 +56069,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -52726,6 +56141,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -52823,6 +56244,18 @@ "title": "Source Api Response", "description": "metadata from remote source" }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page)" + }, "name": { "anyOf": [ { @@ -52956,6 +56389,9 @@ { "$ref": "#/components/schemas/ControlAdapterDefaultSettings" }, + { + "$ref": "#/components/schemas/ExternalApiModelDefaultSettings" + }, { "type": "null" } @@ -52963,6 +56399,41 @@ "title": "Default Settings", "description": "Default settings for this model" }, + "provider_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider Id", + "description": "External provider identifier" + }, + "provider_model_id": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Provider Model Id", + "description": "External provider model identifier" + }, + "capabilities": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalModelCapabilities" + }, + { + "type": "null" + } + ], + "description": "External model capabilities" + }, "cpu_only": { "anyOf": [ { @@ -53045,6 +56516,12 @@ "title": "ModelRecordChanges", "description": "A set of changes to apply to a model." }, + "ModelRecordOrderBy": { + "type": "string", + "enum": ["default", "type", "base", "name", "format", "size", "created_at", "updated_at", "path"], + "title": "ModelRecordOrderBy", + "description": "The order in which to return model summaries." + }, "ModelRelationshipBatchRequest": { "properties": { "model_keys": { @@ -53110,7 +56587,7 @@ }, "ModelSourceType": { "type": "string", - "enum": ["path", "url", "hf_repo_id"], + "enum": ["path", "url", "hf_repo_id", "external"], "title": "ModelSourceType", "description": "Model source type." }, @@ -53130,10 +56607,13 @@ "t2i_adapter", "t5_encoder", "qwen3_encoder", + "qwen_vl_encoder", "spandrel_image_to_image", "siglip", "flux_redux", "llava_onevision", + "text_llm", + "external_image_generator", "unknown" ], "title": "ModelType", @@ -53234,6 +56714,9 @@ { "$ref": "#/components/schemas/VAE_Checkpoint_Flux2_Config" }, + { + "$ref": "#/components/schemas/VAE_Checkpoint_QwenImage_Config" + }, { "$ref": "#/components/schemas/VAE_Checkpoint_Anima_Config" }, @@ -53339,6 +56822,12 @@ { "$ref": "#/components/schemas/Qwen3Encoder_GGUF_Config" }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/QwenVLEncoder_Checkpoint_Config" + }, { "$ref": "#/components/schemas/TI_File_SD1_Config" }, @@ -53405,6 +56894,12 @@ { "$ref": "#/components/schemas/LlavaOnevision_Diffusers_Config" }, + { + "$ref": "#/components/schemas/TextLLM_Diffusers_Config" + }, + { + "$ref": "#/components/schemas/ExternalApiModelConfig" + }, { "$ref": "#/components/schemas/Unknown_Config" } @@ -53522,6 +57017,58 @@ "required": ["node_path", "field_name", "value"], "title": "NodeFieldValue" }, + "NodePackInfo": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the node pack." + }, + "path": { + "type": "string", + "title": "Path", + "description": "The path to the node pack directory." + }, + "node_count": { + "type": "integer", + "title": "Node Count", + "description": "The number of nodes in the pack." + }, + "node_types": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Node Types", + "description": "The invocation types provided by this node pack." + } + }, + "type": "object", + "required": ["name", "path", "node_count", "node_types"], + "title": "NodePackInfo", + "description": "Information about an installed node pack." + }, + "NodePackListResponse": { + "properties": { + "node_packs": { + "items": { + "$ref": "#/components/schemas/NodePackInfo" + }, + "type": "array", + "title": "Node Packs", + "description": "List of installed node packs." + }, + "custom_nodes_path": { + "type": "string", + "title": "Custom Nodes Path", + "description": "The configured custom nodes directory path." + } + }, + "type": "object", + "required": ["node_packs", "custom_nodes_path"], + "title": "NodePackListResponse", + "description": "Response for listing installed node packs." + }, "NoiseInvocation": { "category": "latents", "class": "invocation", @@ -53653,7 +57200,7 @@ "type": "object" }, "NormalMapInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generates a normal map.", @@ -53715,7 +57262,276 @@ "title": "Use Cache", "type": "boolean" }, - "image": { + "image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The image to process", + "field_kind": "input", + "input": "any", + "orig_required": true + }, + "type": { + "const": "normal_map", + "default": "normal_map", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["controlnet", "normal"], + "title": "Normal Map", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/ImageOutput" + } + }, + "OffsetPaginatedResults_BoardDTO_": { + "properties": { + "limit": { + "type": "integer", + "title": "Limit", + "description": "Limit of items to get" + }, + "offset": { + "type": "integer", + "title": "Offset", + "description": "Offset from which to retrieve items" + }, + "total": { + "type": "integer", + "title": "Total", + "description": "Total number of items in result" + }, + "items": { + "items": { + "$ref": "#/components/schemas/BoardDTO" + }, + "type": "array", + "title": "Items", + "description": "Items" + } + }, + "type": "object", + "required": ["limit", "offset", "total", "items"], + "title": "OffsetPaginatedResults[BoardDTO]" + }, + "OffsetPaginatedResults_ImageDTO_": { + "properties": { + "limit": { + "type": "integer", + "title": "Limit", + "description": "Limit of items to get" + }, + "offset": { + "type": "integer", + "title": "Offset", + "description": "Offset from which to retrieve items" + }, + "total": { + "type": "integer", + "title": "Total", + "description": "Total number of items in result" + }, + "items": { + "items": { + "$ref": "#/components/schemas/ImageDTO" + }, + "type": "array", + "title": "Items", + "description": "Items" + } + }, + "type": "object", + "required": ["limit", "offset", "total", "items"], + "title": "OffsetPaginatedResults[ImageDTO]" + }, + "OpenAIImageGenerationInvocation": { + "category": "image", + "class": "invocation", + "classification": "stable", + "description": "Generate images using an OpenAI-hosted external model.", + "node_pack": "invokeai", + "properties": { + "board": { + "anyOf": [ + { + "$ref": "#/components/schemas/BoardField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The board to save the image to", + "field_kind": "internal", + "input": "direct", + "orig_required": false, + "ui_hidden": false + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional metadata to be saved with the image", + "field_kind": "internal", + "input": "connection", + "orig_required": false, + "ui_hidden": false + }, + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelIdentifierField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Main model (UNet, VAE, CLIP) to load", + "field_kind": "input", + "input": "any", + "orig_required": true, + "ui_model_base": ["external"], + "ui_model_format": ["external_api"], + "ui_model_provider_id": ["openai"], + "ui_model_type": ["external_image_generator"] + }, + "mode": { + "default": "txt2img", + "description": "Generation mode.", + "enum": ["txt2img", "img2img", "inpaint"], + "field_kind": "input", + "input": "any", + "orig_default": "txt2img", + "orig_required": false, + "title": "Mode", + "type": "string", + "ui_hidden": true + }, + "prompt": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Prompt", + "field_kind": "input", + "input": "any", + "orig_required": true, + "title": "Prompt" + }, + "seed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Seed for random number generation", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Seed" + }, + "num_images": { + "default": 1, + "description": "Number of images to generate", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1, + "orig_required": false, + "title": "Num Images", + "type": "integer" + }, + "width": { + "default": 1024, + "description": "Width of output (px)", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1024, + "orig_required": false, + "title": "Width", + "type": "integer" + }, + "height": { + "default": 1024, + "description": "Height of output (px)", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1024, + "orig_required": false, + "title": "Height", + "type": "integer" + }, + "image_size": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Image size preset (e.g. 1K, 2K, 4K)", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Image Size" + }, + "init_image": { "anyOf": [ { "$ref": "#/components/schemas/ImageField" @@ -53725,88 +57541,100 @@ } ], "default": null, - "description": "The image to process", + "description": "Init image (use reference_images instead)", "field_kind": "input", "input": "any", - "orig_required": true + "orig_default": null, + "orig_required": false, + "ui_hidden": true + }, + "mask_image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Mask image for inpaint", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "ui_hidden": true + }, + "reference_images": { + "default": [], + "description": "Reference images", + "field_kind": "input", + "input": "any", + "items": { + "$ref": "#/components/schemas/ImageField" + }, + "orig_default": [], + "orig_required": false, + "title": "Reference Images", + "type": "array" + }, + "quality": { + "default": "auto", + "description": "Output image quality", + "enum": ["auto", "high", "medium", "low"], + "field_kind": "input", + "input": "any", + "orig_default": "auto", + "orig_required": false, + "title": "Quality", + "type": "string" + }, + "background": { + "default": "auto", + "description": "Background transparency handling", + "enum": ["auto", "transparent", "opaque"], + "field_kind": "input", + "input": "any", + "orig_default": "auto", + "orig_required": false, + "title": "Background", + "type": "string" + }, + "input_fidelity": { + "anyOf": [ + { + "enum": ["low", "high"], + "type": "string" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Fidelity to source images (edits only)", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Input Fidelity" }, "type": { - "const": "normal_map", - "default": "normal_map", + "const": "openai_image_generation", + "default": "openai_image_generation", "field_kind": "node_attribute", "title": "type", "type": "string" } }, "required": ["type", "id"], - "tags": ["controlnet", "normal"], - "title": "Normal Map", + "tags": ["external", "generation", "openai"], + "title": "OpenAI Image Generation", "type": "object", "version": "1.0.0", "output": { - "$ref": "#/components/schemas/ImageOutput" + "$ref": "#/components/schemas/ImageCollectionOutput" } }, - "OffsetPaginatedResults_BoardDTO_": { - "properties": { - "limit": { - "type": "integer", - "title": "Limit", - "description": "Limit of items to get" - }, - "offset": { - "type": "integer", - "title": "Offset", - "description": "Offset from which to retrieve items" - }, - "total": { - "type": "integer", - "title": "Total", - "description": "Total number of items in result" - }, - "items": { - "items": { - "$ref": "#/components/schemas/BoardDTO" - }, - "type": "array", - "title": "Items", - "description": "Items" - } - }, - "type": "object", - "required": ["limit", "offset", "total", "items"], - "title": "OffsetPaginatedResults[BoardDTO]" - }, - "OffsetPaginatedResults_ImageDTO_": { - "properties": { - "limit": { - "type": "integer", - "title": "Limit", - "description": "Limit of items to get" - }, - "offset": { - "type": "integer", - "title": "Offset", - "description": "Offset from which to retrieve items" - }, - "total": { - "type": "integer", - "title": "Total", - "description": "Total number of items in result" - }, - "items": { - "items": { - "$ref": "#/components/schemas/ImageDTO" - }, - "type": "array", - "title": "Items", - "description": "Items" - } - }, - "type": "object", - "required": ["limit", "offset", "total", "items"], - "title": "OffsetPaginatedResults[ImageDTO]" - }, "OrphanedModelInfo": { "properties": { "path": { @@ -53878,7 +57706,7 @@ "type": "object" }, "PBRMapsInvocation": { - "category": "image", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generate Normal, Displacement and Roughness Map from a given image", @@ -54289,7 +58117,7 @@ } }, "PiDiNetEdgeDetectionInvocation": { - "category": "controlnet", + "category": "controlnet_preprocessors", "class": "invocation", "classification": "stable", "description": "Generates an edge map using PiDiNet.", @@ -54780,6 +58608,19 @@ "title": "Status", "type": "string" }, + "status_sequence": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "A monotonically increasing version for this queue item's visible status lifecycle", + "title": "Status Sequence" + }, "error_type": { "anyOf": [ { @@ -54878,6 +58719,7 @@ "destination", "user_id", "status", + "status_sequence", "error_type", "error_message", "error_traceback", @@ -55002,6 +58844,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -55072,6 +58926,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "base", @@ -55143,6 +58998,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -55213,6 +59080,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "base", @@ -55284,6 +59152,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -55342,6 +59222,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "base", "type", @@ -56142,7 +60023,7 @@ "category": "model", "class": "invocation", "classification": "prototype", - "description": "Loads a Qwen Image model, outputting its submodels.\n\nThe transformer is always loaded from the main model (Diffusers or GGUF).\n\nFor GGUF quantized models, the VAE and Qwen VL encoder must come from a\nseparate Diffusers model specified in the \"Component Source\" field.\n\nFor Diffusers models, all components are extracted from the main model\nautomatically. The \"Component Source\" field is ignored.", + "description": "Loads a Qwen Image model, outputting its submodels.\n\nThe transformer is always loaded from the main model (Diffusers or GGUF).\n\nComponents can be mixed and matched:\n- VAE: standalone Qwen Image VAE checkpoint, the Component Source (Diffusers),\n or the main model if it's Diffusers.\n- Qwen VL Encoder: standalone Qwen2.5-VL encoder, the Component Source\n (Diffusers), or the main model if it's Diffusers.\n\nTogether, the standalone VAE and standalone encoder allow running a GGUF\ntransformer without ever downloading the full ~40 GB Diffusers pipeline.", "node_pack": "invokeai", "properties": { "id": { @@ -56179,6 +60060,43 @@ "ui_model_base": ["qwen-image"], "ui_model_type": ["main"] }, + "vae_model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelIdentifierField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Standalone Qwen Image VAE model. If not provided, VAE will be loaded from the Component Source (or from the main model if it is Diffusers).", + "field_kind": "input", + "input": "direct", + "orig_default": null, + "orig_required": false, + "title": "VAE", + "ui_model_base": ["qwen-image"], + "ui_model_type": ["vae"] + }, + "qwen_vl_encoder_model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelIdentifierField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Standalone Qwen2.5-VL encoder model. If not provided, the encoder will be loaded from the Component Source (or from the main model if it is Diffusers).", + "field_kind": "input", + "input": "direct", + "orig_default": null, + "orig_required": false, + "title": "Qwen VL Encoder", + "ui_model_type": ["qwen_vl_encoder"] + }, "component_source": { "anyOf": [ { @@ -56189,7 +60107,7 @@ } ], "default": null, - "description": "Diffusers Qwen Image model to extract the VAE and Qwen VL encoder from. Required when using a GGUF quantized transformer. Ignored when the main model is already in Diffusers format.", + "description": "Diffusers Qwen Image model to extract VAE and/or Qwen VL encoder from. Use this if you don't have separate VAE/encoder models. Ignored for any submodel that is provided separately.", "field_kind": "input", "input": "direct", "orig_default": null, @@ -56211,7 +60129,7 @@ "tags": ["model", "qwen_image"], "title": "Main Model - Qwen Image", "type": "object", - "version": "1.1.0", + "version": "1.2.0", "output": { "$ref": "#/components/schemas/QwenImageModelLoaderOutput" } @@ -56380,6 +60298,265 @@ "title": "QwenVLEncoderField", "type": "object" }, + "QwenVLEncoder_Checkpoint_Config": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "A unique key for this model." + }, + "hash": { + "type": "string", + "title": "Hash", + "description": "The hash of the model file(s)." + }, + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + }, + "file_size": { + "type": "integer", + "title": "File Size", + "description": "The size of the model in bytes." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the model." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Model description" + }, + "source": { + "type": "string", + "title": "Source", + "description": "The original source of the model (path, URL or repo_id)." + }, + "source_type": { + "$ref": "#/components/schemas/ModelSourceType", + "description": "The type of source" + }, + "source_api_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Api Response", + "description": "The original API response from the source, as stringified JSON." + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, + "cover_image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image", + "description": "Url for image to preview model" + }, + "config_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Config Path", + "description": "Path to the config for this model, if any." + }, + "base": { + "type": "string", + "const": "any", + "title": "Base", + "default": "any" + }, + "type": { + "type": "string", + "const": "qwen_vl_encoder", + "title": "Type", + "default": "qwen_vl_encoder" + }, + "format": { + "type": "string", + "const": "checkpoint", + "title": "Format", + "default": "checkpoint" + } + }, + "type": "object", + "required": [ + "key", + "hash", + "path", + "file_size", + "name", + "description", + "source", + "source_type", + "source_api_response", + "source_url", + "cover_image", + "config_path", + "base", + "type", + "format" + ], + "title": "QwenVLEncoder_Checkpoint_Config", + "description": "Configuration for single-file Qwen2.5-VL encoder checkpoints (safetensors).\n\nThis matches ComfyUI-style consolidated single-file encoders such as\n`qwen_2.5_vl_7b_fp8_scaled.safetensors`, which bundle the language model\nand the visual tower into one file (typically with FP8 + per-tensor\n`weight_scale` ComfyUI quantization).\n\nThe matching tokenizer + processor are pulled from HuggingFace\n(`Qwen/Qwen2.5-VL-7B-Instruct`) on first use and cached for offline use." + }, + "QwenVLEncoder_Diffusers_Config": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "A unique key for this model." + }, + "hash": { + "type": "string", + "title": "Hash", + "description": "The hash of the model file(s)." + }, + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + }, + "file_size": { + "type": "integer", + "title": "File Size", + "description": "The size of the model in bytes." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the model." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Model description" + }, + "source": { + "type": "string", + "title": "Source", + "description": "The original source of the model (path, URL or repo_id)." + }, + "source_type": { + "$ref": "#/components/schemas/ModelSourceType", + "description": "The type of source" + }, + "source_api_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Api Response", + "description": "The original API response from the source, as stringified JSON." + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, + "cover_image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image", + "description": "Url for image to preview model" + }, + "base": { + "type": "string", + "const": "any", + "title": "Base", + "default": "any" + }, + "type": { + "type": "string", + "const": "qwen_vl_encoder", + "title": "Type", + "default": "qwen_vl_encoder" + }, + "format": { + "type": "string", + "const": "qwen_vl_encoder", + "title": "Format", + "default": "qwen_vl_encoder" + } + }, + "type": "object", + "required": [ + "key", + "hash", + "path", + "file_size", + "name", + "description", + "source", + "source_type", + "source_api_response", + "source_url", + "cover_image", + "base", + "type", + "format" + ], + "title": "QwenVLEncoder_Diffusers_Config", + "description": "Configuration for standalone Qwen2.5-VL encoder models in diffusers-style folder layout.\n\nExpected structure:\n /\n text_encoder/\n config.json (with `_class_name` or `architectures` listing\n `Qwen2_5_VLForConditionalGeneration`)\n model.safetensors\n tokenizer/\n tokenizer_config.json\n ...\n processor/ (optional, for vision preprocessing)\n preprocessor_config.json\n\nThis lets users avoid downloading the full ~40 GB Qwen Image diffusers pipeline\nwhen they only need the Qwen2.5-VL encoder for use with a GGUF transformer." + }, "RandomFloatInvocation": { "category": "math", "class": "invocation", @@ -56527,7 +60704,7 @@ } }, "RandomRangeInvocation": { - "category": "collections", + "category": "batch", "class": "invocation", "classification": "stable", "description": "Creates a collection of random numbers", @@ -56617,7 +60794,7 @@ } }, "RangeInvocation": { - "category": "collections", + "category": "batch", "class": "invocation", "classification": "stable", "description": "Creates a range of numbers from start to stop with step", @@ -56695,7 +60872,7 @@ } }, "RangeOfSizeInvocation": { - "category": "collections", + "category": "batch", "class": "invocation", "classification": "stable", "description": "Creates a range from start to start + (size * step) incremented by step", @@ -57145,7 +61322,7 @@ "type": "object" }, "RectangleMaskInvocation": { - "category": "conditioning", + "category": "mask", "class": "invocation", "classification": "stable", "description": "Create a rectangular mask.", @@ -57678,7 +61855,7 @@ "type": "object" }, "SD3DenoiseInvocation": { - "category": "image", + "category": "latents", "class": "invocation", "classification": "stable", "description": "Run denoising process with a SD3 model.", @@ -57923,7 +62100,7 @@ } }, "SD3ImageToLatentsInvocation": { - "category": "image", + "category": "latents", "class": "invocation", "classification": "stable", "description": "Generates latents from an image.", @@ -58143,7 +62320,7 @@ } }, "SDXLCompelPromptInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "stable", "description": "Parse prompt using compel package to conditioning.", @@ -58740,7 +62917,7 @@ "type": "object" }, "SDXLRefinerCompelPromptInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "stable", "description": "Parse prompt using compel package to conditioning.", @@ -58971,7 +63148,7 @@ "title": "SQLiteDirection" }, "SaveImageInvocation": { - "category": "primitives", + "category": "image", "class": "invocation", "classification": "stable", "description": "Saves an image. Unlike an image primitive, this invocation stores a copy of the image.", @@ -59227,6 +63404,7 @@ "dpmpp_3m_k", "dpmpp_sde", "dpmpp_sde_k", + "er_sde", "unipc", "unipc_k", "lcm", @@ -59289,6 +63467,7 @@ "dpmpp_3m_k", "dpmpp_sde", "dpmpp_sde_k", + "er_sde", "unipc", "unipc_k", "lcm", @@ -59502,7 +63681,7 @@ "type": "object" }, "Sd3TextEncoderInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "stable", "description": "Encodes and preps a prompt for a SD3 image.", @@ -59645,122 +63824,382 @@ "title": "Use Cache", "type": "boolean" }, - "unet": { + "unet": { + "anyOf": [ + { + "$ref": "#/components/schemas/UNetField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "UNet (scheduler, LoRAs)", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "UNet" + }, + "vae": { + "anyOf": [ + { + "$ref": "#/components/schemas/VAEField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "VAE model to load", + "field_kind": "input", + "input": "connection", + "orig_default": null, + "orig_required": false, + "title": "VAE" + }, + "seamless_y": { + "default": true, + "description": "Specify whether Y axis is seamless", + "field_kind": "input", + "input": "any", + "orig_default": true, + "orig_required": false, + "title": "Seamless Y", + "type": "boolean" + }, + "seamless_x": { + "default": true, + "description": "Specify whether X axis is seamless", + "field_kind": "input", + "input": "any", + "orig_default": true, + "orig_required": false, + "title": "Seamless X", + "type": "boolean" + }, + "type": { + "const": "seamless", + "default": "seamless", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["seamless", "model"], + "title": "Apply Seamless - SD1.5, SDXL", + "type": "object", + "version": "1.0.2", + "output": { + "$ref": "#/components/schemas/SeamlessModeOutput" + } + }, + "SeamlessModeOutput": { + "class": "output", + "description": "Modified Seamless Model output", + "properties": { + "unet": { + "anyOf": [ + { + "$ref": "#/components/schemas/UNetField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "UNet (scheduler, LoRAs)", + "field_kind": "output", + "title": "UNet", + "ui_hidden": false + }, + "vae": { + "anyOf": [ + { + "$ref": "#/components/schemas/VAEField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "VAE", + "field_kind": "output", + "title": "VAE", + "ui_hidden": false + }, + "type": { + "const": "seamless_output", + "default": "seamless_output", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["output_meta", "unet", "vae", "type", "type"], + "title": "SeamlessModeOutput", + "type": "object" + }, + "SeedreamImageGenerationInvocation": { + "category": "image", + "class": "invocation", + "classification": "stable", + "description": "Generate images using a BytePlus Seedream model.", + "node_pack": "invokeai", + "properties": { + "board": { + "anyOf": [ + { + "$ref": "#/components/schemas/BoardField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The board to save the image to", + "field_kind": "internal", + "input": "direct", + "orig_required": false, + "ui_hidden": false + }, + "metadata": { + "anyOf": [ + { + "$ref": "#/components/schemas/MetadataField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Optional metadata to be saved with the image", + "field_kind": "internal", + "input": "connection", + "orig_required": false, + "ui_hidden": false + }, + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "model": { "anyOf": [ { - "$ref": "#/components/schemas/UNetField" + "$ref": "#/components/schemas/ModelIdentifierField" }, { "type": "null" } ], "default": null, - "description": "UNet (scheduler, LoRAs)", + "description": "Main model (UNet, VAE, CLIP) to load", "field_kind": "input", - "input": "connection", - "orig_default": null, + "input": "any", + "orig_required": true, + "ui_model_base": ["external"], + "ui_model_format": ["external_api"], + "ui_model_provider_id": ["seedream"], + "ui_model_type": ["external_image_generator"] + }, + "mode": { + "default": "txt2img", + "description": "Generation mode.", + "enum": ["txt2img", "img2img", "inpaint"], + "field_kind": "input", + "input": "any", + "orig_default": "txt2img", "orig_required": false, - "title": "UNet" + "title": "Mode", + "type": "string", + "ui_hidden": true }, - "vae": { + "prompt": { "anyOf": [ { - "$ref": "#/components/schemas/VAEField" + "type": "string" }, { "type": "null" } ], "default": null, - "description": "VAE model to load", + "description": "Prompt", "field_kind": "input", - "input": "connection", + "input": "any", + "orig_required": true, + "title": "Prompt" + }, + "seed": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Seed for random number generation", + "field_kind": "input", + "input": "any", "orig_default": null, "orig_required": false, - "title": "VAE" + "title": "Seed" }, - "seamless_y": { - "default": true, - "description": "Specify whether Y axis is seamless", + "num_images": { + "default": 1, + "description": "Number of images to generate", + "exclusiveMinimum": 0, "field_kind": "input", "input": "any", - "orig_default": true, + "orig_default": 1, "orig_required": false, - "title": "Seamless Y", - "type": "boolean" + "title": "Num Images", + "type": "integer" }, - "seamless_x": { - "default": true, - "description": "Specify whether X axis is seamless", + "width": { + "default": 1024, + "description": "Width of output (px)", + "exclusiveMinimum": 0, "field_kind": "input", "input": "any", - "orig_default": true, + "orig_default": 1024, "orig_required": false, - "title": "Seamless X", - "type": "boolean" + "title": "Width", + "type": "integer" }, - "type": { - "const": "seamless", - "default": "seamless", - "field_kind": "node_attribute", - "title": "type", - "type": "string" - } - }, - "required": ["type", "id"], - "tags": ["seamless", "model"], - "title": "Apply Seamless - SD1.5, SDXL", - "type": "object", - "version": "1.0.2", - "output": { - "$ref": "#/components/schemas/SeamlessModeOutput" - } - }, - "SeamlessModeOutput": { - "class": "output", - "description": "Modified Seamless Model output", - "properties": { - "unet": { + "height": { + "default": 1024, + "description": "Height of output (px)", + "exclusiveMinimum": 0, + "field_kind": "input", + "input": "any", + "orig_default": 1024, + "orig_required": false, + "title": "Height", + "type": "integer" + }, + "image_size": { "anyOf": [ { - "$ref": "#/components/schemas/UNetField" + "type": "string" }, { "type": "null" } ], "default": null, - "description": "UNet (scheduler, LoRAs)", - "field_kind": "output", - "title": "UNet", - "ui_hidden": false + "description": "Image size preset (e.g. 1K, 2K, 4K)", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "title": "Image Size" }, - "vae": { + "init_image": { "anyOf": [ { - "$ref": "#/components/schemas/VAEField" + "$ref": "#/components/schemas/ImageField" }, { "type": "null" } ], "default": null, - "description": "VAE", - "field_kind": "output", - "title": "VAE", - "ui_hidden": false + "description": "Init image for img2img/inpaint", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false + }, + "mask_image": { + "anyOf": [ + { + "$ref": "#/components/schemas/ImageField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "Mask image for inpaint", + "field_kind": "input", + "input": "any", + "orig_default": null, + "orig_required": false, + "ui_hidden": true + }, + "reference_images": { + "default": [], + "description": "Reference images", + "field_kind": "input", + "input": "any", + "items": { + "$ref": "#/components/schemas/ImageField" + }, + "orig_default": [], + "orig_required": false, + "title": "Reference Images", + "type": "array" + }, + "watermark": { + "default": false, + "description": "Add watermark to generated images", + "field_kind": "input", + "input": "any", + "orig_default": false, + "orig_required": false, + "title": "Watermark", + "type": "boolean" + }, + "optimize_prompt": { + "default": false, + "description": "Let the model optimize the prompt before generation", + "field_kind": "input", + "input": "any", + "orig_default": false, + "orig_required": false, + "title": "Optimize Prompt", + "type": "boolean" }, "type": { - "const": "seamless_output", - "default": "seamless_output", + "const": "seedream_image_generation", + "default": "seedream_image_generation", "field_kind": "node_attribute", "title": "type", "type": "string" } }, - "required": ["output_meta", "unet", "vae", "type", "type"], - "title": "SeamlessModeOutput", - "type": "object" + "required": ["type", "id"], + "tags": ["external", "generation", "seedream"], + "title": "Seedream Image Generation", + "type": "object", + "version": "1.1.0", + "output": { + "$ref": "#/components/schemas/ImageCollectionOutput" + } }, "SegmentAnythingInvocation": { "category": "segmentation", @@ -60003,6 +64442,18 @@ "description": "The status of this queue item", "default": "pending" }, + "status_sequence": { + "anyOf": [ + { + "type": "integer" + }, + { + "type": "null" + } + ], + "title": "Status Sequence", + "description": "A monotonically increasing version for this queue item's visible status lifecycle" + }, "priority": { "type": "integer", "title": "Priority", @@ -60525,6 +64976,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -60583,6 +65046,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -60919,6 +65383,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -60961,6 +65437,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "base", "type", @@ -61056,135 +65533,195 @@ "title": "Is Installed", "default": false }, - "previous_names": { - "items": { - "type": "string" - }, - "type": "array", - "title": "Previous Names", - "default": [] - }, - "dependencies": { + "capabilities": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalModelCapabilities" + }, + { + "type": "null" + } + ] + }, + "default_settings": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalApiModelDefaultSettings" + }, + { + "type": "null" + } + ] + }, + "panel_schema": { + "anyOf": [ + { + "$ref": "#/components/schemas/ExternalModelPanelSchema" + }, + { + "type": "null" + } + ] + }, + "previous_names": { + "items": { + "type": "string" + }, + "type": "array", + "title": "Previous Names", + "default": [] + }, + "dependencies": { + "anyOf": [ + { + "items": { + "$ref": "#/components/schemas/StarterModelWithoutDependencies" + }, + "type": "array" + }, + { + "type": "null" + } + ], + "title": "Dependencies" + } + }, + "type": "object", + "required": ["description", "source", "name", "base", "type"], + "title": "StarterModel" + }, + "StarterModelBundle": { + "properties": { + "name": { + "type": "string", + "title": "Name" + }, + "models": { + "items": { + "$ref": "#/components/schemas/StarterModel" + }, + "type": "array", + "title": "Models" + } + }, + "type": "object", + "required": ["name", "models"], + "title": "StarterModelBundle" + }, + "StarterModelResponse": { + "properties": { + "starter_models": { + "items": { + "$ref": "#/components/schemas/StarterModel" + }, + "type": "array", + "title": "Starter Models" + }, + "starter_bundles": { + "additionalProperties": { + "$ref": "#/components/schemas/StarterModelBundle" + }, + "type": "object", + "title": "Starter Bundles" + } + }, + "type": "object", + "required": ["starter_models", "starter_bundles"], + "title": "StarterModelResponse" + }, + "StarterModelWithoutDependencies": { + "properties": { + "description": { + "type": "string", + "title": "Description" + }, + "source": { + "type": "string", + "title": "Source" + }, + "name": { + "type": "string", + "title": "Name" + }, + "base": { + "$ref": "#/components/schemas/BaseModelType" + }, + "type": { + "$ref": "#/components/schemas/ModelType" + }, + "format": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelFormat" + }, + { + "type": "null" + } + ] + }, + "variant": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelVariantType" + }, + { + "$ref": "#/components/schemas/ClipVariantType" + }, + { + "$ref": "#/components/schemas/FluxVariantType" + }, + { + "$ref": "#/components/schemas/Flux2VariantType" + }, + { + "$ref": "#/components/schemas/ZImageVariantType" + }, + { + "$ref": "#/components/schemas/QwenImageVariantType" + }, + { + "$ref": "#/components/schemas/Qwen3VariantType" + }, + { + "type": "null" + } + ], + "title": "Variant" + }, + "is_installed": { + "type": "boolean", + "title": "Is Installed", + "default": false + }, + "capabilities": { "anyOf": [ { - "items": { - "$ref": "#/components/schemas/StarterModelWithoutDependencies" - }, - "type": "array" + "$ref": "#/components/schemas/ExternalModelCapabilities" }, { "type": "null" } - ], - "title": "Dependencies" - } - }, - "type": "object", - "required": ["description", "source", "name", "base", "type"], - "title": "StarterModel" - }, - "StarterModelBundle": { - "properties": { - "name": { - "type": "string", - "title": "Name" - }, - "models": { - "items": { - "$ref": "#/components/schemas/StarterModel" - }, - "type": "array", - "title": "Models" - } - }, - "type": "object", - "required": ["name", "models"], - "title": "StarterModelBundle" - }, - "StarterModelResponse": { - "properties": { - "starter_models": { - "items": { - "$ref": "#/components/schemas/StarterModel" - }, - "type": "array", - "title": "Starter Models" - }, - "starter_bundles": { - "additionalProperties": { - "$ref": "#/components/schemas/StarterModelBundle" - }, - "type": "object", - "title": "Starter Bundles" - } - }, - "type": "object", - "required": ["starter_models", "starter_bundles"], - "title": "StarterModelResponse" - }, - "StarterModelWithoutDependencies": { - "properties": { - "description": { - "type": "string", - "title": "Description" - }, - "source": { - "type": "string", - "title": "Source" - }, - "name": { - "type": "string", - "title": "Name" - }, - "base": { - "$ref": "#/components/schemas/BaseModelType" - }, - "type": { - "$ref": "#/components/schemas/ModelType" + ] }, - "format": { + "default_settings": { "anyOf": [ { - "$ref": "#/components/schemas/ModelFormat" + "$ref": "#/components/schemas/ExternalApiModelDefaultSettings" }, { "type": "null" } ] }, - "variant": { + "panel_schema": { "anyOf": [ { - "$ref": "#/components/schemas/ModelVariantType" - }, - { - "$ref": "#/components/schemas/ClipVariantType" - }, - { - "$ref": "#/components/schemas/FluxVariantType" - }, - { - "$ref": "#/components/schemas/Flux2VariantType" - }, - { - "$ref": "#/components/schemas/ZImageVariantType" - }, - { - "$ref": "#/components/schemas/QwenImageVariantType" - }, - { - "$ref": "#/components/schemas/Qwen3VariantType" + "$ref": "#/components/schemas/ExternalModelPanelSchema" }, { "type": "null" } - ], - "title": "Variant" - }, - "is_installed": { - "type": "boolean", - "title": "Is Installed", - "default": false + ] }, "previous_names": { "items": { @@ -61230,7 +65767,7 @@ "type": "object" }, "StringBatchInvocation": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Create a batched generation, where the workflow is executed once for each string in the batch.", @@ -61396,7 +65933,7 @@ "type": "object" }, "StringGenerator": { - "category": "primitives", + "category": "batch", "class": "invocation", "classification": "special", "description": "Generated a range of strings for use in a batched generation", @@ -61542,7 +66079,7 @@ } }, "StringJoinInvocation": { - "category": "string", + "category": "strings", "class": "invocation", "classification": "stable", "description": "Joins string left to string right", @@ -61612,7 +66149,7 @@ } }, "StringJoinThreeInvocation": { - "category": "string", + "category": "strings", "class": "invocation", "classification": "stable", "description": "Joins string left to string middle to string right", @@ -61746,7 +66283,7 @@ "type": "object" }, "StringReplaceInvocation": { - "category": "string", + "category": "strings", "class": "invocation", "classification": "stable", "description": "Replaces the search string with the replace string", @@ -61837,7 +66374,7 @@ } }, "StringSplitInvocation": { - "category": "string", + "category": "strings", "class": "invocation", "classification": "stable", "description": "Splits string into two strings, based on the first occurance of the delimiter. The delimiter will be removed from the string", @@ -61906,7 +66443,7 @@ } }, "StringSplitNegInvocation": { - "category": "string", + "category": "strings", "class": "invocation", "classification": "stable", "description": "Splits string into two strings, inside [] goes into negative string everthing else goes into positive string. Each [ and ] character is replaced with a space", @@ -62200,7 +66737,7 @@ "type": "object" }, "T2IAdapterInvocation": { - "category": "t2i_adapter", + "category": "conditioning", "class": "invocation", "classification": "stable", "description": "Collects T2I-Adapter info to pass to other nodes.", @@ -62485,6 +67022,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -62541,6 +67090,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -62610,6 +67160,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -62666,6 +67228,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -62758,6 +67321,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -62812,6 +67387,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "base", "type", @@ -62881,6 +67457,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -62935,6 +67523,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "base", "type", @@ -63027,6 +67616,140 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, + "cover_image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image", + "description": "Url for image to preview model" + }, + "type": { + "type": "string", + "const": "embedding", + "title": "Type", + "default": "embedding" + }, + "format": { + "type": "string", + "const": "embedding_file", + "title": "Format", + "default": "embedding_file" + }, + "base": { + "type": "string", + "const": "sd-1", + "title": "Base", + "default": "sd-1" + } + }, + "type": "object", + "required": [ + "key", + "hash", + "path", + "file_size", + "name", + "description", + "source", + "source_type", + "source_api_response", + "source_url", + "cover_image", + "type", + "format", + "base" + ], + "title": "TI_File_SD1_Config" + }, + "TI_File_SD2_Config": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "A unique key for this model." + }, + "hash": { + "type": "string", + "title": "Hash", + "description": "The hash of the model file(s)." + }, + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + }, + "file_size": { + "type": "integer", + "title": "File Size", + "description": "The size of the model in bytes." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the model." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Model description" + }, + "source": { + "type": "string", + "title": "Source", + "description": "The original source of the model (path, URL or repo_id)." + }, + "source_type": { + "$ref": "#/components/schemas/ModelSourceType", + "description": "The type of source" + }, + "source_api_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Api Response", + "description": "The original API response from the source, as stringified JSON." + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -63053,9 +67776,9 @@ }, "base": { "type": "string", - "const": "sd-1", + "const": "sd-2", "title": "Base", - "default": "sd-1" + "default": "sd-2" } }, "type": "object", @@ -63069,14 +67792,15 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", "base" ], - "title": "TI_File_SD1_Config" + "title": "TI_File_SD2_Config" }, - "TI_File_SD2_Config": { + "TI_File_SDXL_Config": { "properties": { "key": { "type": "string", @@ -63136,6 +67860,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -63162,9 +67898,9 @@ }, "base": { "type": "string", - "const": "sd-2", + "const": "sdxl", "title": "Base", - "default": "sd-2" + "default": "sdxl" } }, "type": "object", @@ -63178,14 +67914,15 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", "base" ], - "title": "TI_File_SD2_Config" + "title": "TI_File_SDXL_Config" }, - "TI_File_SDXL_Config": { + "TI_Folder_SD1_Config": { "properties": { "key": { "type": "string", @@ -63245,6 +67982,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -63265,15 +68014,15 @@ }, "format": { "type": "string", - "const": "embedding_file", + "const": "embedding_folder", "title": "Format", - "default": "embedding_file" + "default": "embedding_folder" }, "base": { "type": "string", - "const": "sdxl", + "const": "sd-1", "title": "Base", - "default": "sdxl" + "default": "sd-1" } }, "type": "object", @@ -63287,14 +68036,15 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", "base" ], - "title": "TI_File_SDXL_Config" + "title": "TI_Folder_SD1_Config" }, - "TI_Folder_SD1_Config": { + "TI_Folder_SD2_Config": { "properties": { "key": { "type": "string", @@ -63354,6 +68104,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -63380,9 +68142,9 @@ }, "base": { "type": "string", - "const": "sd-1", + "const": "sd-2", "title": "Base", - "default": "sd-1" + "default": "sd-2" } }, "type": "object", @@ -63396,14 +68158,15 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", "base" ], - "title": "TI_Folder_SD1_Config" + "title": "TI_Folder_SD2_Config" }, - "TI_Folder_SD2_Config": { + "TI_Folder_SDXL_Config": { "properties": { "key": { "type": "string", @@ -63463,6 +68226,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -63489,9 +68264,9 @@ }, "base": { "type": "string", - "const": "sd-2", + "const": "sdxl", "title": "Base", - "default": "sd-2" + "default": "sdxl" } }, "type": "object", @@ -63505,14 +68280,127 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "type", "format", "base" ], - "title": "TI_Folder_SD2_Config" + "title": "TI_Folder_SDXL_Config" }, - "TI_Folder_SDXL_Config": { + "TensorField": { + "description": "A tensor primitive field.", + "properties": { + "tensor_name": { + "description": "The name of a tensor.", + "title": "Tensor Name", + "type": "string" + } + }, + "required": ["tensor_name"], + "title": "TensorField", + "type": "object" + }, + "TextLLMInvocation": { + "category": "llm", + "class": "invocation", + "classification": "beta", + "description": "Run a text language model to generate or expand text (e.g. for prompt expansion).", + "node_pack": "invokeai", + "properties": { + "id": { + "description": "The id of this instance of an invocation. Must be unique among all instances of invocations.", + "field_kind": "node_attribute", + "title": "Id", + "type": "string" + }, + "is_intermediate": { + "default": false, + "description": "Whether or not this is an intermediate invocation.", + "field_kind": "node_attribute", + "input": "direct", + "orig_required": true, + "title": "Is Intermediate", + "type": "boolean", + "ui_hidden": false, + "ui_type": "IsIntermediate" + }, + "use_cache": { + "default": true, + "description": "Whether or not to use the cache", + "field_kind": "node_attribute", + "title": "Use Cache", + "type": "boolean" + }, + "prompt": { + "default": "", + "description": "Input text prompt.", + "field_kind": "input", + "input": "any", + "orig_default": "", + "orig_required": false, + "title": "Prompt", + "type": "string", + "ui_component": "textarea" + }, + "system_prompt": { + "default": "You are an expert prompt writer for AI image generation. Given a brief description, expand it into a detailed, vivid prompt suitable for generating high-quality images. Only output the expanded prompt, nothing else.", + "description": "System prompt that guides the model's behavior.", + "field_kind": "input", + "input": "any", + "orig_default": "You are an expert prompt writer for AI image generation. Given a brief description, expand it into a detailed, vivid prompt suitable for generating high-quality images. Only output the expanded prompt, nothing else.", + "orig_required": false, + "title": "System Prompt", + "type": "string", + "ui_component": "textarea" + }, + "text_llm_model": { + "anyOf": [ + { + "$ref": "#/components/schemas/ModelIdentifierField" + }, + { + "type": "null" + } + ], + "default": null, + "description": "The text language model to use for text generation", + "field_kind": "input", + "input": "any", + "orig_required": true, + "title": "Text LLM Model", + "ui_model_type": ["text_llm"] + }, + "max_tokens": { + "default": 300, + "description": "Maximum number of tokens to generate.", + "field_kind": "input", + "input": "any", + "maximum": 2048, + "minimum": 1, + "orig_default": 300, + "orig_required": false, + "title": "Max Tokens", + "type": "integer" + }, + "type": { + "const": "text_llm", + "default": "text_llm", + "field_kind": "node_attribute", + "title": "type", + "type": "string" + } + }, + "required": ["type", "id"], + "tags": ["llm", "text", "prompt"], + "title": "Text LLM", + "type": "object", + "version": "1.0.0", + "output": { + "$ref": "#/components/schemas/StringOutput" + } + }, + "TextLLM_Diffusers_Config": { "properties": { "key": { "type": "string", @@ -63572,6 +68460,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -63584,23 +68484,39 @@ "title": "Cover Image", "description": "Url for image to preview model" }, - "type": { - "type": "string", - "const": "embedding", - "title": "Type", - "default": "embedding" - }, "format": { "type": "string", - "const": "embedding_folder", + "const": "diffusers", "title": "Format", - "default": "embedding_folder" + "default": "diffusers" + }, + "repo_variant": { + "$ref": "#/components/schemas/ModelRepoVariant", + "default": "" + }, + "type": { + "type": "string", + "const": "text_llm", + "title": "Type", + "default": "text_llm" }, "base": { "type": "string", - "const": "sdxl", + "const": "any", "title": "Base", - "default": "sdxl" + "default": "any" + }, + "cpu_only": { + "anyOf": [ + { + "type": "boolean" + }, + { + "type": "null" + } + ], + "title": "Cpu Only", + "description": "Whether this model should run on CPU only" } }, "type": "object", @@ -63614,25 +68530,16 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", - "type", "format", - "base" + "repo_variant", + "type", + "base", + "cpu_only" ], - "title": "TI_Folder_SDXL_Config" - }, - "TensorField": { - "description": "A tensor primitive field.", - "properties": { - "tensor_name": { - "description": "The name of a tensor.", - "title": "Tensor Name", - "type": "string" - } - }, - "required": ["tensor_name"], - "title": "TensorField", - "type": "object" + "title": "TextLLM_Diffusers_Config", + "description": "Model config for text-only causal language models (e.g. Llama, Phi, Qwen, Mistral)." }, "Tile": { "properties": { @@ -64038,6 +68945,7 @@ "dpmpp_3m_k", "dpmpp_sde", "dpmpp_sde_k", + "er_sde", "unipc", "unipc_k", "lcm", @@ -64402,6 +69310,29 @@ "required": ["url_regex", "token"], "title": "URLRegexTokenPair" }, + "UninstallNodePackResponse": { + "properties": { + "name": { + "type": "string", + "title": "Name", + "description": "The name of the uninstalled node pack." + }, + "success": { + "type": "boolean", + "title": "Success", + "description": "Whether the uninstall was successful." + }, + "message": { + "type": "string", + "title": "Message", + "description": "Status message." + } + }, + "type": "object", + "required": ["name", "success", "message"], + "title": "UninstallNodePackResponse", + "description": "Response after uninstalling a node pack." + }, "Unknown_Config": { "properties": { "key": { @@ -64462,6 +69393,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -64504,6 +69447,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "base", "type", @@ -64652,6 +69596,32 @@ "required": ["affected_boards", "unstarred_images"], "title": "UnstarredImagesResult" }, + "UpdateAppGenerationSettingsRequest": { + "properties": { + "image_subfolder_strategy": { + "type": "string", + "enum": ["flat", "date", "type", "hash"], + "title": "Image Subfolder Strategy", + "description": "Strategy for organizing images into subfolders." + }, + "max_queue_history": { + "anyOf": [ + { + "type": "integer", + "minimum": 0.0 + }, + { + "type": "null" + } + ], + "title": "Max Queue History", + "description": "Keep the last N completed, failed, and canceled queue items on startup. Set to 0 to prune all terminal items." + } + }, + "type": "object", + "title": "UpdateAppGenerationSettingsRequest", + "description": "Writable generation-related app settings." + }, "UserDTO": { "properties": { "user_id": { @@ -64930,6 +69900,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -64984,6 +69966,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -65053,6 +70036,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65107,6 +70102,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -65175,6 +70171,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65229,6 +70237,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -65238,6 +70247,142 @@ "title": "VAE_Checkpoint_Flux2_Config", "description": "Model config for FLUX.2 VAE checkpoint models (AutoencoderKLFlux2)." }, + "VAE_Checkpoint_QwenImage_Config": { + "properties": { + "key": { + "type": "string", + "title": "Key", + "description": "A unique key for this model." + }, + "hash": { + "type": "string", + "title": "Hash", + "description": "The hash of the model file(s)." + }, + "path": { + "type": "string", + "title": "Path", + "description": "Path to the model on the filesystem. Relative paths are relative to the Invoke root directory." + }, + "file_size": { + "type": "integer", + "title": "File Size", + "description": "The size of the model in bytes." + }, + "name": { + "type": "string", + "title": "Name", + "description": "Name of the model." + }, + "description": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Description", + "description": "Model description" + }, + "source": { + "type": "string", + "title": "Source", + "description": "The original source of the model (path, URL or repo_id)." + }, + "source_type": { + "$ref": "#/components/schemas/ModelSourceType", + "description": "The type of source" + }, + "source_api_response": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Api Response", + "description": "The original API response from the source, as stringified JSON." + }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, + "cover_image": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image", + "description": "Url for image to preview model" + }, + "config_path": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Config Path", + "description": "Path to the config for this model, if any." + }, + "type": { + "type": "string", + "const": "vae", + "title": "Type", + "default": "vae" + }, + "format": { + "type": "string", + "const": "checkpoint", + "title": "Format", + "default": "checkpoint" + }, + "base": { + "type": "string", + "const": "qwen-image", + "title": "Base", + "default": "qwen-image" + } + }, + "type": "object", + "required": [ + "key", + "hash", + "path", + "file_size", + "name", + "description", + "source", + "source_type", + "source_api_response", + "source_url", + "cover_image", + "config_path", + "type", + "format", + "base" + ], + "title": "VAE_Checkpoint_QwenImage_Config", + "description": "Model config for Qwen Image VAE checkpoint models (AutoencoderKLQwenImage)." + }, "VAE_Checkpoint_SD1_Config": { "properties": { "key": { @@ -65298,6 +70443,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65352,6 +70509,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -65420,6 +70578,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65474,6 +70644,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -65542,6 +70713,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65596,6 +70779,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "config_path", "type", @@ -65664,6 +70848,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65710,6 +70906,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -65779,6 +70976,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65825,6 +71034,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -65893,6 +71103,18 @@ "title": "Source Api Response", "description": "The original API response from the source, as stringified JSON." }, + "source_url": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Source Url", + "description": "Optional URL for the model (e.g. download page or model page)." + }, "cover_image": { "anyOf": [ { @@ -65939,6 +71161,7 @@ "source", "source_type", "source_api_response", + "source_url", "cover_image", "format", "repo_variant", @@ -65976,6 +71199,51 @@ "required": ["loc", "msg", "type"], "title": "ValidationError" }, + "VirtualSubBoardDTO": { + "properties": { + "virtual_board_id": { + "type": "string", + "title": "Virtual Board Id", + "description": "The virtual board ID, e.g. 'by_date:2026-03-18'." + }, + "board_name": { + "type": "string", + "title": "Board Name", + "description": "The display name of the virtual sub-board, e.g. '2026-03-18'." + }, + "date": { + "type": "string", + "title": "Date", + "description": "The ISO date string, e.g. '2026-03-18'." + }, + "image_count": { + "type": "integer", + "title": "Image Count", + "description": "The number of general images for this date." + }, + "asset_count": { + "type": "integer", + "title": "Asset Count", + "description": "The number of asset images for this date." + }, + "cover_image_name": { + "anyOf": [ + { + "type": "string" + }, + { + "type": "null" + } + ], + "title": "Cover Image Name", + "description": "The most recent image name for this date." + } + }, + "type": "object", + "required": ["virtual_board_id", "board_name", "date", "image_count", "asset_count"], + "title": "VirtualSubBoardDTO", + "description": "A virtual sub-board computed from image metadata, not stored in the database." + }, "Workflow": { "properties": { "name": { @@ -66598,7 +71866,7 @@ "type": "object" }, "ZImageControlInvocation": { - "category": "control", + "category": "conditioning", "class": "invocation", "classification": "prototype", "description": "Configure Z-Image ControlNet for spatial conditioning.\n\nTakes a preprocessed control image (e.g., Canny edges, depth map, pose)\nand a Z-Image ControlNet adapter model to enable spatial control.\n\nSupports 5 control modes: Canny, HED, Depth, Pose, MLSD.\nRecommended control_context_scale: 0.65-0.80.", @@ -66737,7 +72005,7 @@ "type": "object" }, "ZImageDenoiseInvocation": { - "category": "image", + "category": "latents", "class": "invocation", "classification": "prototype", "description": "Run the denoising process with a Z-Image model.\n\nSupports regional prompting by connecting multiple conditioning inputs with masks.", @@ -67032,7 +72300,7 @@ } }, "ZImageDenoiseMetaInvocation": { - "category": "latents", + "category": "metadata", "class": "invocation", "classification": "stable", "description": "Run denoising process with a Z-Image transformer model + metadata.", @@ -67343,7 +72611,7 @@ } }, "ZImageImageToLatentsInvocation": { - "category": "image", + "category": "latents", "class": "invocation", "classification": "prototype", "description": "Generates latents from an image using Z-Image VAE (supports both Diffusers and FLUX VAE).", @@ -67976,7 +73244,7 @@ "type": "object" }, "ZImageSeedVarianceEnhancerInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "prototype", "description": "Adds seed-based noise to Z-Image conditioning to increase variance between seeds.\n\nZ-Image-Turbo can produce relatively similar images with different seeds,\nmaking it harder to explore variations of a prompt. This node implements\nreproducible, seed-based noise injection into text embeddings to increase\nvisual variation while maintaining reproducibility.\n\nThe noise strength is auto-calibrated relative to the embedding's standard\ndeviation, ensuring consistent results across different prompts.", @@ -68075,7 +73343,7 @@ } }, "ZImageTextEncoderInvocation": { - "category": "conditioning", + "category": "prompt", "class": "invocation", "classification": "prototype", "description": "Encodes and preps a prompt for a Z-Image image.\n\nSupports regional prompting by connecting a mask input.", diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index 68f24a26ec1..ff34d829717 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1001,6 +1001,57 @@ export type paths = { patch?: never; trace?: never; }; + "/api/v1/image_moves/start": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start Image Move */ + post: operations["start_image_move"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/image_moves/recover": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + get?: never; + put?: never; + /** Start Image Move Recovery */ + post: operations["start_image_move_recovery"]; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; + "/api/v1/image_moves/status": { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + /** Get Image Move Status */ + get: operations["get_image_move_status"]; + put?: never; + post?: never; + delete?: never; + options?: never; + head?: never; + patch?: never; + trace?: never; + }; "/api/v1/images/upload": { parameters: { query?: never; @@ -14279,6 +14330,50 @@ export type components = { */ type: "image_mask_to_tensor"; }; + /** ImageMoveJobResponse */ + ImageMoveJobResponse: { + /** + * Id + * @description The image move job id. + */ + id: number; + /** + * State + * @description The image move job state. + * @enum {string} + */ + state: "planned" | "moving" | "moved" | "committed" | "error"; + /** + * Error Message + * @description The last error recorded for the job, if any. + */ + error_message?: string | null; + }; + /** ImageMoveStatusResponse */ + ImageMoveStatusResponse: { + /** + * Is Running + * @description Whether an image move background operation is currently running. + */ + is_running: boolean; + /** + * Operation + * @description The active background operation, if any. + */ + operation?: ("move_all" | "recovery") | null; + /** + * Active Job Id + * @description The active journal job id, if any. + */ + active_job_id?: number | null; + /** @description The latest journal job, if any. */ + latest_job?: components["schemas"]["ImageMoveJobResponse"] | null; + /** + * Last Error + * @description The last background worker error, if any. + */ + last_error?: string | null; + }; /** * Multiply Images * @description Multiplies two images together using `PIL.ImageChops.multiply()`. @@ -34761,6 +34856,66 @@ export interface operations { }; }; }; + start_image_move: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageMoveStatusResponse"]; + }; + }; + }; + }; + start_image_move_recovery: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 202: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageMoveStatusResponse"]; + }; + }; + }; + }; + get_image_move_status: { + parameters: { + query?: never; + header?: never; + path?: never; + cookie?: never; + }; + requestBody?: never; + responses: { + /** @description Successful Response */ + 200: { + headers: { + [name: string]: unknown; + }; + content: { + "application/json": components["schemas"]["ImageMoveStatusResponse"]; + }; + }; + }; + }; upload_image: { parameters: { query: { diff --git a/tests/app/routers/test_image_moves.py b/tests/app/routers/test_image_moves.py new file mode 100644 index 00000000000..caf6bc11732 --- /dev/null +++ b/tests/app/routers/test_image_moves.py @@ -0,0 +1,187 @@ +import logging +from unittest.mock import MagicMock + +import pytest +from fastapi import status +from fastapi.testclient import TestClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.app.services.auth.token_service import set_jwt_secret +from invokeai.app.services.board_image_records.board_image_records_sqlite import SqliteBoardImageRecordStorage +from invokeai.app.services.board_records.board_records_sqlite import SqliteBoardRecordStorage +from invokeai.app.services.boards.boards_default import BoardService +from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService +from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite +from invokeai.app.services.config.config_default import InvokeAIAppConfig +from invokeai.app.services.image_moves.image_moves_default import ImageMoveJobAlreadyRunning +from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage +from invokeai.app.services.images.images_default import ImageService +from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache +from invokeai.app.services.invocation_services import InvocationServices +from invokeai.app.services.invocation_stats.invocation_stats_default import InvocationStatsService +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.users.users_common import UserCreateRequest +from invokeai.app.services.users.users_default import UserService +from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage +from invokeai.backend.util.logging import InvokeAILogger +from tests.fixtures.sqlite_database import create_mock_sqlite_database +from tests.test_nodes import TestEventService + + +class MockApiDependencies(ApiDependencies): + invoker: Invoker + + def __init__(self, invoker: Invoker) -> None: + self.invoker = invoker + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.fixture +def mock_services() -> InvocationServices: + configuration = InvokeAIAppConfig(use_memory_db=True, node_cache_size=0) + logger = InvokeAILogger.get_logger() + db = create_mock_sqlite_database(configuration, logger) + image_moves = MagicMock() + return InvocationServices( + board_image_records=SqliteBoardImageRecordStorage(db=db), + board_images=None, # type: ignore + board_records=SqliteBoardRecordStorage(db=db), + boards=BoardService(), + bulk_download=BulkDownloadService(), + configuration=configuration, + events=TestEventService(), + image_files=None, # type: ignore + image_records=SqliteImageRecordStorage(db=db), + images=ImageService(), + invocation_cache=MemoryInvocationCache(max_cache_size=0), + logger=logging, # type: ignore + model_images=None, # type: ignore + model_manager=None, # type: ignore + download_queue=None, # type: ignore + external_generation=None, # type: ignore + names=None, # type: ignore + performance_statistics=InvocationStatsService(), + session_processor=None, # type: ignore + session_queue=None, # type: ignore + urls=None, # type: ignore + workflow_records=SqliteWorkflowRecordsStorage(db=db), + tensors=None, # type: ignore + conditioning=None, # type: ignore + style_preset_records=None, # type: ignore + style_preset_image_files=None, # type: ignore + workflow_thumbnails=None, # type: ignore + model_relationship_records=None, # type: ignore + model_relationships=None, # type: ignore + client_state_persistence=ClientStatePersistenceSqlite(db=db), + users=UserService(db), + image_moves=image_moves, + ) + + +@pytest.fixture +def mock_invoker(mock_services: InvocationServices, monkeypatch: pytest.MonkeyPatch) -> Invoker: + invoker = Invoker(services=mock_services) + mock_deps = MockApiDependencies(invoker) + monkeypatch.setattr("invokeai.app.api.routers.image_moves.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.auth.ApiDependencies", mock_deps) + return invoker + + +def _status_payload(is_running: bool = True, operation: str = "move_all") -> dict: + return { + "is_running": is_running, + "operation": operation, + "active_job_id": None, + "latest_job": None, + "last_error": None, + } + + +def _create_user(invoker: Invoker, email: str, is_admin: bool) -> None: + invoker.services.users.create( + UserCreateRequest( + email=email, + display_name=email, + password="TestPass123", + is_admin=is_admin, + ) + ) + + +def _login(client: TestClient, email: str) -> str: + response = client.post("/api/v1/auth/login", json={"email": email, "password": "TestPass123"}) + assert response.status_code == status.HTTP_200_OK + return response.json()["token"] + + +def test_start_image_move_returns_accepted_without_running_job_inline( + client: TestClient, mock_invoker: Invoker +) -> None: + image_moves = mock_invoker.services.image_moves + image_moves.start_background_move_all.return_value = _status_payload() + + response = client.post("/api/v1/image_moves/start") + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json()["is_running"] is True + image_moves.start_background_move_all.assert_called_once_with() + image_moves.move_all_images.assert_not_called() + + +def test_start_image_move_rejects_overlapping_background_job(client: TestClient, mock_invoker: Invoker) -> None: + image_moves = mock_invoker.services.image_moves + image_moves.start_background_move_all.side_effect = ImageMoveJobAlreadyRunning("already running") + + response = client.post("/api/v1/image_moves/start") + + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json()["detail"] == "already running" + + +def test_force_recovery_returns_accepted(client: TestClient, mock_invoker: Invoker) -> None: + image_moves = mock_invoker.services.image_moves + image_moves.start_background_recovery.return_value = _status_payload(operation="recovery") + + response = client.post("/api/v1/image_moves/recover") + + assert response.status_code == status.HTTP_202_ACCEPTED + assert response.json()["operation"] == "recovery" + image_moves.start_background_recovery.assert_called_once_with() + + +def test_image_move_status_uses_service_status(client: TestClient, mock_invoker: Invoker) -> None: + image_moves = mock_invoker.services.image_moves + image_moves.get_background_status.return_value = _status_payload(is_running=False) + + response = client.get("/api/v1/image_moves/status") + + assert response.status_code == status.HTTP_200_OK + assert response.json()["is_running"] is False + image_moves.get_background_status.assert_called_once_with() + + +@pytest.mark.parametrize( + ("method", "path"), + [ + ("post", "/api/v1/image_moves/start"), + ("post", "/api/v1/image_moves/recover"), + ("get", "/api/v1/image_moves/status"), + ], +) +def test_image_move_endpoints_require_admin_in_multiuser_mode( + client: TestClient, mock_invoker: Invoker, method: str, path: str +) -> None: + set_jwt_secret("test-secret") + mock_invoker.services.configuration.multiuser = True + _create_user(mock_invoker, "user@test.com", is_admin=False) + token = _login(client, "user@test.com") + + response = getattr(client, method)(path, headers={"Authorization": f"Bearer {token}"}) + + assert response.status_code == status.HTTP_403_FORBIDDEN diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index 00541e68a91..1c927001ab3 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -1,3 +1,4 @@ +import threading from pathlib import Path from shutil import copy2 from unittest.mock import MagicMock, patch @@ -7,7 +8,7 @@ from invokeai.app.services.config.config_default import InvokeAIAppConfig from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage -from invokeai.app.services.image_moves.image_moves_default import ImageMoveService +from invokeai.app.services.image_moves.image_moves_default import BACKGROUND_SHUTDOWN_ERROR, ImageMoveService from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase @@ -169,6 +170,64 @@ def test_startup_recovery_completes_planned_job_before_any_file_move(tmp_path: P assert _job_item_states(service, job_id) == {image_name: "committed"} +def test_background_recovery_can_start_when_journal_job_is_active(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-background-recovery.png" + _save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple") + job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100)) + + status = service.start_background_recovery() + assert status.is_running is True + assert status.operation == "recovery" + + assert service._future is not None + service._future.result(timeout=5) + + assert records.get(image_name).image_subfolder == "2024/03/05" + assert service.get_job(job_id).state == "committed" + + +def test_background_worker_error_is_exposed_in_status(tmp_path: Path) -> None: + service, _records = _service(tmp_path, strategy="date") + + def raise_error() -> None: + raise RuntimeError("background failed") + + status = service._start_background_operation("move_all", raise_error) + assert status.is_running is True + + assert service._future is not None + service._future.result(timeout=5) + + status = service.get_background_status() + assert status.is_running is False + assert status.operation is None + assert status.last_error == "background failed" + + +def test_stop_records_error_message_for_active_background_job(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-background-stop.png" + _save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple") + job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100)) + release_worker = threading.Event() + + def wait_for_shutdown() -> None: + release_worker.wait(timeout=5) + + service._start_background_operation("recovery", wait_for_shutdown) + + try: + service.stop() + + assert service.get_job(job_id).error_message == BACKGROUND_SHUTDOWN_ERROR + assert service.get_background_status().last_error == BACKGROUND_SHUTDOWN_ERROR + finally: + release_worker.set() + assert service._future is not None + service._future.result(timeout=5) + + def test_startup_recovery_completes_partial_multi_image_move(tmp_path: Path) -> None: service, records = _service(tmp_path, strategy="date") _save_image(service, records, "image-f.png", "", "2024-04-05 06:07:08.000", "orange") From c263c74b70aa571ba6bcd726b892e48fa6eb76e9 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 12 May 2026 09:11:45 -0500 Subject: [PATCH 03/12] Document image storage maintenance --- .../docs/configuration/invokeai-yaml.mdx | 2 +- docs/src/content/docs/features/gallery.mdx | 4 +++ .../features/image-storage-maintenance.mdx | 32 +++++++++++++++++++ 3 files changed, 37 insertions(+), 1 deletion(-) create mode 100644 docs/src/content/docs/features/image-storage-maintenance.mdx diff --git a/docs/src/content/docs/configuration/invokeai-yaml.mdx b/docs/src/content/docs/configuration/invokeai-yaml.mdx index 987c8eb98a2..c43b76d82ed 100644 --- a/docs/src/content/docs/configuration/invokeai-yaml.mdx +++ b/docs/src/content/docs/configuration/invokeai-yaml.mdx @@ -131,7 +131,7 @@ Available strategies: | `type` | `outputs/images/general/abc123.png` | Organize images by image category. | | `hash` | `outputs/images/ab/abc123.png` | Use the first two characters of the image UUID for filesystem performance with large collections. | -Changing this setting only affects newly-created images. Existing images remain in their current locations. +Changing this setting only affects newly-created images. Existing images remain in their current locations unless you run [Image Storage Maintenance](/features/image-storage-maintenance/). #### Logging diff --git a/docs/src/content/docs/features/gallery.mdx b/docs/src/content/docs/features/gallery.mdx index fec8c918a3e..8e9990b8ed5 100644 --- a/docs/src/content/docs/features/gallery.mdx +++ b/docs/src/content/docs/features/gallery.mdx @@ -55,6 +55,10 @@ Each board has a context menu accessible via right-click (or Ctrl+click). Deleting a board will **permanently delete all images** contained within it. Proceed with caution! ::: +### Image Storage Maintenance + +Administrators can use [Image Storage Maintenance](/features/image-storage-maintenance/) to move existing image files and thumbnails into the current image subfolder strategy. This is separate from board organization and does not change image names, boards, generation metadata, or gallery records. + ### Board Contents Every board is organized into two distinct tabs: diff --git a/docs/src/content/docs/features/image-storage-maintenance.mdx b/docs/src/content/docs/features/image-storage-maintenance.mdx new file mode 100644 index 00000000000..8844402b312 --- /dev/null +++ b/docs/src/content/docs/features/image-storage-maintenance.mdx @@ -0,0 +1,32 @@ +--- +title: Image Storage Maintenance +description: Move existing images into the current image subfolder strategy with crash recovery. +--- + +InvokeAI can move existing images into the folder layout selected by [`image_subfolder_strategy`](/configuration/invokeai-yaml/#image-subfolder-strategy). This is useful after changing the image subfolder strategy from `flat` to `date`, `type`, or `hash`, or when reorganizing an older image library. + +This operation changes where image files and thumbnails are stored on disk. It does not change image names, boards, generation metadata, or gallery records. + +## Access + +Image storage maintenance is available from the in-application Settings panel. + +Administrators can start image moves, force recovery, and inspect move job details. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode cannot run or inspect image move jobs. + +The same move implementation is also available through an external Python maintenance script for administrators who need to run startup recovery or start an image move outside the web UI. + +## Maintenance Mode + +Image moves run as a maintenance operation. Before a move starts, InvokeAI checks that no queue work is active. While the move is running, InvokeAI prevents image reads, uploads, deletes, generation jobs, and gallery mutations from racing with the filesystem move. + +The UI shows the move or recovery state until the job is complete or requires manual attention. + +## Crash Recovery + +The move process is crash-recoverable. InvokeAI records each move job in its database before moving files, moves full-size images and thumbnails on disk, and updates `images.image_subfolder` only after the filesystem move succeeds. + +If InvokeAI stops during a move, recovery resumes incomplete jobs. If recovery finds an ambiguous filesystem state, such as both the old and new full-size image files existing or neither file existing, it halts the job for manual repair instead of blindly updating the database. + +For the `date` strategy, existing images are moved according to their original image creation timestamp stored in the database, not according to the time the maintenance job is run. + +Empty source directories left behind by successful moves are removed when safe. From 3b08b5160bf536622f9463ba3f7067b06aaf25d5 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 12 May 2026 18:19:05 -0500 Subject: [PATCH 04/12] Guard image moves during maintenance --- invokeai/app/api/routers/board_images.py | 7 ++ .../app/api/routers/image_move_maintenance.py | 17 ++++ invokeai/app/api/routers/image_moves.py | 3 +- invokeai/app/api/routers/images.py | 19 +++++ invokeai/app/api/routers/recall_parameters.py | 2 + invokeai/app/api/routers/session_queue.py | 3 + invokeai/app/api/routers/utilities.py | 3 + .../image_moves/image_moves_default.py | 50 +++++++++++- .../routers/test_board_images_maintenance.py | 50 ++++++++++++ tests/app/routers/test_image_moves.py | 12 ++- tests/app/routers/test_images.py | 70 ++++++++++++++++ tests/app/routers/test_recall_parameters.py | 13 +++ ...st_session_queue_image_move_maintenance.py | 35 ++++++++ .../image_moves/test_image_moves_default.py | 80 ++++++++++++++++++- 14 files changed, 357 insertions(+), 7 deletions(-) create mode 100644 invokeai/app/api/routers/image_move_maintenance.py create mode 100644 tests/app/routers/test_board_images_maintenance.py create mode 100644 tests/app/routers/test_session_queue_image_move_maintenance.py diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index f94e4f2437c..3a038a937a2 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -3,6 +3,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.services.images.images_common import AddImagesToBoardResult, RemoveImagesFromBoardResult board_images_router = APIRouter(prefix="/v1/board_images", tags=["boards"]) @@ -63,6 +64,7 @@ async def add_image_to_board( image_name: str = Body(description="The name of the image to add"), ) -> AddImagesToBoardResult: """Creates a board_image""" + assert_image_move_maintenance_inactive() _assert_board_write_access(board_id, current_user) _assert_image_direct_owner(image_name, current_user) try: @@ -96,6 +98,8 @@ async def remove_image_from_board( image_name: str = Body(description="The name of the image to remove", embed=True), ) -> RemoveImagesFromBoardResult: """Removes an image from its board, if it had one""" + assert_image_move_maintenance_inactive() + try: old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" if old_board_id != "none": @@ -132,6 +136,7 @@ async def add_images_to_board( image_names: list[str] = Body(description="The names of the images to add", embed=True), ) -> AddImagesToBoardResult: """Adds a list of images to a board""" + assert_image_move_maintenance_inactive() _assert_board_write_access(board_id, current_user) try: added_images: set[str] = set() @@ -178,6 +183,8 @@ async def remove_images_from_board( image_names: list[str] = Body(description="The names of the images to remove", embed=True), ) -> RemoveImagesFromBoardResult: """Removes a list of images from their board, if they had one""" + assert_image_move_maintenance_inactive() + try: removed_images: set[str] = set() affected_boards: set[str] = set() diff --git a/invokeai/app/api/routers/image_move_maintenance.py b/invokeai/app/api/routers/image_move_maintenance.py new file mode 100644 index 00000000000..2f05492671b --- /dev/null +++ b/invokeai/app/api/routers/image_move_maintenance.py @@ -0,0 +1,17 @@ +from fastapi import HTTPException, status + +from invokeai.app.api.dependencies import ApiDependencies + +IMAGE_MOVE_MAINTENANCE_ACTIVE_DETAIL = "Image storage maintenance is active" + + +def assert_image_move_maintenance_inactive() -> None: + invoker = getattr(ApiDependencies, "invoker", None) + if invoker is None: + return + image_moves = getattr(invoker.services, "image_moves", None) + if image_moves is not None and image_moves.is_maintenance_active(): + raise HTTPException( + status_code=status.HTTP_409_CONFLICT, + detail=IMAGE_MOVE_MAINTENANCE_ACTIVE_DETAIL, + ) diff --git a/invokeai/app/api/routers/image_moves.py b/invokeai/app/api/routers/image_moves.py index d2b92699fd7..0c300838855 100644 --- a/invokeai/app/api/routers/image_moves.py +++ b/invokeai/app/api/routers/image_moves.py @@ -9,6 +9,7 @@ ImageMoveBackgroundStatus, ImageMoveJob, ImageMoveJobAlreadyRunning, + ImageMoveQueueActive, MoveJobState, ) @@ -65,7 +66,7 @@ def _status_to_response(service_status: ImageMoveBackgroundStatus | dict) -> Ima async def start_image_move(_: AdminUserOrDefault) -> ImageMoveStatusResponse: try: return _status_to_response(_get_image_move_service().start_background_move_all()) - except ImageMoveJobAlreadyRunning as e: + except (ImageMoveJobAlreadyRunning, ImageMoveQueueActive) as e: raise HTTPException(status_code=status.HTTP_409_CONFLICT, detail=str(e)) from e diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index a3ae6fce82b..c67d93b0367 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -12,6 +12,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_image +from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.invocations.fields import MetadataField from invokeai.app.services.image_records.image_records_common import ( ImageCategory, @@ -173,6 +174,8 @@ async def upload_image( ), ) -> ImageDTO: """Uploads an image for the current user""" + assert_image_move_maintenance_inactive() + # If uploading into a board, verify the user has write access. # Public boards allow uploads from any authenticated user. if board_id is not None: @@ -275,6 +278,7 @@ async def delete_image( image_name: str = Path(description="The name of the image to delete"), ) -> DeleteImagesResult: """Deletes an image""" + assert_image_move_maintenance_inactive() _assert_image_owner(image_name, current_user) deleted_images: set[str] = set() @@ -301,6 +305,7 @@ async def clear_intermediates( current_user: CurrentUserOrDefault, ) -> int: """Clears all intermediates. Requires admin.""" + assert_image_move_maintenance_inactive() if not current_user.is_admin: raise HTTPException(status_code=403, detail="Only admins can clear all intermediates") @@ -335,6 +340,7 @@ async def update_image( image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"), ) -> ImageDTO: """Updates an image""" + assert_image_move_maintenance_inactive() _assert_image_owner(image_name, current_user) try: @@ -434,6 +440,8 @@ async def get_image_full( via tags which cannot send Bearer tokens. Image names are UUIDs, providing security through unguessability. """ + assert_image_move_maintenance_inactive() + try: path = ApiDependencies.invoker.services.images.get_path(image_name) with open(path, "rb") as f: @@ -467,6 +475,8 @@ async def get_image_thumbnail( via tags which cannot send Bearer tokens. Image names are UUIDs, providing security through unguessability. """ + assert_image_move_maintenance_inactive() + try: path = ApiDependencies.invoker.services.images.get_path(image_name, thumbnail=True) with open(path, "rb") as f: @@ -550,6 +560,8 @@ async def delete_images_from_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to delete", embed=True), ) -> DeleteImagesResult: + assert_image_move_maintenance_inactive() + try: deleted_images: set[str] = set() affected_boards: set[str] = set() @@ -580,6 +592,7 @@ async def delete_uncategorized_images( current_user: CurrentUserOrDefault, ) -> DeleteImagesResult: """Deletes all uncategorized images owned by the current user (or all if admin)""" + assert_image_move_maintenance_inactive() image_names = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( board_id="none", categories=None, is_intermediate=None @@ -616,6 +629,8 @@ async def star_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to star", embed=True), ) -> StarredImagesResult: + assert_image_move_maintenance_inactive() + try: starred_images: set[str] = set() affected_boards: set[str] = set() @@ -646,6 +661,8 @@ async def unstar_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to unstar", embed=True), ) -> UnstarredImagesResult: + assert_image_move_maintenance_inactive() + try: unstarred_images: set[str] = set() affected_boards: set[str] = set() @@ -693,6 +710,8 @@ async def download_images_from_list( default=None, description="The board from which image should be downloaded", embed=True ), ) -> ImagesDownloaded: + assert_image_move_maintenance_inactive() + if (image_names is None or len(image_names) == 0) and board_id is None: raise HTTPException(status_code=400, detail="No images or board id specified.") diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py index 31120d59a02..20c04112d8c 100644 --- a/invokeai/app/api/routers/recall_parameters.py +++ b/invokeai/app/api/routers/recall_parameters.py @@ -9,6 +9,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.backend.image_util.controlnet_processor import process_controlnet_image from invokeai.backend.model_manager.taxonomy import ModelType @@ -436,6 +437,7 @@ async def update_recall_parameters( # are cleared. In non-strict mode (default) they would be left as-is. """ logger = ApiDependencies.invoker.services.logger + assert_image_move_maintenance_inactive() # Validate image access before processing — prevents information leakage # (dimensions) and derived-image minting via ControlNet preprocessors. diff --git a/invokeai/app/api/routers/session_queue.py b/invokeai/app/api/routers/session_queue.py index 41a5a411c7a..841ec2b9ffb 100644 --- a/invokeai/app/api/routers/session_queue.py +++ b/invokeai/app/api/routers/session_queue.py @@ -6,6 +6,7 @@ from invokeai.app.api.auth_dependencies import AdminUserOrDefault, CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.services.session_processor.session_processor_common import SessionProcessorStatus from invokeai.app.services.session_queue.session_queue_common import ( Batch, @@ -97,6 +98,8 @@ async def enqueue_batch( prepend: bool = Body(default=False, description="Whether or not to prepend this batch in the queue"), ) -> EnqueueBatchResult: """Processes a batch and enqueues the output graphs for execution for the current user.""" + assert_image_move_maintenance_inactive() + try: return await ApiDependencies.invoker.services.session_queue.enqueue_batch( queue_id=queue_id, batch=batch, prepend=prepend, user_id=current_user.user_id diff --git a/invokeai/app/api/routers/utilities.py b/invokeai/app/api/routers/utilities.py index f77f77a8534..a7161bcdbe7 100644 --- a/invokeai/app/api/routers/utilities.py +++ b/invokeai/app/api/routers/utilities.py @@ -13,6 +13,7 @@ from transformers import AutoProcessor, AutoTokenizer, LlavaOnevisionForConditionalGeneration, LlavaOnevisionProcessor from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.services.image_files.image_files_common import ImageFileNotFoundException from invokeai.app.services.model_records.model_records_base import UnknownModelException from invokeai.backend.llava_onevision_pipeline import LlavaOnevisionPipeline @@ -201,6 +202,8 @@ def _run_image_to_prompt(image_name: str, model_key: str, instruction: str) -> s ) async def image_to_prompt(body: ImageToPromptRequest) -> ImageToPromptResponse: """Generate a descriptive prompt from an image using a vision-language model.""" + assert_image_move_maintenance_inactive() + try: prompt = await asyncio.to_thread( _run_image_to_prompt, diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index f5ac2b2f947..d988c627357 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -12,6 +12,7 @@ from invokeai.app.services.config import InvokeAIAppConfig from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase from invokeai.app.services.image_records.image_records_common import ImageCategory +from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase from invokeai.app.util.thumbnails import make_thumbnail @@ -58,6 +59,10 @@ class ImageMoveJobAlreadyRunning(Exception): pass +class ImageMoveQueueActive(Exception): + pass + + BACKGROUND_SHUTDOWN_ERROR = "Image move service stopped while background operation was running" @@ -78,6 +83,10 @@ def __init__( self._future: Future | None = None self._future_operation: ImageMoveBackgroundOperation | None = None self._last_background_error: str | None = None + self._invoker = None + + def start(self, invoker) -> None: + self._invoker = invoker def stop(self, *args, **kwargs) -> None: with self._future_lock: @@ -87,7 +96,7 @@ def stop(self, *args, **kwargs) -> None: self._executor.shutdown(wait=False, cancel_futures=False) def start_background_move_all(self) -> ImageMoveBackgroundStatus: - return self._start_background_operation("move_all", self.move_all_images) + return self._start_background_operation("move_all", self.move_all_images, require_idle_queue=True) def start_background_recovery(self) -> ImageMoveBackgroundStatus: return self._start_background_operation("recovery", self.startup_recovery) @@ -97,17 +106,50 @@ def get_background_status(self) -> ImageMoveBackgroundStatus: self._refresh_finished_future_locked() return self._build_background_status_locked() - def _start_background_operation(self, operation: ImageMoveBackgroundOperation, target) -> ImageMoveBackgroundStatus: + def is_maintenance_active(self) -> bool: + with self._future_lock: + self._refresh_finished_future_locked() + is_running = self._future is not None and not self._future.done() + operation_reserved = self._future_operation is not None + return operation_reserved or is_running or self._get_active_job_id() is not None + + def _assert_no_active_queue_work(self) -> None: + if self._invoker is None: + return + session_queue = getattr(self._invoker.services, "session_queue", None) + if session_queue is None: + return + queue_status = session_queue.get_queue_status(DEFAULT_QUEUE_ID) + if queue_status.pending > 0 or queue_status.in_progress > 0: + raise ImageMoveQueueActive("Cannot start image move while queue work is active") + + def _start_background_operation( + self, + operation: ImageMoveBackgroundOperation, + target, + require_idle_queue: bool = False, + ) -> ImageMoveBackgroundStatus: with self._future_lock: self._refresh_finished_future_locked() - if self._future is not None and not self._future.done(): + if self._future_operation is not None or (self._future is not None and not self._future.done()): raise ImageMoveJobAlreadyRunning("An image move job is already running") active_job_id = self._get_active_job_id() if operation != "recovery" and active_job_id is not None: raise ImageMoveJobAlreadyRunning("An image move job is already active") self._last_background_error = None self._future_operation = operation - self._future = self._executor.submit(self._run_background_operation, operation, target) + + try: + if require_idle_queue: + self._assert_no_active_queue_work() + future = self._executor.submit(self._run_background_operation, operation, target) + except Exception: + with self._future_lock: + self._future_operation = None + raise + + with self._future_lock: + self._future = future return self._build_background_status_locked() def _run_background_operation(self, operation: ImageMoveBackgroundOperation, target) -> None: diff --git a/tests/app/routers/test_board_images_maintenance.py b/tests/app/routers/test_board_images_maintenance.py new file mode 100644 index 00000000000..8e2917efd4c --- /dev/null +++ b/tests/app/routers/test_board_images_maintenance.py @@ -0,0 +1,50 @@ +from unittest.mock import MagicMock + +import pytest +from fastapi.testclient import TestClient + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api_app import app +from invokeai.app.services.invoker import Invoker + + +class MockApiDependencies(ApiDependencies): + invoker: Invoker + + def __init__(self, invoker) -> None: + self.invoker = invoker + + +@pytest.fixture +def client() -> TestClient: + return TestClient(app) + + +@pytest.mark.parametrize( + ("method", "path", "json_body"), + [ + ("post", "/api/v1/board_images/", {"board_id": "board-id", "image_name": "image.png"}), + ("delete", "/api/v1/board_images/", {"image_name": "image.png"}), + ("post", "/api/v1/board_images/batch", {"board_id": "board-id", "image_names": ["image.png"]}), + ("post", "/api/v1/board_images/batch/delete", {"image_names": ["image.png"]}), + ], +) +def test_board_image_mutations_are_blocked_during_image_move_maintenance( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + client: TestClient, + method: str, + path: str, + json_body: dict, +) -> None: + mock_deps = MockApiDependencies(mock_invoker) + mock_invoker.services.image_moves = MagicMock() + mock_invoker.services.image_moves.is_maintenance_active.return_value = True + monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + + response = client.request(method, path, json=json_body) + + assert response.status_code == 409 + assert response.json()["detail"] == "Image storage maintenance is active" diff --git a/tests/app/routers/test_image_moves.py b/tests/app/routers/test_image_moves.py index caf6bc11732..5c09673e5d0 100644 --- a/tests/app/routers/test_image_moves.py +++ b/tests/app/routers/test_image_moves.py @@ -14,7 +14,7 @@ from invokeai.app.services.bulk_download.bulk_download_default import BulkDownloadService from invokeai.app.services.client_state_persistence.client_state_persistence_sqlite import ClientStatePersistenceSqlite from invokeai.app.services.config.config_default import InvokeAIAppConfig -from invokeai.app.services.image_moves.image_moves_default import ImageMoveJobAlreadyRunning +from invokeai.app.services.image_moves.image_moves_default import ImageMoveJobAlreadyRunning, ImageMoveQueueActive from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage from invokeai.app.services.images.images_default import ImageService from invokeai.app.services.invocation_cache.invocation_cache_memory import MemoryInvocationCache @@ -144,6 +144,16 @@ def test_start_image_move_rejects_overlapping_background_job(client: TestClient, assert response.json()["detail"] == "already running" +def test_start_image_move_rejects_active_queue_work(client: TestClient, mock_invoker: Invoker) -> None: + image_moves = mock_invoker.services.image_moves + image_moves.start_background_move_all.side_effect = ImageMoveQueueActive("queue work is active") + + response = client.post("/api/v1/image_moves/start") + + assert response.status_code == status.HTTP_409_CONFLICT + assert response.json()["detail"] == "queue work is active" + + def test_force_recovery_returns_accepted(client: TestClient, mock_invoker: Invoker) -> None: image_moves = mock_invoker.services.image_moves image_moves.start_background_recovery.return_value = _status_payload(operation="recovery") diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 619ecb78c4f..6209a63863f 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -1,6 +1,7 @@ import os from pathlib import Path from typing import Any +from unittest.mock import MagicMock import pytest from fastapi import BackgroundTasks @@ -66,6 +67,75 @@ def mock_add_task(*args, **kwargs): monkeypatch.setattr(BackgroundTasks, "add_task", mock_add_task) +def prepare_image_maintenance_test(monkeypatch: Any, mock_invoker: Invoker) -> None: + mock_deps = MockApiDependencies(mock_invoker) + mock_invoker.services.image_moves = MagicMock() + mock_invoker.services.image_moves.is_maintenance_active.return_value = True + monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + + +@pytest.mark.parametrize( + ("method", "path", "json_body"), + [ + ("get", "/api/v1/images/i/test.png/full", None), + ("head", "/api/v1/images/i/test.png/full", None), + ("get", "/api/v1/images/i/test.png/thumbnail", None), + ("delete", "/api/v1/images/i/test.png", None), + ("delete", "/api/v1/images/intermediates", None), + ("delete", "/api/v1/images/uncategorized", None), + ("patch", "/api/v1/images/i/test.png", {"starred": True}), + ("post", "/api/v1/images/delete", {"image_names": ["test.png"]}), + ("post", "/api/v1/images/star", {"image_names": ["test.png"]}), + ("post", "/api/v1/images/unstar", {"image_names": ["test.png"]}), + ("post", "/api/v1/images/download", {"image_names": ["test.png"]}), + ], +) +def test_image_operations_are_blocked_during_image_move_maintenance( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient, method: str, path: str, json_body: dict | None +) -> None: + prepare_image_maintenance_test(monkeypatch, mock_invoker) + + if json_body is not None: + response = getattr(client, method)(path, json=json_body) + else: + response = getattr(client, method)(path) + + assert response.status_code == 409 + if method != "head": + assert response.json()["detail"] == "Image storage maintenance is active" + + +def test_image_upload_is_blocked_during_image_move_maintenance( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + prepare_image_maintenance_test(monkeypatch, mock_invoker) + + response = client.post( + "/api/v1/images/upload", + params={"image_category": "general", "is_intermediate": False}, + files={"file": ("test.png", b"not-read-during-maintenance", "image/png")}, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "Image storage maintenance is active" + + +def test_image_to_prompt_is_blocked_during_image_move_maintenance( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + prepare_image_maintenance_test(monkeypatch, mock_invoker) + + response = client.post( + "/api/v1/utilities/image-to-prompt", + json={"image_name": "test.png", "model_key": "model-key", "instruction": "describe"}, + ) + + assert response.status_code == 409 + assert response.json()["detail"] == "Image storage maintenance is active" + + def test_download_images_with_empty_image_list_and_no_board_id( monkeypatch: Any, mock_invoker: Invoker, client: TestClient ) -> None: diff --git a/tests/app/routers/test_recall_parameters.py b/tests/app/routers/test_recall_parameters.py index 9dddf497ec6..777de45974d 100644 --- a/tests/app/routers/test_recall_parameters.py +++ b/tests/app/routers/test_recall_parameters.py @@ -89,6 +89,19 @@ def _load(image_name: str) -> Optional[dict[str, Any]]: return _load +def test_recall_parameters_is_blocked_during_image_move_maintenance( + monkeypatch: Any, patched_dependencies: MockApiDependencies, mock_invoker: Invoker, client: TestClient +) -> None: + mock_invoker.services.image_moves = MagicMock() + mock_invoker.services.image_moves.is_maintenance_active.return_value = True + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", patched_dependencies) + + response = client.post("/api/v1/recall/default", json={"positive_prompt": "hello"}) + + assert response.status_code == 409 + assert response.json()["detail"] == "Image storage maintenance is active" + + class TestReferenceImagesRecall: def test_reference_images_forwarded_when_image_exists( self, monkeypatch: Any, patched_dependencies: MockApiDependencies, client: TestClient diff --git a/tests/app/routers/test_session_queue_image_move_maintenance.py b/tests/app/routers/test_session_queue_image_move_maintenance.py new file mode 100644 index 00000000000..3ccb65dc3d9 --- /dev/null +++ b/tests/app/routers/test_session_queue_image_move_maintenance.py @@ -0,0 +1,35 @@ +from unittest.mock import MagicMock + +import pytest +from fastapi import HTTPException + +from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.session_queue import enqueue_batch +from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID, Batch +from invokeai.app.services.shared.graph import Graph + + +class MockApiDependencies(ApiDependencies): + def __init__(self, invoker) -> None: + self.invoker = invoker + + +@pytest.mark.anyio +async def test_enqueue_batch_is_blocked_during_image_move_maintenance( + monkeypatch: pytest.MonkeyPatch, mock_invoker +) -> None: + mock_deps = MockApiDependencies(mock_invoker) + mock_invoker.services.image_moves = MagicMock() + mock_invoker.services.image_moves.is_maintenance_active.return_value = True + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) + + with pytest.raises(HTTPException) as exc: + await enqueue_batch( + current_user=MagicMock(user_id="user-id"), + queue_id=DEFAULT_QUEUE_ID, + batch=Batch(graph=Graph()), + prepend=False, + ) + + assert exc.value.status_code == 409 + assert exc.value.detail == "Image storage maintenance is active" diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index 1c927001ab3..032954fda97 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -8,9 +8,14 @@ from invokeai.app.services.config.config_default import InvokeAIAppConfig from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage -from invokeai.app.services.image_moves.image_moves_default import BACKGROUND_SHUTDOWN_ERROR, ImageMoveService +from invokeai.app.services.image_moves.image_moves_default import ( + BACKGROUND_SHUTDOWN_ERROR, + ImageMoveQueueActive, + ImageMoveService, +) from invokeai.app.services.image_records.image_records_common import ImageCategory, ResourceOrigin from invokeai.app.services.image_records.image_records_sqlite import SqliteImageRecordStorage +from invokeai.app.services.session_queue.session_queue_common import DEFAULT_QUEUE_ID, SessionQueueStatus from invokeai.app.services.shared.sqlite.sqlite_database import SqliteDatabase from invokeai.app.services.shared.sqlite.sqlite_util import init_db from invokeai.backend.util.logging import InvokeAILogger @@ -187,6 +192,79 @@ def test_background_recovery_can_start_when_journal_job_is_active(tmp_path: Path assert service.get_job(job_id).state == "committed" +@pytest.mark.parametrize(("pending", "in_progress"), [(1, 0), (0, 1)]) +def test_background_move_rejects_active_queue_work(tmp_path: Path, pending: int, in_progress: int) -> None: + service, _records = _service(tmp_path, strategy="date") + invoker = MagicMock() + invoker.services.session_queue.get_queue_status.return_value = SessionQueueStatus( + queue_id=DEFAULT_QUEUE_ID, + item_id=None, + batch_id=None, + session_id=None, + pending=pending, + in_progress=in_progress, + completed=0, + failed=0, + canceled=0, + total=1, + ) + service.start(invoker) + + with pytest.raises(ImageMoveQueueActive, match="queue work is active"): + service.start_background_move_all() + + +def test_background_move_is_reserved_before_queue_check(tmp_path: Path) -> None: + service, _records = _service(tmp_path, strategy="date") + invoker = MagicMock() + + def get_queue_status(queue_id: str) -> SessionQueueStatus: + assert queue_id == DEFAULT_QUEUE_ID + assert service.is_maintenance_active() is True + return SessionQueueStatus( + queue_id=DEFAULT_QUEUE_ID, + item_id=None, + batch_id=None, + session_id=None, + pending=1, + in_progress=0, + completed=0, + failed=0, + canceled=0, + total=1, + ) + + invoker.services.session_queue.get_queue_status.side_effect = get_queue_status + service.start(invoker) + + with pytest.raises(ImageMoveQueueActive, match="queue work is active"): + service.start_background_move_all() + + assert service.is_maintenance_active() is False + + +def test_maintenance_is_active_while_background_job_or_uncommitted_journal_exists(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-maintenance-active.png" + _save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple") + service.create_move_job(service.plan_batch(last_image_name="", limit=100)) + + assert service.is_maintenance_active() is True + + release_worker = threading.Event() + + def wait_for_release() -> None: + release_worker.wait(timeout=5) + + service._start_background_operation("recovery", wait_for_release) + try: + assert service.is_maintenance_active() is True + finally: + release_worker.set() + assert service._future is not None + service._future.result(timeout=5) + + def test_background_worker_error_is_exposed_in_status(tmp_path: Path) -> None: service, _records = _service(tmp_path, strategy="date") From 9ab9c02ec93ba4abe24608d5cfaf26411088bea4 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 12 May 2026 19:22:56 -0500 Subject: [PATCH 05/12] Add image storage maintenance entry points --- .../features/image-storage-maintenance.mdx | 8 +- .../image_moves/image_moves_default.py | 24 ++- invokeai/app/services/invocation_services.py | 2 +- .../session_processor_default.py | 9 ++ .../backend/util/image_storage_maintenance.py | 89 ++++++++++ invokeai/frontend/web/public/locales/en.json | 18 +++ .../SettingsImageStorageMaintenance.tsx | 153 ++++++++++++++++++ .../SettingsModal/SettingsModal.tsx | 2 + .../src/services/api/endpoints/imageMoves.ts | 42 +++++ .../frontend/web/src/services/api/index.ts | 1 + scripts/image_storage_maintenance.py | 5 + .../image_moves/test_image_moves_default.py | 33 ++++ .../test_image_move_startup_safety.py | 76 +++++++++ .../util/test_image_storage_maintenance.py | 108 +++++++++++++ 14 files changed, 565 insertions(+), 5 deletions(-) create mode 100644 invokeai/backend/util/image_storage_maintenance.py create mode 100644 invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx create mode 100644 invokeai/frontend/web/src/services/api/endpoints/imageMoves.ts create mode 100644 scripts/image_storage_maintenance.py create mode 100644 tests/app/services/test_image_move_startup_safety.py create mode 100644 tests/backend/util/test_image_storage_maintenance.py diff --git a/docs/src/content/docs/features/image-storage-maintenance.mdx b/docs/src/content/docs/features/image-storage-maintenance.mdx index 8844402b312..cec83ec4ff7 100644 --- a/docs/src/content/docs/features/image-storage-maintenance.mdx +++ b/docs/src/content/docs/features/image-storage-maintenance.mdx @@ -13,7 +13,13 @@ Image storage maintenance is available from the in-application Settings panel. Administrators can start image moves, force recovery, and inspect move job details. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode cannot run or inspect image move jobs. -The same move implementation is also available through an external Python maintenance script for administrators who need to run startup recovery or start an image move outside the web UI. +The same move implementation is also available through `scripts/image_storage_maintenance.py` for administrators who need to inspect status, run startup recovery, or start an image move outside the web UI: + +```bash +python scripts/image_storage_maintenance.py status --root /path/to/invokeai +python scripts/image_storage_maintenance.py recover --root /path/to/invokeai +python scripts/image_storage_maintenance.py move --root /path/to/invokeai +``` ## Maintenance Mode diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index d988c627357..7ce0c852788 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -84,9 +84,21 @@ def __init__( self._future_operation: ImageMoveBackgroundOperation | None = None self._last_background_error: str | None = None self._invoker = None + self._session_queue = None def start(self, invoker) -> None: self._invoker = invoker + self._session_queue = getattr(invoker.services, "session_queue", None) + result = self.startup_recovery() + if result.committed > 0 or result.errors > 0: + self._logger.info( + "Image move startup recovery completed: committed=%s, errors=%s", + result.committed, + result.errors, + ) + + def set_session_queue(self, session_queue) -> None: + self._session_queue = session_queue def stop(self, *args, **kwargs) -> None: with self._future_lock: @@ -106,6 +118,9 @@ def get_background_status(self) -> ImageMoveBackgroundStatus: self._refresh_finished_future_locked() return self._build_background_status_locked() + def get_active_job_id(self) -> int | None: + return self._get_active_job_id() + def is_maintenance_active(self) -> bool: with self._future_lock: self._refresh_finished_future_locked() @@ -114,15 +129,18 @@ def is_maintenance_active(self) -> bool: return operation_reserved or is_running or self._get_active_job_id() is not None def _assert_no_active_queue_work(self) -> None: - if self._invoker is None: - return - session_queue = getattr(self._invoker.services, "session_queue", None) + session_queue = self._session_queue + if session_queue is None and self._invoker is not None: + session_queue = getattr(self._invoker.services, "session_queue", None) if session_queue is None: return queue_status = session_queue.get_queue_status(DEFAULT_QUEUE_ID) if queue_status.pending > 0 or queue_status.in_progress > 0: raise ImageMoveQueueActive("Cannot start image move while queue work is active") + def assert_no_active_queue_work(self) -> None: + self._assert_no_active_queue_work() + def _start_background_operation( self, operation: ImageMoveBackgroundOperation, diff --git a/invokeai/app/services/invocation_services.py b/invokeai/app/services/invocation_services.py index 0b36705ab3f..54f9d82b786 100644 --- a/invokeai/app/services/invocation_services.py +++ b/invokeai/app/services/invocation_services.py @@ -101,6 +101,7 @@ def __init__( self.external_generation = external_generation self.performance_statistics = performance_statistics self.session_queue = session_queue + self.image_moves = image_moves self.session_processor = session_processor self.invocation_cache = invocation_cache self.names = names @@ -113,4 +114,3 @@ def __init__( self.workflow_thumbnails = workflow_thumbnails self.client_state_persistence = client_state_persistence self.users = users - self.image_moves = image_moves diff --git a/invokeai/app/services/session_processor/session_processor_default.py b/invokeai/app/services/session_processor/session_processor_default.py index 7159c19e746..b10aa6bbb6e 100644 --- a/invokeai/app/services/session_processor/session_processor_default.py +++ b/invokeai/app/services/session_processor/session_processor_default.py @@ -416,6 +416,10 @@ def get_status(self) -> SessionProcessorStatus: is_processing=self._queue_item is not None, ) + def _is_image_move_maintenance_active(self) -> bool: + image_moves = getattr(self._invoker.services, "image_moves", None) + return image_moves is not None and image_moves.is_maintenance_active() + def _process( self, stop_event: ThreadEvent, @@ -437,6 +441,11 @@ def _process( # If we are paused, wait for resume event resume_event.wait() + if self._is_image_move_maintenance_active(): + self._invoker.services.logger.debug("Image storage maintenance is active") + poll_now_event.wait(self._polling_interval) + continue + # Get the next session to process self._queue_item = self._invoker.services.session_queue.dequeue() diff --git a/invokeai/backend/util/image_storage_maintenance.py b/invokeai/backend/util/image_storage_maintenance.py new file mode 100644 index 00000000000..d62ba868f00 --- /dev/null +++ b/invokeai/backend/util/image_storage_maintenance.py @@ -0,0 +1,89 @@ +import argparse +import sys +from pathlib import Path +from typing import Sequence + +from invokeai.app.services.config.config_default import InvokeAIAppConfig, load_and_migrate_config +from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage +from invokeai.app.services.image_moves.image_moves_default import ImageMoveResult, ImageMoveService +from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue +from invokeai.app.services.shared.sqlite.sqlite_util import init_db +from invokeai.backend.util.logging import InvokeAILogger + + +def build_image_move_service(root: Path | None = None, config_file: Path | None = None) -> ImageMoveService: + config = InvokeAIAppConfig() + if root is not None: + config._root = root + if config_file is not None: + config._config_file = config_file + + if config.config_file_path.exists(): + config.update_config(load_and_migrate_config(config.config_file_path), clobber=False) + + if config.outputs_path is None: + raise RuntimeError("Output folder is not set") + + logger = InvokeAILogger.get_logger() + image_files = DiskImageFileStorage(config.outputs_path / "images") + db = init_db(config=config, logger=logger, image_files=image_files) + service = ImageMoveService(db=db, image_files=image_files, config=config, logger=logger) + service.set_session_queue(SqliteSessionQueue(db=db)) + return service + + +def _print_result(operation: str, result: ImageMoveResult) -> None: + print( + f"{operation}: planned={result.planned}, committed={result.committed}, errors={result.errors}", + flush=True, + ) + + +def _print_status(service: ImageMoveService) -> bool: + latest_job = service.get_latest_job() + active_job_id = service.get_active_job_id() + if latest_job is None: + print("No image storage maintenance jobs found.", flush=True) + else: + print( + "Latest image storage maintenance job: " + f"id={latest_job.id}, state={latest_job.state}, error={latest_job.error_message or 'none'}", + flush=True, + ) + if active_job_id is not None: + print(f"Active image storage maintenance job: id={active_job_id}", flush=True) + return active_job_id is not None or (latest_job is not None and latest_job.state == "error") + + +def main(argv: Sequence[str] | None = None) -> int: + parser = argparse.ArgumentParser(description="InvokeAI image storage maintenance utility") + parser.add_argument("operation", choices=["status", "recover", "move"], help="Operation to perform.") + parser.add_argument("--root", type=Path, default=None, help="InvokeAI root directory.") + parser.add_argument("--config", dest="config_file", type=Path, default=None, help="Path to invokeai.yaml.") + args = parser.parse_args(argv) + + try: + service = build_image_move_service(root=args.root, config_file=args.config_file) + if args.operation == "status": + requires_attention = _print_status(service) + return 1 if requires_attention else 0 + if args.operation == "recover": + result = service.startup_recovery() + else: + service.assert_no_active_queue_work() + result = service.move_all_images() + _print_result(args.operation, result) + if result.errors > 0 or service.is_maintenance_active(): + print("Image storage maintenance requires operator attention.", file=sys.stderr, flush=True) + return 1 + return 0 + except KeyboardInterrupt: + print("Image storage maintenance canceled.", file=sys.stderr, flush=True) + return 130 + except Exception as e: + print(f"Image storage maintenance failed: {e}", file=sys.stderr, flush=True) + return 1 + + +if __name__ == "__main__": + raise SystemExit(main()) diff --git a/invokeai/frontend/web/public/locales/en.json b/invokeai/frontend/web/public/locales/en.json index d99bb04a631..5344db30de3 100644 --- a/invokeai/frontend/web/public/locales/en.json +++ b/invokeai/frontend/web/public/locales/en.json @@ -1808,6 +1808,24 @@ "imageSubfolderStrategySaveFailed": "Failed to save Image Subfolder Strategy", "imageSubfolderStrategyType": "Type", "imageSubfolderStrategyUnknown": "Unknown ({{strategy}})", + "imageStorageMaintenance": "Image Storage Maintenance", + "imageStorageMaintenanceOperationMove": "Move Images", + "imageStorageMaintenanceOperationNone": "None", + "imageStorageMaintenanceOperationRecovery": "Recovery", + "imageStorageMaintenanceRecover": "Recover", + "imageStorageMaintenanceRecoveryFailed": "Failed to start Image Storage Maintenance recovery", + "imageStorageMaintenanceStart": "Start Move", + "imageStorageMaintenanceStartFailed": "Failed to start Image Storage Maintenance", + "imageStorageMaintenanceStateCommitted": "Committed", + "imageStorageMaintenanceStateError": "Error", + "imageStorageMaintenanceStateMoved": "Moved", + "imageStorageMaintenanceStateMoving": "Moving", + "imageStorageMaintenanceStateNone": "No jobs", + "imageStorageMaintenanceStatePlanned": "Planned", + "imageStorageMaintenanceStatusIdle": "Status: {{state}}", + "imageStorageMaintenanceStatusNeedsRecovery": "Recovery needed for job {{jobId}}", + "imageStorageMaintenanceStatusRunning": "Running: {{operation}}", + "imageStorageMaintenanceStatusUnavailable": "Status unavailable", "maxQueueHistory": "Max Queue History", "maxQueueHistorySaveFailed": "Failed to save Max Queue History", "models": "Models", diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx new file mode 100644 index 00000000000..a85413c9634 --- /dev/null +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx @@ -0,0 +1,153 @@ +import { Button, Flex, FormControl, FormLabel, Text } from '@invoke-ai/ui-library'; +import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; +import { selectCurrentUser } from 'features/auth/store/authSlice'; +import { toast } from 'features/toast/toast'; +import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import { useTranslation } from 'react-i18next'; +import { api, LIST_TAG } from 'services/api'; +import { useGetRuntimeConfigQuery } from 'services/api/endpoints/appInfo'; +import { + useGetImageMoveStatusQuery, + useStartImageMoveMutation, + useStartImageMoveRecoveryMutation, +} from 'services/api/endpoints/imageMoves'; +import type { S } from 'services/api/types'; + +const getOperationKey = (operation: S['ImageMoveStatusResponse']['operation']) => { + if (operation === 'move_all') { + return 'settings.imageStorageMaintenanceOperationMove'; + } + if (operation === 'recovery') { + return 'settings.imageStorageMaintenanceOperationRecovery'; + } + return 'settings.imageStorageMaintenanceOperationNone'; +}; + +const getJobStateKey = (state: S['ImageMoveJobResponse']['state'] | undefined) => { + if (state === 'planned') { + return 'settings.imageStorageMaintenanceStatePlanned'; + } + if (state === 'moving') { + return 'settings.imageStorageMaintenanceStateMoving'; + } + if (state === 'moved') { + return 'settings.imageStorageMaintenanceStateMoved'; + } + if (state === 'committed') { + return 'settings.imageStorageMaintenanceStateCommitted'; + } + if (state === 'error') { + return 'settings.imageStorageMaintenanceStateError'; + } + return 'settings.imageStorageMaintenanceStateNone'; +}; + +export const SettingsImageStorageMaintenance = memo(() => { + const { t } = useTranslation(); + const dispatch = useAppDispatch(); + const currentUser = useAppSelector(selectCurrentUser); + const { data: runtimeConfig } = useGetRuntimeConfigQuery(); + const canAccess = runtimeConfig ? !runtimeConfig.config.multiuser || currentUser?.is_admin : false; + const [startImageMove, startImageMoveState] = useStartImageMoveMutation(); + const [startImageMoveRecovery, startImageMoveRecoveryState] = useStartImageMoveRecoveryMutation(); + const lastInvalidatedJobIdRef = useRef(null); + const { data: status, isFetching } = useGetImageMoveStatusQuery(undefined, { + skip: !canAccess, + pollingInterval: canAccess ? 2000 : 0, + }); + + const isRunning = status?.is_running ?? false; + const latestJob = status?.latest_job; + const hasActiveJob = status?.active_job_id !== null && status?.active_job_id !== undefined; + const isBusy = isRunning || startImageMoveState.isLoading || startImageMoveRecoveryState.isLoading; + + useEffect(() => { + if (!latestJob || latestJob.state !== 'committed' || isRunning) { + return; + } + if (lastInvalidatedJobIdRef.current === latestJob.id) { + return; + } + lastInvalidatedJobIdRef.current = latestJob.id; + dispatch( + api.util.invalidateTags([ + 'Image', + 'ImageList', + 'ImageMetadata', + 'ImageWorkflow', + 'ImageNameList', + 'ImageCollectionCounts', + 'ImageMoveStatus', + { type: 'ImageCollection', id: LIST_TAG }, + ]) + ); + }, [dispatch, isRunning, latestJob]); + + const statusText = useMemo(() => { + if (!status) { + return t('settings.imageStorageMaintenanceStatusUnavailable'); + } + if (hasActiveJob && !isRunning) { + return t('settings.imageStorageMaintenanceStatusNeedsRecovery', { jobId: status.active_job_id }); + } + if (isRunning) { + return t('settings.imageStorageMaintenanceStatusRunning', { operation: t(getOperationKey(status.operation)) }); + } + return t('settings.imageStorageMaintenanceStatusIdle', { state: t(getJobStateKey(latestJob?.state)) }); + }, [hasActiveJob, isRunning, latestJob?.state, status, t]); + + const onStart = useCallback(async () => { + try { + await startImageMove().unwrap(); + } catch { + toast({ + id: 'IMAGE_STORAGE_MAINTENANCE_START_FAILED', + title: t('settings.imageStorageMaintenanceStartFailed'), + status: 'error', + }); + } + }, [startImageMove, t]); + + const onRecover = useCallback(async () => { + try { + await startImageMoveRecovery().unwrap(); + } catch { + toast({ + id: 'IMAGE_STORAGE_MAINTENANCE_RECOVERY_FAILED', + title: t('settings.imageStorageMaintenanceRecoveryFailed'), + status: 'error', + }); + } + }, [startImageMoveRecovery, t]); + + if (!canAccess) { + return null; + } + + return ( + + {t('settings.imageStorageMaintenance')} + + + + + {statusText} + {latestJob?.error_message ? {latestJob.error_message} : null} + + ); +}); + +SettingsImageStorageMaintenance.displayName = 'SettingsImageStorageMaintenance'; diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx index 64478953a37..2fa8d6fde29 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsModal.tsx @@ -28,6 +28,7 @@ import { useRefreshAfterResetModal } from 'features/system/components/SettingsMo import { SettingsDeveloperLogIsEnabled } from 'features/system/components/SettingsModal/SettingsDeveloperLogIsEnabled'; import { SettingsDeveloperLogLevel } from 'features/system/components/SettingsModal/SettingsDeveloperLogLevel'; import { SettingsDeveloperLogNamespaces } from 'features/system/components/SettingsModal/SettingsDeveloperLogNamespaces'; +import { SettingsImageStorageMaintenance } from 'features/system/components/SettingsModal/SettingsImageStorageMaintenance'; import { SettingsImageSubfolderStrategySelect } from 'features/system/components/SettingsModal/SettingsImageSubfolderStrategySelect'; import { useClearIntermediates } from 'features/system/components/SettingsModal/useClearIntermediates'; import { StickyScrollable } from 'features/system/components/StickyScrollable'; @@ -321,6 +322,7 @@ const SettingsModal = (props: { children: ReactElement }) => { + diff --git a/invokeai/frontend/web/src/services/api/endpoints/imageMoves.ts b/invokeai/frontend/web/src/services/api/endpoints/imageMoves.ts new file mode 100644 index 00000000000..444ecf2379e --- /dev/null +++ b/invokeai/frontend/web/src/services/api/endpoints/imageMoves.ts @@ -0,0 +1,42 @@ +import type { paths } from 'services/api/schema'; + +import { api, buildV1Url, LIST_TAG } from '..'; + +const buildImageMovesUrl = (path: string = '') => buildV1Url(`image_moves/${path}`); + +type ImageMoveStatusResponse = + paths['/api/v1/image_moves/status']['get']['responses']['200']['content']['application/json']; + +const imageMovesApi = api.injectEndpoints({ + endpoints: (build) => ({ + getImageMoveStatus: build.query({ + query: () => ({ + url: buildImageMovesUrl('status'), + method: 'GET', + }), + providesTags: ['ImageMoveStatus', 'FetchOnReconnect'], + }), + startImageMove: build.mutation({ + query: () => ({ + url: buildImageMovesUrl('start'), + method: 'POST', + }), + invalidatesTags: ['ImageMoveStatus'], + }), + startImageMoveRecovery: build.mutation({ + query: () => ({ + url: buildImageMovesUrl('recover'), + method: 'POST', + }), + invalidatesTags: [ + 'ImageMoveStatus', + 'ImageNameList', + 'ImageCollectionCounts', + { type: 'ImageCollection', id: LIST_TAG }, + ], + }), + }), +}); + +export const { useGetImageMoveStatusQuery, useStartImageMoveMutation, useStartImageMoveRecoveryMutation } = + imageMovesApi; diff --git a/invokeai/frontend/web/src/services/api/index.ts b/invokeai/frontend/web/src/services/api/index.ts index a586273f3a7..8df82602e8c 100644 --- a/invokeai/frontend/web/src/services/api/index.ts +++ b/invokeai/frontend/web/src/services/api/index.ts @@ -23,6 +23,7 @@ const tagTypes = [ 'ImageList', 'ImageMetadata', 'ImageWorkflow', + 'ImageMoveStatus', 'ImageCollectionCounts', 'ImageCollection', 'ImageMetadataFromFile', diff --git a/scripts/image_storage_maintenance.py b/scripts/image_storage_maintenance.py new file mode 100644 index 00000000000..90e864a7144 --- /dev/null +++ b/scripts/image_storage_maintenance.py @@ -0,0 +1,5 @@ +#!/usr/bin/env python3 + +from invokeai.backend.util.image_storage_maintenance import main + +raise SystemExit(main()) diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index 032954fda97..9a623154d84 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -192,6 +192,33 @@ def test_background_recovery_can_start_when_journal_job_is_active(tmp_path: Path assert service.get_job(job_id).state == "committed" +def test_start_runs_recovery_before_normal_operation(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-startup-recovery.png" + _save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple") + job_id = service.create_move_job(service.plan_batch(last_image_name="", limit=100)) + service.perform_filesystem_moves(job_id) + + service.start(MagicMock()) + + assert records.get(image_name).image_subfolder == "2024/03/05" + assert service.get_job(job_id).state == "committed" + assert service.is_maintenance_active() is False + + +def test_start_leaves_maintenance_active_when_recovery_remains_incomplete(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-startup-recovery-retry.png" + _save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple") + service.create_move_job(service.plan_batch(last_image_name="", limit=100)) + + with patch.object(service, "complete_partial_filesystem_moves", side_effect=OSError("temporary failure")): + service.start(MagicMock()) + + assert records.get(image_name).image_subfolder == "" + assert service.is_maintenance_active() is True + + @pytest.mark.parametrize(("pending", "in_progress"), [(1, 0), (0, 1)]) def test_background_move_rejects_active_queue_work(tmp_path: Path, pending: int, in_progress: int) -> None: service, _records = _service(tmp_path, strategy="date") @@ -267,14 +294,20 @@ def wait_for_release() -> None: def test_background_worker_error_is_exposed_in_status(tmp_path: Path) -> None: service, _records = _service(tmp_path, strategy="date") + started_worker = threading.Event() + release_worker = threading.Event() def raise_error() -> None: + started_worker.set() + release_worker.wait(timeout=5) raise RuntimeError("background failed") status = service._start_background_operation("move_all", raise_error) + assert started_worker.wait(timeout=5) is True assert status.is_running is True assert service._future is not None + release_worker.set() service._future.result(timeout=5) status = service.get_background_status() diff --git a/tests/app/services/test_image_move_startup_safety.py b/tests/app/services/test_image_move_startup_safety.py new file mode 100644 index 00000000000..f7f673322ea --- /dev/null +++ b/tests/app/services/test_image_move_startup_safety.py @@ -0,0 +1,76 @@ +from unittest.mock import MagicMock + +from invokeai.app.services.invocation_services import InvocationServices +from invokeai.app.services.invoker import Invoker +from invokeai.app.services.session_processor.session_processor_default import DefaultSessionProcessor + + +def _services(**overrides): + services = { + "board_image_records": object(), + "board_images": object(), + "board_records": object(), + "boards": object(), + "bulk_download": object(), + "configuration": object(), + "events": object(), + "images": object(), + "image_files": object(), + "image_records": object(), + "logger": object(), + "model_images": object(), + "model_manager": object(), + "model_relationships": object(), + "model_relationship_records": object(), + "download_queue": object(), + "external_generation": object(), + "performance_statistics": object(), + "session_queue": object(), + "session_processor": object(), + "invocation_cache": object(), + "names": object(), + "urls": object(), + "workflow_records": object(), + "tensors": object(), + "conditioning": object(), + "style_preset_records": object(), + "style_preset_image_files": object(), + "workflow_thumbnails": object(), + "client_state_persistence": object(), + "users": object(), + "image_moves": None, + } + services.update(overrides) + return InvocationServices(**services) + + +def test_image_moves_start_before_session_processor() -> None: + started: list[str] = [] + image_moves = MagicMock() + image_moves.start.side_effect = lambda _invoker: started.append("image_moves") + session_processor = MagicMock() + session_processor.start.side_effect = lambda _invoker: started.append("session_processor") + + Invoker(_services(image_moves=image_moves, session_processor=session_processor)) + + assert started == ["image_moves", "session_processor"] + + +def test_session_processor_detects_active_image_move_maintenance() -> None: + image_moves = MagicMock() + image_moves.is_maintenance_active.return_value = True + processor = DefaultSessionProcessor() + processor._invoker = MagicMock() + processor._invoker.services.image_moves = image_moves + + assert processor._is_image_move_maintenance_active() is True + + +def test_session_processor_allows_processing_without_image_move_maintenance() -> None: + image_moves = MagicMock() + image_moves.is_maintenance_active.return_value = False + processor = DefaultSessionProcessor() + processor._invoker = MagicMock() + processor._invoker.services.image_moves = image_moves + + assert processor._is_image_move_maintenance_active() is False diff --git a/tests/backend/util/test_image_storage_maintenance.py b/tests/backend/util/test_image_storage_maintenance.py new file mode 100644 index 00000000000..8ed01f3f5cb --- /dev/null +++ b/tests/backend/util/test_image_storage_maintenance.py @@ -0,0 +1,108 @@ +from dataclasses import dataclass + +from invokeai.app.services.image_moves.image_moves_default import ImageMoveJob, ImageMoveQueueActive, ImageMoveResult +from invokeai.backend.util import image_storage_maintenance + + +@dataclass +class _FakeImageMoveService: + result: ImageMoveResult = ImageMoveResult() + active: bool = False + latest_job: ImageMoveJob | None = None + recovered: bool = False + moved: bool = False + checked_queue: bool = False + + def startup_recovery(self) -> ImageMoveResult: + self.recovered = True + return self.result + + def move_all_images(self) -> ImageMoveResult: + self.moved = True + return self.result + + def assert_no_active_queue_work(self) -> None: + self.checked_queue = True + + def is_maintenance_active(self) -> bool: + return self.active + + def get_latest_job(self) -> ImageMoveJob | None: + return self.latest_job + + def get_active_job_id(self) -> int | None: + return self.latest_job.id if self.active and self.latest_job is not None else None + + +def test_script_recover_uses_shared_image_move_service(monkeypatch, capsys) -> None: + service = _FakeImageMoveService(result=ImageMoveResult(committed=2)) + monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) + + exit_code = image_storage_maintenance.main(["recover", "--root", "/tmp/invokeai"]) + + assert exit_code == 0 + assert service.recovered is True + assert service.moved is False + assert "recover: planned=0, committed=2, errors=0" in capsys.readouterr().out + + +def test_script_move_exits_nonzero_when_job_remains_active(monkeypatch, capsys) -> None: + service = _FakeImageMoveService( + result=ImageMoveResult(planned=1, committed=0, errors=1), + active=True, + latest_job=ImageMoveJob(id=1, state="planned", error_message="temporary failure"), + ) + monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) + + exit_code = image_storage_maintenance.main(["move"]) + + assert exit_code == 1 + assert service.checked_queue is True + assert service.moved is True + assert "requires operator attention" in capsys.readouterr().err + + +def test_script_move_rejects_active_queue_work(monkeypatch, capsys) -> None: + service = _FakeImageMoveService() + + def raise_active_queue() -> None: + service.checked_queue = True + raise ImageMoveQueueActive("queue work is active") + + service.assert_no_active_queue_work = raise_active_queue + monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) + + exit_code = image_storage_maintenance.main(["move"]) + + assert exit_code == 1 + assert service.checked_queue is True + assert service.moved is False + assert "queue work is active" in capsys.readouterr().err + + +def test_script_status_reports_active_job(monkeypatch, capsys) -> None: + service = _FakeImageMoveService( + active=True, + latest_job=ImageMoveJob(id=7, state="planned", error_message="temporary failure"), + ) + monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) + + exit_code = image_storage_maintenance.main(["status"]) + + assert exit_code == 1 + output = capsys.readouterr().out + assert "id=7, state=planned, error=temporary failure" in output + assert "Active image storage maintenance job: id=7" in output + + +def test_script_status_exits_nonzero_for_error_job(monkeypatch, capsys) -> None: + service = _FakeImageMoveService( + active=False, + latest_job=ImageMoveJob(id=8, state="error", error_message="ambiguous filesystem state"), + ) + monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) + + exit_code = image_storage_maintenance.main(["status"]) + + assert exit_code == 1 + assert "id=8, state=error, error=ambiguous filesystem state" in capsys.readouterr().out From 47f6cf2ccc2449a0f80f95e06b9fa30b521c8a50 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 12 May 2026 19:30:45 -0500 Subject: [PATCH 06/12] Fix image move maintenance edge cases --- invokeai/app/api/routers/images.py | 1 + .../services/image_files/image_files_disk.py | 6 ++--- .../image_moves/image_moves_default.py | 1 + tests/app/routers/test_images.py | 1 + .../image_files/test_image_files_disk.py | 4 +++- .../image_moves/test_image_moves_default.py | 24 +++++++++++++++++++ 6 files changed, 32 insertions(+), 5 deletions(-) diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index c67d93b0367..f49ab5aeae8 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -397,6 +397,7 @@ async def get_image_workflow( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image whose workflow to get"), ) -> WorkflowAndGraphResponse: + assert_image_move_maintenance_inactive() _assert_image_read_access(image_name, current_user) try: diff --git a/invokeai/app/services/image_files/image_files_disk.py b/invokeai/app/services/image_files/image_files_disk.py index 278a965b97e..c54f767b164 100644 --- a/invokeai/app/services/image_files/image_files_disk.py +++ b/invokeai/app/services/image_files/image_files_disk.py @@ -97,8 +97,7 @@ def save( compress_level=self.__invoker.services.configuration.pil_compress_level, ) - thumbnail_name = get_thumbnail_name(image_name) - thumbnail_path = self.get_path(thumbnail_name, thumbnail=True, image_subfolder=image_subfolder) + thumbnail_path = self.get_path(image_name, thumbnail=True, image_subfolder=image_subfolder) # Ensure thumbnail subfolder directories exist thumbnail_path.parent.mkdir(parents=True, exist_ok=True) @@ -120,8 +119,7 @@ def delete(self, image_name: str, image_subfolder: str = "") -> None: if image_path in self.__cache: del self.__cache[image_path] - thumbnail_name = get_thumbnail_name(image_name) - thumbnail_path = self.get_path(thumbnail_name, True, image_subfolder=image_subfolder) + thumbnail_path = self.get_path(image_name, True, image_subfolder=image_subfolder) if thumbnail_path.exists(): thumbnail_path.unlink() diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index 7ce0c852788..37a25194c77 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -249,6 +249,7 @@ def startup_recovery(self) -> ImageMoveResult: for job_id in job_ids: try: self.complete_partial_filesystem_moves(job_id) + self.cleanup_empty_source_dirs(job_id) self.commit_database_updates(job_id) committed += len(self._get_items(job_id)) except Exception as e: diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index 6209a63863f..c35cdf0cbf9 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -82,6 +82,7 @@ def prepare_image_maintenance_test(monkeypatch: Any, mock_invoker: Invoker) -> N ("get", "/api/v1/images/i/test.png/full", None), ("head", "/api/v1/images/i/test.png/full", None), ("get", "/api/v1/images/i/test.png/thumbnail", None), + ("get", "/api/v1/images/i/test.png/workflow", None), ("delete", "/api/v1/images/i/test.png", None), ("delete", "/api/v1/images/intermediates", None), ("delete", "/api/v1/images/uncategorized", None), diff --git a/tests/app/services/image_files/test_image_files_disk.py b/tests/app/services/image_files/test_image_files_disk.py index 9db9f7ba5d0..58c1a78635f 100644 --- a/tests/app/services/image_files/test_image_files_disk.py +++ b/tests/app/services/image_files/test_image_files_disk.py @@ -134,7 +134,9 @@ def test_save_and_delete_with_subfolder(self, disk_storage: DiskImageFileStorage # Thumbnail file exists in mirrored subfolder thumbnail_name = get_thumbnail_name(image_name) - thumb_path = disk_storage.get_path(thumbnail_name, thumbnail=True, image_subfolder=subfolder) + thumb_path = disk_storage.get_path(image_name, thumbnail=True, image_subfolder=subfolder) + assert thumb_path.name == thumbnail_name + assert not thumb_path.name.startswith("thumbnail_thumbnail_") assert thumb_path.exists() # Round-trip read diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index 9a623154d84..c0605655ea8 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -129,6 +129,30 @@ def test_cleanup_empty_source_directories_after_move(tmp_path: Path) -> None: assert service.image_files.thumbnail_root.exists() +def test_startup_recovery_cleans_empty_source_directories(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "image-recovery-cleanup.png" + old_subfolder = "old/recovery" + _save_image(service, records, image_name, old_subfolder, "2024-11-13 01:02:03.000", "green") + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + move = moves[0] + old_parent = service.image_files.get_path(image_name, image_subfolder=old_subfolder).parent + old_thumb_parent = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder).parent + move.new_path.parent.mkdir(parents=True, exist_ok=True) + move.new_thumbnail_path.parent.mkdir(parents=True, exist_ok=True) + move.old_path.replace(move.new_path) + move.old_thumbnail_path.replace(move.new_thumbnail_path) + + recovered = service.startup_recovery() + + assert recovered.committed == 1 + assert recovered.errors == 0 + assert not old_parent.exists() + assert not old_thumb_parent.exists() + assert service.get_job(job_id).state == "committed" + + def test_preflight_rejects_active_uncommitted_job_for_same_image(tmp_path: Path) -> None: service, records = _service(tmp_path, strategy="date") image_name = "image-d.png" From 3d4d729381f1e82e004c1809b903c18141e9569e Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Tue, 12 May 2026 19:36:05 -0500 Subject: [PATCH 07/12] Added TODO for creating global locking --- invokeai/backend/util/image_storage_maintenance.py | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/backend/util/image_storage_maintenance.py b/invokeai/backend/util/image_storage_maintenance.py index d62ba868f00..258c1e34c87 100644 --- a/invokeai/backend/util/image_storage_maintenance.py +++ b/invokeai/backend/util/image_storage_maintenance.py @@ -64,6 +64,7 @@ def main(argv: Sequence[str] | None = None) -> int: try: service = build_image_move_service(root=args.root, config_file=args.config_file) + # TODO: Add an interprocess guard so this script cannot run image moves while Invoke is active. if args.operation == "status": requires_attention = _print_status(service) return 1 if requires_attention else 0 From f8578503477c5b5e806213a9864cd73dfe76a087 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Wed, 13 May 2026 09:01:54 -0500 Subject: [PATCH 08/12] Handle missing intermediate image moves --- .../features/image-storage-maintenance.mdx | 2 + .../image_moves/image_moves_default.py | 39 ++++++- .../migrations/migration_32.py | 1 + .../image_moves/test_image_moves_default.py | 101 +++++++++++++++++- 4 files changed, 138 insertions(+), 5 deletions(-) diff --git a/docs/src/content/docs/features/image-storage-maintenance.mdx b/docs/src/content/docs/features/image-storage-maintenance.mdx index cec83ec4ff7..c846a7411df 100644 --- a/docs/src/content/docs/features/image-storage-maintenance.mdx +++ b/docs/src/content/docs/features/image-storage-maintenance.mdx @@ -33,6 +33,8 @@ The move process is crash-recoverable. InvokeAI records each move job in its dat If InvokeAI stops during a move, recovery resumes incomplete jobs. If recovery finds an ambiguous filesystem state, such as both the old and new full-size image files existing or neither file existing, it halts the job for manual repair instead of blindly updating the database. +Missing intermediate image files are treated as already cleaned up and do not halt the move. Missing non-intermediate image files still require operator attention. + For the `date` strategy, existing images are moved according to their original image creation timestamp stored in the database, not according to the time the maintenance job is run. Empty source directories left behind by successful moves are removed when safe. diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index 37a25194c77..aab4d350e83 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -26,6 +26,7 @@ class PlannedImageMove: image_name: str old_subfolder: str new_subfolder: str + is_intermediate: bool old_path: Path new_path: Path old_thumbnail_path: Path @@ -292,6 +293,7 @@ def plan_batch(self, last_image_name: str, limit: int) -> list[PlannedImageMove] image_name=image_name, old_subfolder=old_subfolder, new_subfolder=new_subfolder, + is_intermediate=bool(row["is_intermediate"]), old_path=self.image_files.get_path(image_name, image_subfolder=old_subfolder), new_path=self.image_files.get_path(image_name, image_subfolder=new_subfolder), old_thumbnail_path=self.image_files.get_path( @@ -328,12 +330,13 @@ def create_move_job(self, moves: Sequence[PlannedImageMove]) -> int: image_name, old_subfolder, new_subfolder, + is_intermediate, old_path, new_path, old_thumbnail_path, new_thumbnail_path, state - ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, 'planned'); + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'planned'); """, [ ( @@ -341,6 +344,7 @@ def create_move_job(self, moves: Sequence[PlannedImageMove]) -> int: move.image_name, move.old_subfolder, move.new_subfolder, + int(move.is_intermediate), str(move.old_path), str(move.new_path), str(move.old_thumbnail_path), @@ -356,7 +360,9 @@ def preflight_moves(self, moves: Sequence[PlannedImageMove]) -> None: thumbnail_destinations: set[Path] = set() for move in moves: if not move.old_path.exists(): - raise FileNotFoundError(f"Source image does not exist: {move.old_path}") + if not move.is_intermediate: + raise FileNotFoundError(f"Source image does not exist: {move.old_path}") + continue if move.new_path.exists(): raise FileExistsError(f"Destination image already exists: {move.new_path}") if move.old_path == move.new_path: @@ -399,6 +405,16 @@ def complete_partial_filesystem_moves(self, job_id: int) -> None: if old_exists and new_exists: raise RuntimeError(f"Both old and new image files exist for {item.image_name}") if not old_exists and not new_exists: + if item.is_intermediate: + self._mark_missing_intermediate_moved( + job_id=job_id, + image_name=item.image_name, + old_path=old_path, + new_path=new_path, + old_thumbnail_path=old_thumbnail_path, + new_thumbnail_path=new_thumbnail_path, + ) + continue raise RuntimeError(f"Neither old nor new image file exists for {item.image_name}") if old_exists: new_path.parent.mkdir(parents=True, exist_ok=True) @@ -562,7 +578,7 @@ def _get_items(self, job_id: int) -> list[PlannedImageMove]: with self._db.transaction() as cursor: cursor.execute( """--sql - SELECT image_name, old_subfolder, new_subfolder + SELECT image_name, old_subfolder, new_subfolder, is_intermediate FROM image_subfolder_move_items WHERE job_id = ? ORDER BY image_name; @@ -575,6 +591,7 @@ def _get_items(self, job_id: int) -> list[PlannedImageMove]: image_name=row["image_name"], old_subfolder=row["old_subfolder"], new_subfolder=row["new_subfolder"], + is_intermediate=bool(row["is_intermediate"]), old_path=self.image_files.get_path(row["image_name"], image_subfolder=row["old_subfolder"]), new_path=self.image_files.get_path(row["image_name"], image_subfolder=row["new_subfolder"]), old_thumbnail_path=self.image_files.get_path( @@ -652,6 +669,22 @@ def _regenerate_thumbnail(self, image_path: Path, thumbnail_path: Path) -> None: finally: temp_path.unlink(missing_ok=True) + def _mark_missing_intermediate_moved( + self, + job_id: int, + image_name: str, + old_path: Path, + new_path: Path, + old_thumbnail_path: Path, + new_thumbnail_path: Path, + ) -> None: + for path in (old_thumbnail_path, new_thumbnail_path): + if path.exists(): + path.unlink() + self._fsync_dir(path.parent) + self.image_files.evict_cache_paths([old_path, new_path, old_thumbnail_path, new_thumbnail_path]) + self.mark_item_moved(job_id, image_name) + def _remove_empty_parents(self, start: Path, root: Path) -> None: root = root.resolve() current = start.resolve() diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py index 09e09e8f783..d15413b4ac7 100644 --- a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_32.py @@ -25,6 +25,7 @@ def __call__(self, cursor: sqlite3.Cursor) -> None: image_name TEXT NOT NULL REFERENCES images(image_name), old_subfolder TEXT NOT NULL, new_subfolder TEXT NOT NULL, + is_intermediate BOOLEAN NOT NULL DEFAULT FALSE, old_path TEXT, new_path TEXT, old_thumbnail_path TEXT, diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index c0605655ea8..4d4f8666712 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -29,7 +29,13 @@ def _build_db(tmp_path: Path) -> SqliteDatabase: return init_db(config=config, logger=logger, image_files=image_files) -def _save_record(records: SqliteImageRecordStorage, image_name: str, subfolder: str, created_at: str) -> None: +def _save_record( + records: SqliteImageRecordStorage, + image_name: str, + subfolder: str, + created_at: str, + is_intermediate: bool = False, +) -> None: records.save( image_name=image_name, image_origin=ResourceOrigin.INTERNAL, @@ -37,6 +43,7 @@ def _save_record(records: SqliteImageRecordStorage, image_name: str, subfolder: width=16, height=16, has_workflow=False, + is_intermediate=is_intermediate, image_subfolder=subfolder, ) with records._db.transaction() as cursor: @@ -50,8 +57,15 @@ def _save_image( subfolder: str, created_at: str, color: str, + is_intermediate: bool = False, ) -> None: - _save_record(records, image_name=image_name, subfolder=subfolder, created_at=created_at) + _save_record( + records, + image_name=image_name, + subfolder=subfolder, + created_at=created_at, + is_intermediate=is_intermediate, + ) service.image_files.save(Image.new("RGB", (16, 16), color), image_name=image_name, image_subfolder=subfolder) @@ -93,6 +107,89 @@ def test_move_all_images_uses_created_at_for_date_strategy(tmp_path: Path) -> No assert not service.image_files.get_path(image_name, image_subfolder="").exists() +def test_missing_intermediate_source_file_is_treated_as_success(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "missing-intermediate.png" + _save_record( + records, + image_name=image_name, + subfolder="", + created_at="2024-02-04 04:05:06.000", + is_intermediate=True, + ) + + result = service.move_all_images() + + assert result.planned == 1 + assert result.committed == 1 + assert result.errors == 0 + record = records.get(image_name) + assert record.image_subfolder == "2024/02/04" + assert service.get_latest_job().state == "committed" + + +def test_missing_intermediate_source_file_removes_orphaned_thumbnail(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "missing-intermediate-with-thumbnail.png" + old_subfolder = "old/intermediate" + _save_image( + service, + records, + image_name=image_name, + subfolder=old_subfolder, + created_at="2024-02-04 04:05:06.000", + color="red", + is_intermediate=True, + ) + old_path = service.image_files.get_path(image_name, image_subfolder=old_subfolder) + old_thumbnail_path = service.image_files.get_path(image_name, thumbnail=True, image_subfolder=old_subfolder) + assert old_thumbnail_path.exists() + old_path.unlink() + + result = service.move_all_images() + + assert result.committed == 1 + assert not old_thumbnail_path.exists() + assert records.get(image_name).image_subfolder == "2024/02/04" + + +def test_missing_non_intermediate_source_file_still_fails(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "missing-general.png" + _save_record( + records, + image_name=image_name, + subfolder="", + created_at="2024-02-04 04:05:06.000", + is_intermediate=False, + ) + + with pytest.raises(FileNotFoundError, match="Source image does not exist"): + service.move_all_images() + + +def test_recovery_treats_missing_intermediate_source_file_as_success(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + image_name = "missing-intermediate-recovery.png" + _save_record( + records, + image_name=image_name, + subfolder="", + created_at="2024-02-05 04:05:06.000", + is_intermediate=True, + ) + moves = service.plan_batch(last_image_name="", limit=100) + job_id = service.create_move_job(moves) + + recovered = service.startup_recovery() + + assert recovered.committed == 1 + assert recovered.errors == 0 + assert records.get(image_name).image_subfolder == "2024/02/05" + assert service.get_job(job_id).state == "committed" + assert _job_item_states(service, job_id) == {image_name: "committed"} + + def test_startup_recovery_commits_after_files_moved_but_db_not_updated(tmp_path: Path) -> None: service, records = _service(tmp_path, strategy="date") image_name = "image-b.png" From 0da7e986a90a83ab83e3afb40095cf00f050f9b5 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Wed, 13 May 2026 10:37:27 -0500 Subject: [PATCH 09/12] Bugfixes and removal of the external utility program --- .../features/image-storage-maintenance.mdx | 10 +- invokeai/app/api/routers/boards.py | 4 + invokeai/app/api/routers/images.py | 14 ++- invokeai/app/api/routers/recall_parameters.py | 4 +- .../image_moves/image_moves_default.py | 76 ++++++++++-- .../backend/util/image_storage_maintenance.py | 90 --------------- .../SettingsImageStorageMaintenance.tsx | 26 +++-- scripts/image_storage_maintenance.py | 5 - .../routers/test_board_images_maintenance.py | 36 ++++++ .../image_moves/test_image_moves_default.py | 72 ++++++++++-- .../util/test_image_storage_maintenance.py | 108 ------------------ 11 files changed, 197 insertions(+), 248 deletions(-) delete mode 100644 invokeai/backend/util/image_storage_maintenance.py delete mode 100644 scripts/image_storage_maintenance.py delete mode 100644 tests/backend/util/test_image_storage_maintenance.py diff --git a/docs/src/content/docs/features/image-storage-maintenance.mdx b/docs/src/content/docs/features/image-storage-maintenance.mdx index c846a7411df..4ae0a4611e6 100644 --- a/docs/src/content/docs/features/image-storage-maintenance.mdx +++ b/docs/src/content/docs/features/image-storage-maintenance.mdx @@ -13,19 +13,11 @@ Image storage maintenance is available from the in-application Settings panel. Administrators can start image moves, force recovery, and inspect move job details. If multi-user mode is disabled, the single local user has the same access. Non-admin users in multi-user mode cannot run or inspect image move jobs. -The same move implementation is also available through `scripts/image_storage_maintenance.py` for administrators who need to inspect status, run startup recovery, or start an image move outside the web UI: - -```bash -python scripts/image_storage_maintenance.py status --root /path/to/invokeai -python scripts/image_storage_maintenance.py recover --root /path/to/invokeai -python scripts/image_storage_maintenance.py move --root /path/to/invokeai -``` - ## Maintenance Mode Image moves run as a maintenance operation. Before a move starts, InvokeAI checks that no queue work is active. While the move is running, InvokeAI prevents image reads, uploads, deletes, generation jobs, and gallery mutations from racing with the filesystem move. -The UI shows the move or recovery state until the job is complete or requires manual attention. +The UI shows the move or recovery state until the job is complete or requires manual attention. Gallery images and thumbnails may be unavailable while maintenance is active. ## Crash Recovery diff --git a/invokeai/app/api/routers/boards.py b/invokeai/app/api/routers/boards.py index 6897e90aff4..067936834ee 100644 --- a/invokeai/app/api/routers/boards.py +++ b/invokeai/app/api/routers/boards.py @@ -6,6 +6,7 @@ from invokeai.app.api.auth_dependencies import CurrentUserOrDefault from invokeai.app.api.dependencies import ApiDependencies +from invokeai.app.api.routers.image_move_maintenance import assert_image_move_maintenance_inactive from invokeai.app.services.board_records.board_records_common import BoardChanges, BoardRecordOrderBy, BoardVisibility from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.image_records.image_records_common import ImageCategory @@ -118,6 +119,7 @@ async def delete_board( try: if include_images is True: + assert_image_move_maintenance_inactive() deleted_images = ApiDependencies.invoker.services.board_images.get_all_board_image_names_for_board( board_id=board_id, categories=None, @@ -142,6 +144,8 @@ async def delete_board( deleted_board_images=deleted_board_images, deleted_images=[], ) + except HTTPException: + raise except Exception: raise HTTPException(status_code=500, detail="Failed to delete board") diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index f49ab5aeae8..784a8d4f257 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -278,8 +278,8 @@ async def delete_image( image_name: str = Path(description="The name of the image to delete"), ) -> DeleteImagesResult: """Deletes an image""" - assert_image_move_maintenance_inactive() _assert_image_owner(image_name, current_user) + assert_image_move_maintenance_inactive() deleted_images: set[str] = set() affected_boards: set[str] = set() @@ -305,9 +305,9 @@ async def clear_intermediates( current_user: CurrentUserOrDefault, ) -> int: """Clears all intermediates. Requires admin.""" - assert_image_move_maintenance_inactive() if not current_user.is_admin: raise HTTPException(status_code=403, detail="Only admins can clear all intermediates") + assert_image_move_maintenance_inactive() try: count_deleted = ApiDependencies.invoker.services.images.delete_intermediates() @@ -340,8 +340,8 @@ async def update_image( image_changes: ImageRecordChanges = Body(description="The changes to apply to the image"), ) -> ImageDTO: """Updates an image""" - assert_image_move_maintenance_inactive() _assert_image_owner(image_name, current_user) + assert_image_move_maintenance_inactive() try: return ApiDependencies.invoker.services.images.update(image_name, image_changes) @@ -397,8 +397,8 @@ async def get_image_workflow( current_user: CurrentUserOrDefault, image_name: str = Path(description="The name of image whose workflow to get"), ) -> WorkflowAndGraphResponse: - assert_image_move_maintenance_inactive() _assert_image_read_access(image_name, current_user) + assert_image_move_maintenance_inactive() try: workflow = ApiDependencies.invoker.services.images.get_workflow(image_name) @@ -439,7 +439,8 @@ async def get_image_full( This endpoint is intentionally unauthenticated because browsers load images via tags which cannot send Bearer tokens. Image names are UUIDs, - providing security through unguessability. + providing security through unguessability. Returns 409 while image storage + maintenance is active. """ assert_image_move_maintenance_inactive() @@ -474,7 +475,8 @@ async def get_image_thumbnail( This endpoint is intentionally unauthenticated because browsers load images via tags which cannot send Bearer tokens. Image names are UUIDs, - providing security through unguessability. + providing security through unguessability. Returns 409 while image storage + maintenance is active. """ assert_image_move_maintenance_inactive() diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py index 20c04112d8c..ec3c93e1ade 100644 --- a/invokeai/app/api/routers/recall_parameters.py +++ b/invokeai/app/api/routers/recall_parameters.py @@ -437,11 +437,11 @@ async def update_recall_parameters( # are cleared. In non-strict mode (default) they would be left as-is. """ logger = ApiDependencies.invoker.services.logger - assert_image_move_maintenance_inactive() - # Validate image access before processing — prevents information leakage + # Validate image access before processing - prevents information leakage # (dimensions) and derived-image minting via ControlNet preprocessors. _assert_recall_image_access(parameters, current_user) + assert_image_move_maintenance_inactive() try: # In strict mode, include all parameters so the frontend clears anything diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index aab4d350e83..eead858c582 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -64,9 +64,6 @@ class ImageMoveQueueActive(Exception): pass -BACKGROUND_SHUTDOWN_ERROR = "Image move service stopped while background operation was running" - - class ImageMoveService: def __init__( self, @@ -102,11 +99,7 @@ def set_session_queue(self, session_queue) -> None: self._session_queue = session_queue def stop(self, *args, **kwargs) -> None: - with self._future_lock: - is_running = self._future is not None and not self._future.done() - if is_running: - self._record_background_error(BACKGROUND_SHUTDOWN_ERROR) - self._executor.shutdown(wait=False, cancel_futures=False) + self._executor.shutdown(wait=True, cancel_futures=False) def start_background_move_all(self) -> ImageMoveBackgroundStatus: return self._start_background_operation("move_all", self.move_all_images, require_idle_queue=True) @@ -212,7 +205,10 @@ def move_all_images(self) -> ImageMoveResult: errors = recovered.errors while True: - moves = self.plan_batch(last_image_name=last_image_name, limit=100) + moves, plan_errors = self._plan_batch( + last_image_name=last_image_name, limit=100, record_missing_errors=True + ) + errors += plan_errors if not moves: next_name = self._next_image_name(last_image_name) if next_name is None: @@ -262,6 +258,12 @@ def startup_recovery(self) -> ImageMoveResult: return ImageMoveResult(committed=committed, errors=errors) def plan_batch(self, last_image_name: str, limit: int) -> list[PlannedImageMove]: + moves, _errors = self._plan_batch(last_image_name=last_image_name, limit=limit, record_missing_errors=False) + return moves + + def _plan_batch( + self, last_image_name: str, limit: int, record_missing_errors: bool + ) -> tuple[list[PlannedImageMove], int]: with self._db.transaction() as cursor: cursor.execute( """--sql @@ -304,8 +306,11 @@ def plan_batch(self, last_image_name: str, limit: int) -> list[PlannedImageMove] ), ) ) + errors = 0 + if record_missing_errors: + moves, errors = self._record_missing_source_errors(moves) self.preflight_moves(moves) - return moves + return moves, errors def create_move_job(self, moves: Sequence[PlannedImageMove]) -> int: if not moves: @@ -355,6 +360,44 @@ def create_move_job(self, moves: Sequence[PlannedImageMove]) -> int: ) return job_id + def create_error_move_job(self, move: PlannedImageMove, message: str) -> int: + with self._db.transaction() as cursor: + cursor.execute( + "INSERT INTO image_subfolder_move_jobs (state, error_message) VALUES ('error', ?);", + (message,), + ) + job_id = cast(int, cursor.lastrowid) + cursor.execute( + """--sql + INSERT INTO image_subfolder_move_items ( + job_id, + image_name, + old_subfolder, + new_subfolder, + is_intermediate, + old_path, + new_path, + old_thumbnail_path, + new_thumbnail_path, + state, + error_message + ) VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, 'error', ?); + """, + ( + job_id, + move.image_name, + move.old_subfolder, + move.new_subfolder, + int(move.is_intermediate), + str(move.old_path), + str(move.new_path), + str(move.old_thumbnail_path), + str(move.new_thumbnail_path), + message, + ), + ) + return job_id + def preflight_moves(self, moves: Sequence[PlannedImageMove]) -> None: destinations: set[Path] = set() thumbnail_destinations: set[Path] = set() @@ -381,6 +424,19 @@ def preflight_moves(self, moves: Sequence[PlannedImageMove]) -> None: raise FileExistsError(f"Destination thumbnail already exists: {move.new_thumbnail_path}") self._assert_same_filesystem(move.old_thumbnail_path, move.new_thumbnail_path) + def _record_missing_source_errors(self, moves: Sequence[PlannedImageMove]) -> tuple[list[PlannedImageMove], int]: + remaining_moves: list[PlannedImageMove] = [] + errors = 0 + for move in moves: + if move.old_path.exists() or move.is_intermediate: + remaining_moves.append(move) + continue + message = f"Source image does not exist: {move.old_path}" + self.create_error_move_job(move, message) + self._logger.error(message) + errors += 1 + return remaining_moves, errors + def perform_filesystem_moves(self, job_id: int) -> None: self._set_job_state(job_id, "moving") self.complete_partial_filesystem_moves(job_id) diff --git a/invokeai/backend/util/image_storage_maintenance.py b/invokeai/backend/util/image_storage_maintenance.py deleted file mode 100644 index 258c1e34c87..00000000000 --- a/invokeai/backend/util/image_storage_maintenance.py +++ /dev/null @@ -1,90 +0,0 @@ -import argparse -import sys -from pathlib import Path -from typing import Sequence - -from invokeai.app.services.config.config_default import InvokeAIAppConfig, load_and_migrate_config -from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage -from invokeai.app.services.image_moves.image_moves_default import ImageMoveResult, ImageMoveService -from invokeai.app.services.session_queue.session_queue_sqlite import SqliteSessionQueue -from invokeai.app.services.shared.sqlite.sqlite_util import init_db -from invokeai.backend.util.logging import InvokeAILogger - - -def build_image_move_service(root: Path | None = None, config_file: Path | None = None) -> ImageMoveService: - config = InvokeAIAppConfig() - if root is not None: - config._root = root - if config_file is not None: - config._config_file = config_file - - if config.config_file_path.exists(): - config.update_config(load_and_migrate_config(config.config_file_path), clobber=False) - - if config.outputs_path is None: - raise RuntimeError("Output folder is not set") - - logger = InvokeAILogger.get_logger() - image_files = DiskImageFileStorage(config.outputs_path / "images") - db = init_db(config=config, logger=logger, image_files=image_files) - service = ImageMoveService(db=db, image_files=image_files, config=config, logger=logger) - service.set_session_queue(SqliteSessionQueue(db=db)) - return service - - -def _print_result(operation: str, result: ImageMoveResult) -> None: - print( - f"{operation}: planned={result.planned}, committed={result.committed}, errors={result.errors}", - flush=True, - ) - - -def _print_status(service: ImageMoveService) -> bool: - latest_job = service.get_latest_job() - active_job_id = service.get_active_job_id() - if latest_job is None: - print("No image storage maintenance jobs found.", flush=True) - else: - print( - "Latest image storage maintenance job: " - f"id={latest_job.id}, state={latest_job.state}, error={latest_job.error_message or 'none'}", - flush=True, - ) - if active_job_id is not None: - print(f"Active image storage maintenance job: id={active_job_id}", flush=True) - return active_job_id is not None or (latest_job is not None and latest_job.state == "error") - - -def main(argv: Sequence[str] | None = None) -> int: - parser = argparse.ArgumentParser(description="InvokeAI image storage maintenance utility") - parser.add_argument("operation", choices=["status", "recover", "move"], help="Operation to perform.") - parser.add_argument("--root", type=Path, default=None, help="InvokeAI root directory.") - parser.add_argument("--config", dest="config_file", type=Path, default=None, help="Path to invokeai.yaml.") - args = parser.parse_args(argv) - - try: - service = build_image_move_service(root=args.root, config_file=args.config_file) - # TODO: Add an interprocess guard so this script cannot run image moves while Invoke is active. - if args.operation == "status": - requires_attention = _print_status(service) - return 1 if requires_attention else 0 - if args.operation == "recover": - result = service.startup_recovery() - else: - service.assert_no_active_queue_work() - result = service.move_all_images() - _print_result(args.operation, result) - if result.errors > 0 or service.is_maintenance_active(): - print("Image storage maintenance requires operator attention.", file=sys.stderr, flush=True) - return 1 - return 0 - except KeyboardInterrupt: - print("Image storage maintenance canceled.", file=sys.stderr, flush=True) - return 130 - except Exception as e: - print(f"Image storage maintenance failed: {e}", file=sys.stderr, flush=True) - return 1 - - -if __name__ == "__main__": - raise SystemExit(main()) diff --git a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx index a85413c9634..17842db4485 100644 --- a/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx +++ b/invokeai/frontend/web/src/features/system/components/SettingsModal/SettingsImageStorageMaintenance.tsx @@ -2,7 +2,7 @@ import { Button, Flex, FormControl, FormLabel, Text } from '@invoke-ai/ui-librar import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { selectCurrentUser } from 'features/auth/store/authSlice'; import { toast } from 'features/toast/toast'; -import { memo, useCallback, useEffect, useMemo, useRef } from 'react'; +import { memo, useCallback, useEffect, useMemo, useState } from 'react'; import { useTranslation } from 'react-i18next'; import { api, LIST_TAG } from 'services/api'; import { useGetRuntimeConfigQuery } from 'services/api/endpoints/appInfo'; @@ -42,18 +42,20 @@ const getJobStateKey = (state: S['ImageMoveJobResponse']['state'] | undefined) = return 'settings.imageStorageMaintenanceStateNone'; }; +const invalidatedImageMoveJobIds = new Set(); + export const SettingsImageStorageMaintenance = memo(() => { const { t } = useTranslation(); const dispatch = useAppDispatch(); const currentUser = useAppSelector(selectCurrentUser); const { data: runtimeConfig } = useGetRuntimeConfigQuery(); - const canAccess = runtimeConfig ? !runtimeConfig.config.multiuser || currentUser?.is_admin : false; + const canAccess = runtimeConfig ? !runtimeConfig.config.multiuser || Boolean(currentUser?.is_admin) : false; const [startImageMove, startImageMoveState] = useStartImageMoveMutation(); const [startImageMoveRecovery, startImageMoveRecoveryState] = useStartImageMoveRecoveryMutation(); - const lastInvalidatedJobIdRef = useRef(null); + const [shouldPollStatus, setShouldPollStatus] = useState(false); const { data: status, isFetching } = useGetImageMoveStatusQuery(undefined, { skip: !canAccess, - pollingInterval: canAccess ? 2000 : 0, + pollingInterval: shouldPollStatus ? 2000 : 0, }); const isRunning = status?.is_running ?? false; @@ -61,14 +63,18 @@ export const SettingsImageStorageMaintenance = memo(() => { const hasActiveJob = status?.active_job_id !== null && status?.active_job_id !== undefined; const isBusy = isRunning || startImageMoveState.isLoading || startImageMoveRecoveryState.isLoading; + useEffect(() => { + setShouldPollStatus(canAccess && isRunning); + }, [canAccess, isRunning]); + useEffect(() => { if (!latestJob || latestJob.state !== 'committed' || isRunning) { return; } - if (lastInvalidatedJobIdRef.current === latestJob.id) { + if (invalidatedImageMoveJobIds.has(latestJob.id)) { return; } - lastInvalidatedJobIdRef.current = latestJob.id; + invalidatedImageMoveJobIds.add(latestJob.id); dispatch( api.util.invalidateTags([ 'Image', @@ -98,8 +104,10 @@ export const SettingsImageStorageMaintenance = memo(() => { const onStart = useCallback(async () => { try { + setShouldPollStatus(true); await startImageMove().unwrap(); } catch { + setShouldPollStatus(false); toast({ id: 'IMAGE_STORAGE_MAINTENANCE_START_FAILED', title: t('settings.imageStorageMaintenanceStartFailed'), @@ -110,8 +118,10 @@ export const SettingsImageStorageMaintenance = memo(() => { const onRecover = useCallback(async () => { try { + setShouldPollStatus(true); await startImageMoveRecovery().unwrap(); } catch { + setShouldPollStatus(false); toast({ id: 'IMAGE_STORAGE_MAINTENANCE_RECOVERY_FAILED', title: t('settings.imageStorageMaintenanceRecoveryFailed'), @@ -145,7 +155,9 @@ export const SettingsImageStorageMaintenance = memo(() => { {statusText} - {latestJob?.error_message ? {latestJob.error_message} : null} + {latestJob?.error_message || status?.last_error ? ( + {latestJob?.error_message || status?.last_error} + ) : null} ); }); diff --git a/scripts/image_storage_maintenance.py b/scripts/image_storage_maintenance.py deleted file mode 100644 index 90e864a7144..00000000000 --- a/scripts/image_storage_maintenance.py +++ /dev/null @@ -1,5 +0,0 @@ -#!/usr/bin/env python3 - -from invokeai.backend.util.image_storage_maintenance import main - -raise SystemExit(main()) diff --git a/tests/app/routers/test_board_images_maintenance.py b/tests/app/routers/test_board_images_maintenance.py index 8e2917efd4c..4112c9e09ab 100644 --- a/tests/app/routers/test_board_images_maintenance.py +++ b/tests/app/routers/test_board_images_maintenance.py @@ -5,6 +5,8 @@ from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api_app import app +from invokeai.app.services.board_records.board_records_common import BoardVisibility +from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.invoker import Invoker @@ -48,3 +50,37 @@ def test_board_image_mutations_are_blocked_during_image_move_maintenance( assert response.status_code == 409 assert response.json()["detail"] == "Image storage maintenance is active" + + +def test_delete_board_with_images_is_blocked_during_image_move_maintenance( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + client: TestClient, +) -> None: + mock_deps = MockApiDependencies(mock_invoker) + mock_invoker.services.image_moves = MagicMock() + mock_invoker.services.image_moves.is_maintenance_active.return_value = True + mock_invoker.services.images.delete_images_on_board = MagicMock() + mock_invoker.services.boards.get_dto = MagicMock( + return_value=BoardDTO( + board_id="board-id", + board_name="Board", + user_id="system", + created_at="2024-01-01 00:00:00.000", + updated_at="2024-01-01 00:00:00.000", + archived=False, + board_visibility=BoardVisibility.Private, + cover_image_name=None, + image_count=0, + asset_count=0, + ) + ) + monkeypatch.setattr("invokeai.app.api.routers.boards.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + + response = client.delete("/api/v1/boards/board-id?include_images=true") + + assert response.status_code == 409 + assert response.json()["detail"] == "Image storage maintenance is active" + mock_invoker.services.images.delete_images_on_board.assert_not_called() diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index 4d4f8666712..54365638ebb 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -1,3 +1,4 @@ +import os import threading from pathlib import Path from shutil import copy2 @@ -9,7 +10,6 @@ from invokeai.app.services.config.config_default import InvokeAIAppConfig from invokeai.app.services.image_files.image_files_disk import DiskImageFileStorage from invokeai.app.services.image_moves.image_moves_default import ( - BACKGROUND_SHUTDOWN_ERROR, ImageMoveQueueActive, ImageMoveService, ) @@ -91,6 +91,12 @@ def _job_item_states(service: ImageMoveService, job_id: int) -> dict[str, str]: return {row["image_name"]: row["state"] for row in cursor.fetchall()} +def _job_states(service: ImageMoveService) -> dict[int, str]: + with service._db.transaction() as cursor: + cursor.execute("SELECT id, state FROM image_subfolder_move_jobs ORDER BY id;") + return {row["id"]: row["state"] for row in cursor.fetchall()} + + def test_move_all_images_uses_created_at_for_date_strategy(tmp_path: Path) -> None: service, records = _service(tmp_path, strategy="date") image_name = "image-a.png" @@ -165,7 +171,30 @@ def test_missing_non_intermediate_source_file_still_fails(tmp_path: Path) -> Non ) with pytest.raises(FileNotFoundError, match="Source image does not exist"): - service.move_all_images() + service.plan_batch(last_image_name="", limit=100) + + +def test_move_all_images_continues_after_missing_non_intermediate_source_file(tmp_path: Path) -> None: + service, records = _service(tmp_path, strategy="date") + missing_image_name = "missing-general.png" + valid_image_name = "valid-general.png" + _save_record( + records, + image_name=missing_image_name, + subfolder="", + created_at="2024-02-04 04:05:06.000", + is_intermediate=False, + ) + _save_image(service, records, valid_image_name, "", "2024-02-05 04:05:06.000", "blue") + + result = service.move_all_images() + + assert result.errors == 1 + assert result.committed == 1 + assert records.get(missing_image_name).image_subfolder == "" + assert records.get(valid_image_name).image_subfolder == "2024/02/05" + assert "error" in _job_states(service).values() + assert "committed" in _job_states(service).values() def test_recovery_treats_missing_intermediate_source_file_as_success(tmp_path: Path) -> None: @@ -226,6 +255,26 @@ def test_cleanup_empty_source_directories_after_move(tmp_path: Path) -> None: assert service.image_files.thumbnail_root.exists() +@pytest.mark.skipif(not hasattr(os, "symlink"), reason="symlinks are not supported on this platform") +def test_cleanup_empty_source_directories_stays_within_symlinked_root(tmp_path: Path) -> None: + service, _records = _service(tmp_path, strategy="date") + real_root = tmp_path / "real-root" + linked_root = tmp_path / "linked-root" + sibling = tmp_path / "sibling" + real_root.mkdir() + sibling.mkdir() + linked_root.symlink_to(real_root, target_is_directory=True) + nested = linked_root / "old" / "nested" + nested.mkdir(parents=True) + + service._remove_empty_parents(nested, linked_root) + + assert real_root.exists() + assert linked_root.exists() + assert sibling.exists() + assert not (real_root / "old").exists() + + def test_startup_recovery_cleans_empty_source_directories(tmp_path: Path) -> None: service, records = _service(tmp_path, strategy="date") image_name = "image-recovery-cleanup.png" @@ -437,7 +486,7 @@ def raise_error() -> None: assert status.last_error == "background failed" -def test_stop_records_error_message_for_active_background_job(tmp_path: Path) -> None: +def test_stop_waits_for_active_background_job_without_recording_error(tmp_path: Path) -> None: service, records = _service(tmp_path, strategy="date") image_name = "image-background-stop.png" _save_image(service, records, image_name, "", "2024-03-05 05:06:07.000", "purple") @@ -449,15 +498,16 @@ def wait_for_shutdown() -> None: service._start_background_operation("recovery", wait_for_shutdown) - try: - service.stop() + stop_thread = threading.Thread(target=service.stop) + stop_thread.start() + assert stop_thread.is_alive() - assert service.get_job(job_id).error_message == BACKGROUND_SHUTDOWN_ERROR - assert service.get_background_status().last_error == BACKGROUND_SHUTDOWN_ERROR - finally: - release_worker.set() - assert service._future is not None - service._future.result(timeout=5) + release_worker.set() + stop_thread.join(timeout=5) + + assert not stop_thread.is_alive() + assert service.get_job(job_id).error_message is None + assert service.get_background_status().last_error is None def test_startup_recovery_completes_partial_multi_image_move(tmp_path: Path) -> None: diff --git a/tests/backend/util/test_image_storage_maintenance.py b/tests/backend/util/test_image_storage_maintenance.py deleted file mode 100644 index 8ed01f3f5cb..00000000000 --- a/tests/backend/util/test_image_storage_maintenance.py +++ /dev/null @@ -1,108 +0,0 @@ -from dataclasses import dataclass - -from invokeai.app.services.image_moves.image_moves_default import ImageMoveJob, ImageMoveQueueActive, ImageMoveResult -from invokeai.backend.util import image_storage_maintenance - - -@dataclass -class _FakeImageMoveService: - result: ImageMoveResult = ImageMoveResult() - active: bool = False - latest_job: ImageMoveJob | None = None - recovered: bool = False - moved: bool = False - checked_queue: bool = False - - def startup_recovery(self) -> ImageMoveResult: - self.recovered = True - return self.result - - def move_all_images(self) -> ImageMoveResult: - self.moved = True - return self.result - - def assert_no_active_queue_work(self) -> None: - self.checked_queue = True - - def is_maintenance_active(self) -> bool: - return self.active - - def get_latest_job(self) -> ImageMoveJob | None: - return self.latest_job - - def get_active_job_id(self) -> int | None: - return self.latest_job.id if self.active and self.latest_job is not None else None - - -def test_script_recover_uses_shared_image_move_service(monkeypatch, capsys) -> None: - service = _FakeImageMoveService(result=ImageMoveResult(committed=2)) - monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) - - exit_code = image_storage_maintenance.main(["recover", "--root", "/tmp/invokeai"]) - - assert exit_code == 0 - assert service.recovered is True - assert service.moved is False - assert "recover: planned=0, committed=2, errors=0" in capsys.readouterr().out - - -def test_script_move_exits_nonzero_when_job_remains_active(monkeypatch, capsys) -> None: - service = _FakeImageMoveService( - result=ImageMoveResult(planned=1, committed=0, errors=1), - active=True, - latest_job=ImageMoveJob(id=1, state="planned", error_message="temporary failure"), - ) - monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) - - exit_code = image_storage_maintenance.main(["move"]) - - assert exit_code == 1 - assert service.checked_queue is True - assert service.moved is True - assert "requires operator attention" in capsys.readouterr().err - - -def test_script_move_rejects_active_queue_work(monkeypatch, capsys) -> None: - service = _FakeImageMoveService() - - def raise_active_queue() -> None: - service.checked_queue = True - raise ImageMoveQueueActive("queue work is active") - - service.assert_no_active_queue_work = raise_active_queue - monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) - - exit_code = image_storage_maintenance.main(["move"]) - - assert exit_code == 1 - assert service.checked_queue is True - assert service.moved is False - assert "queue work is active" in capsys.readouterr().err - - -def test_script_status_reports_active_job(monkeypatch, capsys) -> None: - service = _FakeImageMoveService( - active=True, - latest_job=ImageMoveJob(id=7, state="planned", error_message="temporary failure"), - ) - monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) - - exit_code = image_storage_maintenance.main(["status"]) - - assert exit_code == 1 - output = capsys.readouterr().out - assert "id=7, state=planned, error=temporary failure" in output - assert "Active image storage maintenance job: id=7" in output - - -def test_script_status_exits_nonzero_for_error_job(monkeypatch, capsys) -> None: - service = _FakeImageMoveService( - active=False, - latest_job=ImageMoveJob(id=8, state="error", error_message="ambiguous filesystem state"), - ) - monkeypatch.setattr(image_storage_maintenance, "build_image_move_service", lambda root, config_file: service) - - exit_code = image_storage_maintenance.main(["status"]) - - assert exit_code == 1 - assert "id=8, state=error, error=ambiguous filesystem state" in capsys.readouterr().out From a14052618527cef4d18d987ec98f5dcf4f1326bf Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Wed, 13 May 2026 10:48:11 -0500 Subject: [PATCH 10/12] chore: typegen --- invokeai/frontend/web/src/services/api/schema.ts | 9 ++++++--- 1 file changed, 6 insertions(+), 3 deletions(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index ff34d829717..91480d571ac 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -1198,7 +1198,8 @@ export type paths = { * * This endpoint is intentionally unauthenticated because browsers load images * via tags which cannot send Bearer tokens. Image names are UUIDs, - * providing security through unguessability. + * providing security through unguessability. Returns 409 while image storage + * maintenance is active. */ get: operations["get_image_full"]; put?: never; @@ -1211,7 +1212,8 @@ export type paths = { * * This endpoint is intentionally unauthenticated because browsers load images * via tags which cannot send Bearer tokens. Image names are UUIDs, - * providing security through unguessability. + * providing security through unguessability. Returns 409 while image storage + * maintenance is active. */ head: operations["get_image_full_head"]; patch?: never; @@ -1230,7 +1232,8 @@ export type paths = { * * This endpoint is intentionally unauthenticated because browsers load images * via tags which cannot send Bearer tokens. Image names are UUIDs, - * providing security through unguessability. + * providing security through unguessability. Returns 409 while image storage + * maintenance is active. */ get: operations["get_image_thumbnail"]; put?: never; From 7a2de5829ff0f44b000772602bffcccc6bd8c64f Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Wed, 13 May 2026 14:29:00 -0500 Subject: [PATCH 11/12] Fix image move maintenance edge cases --- invokeai/app/api/routers/board_images.py | 22 +++++-- invokeai/app/api/routers/images.py | 29 ++++++-- .../image_moves/image_moves_default.py | 5 +- .../routers/test_board_images_maintenance.py | 66 +++++++++++++++++++ tests/app/routers/test_images.py | 23 +++++++ .../image_moves/test_image_moves_default.py | 22 ++++++- 6 files changed, 153 insertions(+), 14 deletions(-) diff --git a/invokeai/app/api/routers/board_images.py b/invokeai/app/api/routers/board_images.py index 3a038a937a2..ea0273f02d6 100644 --- a/invokeai/app/api/routers/board_images.py +++ b/invokeai/app/api/routers/board_images.py @@ -64,9 +64,9 @@ async def add_image_to_board( image_name: str = Body(description="The name of the image to add"), ) -> AddImagesToBoardResult: """Creates a board_image""" - assert_image_move_maintenance_inactive() _assert_board_write_access(board_id, current_user) _assert_image_direct_owner(image_name, current_user) + assert_image_move_maintenance_inactive() try: added_images: set[str] = set() affected_boards: set[str] = set() @@ -98,12 +98,11 @@ async def remove_image_from_board( image_name: str = Body(description="The name of the image to remove", embed=True), ) -> RemoveImagesFromBoardResult: """Removes an image from its board, if it had one""" - assert_image_move_maintenance_inactive() - try: old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" if old_board_id != "none": _assert_board_write_access(old_board_id, current_user) + assert_image_move_maintenance_inactive() removed_images: set[str] = set() affected_boards: set[str] = set() ApiDependencies.invoker.services.board_images.remove_image_from_board(image_name=image_name) @@ -136,8 +135,14 @@ async def add_images_to_board( image_names: list[str] = Body(description="The names of the images to add", embed=True), ) -> AddImagesToBoardResult: """Adds a list of images to a board""" - assert_image_move_maintenance_inactive() _assert_board_write_access(board_id, current_user) + try: + assert_image_move_maintenance_inactive() + except HTTPException: + for image_name in image_names: + _assert_image_direct_owner(image_name, current_user) + raise + try: added_images: set[str] = set() affected_boards: set[str] = set() @@ -183,7 +188,14 @@ async def remove_images_from_board( image_names: list[str] = Body(description="The names of the images to remove", embed=True), ) -> RemoveImagesFromBoardResult: """Removes a list of images from their board, if they had one""" - assert_image_move_maintenance_inactive() + try: + assert_image_move_maintenance_inactive() + except HTTPException: + for image_name in image_names: + old_board_id = ApiDependencies.invoker.services.images.get_dto(image_name).board_id or "none" + if old_board_id != "none": + _assert_board_write_access(old_board_id, current_user) + raise try: removed_images: set[str] = set() diff --git a/invokeai/app/api/routers/images.py b/invokeai/app/api/routers/images.py index 784a8d4f257..a27d33e8d82 100644 --- a/invokeai/app/api/routers/images.py +++ b/invokeai/app/api/routers/images.py @@ -174,8 +174,6 @@ async def upload_image( ), ) -> ImageDTO: """Uploads an image for the current user""" - assert_image_move_maintenance_inactive() - # If uploading into a board, verify the user has write access. # Public boards allow uploads from any authenticated user. if board_id is not None: @@ -192,6 +190,8 @@ async def upload_image( ): raise HTTPException(status_code=403, detail="Not authorized to upload to this board") + assert_image_move_maintenance_inactive() + if not file.content_type or not file.content_type.startswith("image"): raise HTTPException(status_code=415, detail="Not an image") @@ -563,7 +563,12 @@ async def delete_images_from_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to delete", embed=True), ) -> DeleteImagesResult: - assert_image_move_maintenance_inactive() + try: + assert_image_move_maintenance_inactive() + except HTTPException: + for image_name in image_names: + _assert_image_owner(image_name, current_user) + raise try: deleted_images: set[str] = set() @@ -632,7 +637,12 @@ async def star_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to star", embed=True), ) -> StarredImagesResult: - assert_image_move_maintenance_inactive() + try: + assert_image_move_maintenance_inactive() + except HTTPException: + for image_name in image_names: + _assert_image_owner(image_name, current_user) + raise try: starred_images: set[str] = set() @@ -664,7 +674,12 @@ async def unstar_images_in_list( current_user: CurrentUserOrDefault, image_names: list[str] = Body(description="The list of names of images to unstar", embed=True), ) -> UnstarredImagesResult: - assert_image_move_maintenance_inactive() + try: + assert_image_move_maintenance_inactive() + except HTTPException: + for image_name in image_names: + _assert_image_owner(image_name, current_user) + raise try: unstarred_images: set[str] = set() @@ -713,8 +728,6 @@ async def download_images_from_list( default=None, description="The board from which image should be downloaded", embed=True ), ) -> ImagesDownloaded: - assert_image_move_maintenance_inactive() - if (image_names is None or len(image_names) == 0) and board_id is None: raise HTTPException(status_code=400, detail="No images or board id specified.") @@ -727,6 +740,8 @@ async def download_images_from_list( for name in image_names: _assert_image_read_access(name, current_user) + assert_image_move_maintenance_inactive() + bulk_download_item_id: str = ApiDependencies.invoker.services.bulk_download.generate_item_id(board_id) background_tasks.add_task( diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index eead858c582..23007d58fc5 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -780,7 +780,10 @@ def _fsync_dir(self, path: Path) -> None: except OSError as e: self._logger.debug("Unable to fsync directory: %s: %s", path, e) finally: - os.close(dir_fd) + try: + os.close(dir_fd) + except OSError as e: + self._logger.debug("Unable to close directory fsync handle: %s: %s", path, e) def _is_unrecoverable_error(self, error: Exception) -> bool: return isinstance(error, RuntimeError) and ( diff --git a/tests/app/routers/test_board_images_maintenance.py b/tests/app/routers/test_board_images_maintenance.py index 4112c9e09ab..ecc59aa10a2 100644 --- a/tests/app/routers/test_board_images_maintenance.py +++ b/tests/app/routers/test_board_images_maintenance.py @@ -3,8 +3,10 @@ import pytest from fastapi.testclient import TestClient +from invokeai.app.api.auth_dependencies import get_current_user_or_default from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api_app import app +from invokeai.app.services.auth.token_service import TokenData from invokeai.app.services.board_records.board_records_common import BoardVisibility from invokeai.app.services.boards.boards_common import BoardDTO from invokeai.app.services.invoker import Invoker @@ -42,6 +44,26 @@ def test_board_image_mutations_are_blocked_during_image_move_maintenance( mock_deps = MockApiDependencies(mock_invoker) mock_invoker.services.image_moves = MagicMock() mock_invoker.services.image_moves.is_maintenance_active.return_value = True + monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system")) + monkeypatch.setattr(mock_invoker.services.images, "get_dto", MagicMock(return_value=MagicMock(board_id=None))) + monkeypatch.setattr( + mock_invoker.services.boards, + "get_dto", + MagicMock( + return_value=BoardDTO( + board_id="board-id", + board_name="Board", + user_id="system", + created_at="2024-01-01 00:00:00.000", + updated_at="2024-01-01 00:00:00.000", + archived=False, + board_visibility=BoardVisibility.Private, + cover_image_name=None, + image_count=0, + asset_count=0, + ) + ), + ) monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) @@ -52,6 +74,50 @@ def test_board_image_mutations_are_blocked_during_image_move_maintenance( assert response.json()["detail"] == "Image storage maintenance is active" +def test_board_image_mutation_checks_access_before_image_move_maintenance( + monkeypatch: pytest.MonkeyPatch, + mock_invoker: Invoker, + client: TestClient, +) -> None: + mock_deps = MockApiDependencies(mock_invoker) + mock_invoker.services.image_moves = MagicMock() + mock_invoker.services.image_moves.is_maintenance_active.return_value = True + monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="other-user")) + monkeypatch.setattr( + mock_invoker.services.boards, + "get_dto", + MagicMock( + return_value=BoardDTO( + board_id="board-id", + board_name="Board", + user_id="system", + created_at="2024-01-01 00:00:00.000", + updated_at="2024-01-01 00:00:00.000", + archived=False, + board_visibility=BoardVisibility.Private, + cover_image_name=None, + image_count=0, + asset_count=0, + ) + ), + ) + monkeypatch.setattr("invokeai.app.api.routers.board_images.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) + monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) + + async def current_user_override() -> TokenData: + return TokenData(user_id="request-user", email="request-user@example.com", is_admin=False) + + app.dependency_overrides[get_current_user_or_default] = current_user_override + try: + response = client.post("/api/v1/board_images/", json={"board_id": "board-id", "image_name": "image.png"}) + + assert response.status_code == 403 + mock_invoker.services.image_moves.is_maintenance_active.assert_not_called() + finally: + app.dependency_overrides.pop(get_current_user_or_default, None) + + def test_delete_board_with_images_is_blocked_during_image_move_maintenance( monkeypatch: pytest.MonkeyPatch, mock_invoker: Invoker, diff --git a/tests/app/routers/test_images.py b/tests/app/routers/test_images.py index c35cdf0cbf9..cee45b539f5 100644 --- a/tests/app/routers/test_images.py +++ b/tests/app/routers/test_images.py @@ -7,8 +7,10 @@ from fastapi import BackgroundTasks from fastapi.testclient import TestClient +from invokeai.app.api.auth_dependencies import get_current_user_or_default from invokeai.app.api.dependencies import ApiDependencies from invokeai.app.api_app import app +from invokeai.app.services.auth.token_service import TokenData from invokeai.app.services.board_records.board_records_common import BoardRecord from invokeai.app.services.invoker import Invoker @@ -71,6 +73,8 @@ def prepare_image_maintenance_test(monkeypatch: Any, mock_invoker: Invoker) -> N mock_deps = MockApiDependencies(mock_invoker) mock_invoker.services.image_moves = MagicMock() mock_invoker.services.image_moves.is_maintenance_active.return_value = True + monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="system")) + monkeypatch.setattr(mock_invoker.services.board_image_records, "get_board_for_image", MagicMock(return_value=None)) monkeypatch.setattr("invokeai.app.api.routers.images.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.routers.image_move_maintenance.ApiDependencies", mock_deps) monkeypatch.setattr("invokeai.app.api.auth_dependencies.ApiDependencies", mock_deps) @@ -108,6 +112,25 @@ def test_image_operations_are_blocked_during_image_move_maintenance( assert response.json()["detail"] == "Image storage maintenance is active" +def test_image_mutation_checks_access_before_image_move_maintenance( + monkeypatch: Any, mock_invoker: Invoker, client: TestClient +) -> None: + prepare_image_maintenance_test(monkeypatch, mock_invoker) + monkeypatch.setattr(mock_invoker.services.image_records, "get_user_id", MagicMock(return_value="other-user")) + + async def current_user_override() -> TokenData: + return TokenData(user_id="request-user", email="request-user@example.com", is_admin=False) + + app.dependency_overrides[get_current_user_or_default] = current_user_override + try: + response = client.delete("/api/v1/images/i/test.png") + + assert response.status_code == 403 + mock_invoker.services.image_moves.is_maintenance_active.assert_not_called() + finally: + app.dependency_overrides.pop(get_current_user_or_default, None) + + def test_image_upload_is_blocked_during_image_move_maintenance( monkeypatch: Any, mock_invoker: Invoker, client: TestClient ) -> None: diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index 54365638ebb..1cf85512fc4 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -263,7 +263,10 @@ def test_cleanup_empty_source_directories_stays_within_symlinked_root(tmp_path: sibling = tmp_path / "sibling" real_root.mkdir() sibling.mkdir() - linked_root.symlink_to(real_root, target_is_directory=True) + try: + linked_root.symlink_to(real_root, target_is_directory=True) + except OSError as e: + pytest.skip(f"symlink creation is not available: {e}") nested = linked_root / "old" / "nested" nested.mkdir(parents=True) @@ -671,3 +674,20 @@ def test_successful_filesystem_move_fsyncs_files_and_directories(tmp_path: Path) fsync_dir.assert_any_call(moved.old_path.parent) fsync_dir.assert_any_call(moved.new_thumbnail_path.parent) fsync_dir.assert_any_call(moved.old_thumbnail_path.parent) + + +def test_fsync_dir_ignores_platform_close_failures(tmp_path: Path) -> None: + service, _records = _service(tmp_path, strategy="date") + + with ( + patch("invokeai.app.services.image_moves.image_moves_default.os.open", return_value=123), + patch( + "invokeai.app.services.image_moves.image_moves_default.os.fsync", + side_effect=OSError(9, "Bad file descriptor"), + ), + patch( + "invokeai.app.services.image_moves.image_moves_default.os.close", + side_effect=OSError(9, "Bad file descriptor"), + ), + ): + service._fsync_dir(tmp_path) From 38662bae4755cf19b1b1ec90a578a411ee8b5e00 Mon Sep 17 00:00:00 2001 From: JPPhoto Date: Wed, 13 May 2026 15:32:22 -0500 Subject: [PATCH 12/12] Fix Windows image move fsync handling --- .../app/services/image_moves/image_moves_default.py | 7 +++++-- .../services/image_moves/test_image_moves_default.py | 12 ++++++++++++ 2 files changed, 17 insertions(+), 2 deletions(-) diff --git a/invokeai/app/services/image_moves/image_moves_default.py b/invokeai/app/services/image_moves/image_moves_default.py index 23007d58fc5..11da7e907ca 100644 --- a/invokeai/app/services/image_moves/image_moves_default.py +++ b/invokeai/app/services/image_moves/image_moves_default.py @@ -766,8 +766,11 @@ def _nearest_existing_parent(self, path: Path) -> Path: return current def _fsync_file(self, path: Path) -> None: - with path.open("rb") as file: - os.fsync(file.fileno()) + try: + with path.open("rb") as file: + os.fsync(file.fileno()) + except OSError as e: + self._logger.debug("Unable to fsync file: %s: %s", path, e) def _fsync_dir(self, path: Path) -> None: try: diff --git a/tests/app/services/image_moves/test_image_moves_default.py b/tests/app/services/image_moves/test_image_moves_default.py index 1cf85512fc4..eeb75c7bf9c 100644 --- a/tests/app/services/image_moves/test_image_moves_default.py +++ b/tests/app/services/image_moves/test_image_moves_default.py @@ -691,3 +691,15 @@ def test_fsync_dir_ignores_platform_close_failures(tmp_path: Path) -> None: ), ): service._fsync_dir(tmp_path) + + +def test_fsync_file_ignores_platform_fsync_failures(tmp_path: Path) -> None: + service, _records = _service(tmp_path, strategy="date") + path = tmp_path / "image.png" + path.write_bytes(b"test") + + with patch( + "invokeai.app.services.image_moves.image_moves_default.os.fsync", + side_effect=OSError(9, "Bad file descriptor"), + ): + service._fsync_file(path)