Files
wehub-resource-sync cddb07a176
docs / deploy (push) Has been cancelled
docs / changes (push) Has been cancelled
docs / check-and-build (push) Has been cancelled
build container image / cpu (push) Has been cancelled
build container image / cuda (push) Has been cancelled
build container image / rocm (push) Has been cancelled
frontend checks / frontend-checks (push) Has been cancelled
frontend tests / frontend-tests (push) Has been cancelled
lfs checks / lfs-check (push) Has been cancelled
python checks / python-checks (push) Has been cancelled
python tests / py3.12: macos-default (push) Has been cancelled
python tests / py3.11: windows-cpu (push) Has been cancelled
python tests / py3.12: windows-cpu (push) Has been cancelled
python tests / py3.11: linux-cpu (push) Has been cancelled
typegen checks / typegen-checks (push) Has been cancelled
uv lock checks / uv-lock-checks (push) Has been cancelled
openapi checks / openapi-checks (push) Has been cancelled
python tests / py3.11: macos-default (push) Has been cancelled
python tests / py3.12: linux-cpu (push) Has been cancelled
chore: import upstream snapshot with attribution
2026-07-13 13:22:06 +08:00

254 lines
10 KiB
Python

# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654) and the InvokeAI Team
import io
import zlib
from pathlib import Path
from queue import Queue
from typing import Optional, Union
from PIL import Image, PngImagePlugin
from PIL.Image import Image as PILImageType
from invokeai.app.services.image_files.image_files_base import ImageFileStorageBase
from invokeai.app.services.image_files.image_files_common import (
ImageFileDeleteException,
ImageFileNotFoundException,
ImageFileSaveException,
)
from invokeai.app.services.invoker import Invoker
from invokeai.app.util.thumbnails import get_thumbnail_name, make_thumbnail
_PNG_RLE_MIN_PIXELS = 512 * 512
_PNG_RLE_SAMPLE_TILE = 32
_PNG_RLE_MIN_RAW_SIZE_PERCENT = 30
_PNG_RLE_MAX_SAMPLE_SIZE_PERCENT = 102
def _get_png_size(image: PILImageType, compress_type: Optional[int] = None) -> int:
output = io.BytesIO()
options = {"compress_level": 1}
if compress_type is not None:
options["compress_type"] = compress_type
image.save(output, "PNG", **options)
return output.tell()
def _should_use_png_rle(image: PILImageType) -> bool:
if image.mode not in {"RGB", "RGBA"} or image.width * image.height < _PNG_RLE_MIN_PIXELS:
return False
# Native-resolution tiles distinguish high-entropy data from filter-friendly structured images.
tile_width = min(_PNG_RLE_SAMPLE_TILE, image.width)
tile_height = min(_PNG_RLE_SAMPLE_TILE, image.height)
x_positions = (0, (image.width - tile_width) // 2, image.width - tile_width)
y_positions = (0, (image.height - tile_height) // 2, image.height - tile_height)
sample = Image.new(image.mode, (tile_width * 3, tile_height * 3))
for row, y in enumerate(y_positions):
for column, x in enumerate(x_positions):
with image.crop((x, y, x + tile_width, y + tile_height)) as tile:
sample.paste(tile, (column * tile_width, row * tile_height))
try:
raw = sample.tobytes()
if len(zlib.compress(raw, level=1)) * 100 < len(raw) * _PNG_RLE_MIN_RAW_SIZE_PERCENT:
return False
default_size = _get_png_size(sample)
rle_size = _get_png_size(sample, zlib.Z_RLE)
return rle_size * 100 <= default_size * _PNG_RLE_MAX_SAMPLE_SIZE_PERCENT
finally:
sample.close()
class DiskImageFileStorage(ImageFileStorageBase):
"""Stores images on disk"""
def __init__(self, output_folder: Union[str, Path]):
self.__cache: dict[Path, PILImageType] = {}
self.__cache_ids = Queue[Path]()
self.__max_cache_size = 10 # TODO: get this from config
self.__output_folder = output_folder if isinstance(output_folder, Path) else Path(output_folder)
self.__thumbnails_folder = self.__output_folder / "thumbnails"
# Validate required output folders at launch
self.__validate_storage_folders()
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)
cache_item = self.__get_cache(image_path)
if cache_item:
return cache_item
image = Image.open(image_path)
self.__set_cache(image_path, image)
return image
except FileNotFoundError as e:
raise ImageFileNotFoundException from e
def save(
self,
image: PILImageType,
image_name: str,
metadata: Optional[str] = None,
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_subfolder=image_subfolder)
# Ensure subfolder directories exist
image_path.parent.mkdir(parents=True, exist_ok=True)
pnginfo = PngImagePlugin.PngInfo()
info_dict = {}
if metadata is not None:
info_dict["invokeai_metadata"] = metadata
pnginfo.add_text("invokeai_metadata", metadata)
if workflow is not None:
info_dict["invokeai_workflow"] = workflow
pnginfo.add_text("invokeai_workflow", workflow)
if graph is not None:
info_dict["invokeai_graph"] = graph
pnginfo.add_text("invokeai_graph", graph)
# When saving the image, the image object's info field is not populated. We need to set it
image.info = info_dict
compress_level = self.__invoker.services.configuration.pil_compress_level
save_options = {"compress_level": compress_level}
if compress_level == 1 and _should_use_png_rle(image):
save_options["compress_type"] = zlib.Z_RLE
image.save(
image_path,
"PNG",
pnginfo=pnginfo,
**save_options,
)
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)
thumbnail_image = make_thumbnail(image, thumbnail_size)
thumbnail_image.save(thumbnail_path)
self.__set_cache(image_path, image)
self.__set_cache(thumbnail_path, thumbnail_image)
except Exception as e:
raise ImageFileSaveException from e
def delete(self, image_name: str, image_subfolder: str = "") -> None:
try:
image_path = self.get_path(image_name, image_subfolder=image_subfolder)
if image_path.exists():
image_path.unlink()
if image_path in self.__cache:
del self.__cache[image_path]
thumbnail_path = self.get_path(image_name, True, image_subfolder=image_subfolder)
if thumbnail_path.exists():
thumbnail_path.unlink()
if thumbnail_path in self.__cache:
del self.__cache[thumbnail_path]
except Exception as e:
raise ImageFileDeleteException from e
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
# 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")
# 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()
resolved_image_path = image_path.resolve()
if not resolved_image_path.is_relative_to(resolved_base):
raise ValueError("Image path outside outputs folder, potential directory traversal detected")
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, 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, 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
return None
def __validate_storage_folders(self) -> None:
"""Checks if the required output folders exist and create them if they don't"""
folders: list[Path] = [self.__output_folder, self.__thumbnails_folder]
for folder in folders:
folder.mkdir(parents=True, exist_ok=True)
def __get_cache(self, image_name: Path) -> Optional[PILImageType]:
return None if image_name not in self.__cache else self.__cache[image_name]
def __set_cache(self, image_name: Path, image: PILImageType):
if image_name not in self.__cache:
self.__cache[image_name] = image
self.__cache_ids.put(image_name) # TODO: this should refresh position for LRU cache
if len(self.__cache) > self.__max_cache_size:
cache_id = self.__cache_ids.get()
if cache_id in self.__cache:
del self.__cache[cache_id]