From c126bc5a43a3890d7ed2234ae28cc0a269e8315d Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Tue, 17 Mar 2026 02:16:50 +0100 Subject: [PATCH 1/7] feat: add configurable image subfolder strategies Add support for organizing output images into subfolders instead of a single flat directory. Four strategies are available via the image_subfolder_strategy config setting: flat (default, current behavior), date (YYYY/MM/DD), type (by image category), and hash (UUID prefix for filesystem performance). The strategy can be changed at any time - existing images keep their paths, only new images use the new strategy. --- docs/configuration.md | 31 +++++++++- invokeai/app/api/routers/recall_parameters.py | 14 ++--- .../app/services/config/config_default.py | 22 ++++++- .../services/image_files/image_files_base.py | 11 ++-- .../services/image_files/image_files_disk.py | 58 ++++++++++++++----- .../image_files/image_subfolder_strategy.py | 58 +++++++++++++++++++ .../image_records/image_records_base.py | 5 +- .../image_records/image_records_common.py | 4 ++ .../image_records/image_records_sqlite.py | 19 ++++-- .../app/services/images/images_default.py | 47 +++++++++++---- .../app/services/shared/sqlite/sqlite_util.py | 2 + .../migrations/migration_28.py | 41 +++++++++++++ 12 files changed, 263 insertions(+), 49 deletions(-) create mode 100644 invokeai/app/services/image_files/image_subfolder_strategy.py create mode 100644 invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py diff --git a/docs/configuration.md b/docs/configuration.md index 80abb9a6d10..5d8749af6f1 100644 --- a/docs/configuration.md +++ b/docs/configuration.md @@ -39,7 +39,7 @@ It has two sections - one for internal use and one for user settings: ```yaml # Internal metadata - do not edit: -schema_version: 4.0.2 +schema_version: 4.0.3 # Put user settings here - see https://invoke-ai.github.io/InvokeAI/features/CONFIGURATION/: host: 0.0.0.0 # serve the app on your local network @@ -144,6 +144,35 @@ Most common algorithms are supported, like `md5`, `sha256`, and `sha512`. These These options set the paths of various directories and files used by InvokeAI. Any user-defined paths should be absolute paths. +#### Image Subfolder Strategy + +By default, all generated images are stored in a single flat directory (`outputs/images/`). This can become unwieldy with a large number of images. The `image_subfolder_strategy` setting lets you organize images into subfolders automatically. + +```yaml +image_subfolder_strategy: flat # default value +``` + +Available strategies: + +| Strategy | Example Path | Description | +|----------|-------------|-------------| +| `flat` | `outputs/images/abc123.png` | **Default.** All images in one directory (current behavior). | +| `date` | `outputs/images/2026/03/17/abc123.png` | Organized by creation date (YYYY/MM/DD). | +| `type` | `outputs/images/general/abc123.png` | Organized by image category (`general`, `intermediate`, `mask`, `control`, etc.). | +| `hash` | `outputs/images/ab/abc123.png` | Uses first 2 characters of the UUID as subfolder. Best for filesystem performance with very large collections (~256 evenly distributed subfolders). | + +!!! tip "Switching Strategies" + + You can switch between strategies at any time. Existing images remain in their original location — only newly generated images will use the new subfolder structure. This works because each image's subfolder path is stored in the database. + +!!! example "Example: Using date-based organization" + + ```yaml + image_subfolder_strategy: date + ``` + + New images will be saved as `outputs/images/2026/03/17/abc123.png`. Thumbnails mirror the same structure under `outputs/images/thumbnails/2026/03/17/abc123.webp`. + #### Logging Several different log handler destinations are available, and multiple destinations are supported by providing a list: diff --git a/invokeai/app/api/routers/recall_parameters.py b/invokeai/app/api/routers/recall_parameters.py index 0af3fd29b0c..9c7844f17c8 100644 --- a/invokeai/app/api/routers/recall_parameters.py +++ b/invokeai/app/api/routers/recall_parameters.py @@ -146,17 +146,15 @@ def load_image_file(image_name: str) -> Optional[dict[str, Any]]: """ logger = ApiDependencies.invoker.services.logger try: - # Prefer using the image_files service to validate & open images - image_files = ApiDependencies.invoker.services.image_files - # Resolve a safe path inside outputs - image_path = image_files.get_path(image_name) + images_service = ApiDependencies.invoker.services.images + # Use images service which handles subfolder resolution via DB record + path = images_service.get_path(image_name) - if not image_files.validate_path(str(image_path)): - logger.warning(f"Image file not found: {image_name} (searched in {image_path.parent})") + if not images_service.validate_path(path): + logger.warning(f"Image file not found: {image_name}") return None - # Open the image via service to leverage caching - pil_image = image_files.get(image_name) + pil_image = images_service.get_pil_image(image_name) width, height = pil_image.size logger.info(f"Found image file: {image_name} ({width}x{height})") return {"image_name": image_name, "width": width, "height": height} diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 5d1b1d0d8d5..2bf9efb556f 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -29,7 +29,8 @@ ATTENTION_SLICE_SIZE = Literal["auto", "balanced", "max", 1, 2, 3, 4, 5, 6, 7, 8] LOG_FORMAT = Literal["plain", "color", "syslog", "legacy"] LOG_LEVEL = Literal["debug", "info", "warning", "error", "critical"] -CONFIG_SCHEMA_VERSION = "4.0.2" +IMAGE_SUBFOLDER_STRATEGY = Literal["flat", "date", "type", "hash"] +CONFIG_SCHEMA_VERSION = "4.0.3" class URLRegexTokenPair(BaseModel): @@ -69,6 +70,7 @@ class InvokeAIAppConfig(BaseSettings): legacy_conf_dir: Path to directory of legacy checkpoint config files. db_dir: Path to InvokeAI databases directory. outputs_dir: Path to directory for outputs. + image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder (default). 'date' organizes by YYYY/MM/DD. 'type' organizes by image category (general, intermediate, etc.). 'hash' uses first 2 characters of UUID for filesystem performance. custom_nodes_dir: Path to directory for custom nodes. style_presets_dir: Path to directory for style presets. workflow_thumbnails_dir: Path to directory for workflow thumbnails. @@ -145,6 +147,7 @@ class InvokeAIAppConfig(BaseSettings): legacy_conf_dir: Path = Field(default=Path("configs"), description="Path to directory of legacy checkpoint config files.") db_dir: Path = Field(default=Path("databases"), description="Path to InvokeAI databases directory.") outputs_dir: Path = Field(default=Path("outputs"), description="Path to directory for outputs.") + image_subfolder_strategy: IMAGE_SUBFOLDER_STRATEGY = Field(default="flat", 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.") custom_nodes_dir: Path = Field(default=Path("nodes"), description="Path to directory for custom nodes.") style_presets_dir: Path = Field(default=Path("style_presets"), description="Path to directory for style presets.") workflow_thumbnails_dir: Path = Field(default=Path("workflow_thumbnails"), description="Path to directory for workflow thumbnails.") @@ -455,6 +458,20 @@ def migrate_v4_0_1_to_4_0_2_config_dict(config_dict: dict[str, Any]) -> dict[str return parsed_config_dict +def migrate_v4_0_2_to_4_0_3_config_dict(config_dict: dict[str, Any]) -> dict[str, Any]: + """Migrate v4.0.2 config dictionary to a v4.0.3 config dictionary. + + Args: + config_dict: A dictionary of settings from a v4.0.2 config file. + + Returns: + A config dict with the settings migrated to v4.0.3. + """ + parsed_config_dict: dict[str, Any] = copy.deepcopy(config_dict) + parsed_config_dict["schema_version"] = "4.0.3" + return parsed_config_dict + + def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig: """Load and migrate a config file to the latest version. @@ -480,6 +497,9 @@ def load_and_migrate_config(config_path: Path) -> InvokeAIAppConfig: if loaded_config_dict["schema_version"] == "4.0.1": migrated = True loaded_config_dict = migrate_v4_0_1_to_4_0_2_config_dict(loaded_config_dict) + if loaded_config_dict["schema_version"] == "4.0.2": + migrated = True + loaded_config_dict = migrate_v4_0_2_to_4_0_3_config_dict(loaded_config_dict) if migrated: shutil.copy(config_path, config_path.with_suffix(".yaml.bak")) diff --git a/invokeai/app/services/image_files/image_files_base.py b/invokeai/app/services/image_files/image_files_base.py index dc6609aa48c..7464cd7941d 100644 --- a/invokeai/app/services/image_files/image_files_base.py +++ b/invokeai/app/services/image_files/image_files_base.py @@ -9,12 +9,12 @@ class ImageFileStorageBase(ABC): """Low-level service responsible for storing and retrieving image files.""" @abstractmethod - def get(self, image_name: str) -> PILImageType: + def get(self, image_name: str, image_subfolder: str = "") -> PILImageType: """Retrieves an image as PIL Image.""" pass @abstractmethod - def get_path(self, image_name: str, thumbnail: bool = False) -> Path: + def get_path(self, image_name: str, thumbnail: bool = False, image_subfolder: str = "") -> Path: """Gets the internal path to an image or thumbnail.""" pass @@ -34,21 +34,22 @@ def save( workflow: Optional[str] = None, graph: Optional[str] = None, thumbnail_size: int = 256, + image_subfolder: str = "", ) -> None: """Saves an image and a 256x256 WEBP thumbnail. Returns a tuple of the image name, thumbnail name, and created timestamp.""" pass @abstractmethod - def delete(self, image_name: str) -> None: + def delete(self, image_name: str, image_subfolder: str = "") -> None: """Deletes an image and its thumbnail (if one exists).""" pass @abstractmethod - def get_workflow(self, image_name: str) -> Optional[str]: + def get_workflow(self, image_name: str, image_subfolder: str = "") -> Optional[str]: """Gets the workflow of an image.""" pass @abstractmethod - def get_graph(self, image_name: str) -> Optional[str]: + def get_graph(self, image_name: str, image_subfolder: str = "") -> Optional[str]: """Gets the graph of an image.""" pass diff --git a/invokeai/app/services/image_files/image_files_disk.py b/invokeai/app/services/image_files/image_files_disk.py index e5bfd72781d..1f776472d4b 100644 --- a/invokeai/app/services/image_files/image_files_disk.py +++ b/invokeai/app/services/image_files/image_files_disk.py @@ -32,9 +32,9 @@ def __init__(self, output_folder: Union[str, Path]): def start(self, invoker: Invoker) -> None: self.__invoker = invoker - def get(self, image_name: str) -> PILImageType: + def get(self, image_name: str, image_subfolder: str = "") -> PILImageType: try: - image_path = self.get_path(image_name) + image_path = self.get_path(image_name, image_subfolder=image_subfolder) cache_item = self.__get_cache(image_path) if cache_item: @@ -54,10 +54,14 @@ def save( workflow: Optional[str] = None, graph: Optional[str] = None, thumbnail_size: int = 256, + image_subfolder: str = "", ) -> None: try: self.__validate_storage_folders() - image_path = self.get_path(image_name) + image_path = self.get_path(image_name, image_subfolder=image_subfolder) + + # Ensure subfolder directories exist + image_path.parent.mkdir(parents=True, exist_ok=True) pnginfo = PngImagePlugin.PngInfo() info_dict = {} @@ -82,7 +86,11 @@ def save( ) thumbnail_name = get_thumbnail_name(image_name) - thumbnail_path = self.get_path(thumbnail_name, thumbnail=True) + thumbnail_path = self.get_path(thumbnail_name, thumbnail=True, image_subfolder=image_subfolder) + + # Ensure thumbnail subfolder directories exist + thumbnail_path.parent.mkdir(parents=True, exist_ok=True) + thumbnail_image = make_thumbnail(image, thumbnail_size) thumbnail_image.save(thumbnail_path) @@ -91,9 +99,9 @@ def save( except Exception as e: raise ImageFileSaveException from e - def delete(self, image_name: str) -> None: + def delete(self, image_name: str, image_subfolder: str = "") -> None: try: - image_path = self.get_path(image_name) + image_path = self.get_path(image_name, image_subfolder=image_subfolder) if image_path.exists(): image_path.unlink() @@ -101,7 +109,7 @@ def delete(self, image_name: str) -> None: del self.__cache[image_path] thumbnail_name = get_thumbnail_name(image_name) - thumbnail_path = self.get_path(thumbnail_name, True) + thumbnail_path = self.get_path(thumbnail_name, True, image_subfolder=image_subfolder) if thumbnail_path.exists(): thumbnail_path.unlink() @@ -110,17 +118,21 @@ def delete(self, image_name: str) -> None: except Exception as e: raise ImageFileDeleteException from e - def get_path(self, image_name: str, thumbnail: bool = False) -> Path: + def get_path(self, image_name: str, thumbnail: bool = False, image_subfolder: str = "") -> Path: base_folder = self.__thumbnails_folder if thumbnail else self.__output_folder filename = get_thumbnail_name(image_name) if thumbnail else image_name - # Strip any path information from the filename + # Validate the filename itself (no path separators allowed in the filename) basename = Path(filename).name - if basename != filename: raise ValueError("Invalid image name, potential directory traversal detected") - image_path = base_folder / basename + # Build the full path with optional subfolder + if image_subfolder: + self._validate_subfolder(image_subfolder) + image_path = base_folder / image_subfolder / basename + else: + image_path = base_folder / basename # Ensure the image path is within the base folder to prevent directory traversal resolved_base = base_folder.resolve() @@ -131,20 +143,36 @@ def get_path(self, image_name: str, thumbnail: bool = False) -> Path: return resolved_image_path + @staticmethod + def _validate_subfolder(subfolder: str) -> None: + """Validates a subfolder path to prevent directory traversal while allowing controlled subdirectories.""" + if not subfolder: + return + if '\\' in subfolder: + raise ValueError("Backslashes not allowed in subfolder path") + if subfolder.startswith('/'): + raise ValueError("Absolute paths not allowed in subfolder path") + parts = subfolder.split('/') + for part in parts: + if part == '..': + raise ValueError("Parent directory references not allowed in subfolder path") + if part == '': + raise ValueError("Empty path segments not allowed in subfolder path") + def validate_path(self, path: Union[str, Path]) -> bool: """Validates the path given for an image or thumbnail.""" path = path if isinstance(path, Path) else Path(path) return path.exists() - def get_workflow(self, image_name: str) -> str | None: - image = self.get(image_name) + def get_workflow(self, image_name: str, image_subfolder: str = "") -> str | None: + image = self.get(image_name, image_subfolder=image_subfolder) workflow = image.info.get("invokeai_workflow", None) if isinstance(workflow, str): return workflow return None - def get_graph(self, image_name: str) -> str | None: - image = self.get(image_name) + def get_graph(self, image_name: str, image_subfolder: str = "") -> str | None: + image = self.get(image_name, image_subfolder=image_subfolder) graph = image.info.get("invokeai_graph", None) if isinstance(graph, str): return graph diff --git a/invokeai/app/services/image_files/image_subfolder_strategy.py b/invokeai/app/services/image_files/image_subfolder_strategy.py new file mode 100644 index 00000000000..66c363c4f95 --- /dev/null +++ b/invokeai/app/services/image_files/image_subfolder_strategy.py @@ -0,0 +1,58 @@ +from abc import ABC, abstractmethod +from datetime import datetime + +from invokeai.app.services.image_records.image_records_common import ImageCategory + + +class ImageSubfolderStrategy(ABC): + """Base class for image subfolder strategies.""" + + @abstractmethod + def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str: + """Returns relative subfolder prefix (e.g. '2026/03/17', 'general'), or empty string for flat.""" + pass + + +class FlatStrategy(ImageSubfolderStrategy): + """No subfolders - all images in one directory (default behavior).""" + + def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str: + return "" + + +class DateStrategy(ImageSubfolderStrategy): + """Organize images by date: YYYY/MM/DD.""" + + def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str: + now = datetime.now() + return f"{now.year}/{now.month:02d}/{now.day:02d}" + + +class TypeStrategy(ImageSubfolderStrategy): + """Organize images by category/type: general, intermediate, mask, control, etc.""" + + def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str: + if is_intermediate: + return "intermediate" + return image_category.value + + +class HashStrategy(ImageSubfolderStrategy): + """Organize images by UUID prefix for filesystem performance (first 2 characters).""" + + def get_subfolder(self, image_name: str, image_category: ImageCategory, is_intermediate: bool) -> str: + return image_name[:2] + + +def create_subfolder_strategy(strategy_name: str) -> ImageSubfolderStrategy: + """Factory function to create a subfolder strategy by name.""" + strategies: dict[str, type[ImageSubfolderStrategy]] = { + "flat": FlatStrategy, + "date": DateStrategy, + "type": TypeStrategy, + "hash": HashStrategy, + } + cls = strategies.get(strategy_name) + if cls is None: + raise ValueError(f"Unknown subfolder strategy: {strategy_name}. Valid options: {', '.join(strategies.keys())}") + return cls() diff --git a/invokeai/app/services/image_records/image_records_base.py b/invokeai/app/services/image_records/image_records_base.py index 16405c52708..415cf1c9b40 100644 --- a/invokeai/app/services/image_records/image_records_base.py +++ b/invokeai/app/services/image_records/image_records_base.py @@ -69,8 +69,8 @@ def delete_many(self, image_names: list[str]) -> None: pass @abstractmethod - def delete_intermediates(self) -> list[str]: - """Deletes all intermediate image records, returning a list of deleted image names.""" + def delete_intermediates(self) -> list[tuple[str, str]]: + """Deletes all intermediate image records, returning a list of (image_name, image_subfolder) tuples.""" pass @abstractmethod @@ -93,6 +93,7 @@ def save( node_id: Optional[str] = None, metadata: Optional[str] = None, user_id: Optional[str] = None, + image_subfolder: str = "", ) -> datetime: """Saves an image record.""" pass diff --git a/invokeai/app/services/image_records/image_records_common.py b/invokeai/app/services/image_records/image_records_common.py index 1e751f29f36..3d4650b77ae 100644 --- a/invokeai/app/services/image_records/image_records_common.py +++ b/invokeai/app/services/image_records/image_records_common.py @@ -115,6 +115,7 @@ def __init__(self, message="Image record not deleted"): "updated_at", "deleted_at", "starred", + "image_subfolder", ] ] ) @@ -156,6 +157,7 @@ class ImageRecord(BaseModelExcludeNull): starred: bool = Field(description="Whether this image is starred.") """Whether this image is starred.""" has_workflow: bool = Field(description="Whether this image has a workflow.") + image_subfolder: str = Field(default="", description="The subfolder where the image is stored on disk.") class ImageRecordChanges(BaseModelExcludeNull, extra="allow"): @@ -200,6 +202,7 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord: is_intermediate = image_dict.get("is_intermediate", False) starred = image_dict.get("starred", False) has_workflow = image_dict.get("has_workflow", False) + image_subfolder = image_dict.get("image_subfolder", "") return ImageRecord( image_name=image_name, @@ -215,6 +218,7 @@ def deserialize_image_record(image_dict: dict) -> ImageRecord: is_intermediate=is_intermediate, starred=starred, has_workflow=has_workflow, + image_subfolder=image_subfolder, ) diff --git a/invokeai/app/services/image_records/image_records_sqlite.py b/invokeai/app/services/image_records/image_records_sqlite.py index c6c237fc1e7..046b286bef3 100644 --- a/invokeai/app/services/image_records/image_records_sqlite.py +++ b/invokeai/app/services/image_records/image_records_sqlite.py @@ -280,17 +280,21 @@ def get_intermediates_count(self) -> int: count = cast(int, cursor.fetchone()[0]) return count - def delete_intermediates(self) -> list[str]: + def delete_intermediates(self) -> list[tuple[str, str]]: + """Deletes all intermediate image records. + + Returns a list of (image_name, image_subfolder) tuples for file cleanup. + """ with self._db.transaction() as cursor: try: cursor.execute( """--sql - SELECT image_name FROM images + SELECT image_name, image_subfolder FROM images WHERE is_intermediate = TRUE; """ ) result = cast(list[sqlite3.Row], cursor.fetchall()) - image_names = [r[0] for r in result] + image_name_subfolder_pairs = [(r[0], r[1]) for r in result] cursor.execute( """--sql DELETE FROM images @@ -299,7 +303,7 @@ def delete_intermediates(self) -> list[str]: ) except sqlite3.Error as e: raise ImageRecordDeleteException from e - return image_names + return image_name_subfolder_pairs def save( self, @@ -315,6 +319,7 @@ def save( node_id: Optional[str] = None, metadata: Optional[str] = None, user_id: Optional[str] = None, + image_subfolder: str = "", ) -> datetime: with self._db.transaction() as cursor: try: @@ -332,9 +337,10 @@ def save( is_intermediate, starred, has_workflow, - user_id + user_id, + image_subfolder ) - VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); + VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?); """, ( image_name, @@ -349,6 +355,7 @@ def save( starred, has_workflow, user_id or "system", + image_subfolder, ), ) diff --git a/invokeai/app/services/images/images_default.py b/invokeai/app/services/images/images_default.py index e82bd7f4de1..c11826974b8 100644 --- a/invokeai/app/services/images/images_default.py +++ b/invokeai/app/services/images/images_default.py @@ -8,6 +8,7 @@ ImageFileNotFoundException, ImageFileSaveException, ) +from invokeai.app.services.image_files.image_subfolder_strategy import create_subfolder_strategy from invokeai.app.services.image_records.image_records_common import ( ImageCategory, ImageNamesResult, @@ -55,6 +56,11 @@ def create( image_name = self.__invoker.services.names.create_image_name() + # Compute subfolder based on configured strategy + strategy_name = self.__invoker.services.configuration.image_subfolder_strategy + strategy = create_subfolder_strategy(strategy_name) + image_subfolder = strategy.get_subfolder(image_name, image_category, is_intermediate or False) + (width, height) = image.size try: @@ -74,6 +80,7 @@ def create( metadata=metadata, session_id=session_id, user_id=user_id, + image_subfolder=image_subfolder, ) if board_id is not None: try: @@ -83,7 +90,12 @@ def create( except Exception as e: self.__invoker.services.logger.warning(f"Failed to add image to board {board_id}: {str(e)}") self.__invoker.services.image_files.save( - image_name=image_name, image=image, metadata=metadata, workflow=workflow, graph=graph + image_name=image_name, + image=image, + metadata=metadata, + workflow=workflow, + graph=graph, + image_subfolder=image_subfolder, ) image_dto = self.get_dto(image_name) @@ -118,7 +130,8 @@ def update( def get_pil_image(self, image_name: str) -> PILImageType: try: - return self.__invoker.services.image_files.get(image_name) + record = self.__invoker.services.image_records.get(image_name) + return self.__invoker.services.image_files.get(image_name, image_subfolder=record.image_subfolder) except ImageFileNotFoundException: self.__invoker.services.logger.error("Failed to get image file") raise @@ -167,7 +180,8 @@ def get_metadata(self, image_name: str) -> Optional[MetadataField]: def get_workflow(self, image_name: str) -> Optional[str]: try: - return self.__invoker.services.image_files.get_workflow(image_name) + record = self.__invoker.services.image_records.get(image_name) + return self.__invoker.services.image_files.get_workflow(image_name, image_subfolder=record.image_subfolder) except ImageFileNotFoundException: self.__invoker.services.logger.error("Image file not found") raise @@ -177,7 +191,8 @@ def get_workflow(self, image_name: str) -> Optional[str]: def get_graph(self, image_name: str) -> Optional[str]: try: - return self.__invoker.services.image_files.get_graph(image_name) + record = self.__invoker.services.image_records.get(image_name) + return self.__invoker.services.image_files.get_graph(image_name, image_subfolder=record.image_subfolder) except ImageFileNotFoundException: self.__invoker.services.logger.error("Image file not found") raise @@ -187,7 +202,12 @@ def get_graph(self, image_name: str) -> Optional[str]: def get_path(self, image_name: str, thumbnail: bool = False) -> str: try: - return str(self.__invoker.services.image_files.get_path(image_name, thumbnail)) + record = self.__invoker.services.image_records.get(image_name) + return str( + self.__invoker.services.image_files.get_path( + image_name, thumbnail, image_subfolder=record.image_subfolder + ) + ) except Exception as e: self.__invoker.services.logger.error("Problem getting image path") raise e @@ -257,7 +277,8 @@ def get_many( def delete(self, image_name: str): try: - self.__invoker.services.image_files.delete(image_name) + record = self.__invoker.services.image_records.get(image_name) + self.__invoker.services.image_files.delete(image_name, image_subfolder=record.image_subfolder) self.__invoker.services.image_records.delete(image_name) self._on_deleted(image_name) except ImageRecordDeleteException: @@ -278,7 +299,11 @@ def delete_images_on_board(self, board_id: str): is_intermediate=None, ) for image_name in image_names: - self.__invoker.services.image_files.delete(image_name) + try: + record = self.__invoker.services.image_records.get(image_name) + self.__invoker.services.image_files.delete(image_name, image_subfolder=record.image_subfolder) + except Exception: + pass self.__invoker.services.image_records.delete_many(image_names) for image_name in image_names: self._on_deleted(image_name) @@ -294,10 +319,10 @@ def delete_images_on_board(self, board_id: str): def delete_intermediates(self) -> int: try: - image_names = self.__invoker.services.image_records.delete_intermediates() - count = len(image_names) - for image_name in image_names: - self.__invoker.services.image_files.delete(image_name) + image_name_subfolder_pairs = self.__invoker.services.image_records.delete_intermediates() + count = len(image_name_subfolder_pairs) + for image_name, image_subfolder in image_name_subfolder_pairs: + self.__invoker.services.image_files.delete(image_name, image_subfolder=image_subfolder) self._on_deleted(image_name) return count except ImageRecordDeleteException: diff --git a/invokeai/app/services/shared/sqlite/sqlite_util.py b/invokeai/app/services/shared/sqlite/sqlite_util.py index 645509f1dde..2478e8cdcae 100644 --- a/invokeai/app/services/shared/sqlite/sqlite_util.py +++ b/invokeai/app/services/shared/sqlite/sqlite_util.py @@ -30,6 +30,7 @@ from invokeai.app.services.shared.sqlite_migrator.migrations.migration_25 import build_migration_25 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_26 import build_migration_26 from invokeai.app.services.shared.sqlite_migrator.migrations.migration_27 import build_migration_27 +from invokeai.app.services.shared.sqlite_migrator.migrations.migration_28 import build_migration_28 from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_impl import SqliteMigrator @@ -77,6 +78,7 @@ def init_db(config: InvokeAIAppConfig, logger: Logger, image_files: ImageFileSto migrator.register_migration(build_migration_25(app_config=config, logger=logger)) migrator.register_migration(build_migration_26(app_config=config, logger=logger)) migrator.register_migration(build_migration_27()) + migrator.register_migration(build_migration_28()) migrator.run_migrations() return db diff --git a/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py new file mode 100644 index 00000000000..509fa4bb495 --- /dev/null +++ b/invokeai/app/services/shared/sqlite_migrator/migrations/migration_28.py @@ -0,0 +1,41 @@ +"""Migration 28: Add image_subfolder column to images table. + +This migration adds an image_subfolder column to the images table to support +configurable image subfolder strategies (flat, date, type, hash). +Existing images get an empty string (flat/root directory). +""" + +import sqlite3 + +from invokeai.app.services.shared.sqlite_migrator.sqlite_migrator_common import Migration + + +class Migration28Callback: + """Migration to add image_subfolder column to images table.""" + + def __call__(self, cursor: sqlite3.Cursor) -> None: + self._add_image_subfolder_column(cursor) + + def _add_image_subfolder_column(self, cursor: sqlite3.Cursor) -> None: + """Add image_subfolder column to images table.""" + cursor.execute("SELECT name FROM sqlite_master WHERE type='table' AND name='images';") + if cursor.fetchone() is None: + return + + cursor.execute("PRAGMA table_info(images);") + columns = [row[1] for row in cursor.fetchall()] + + if "image_subfolder" not in columns: + cursor.execute("ALTER TABLE images ADD COLUMN image_subfolder TEXT NOT NULL DEFAULT '';") + + +def build_migration_28() -> Migration: + """Builds the migration object for migrating from version 27 to version 28. + + This migration adds an image_subfolder column to the images table. + """ + return Migration( + from_version=27, + to_version=28, + callback=Migration28Callback(), + ) From 64460b92998f2a3158c8328bd5fc12a8c4b4966f Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Tue, 17 Mar 2026 02:23:24 +0100 Subject: [PATCH 2/7] Chore ruff check --- invokeai/app/services/image_files/image_files_disk.py | 10 +++++----- 1 file changed, 5 insertions(+), 5 deletions(-) diff --git a/invokeai/app/services/image_files/image_files_disk.py b/invokeai/app/services/image_files/image_files_disk.py index 1f776472d4b..12b737a7cf1 100644 --- a/invokeai/app/services/image_files/image_files_disk.py +++ b/invokeai/app/services/image_files/image_files_disk.py @@ -148,15 +148,15 @@ def _validate_subfolder(subfolder: str) -> None: """Validates a subfolder path to prevent directory traversal while allowing controlled subdirectories.""" if not subfolder: return - if '\\' in subfolder: + if "\\" in subfolder: raise ValueError("Backslashes not allowed in subfolder path") - if subfolder.startswith('/'): + if subfolder.startswith("/"): raise ValueError("Absolute paths not allowed in subfolder path") - parts = subfolder.split('/') + parts = subfolder.split("/") for part in parts: - if part == '..': + if part == "..": raise ValueError("Parent directory references not allowed in subfolder path") - if part == '': + if part == "": raise ValueError("Empty path segments not allowed in subfolder path") def validate_path(self, path: Union[str, Path]) -> bool: From 4d535a867fcd2e2b5e914fc455600d5a7549bfa2 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Tue, 17 Mar 2026 02:26:08 +0100 Subject: [PATCH 3/7] Chore typegen --- invokeai/frontend/web/src/services/api/schema.ts | 16 +++++++++++++++- 1 file changed, 15 insertions(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index fc6506ce22b..e3a22591c23 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -12263,6 +12263,12 @@ export type components = { * @description Whether this image has a workflow. */ has_workflow: boolean; + /** + * Image Subfolder + * @description The subfolder where the image is stored on disk. + * @default + */ + image_subfolder?: string; /** * Board Id * @description The id of the board the image belongs to, if one exists. @@ -14354,6 +14360,7 @@ export type components = { * legacy_conf_dir: Path to directory of legacy checkpoint config files. * db_dir: Path to InvokeAI databases directory. * outputs_dir: Path to directory for outputs. + * image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder (default). 'date' organizes by YYYY/MM/DD. 'type' organizes by image category (general, intermediate, etc.). 'hash' uses first 2 characters of UUID for filesystem performance. * custom_nodes_dir: Path to directory for custom nodes. * style_presets_dir: Path to directory for style presets. * workflow_thumbnails_dir: Path to directory for workflow thumbnails. @@ -14402,7 +14409,7 @@ export type components = { /** * Schema Version * @description Schema version of the config file. This is not a user-configurable setting. - * @default 4.0.2 + * @default 4.0.3 */ schema_version?: string; /** @@ -14514,6 +14521,13 @@ export type components = { * @default outputs */ outputs_dir?: string; + /** + * 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 + * @enum {string} + */ + image_subfolder_strategy?: "flat" | "date" | "type" | "hash"; /** * Custom Nodes Dir * Format: path From 7e1b53232c10f7276f3fa64563e709704b4458fa Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Tue, 17 Mar 2026 02:32:01 +0100 Subject: [PATCH 4/7] Chore fix ts types --- invokeai/frontend/web/src/services/api/types.ts | 1 + 1 file changed, 1 insertion(+) diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 5d56c346f87..8f36eba1fac 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -69,6 +69,7 @@ const _zImageDTO = z.object({ starred: z.boolean(), has_workflow: z.boolean(), board_id: z.string().nullish(), + image_subfolder: z.string(), }); export type ImageDTO = z.infer; assert>(); From b67398653e8b431d923f4135d95928d43b4cf503 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Tue, 17 Mar 2026 02:36:48 +0100 Subject: [PATCH 5/7] make subfolder optional --- invokeai/frontend/web/src/services/api/types.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/types.ts b/invokeai/frontend/web/src/services/api/types.ts index 8f36eba1fac..14a77dad843 100644 --- a/invokeai/frontend/web/src/services/api/types.ts +++ b/invokeai/frontend/web/src/services/api/types.ts @@ -69,7 +69,7 @@ const _zImageDTO = z.object({ starred: z.boolean(), has_workflow: z.boolean(), board_id: z.string().nullish(), - image_subfolder: z.string(), + image_subfolder: z.string().optional(), }); export type ImageDTO = z.infer; assert>(); From 928a3ec77505b31d9fc7000faef4a6e37a4d9636 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Tue, 17 Mar 2026 02:51:45 +0100 Subject: [PATCH 6/7] Add image_subfolder_strategy to InvokeAIAppConfig --- invokeai/app/services/config/config_default.py | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/app/services/config/config_default.py b/invokeai/app/services/config/config_default.py index 2bf9efb556f..9f8ffb6d655 100644 --- a/invokeai/app/services/config/config_default.py +++ b/invokeai/app/services/config/config_default.py @@ -70,7 +70,7 @@ class InvokeAIAppConfig(BaseSettings): legacy_conf_dir: Path to directory of legacy checkpoint config files. db_dir: Path to InvokeAI databases directory. outputs_dir: Path to directory for outputs. - image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder (default). 'date' organizes by YYYY/MM/DD. 'type' organizes by image category (general, intermediate, etc.). 'hash' uses first 2 characters of UUID for filesystem performance. + 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` custom_nodes_dir: Path to directory for custom nodes. style_presets_dir: Path to directory for style presets. workflow_thumbnails_dir: Path to directory for workflow thumbnails. From 2a301953d56a11a7ef92d6f6175ee7c417bd47e5 Mon Sep 17 00:00:00 2001 From: Alexander Eichhorn Date: Tue, 17 Mar 2026 02:59:55 +0100 Subject: [PATCH 7/7] Chore typegen again --- invokeai/frontend/web/src/services/api/schema.ts | 2 +- 1 file changed, 1 insertion(+), 1 deletion(-) diff --git a/invokeai/frontend/web/src/services/api/schema.ts b/invokeai/frontend/web/src/services/api/schema.ts index e3a22591c23..6a70ef5f66f 100644 --- a/invokeai/frontend/web/src/services/api/schema.ts +++ b/invokeai/frontend/web/src/services/api/schema.ts @@ -14360,7 +14360,7 @@ export type components = { * legacy_conf_dir: Path to directory of legacy checkpoint config files. * db_dir: Path to InvokeAI databases directory. * outputs_dir: Path to directory for outputs. - * image_subfolder_strategy: Strategy for organizing images into subfolders. 'flat' stores all images in a single folder (default). 'date' organizes by YYYY/MM/DD. 'type' organizes by image category (general, intermediate, etc.). 'hash' uses first 2 characters of UUID for filesystem performance. + * 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` * custom_nodes_dir: Path to directory for custom nodes. * style_presets_dir: Path to directory for style presets. * workflow_thumbnails_dir: Path to directory for workflow thumbnails.