Skip to content

feat(services): add S3CompatibleImageFileStorage (first cloud impl of ImageFileStorageBase)#9182

Open
goanpeca wants to merge 1 commit into
invoke-ai:mainfrom
goanpeca:enh/s3-image-file-storage
Open

feat(services): add S3CompatibleImageFileStorage (first cloud impl of ImageFileStorageBase)#9182
goanpeca wants to merge 1 commit into
invoke-ai:mainfrom
goanpeca:enh/s3-image-file-storage

Conversation

@goanpeca
Copy link
Copy Markdown

Summary

First concrete cloud impl of ImageFileStorageBase. The ABC was introduced in #1650 with cloud storage explicitly named as the design rationale ("if someone wants to use cloud storage for their images, they should be able to replace the image storage service easily."), but no implementation has ever landed.

S3CompatibleImageFileStorage lives at invokeai/app/services/image_files/image_files_s3.py, parallel to image_files_disk.py. Works against AWS S3 by default and any S3-compatible store (Backblaze B2, MinIO, etc.) when s3_endpoint_url is set. Selected via a new opt-in storage_backend: Literal["disk", "s3"] field on InvokeAIAppConfig, defaulting to "disk" — behavior is unchanged for existing users. No new runtime dep; boto3 is already pulled in by the download service.

Related Issues / Discussions

Open Questions

Opening as draft so these can be discussed against concrete code. Same questions as #9121:

  1. LRU cacheDiskImageFileStorage keeps an in-process LRU; the S3 impl is stateless. Ship the cache now, or v1 without and follow up after profiling?
  2. pil_compress_level — disk reads it from config at save time; S3 currently uses PIL's default. Thread it through?
  3. get_path contract — typed Path, but S3 has no real path. We return a synthetic s3://...; long-term answer is presigned URLs. OK as a stub?
  4. Tests — hand-rolled _FakeS3Client injected via client= (no new dep). Want moto instead?
  5. Follow-up scopeS3CompatibleObjectSerializer (latents) and S3PresignedUrlService (frontend direct-fetch). Keep them as separate PRs?

QA Instructions

export INVOKEAI_STORAGE_BACKEND=s3
export INVOKEAI_S3_BUCKET=<bucket>
export INVOKEAI_S3_ENDPOINT_URL=<empty for AWS, or e.g. https://s3.us-west-004.backblazeb2.com>
export INVOKEAI_S3_REGION=<region>
# AWS_ACCESS_KEY_ID/SECRET, or B2_APPLICATION_KEY_ID/KEY for B2
invokeai-web

Generate an image, confirm gallery preview + workflow round-trip, confirm images/<name>.png and thumbnails/<name>.webp land in the bucket, delete from gallery and confirm both keys disappear. Smoke-tested against AWS us-east-1 and Backblaze B2 us-west-004.

Unit tests: uv run pytest tests/app/services/image_files/test_image_files_s3.py tests/test_config.py -v (13 tests).

Checklist

  • The PR has a short but descriptive title, suitable for a changelog
  • Tests added / updated (if applicable)
  • ❗Changes to a redux slice have a corresponding migration — N/A
  • Documentation added / updated (if applicable)
  • Updated What's New copy (if doing a release after this PR) — happy to do this once a release is targeted

@goanpeca goanpeca requested a review from blessedcoolant as a code owner May 15, 2026 02:39
Copilot AI review requested due to automatic review settings May 15, 2026 02:39
@github-actions github-actions Bot added api python PRs that change python files Root services PRs that change app services python-tests PRs that change python tests docs PRs that change docs labels May 15, 2026
Copy link
Copy Markdown
Contributor

Copilot AI left a comment

Choose a reason for hiding this comment

The reason will be displayed to describe this comment to others. Learn more.

Pull request overview

Adds an opt-in S3-compatible image file storage backend that implements ImageFileStorageBase, parallel to the existing on-disk implementation. The backend is selected via a new storage_backend config field defaulting to "disk", so behavior is unchanged for existing users. boto3 is reused (already a transitive dep), with conveniences for AWS S3, Backblaze B2, and any S3-compatible store.

Changes:

  • New S3CompatibleImageFileStorage plus user-metadata-based workflow/graph lookups and a synthetic s3:// get_path stub.
  • New storage_backend, s3_bucket, s3_endpoint_url config fields and dispatch in dependencies.initialize().
  • Documentation pages (mkdocs and starlight) plus unit tests against a hand-rolled fake S3 client.

Reviewed changes

Copilot reviewed 8 out of 8 changed files in this pull request and generated 6 comments.

Show a summary per file
File Description
invokeai/app/services/image_files/image_files_s3.py New S3-compatible image storage implementation.
invokeai/app/services/config/config_default.py Adds storage_backend, s3_bucket, s3_endpoint_url settings.
invokeai/app/api/dependencies.py Selects disk vs. S3 image storage at startup.
tests/app/services/image_files/test_image_files_s3.py Unit tests using a fake S3 client.
tests/test_config.py Tests for the new storage_backend config field.
docs/src/content/docs/configuration/object-storage.mdx Starlight docs page for the new backend.
docs-old/configuration/object-storage.md mkdocs docs page mirror.
mkdocs.yml Adds the new docs page to navigation.

💡 Add Copilot custom instructions for smarter, more guided reviews. Learn how to get started.

Comment on lines +182 to +186
def get_path(self, image_name: str, thumbnail: bool = False) -> Path:
# Synthetic s3:// path; callers needing a real filesystem path should
# migrate to a presigned-URL service.
key = self._object_key(image_name, thumbnail=thumbnail)
return Path(f"s3://{self._bucket}/{key}")
image.info = info_dict

png_buffer = io.BytesIO()
image.save(png_buffer, format="PNG", pnginfo=pnginfo)
Comment on lines +132 to +136
```
<bucket>/
images/<image-name>.png
thumbnails/<image-name>.thumbnail.webp
```
```
<bucket>/
images/<image-name>.png
thumbnails/<image-name>.thumbnail.webp
Comment on lines +164 to +167
# STORAGE
storage_backend: Literal["disk", "s3"] = Field(default="disk", description='Backend for storing generated images. "disk" uses the local filesystem; "s3" uses any S3-compatible object store (AWS S3, Backblaze B2, etc.).')
s3_bucket: Optional[str] = Field(default=None, description='Bucket name for the s3 storage backend. Required when storage_backend="s3".')
s3_endpoint_url: Optional[str] = Field(default=None, description='Endpoint URL for the s3 storage backend. Leave unset to talk to AWS S3; set to a provider-specific URL (e.g. https://s3.us-west-004.backblazeb2.com for Backblaze B2) for any other S3-compatible store.')
Comment on lines +237 to +255
def _safe_meta(value: str) -> str:
"""Encode arbitrary unicode metadata for safe transport as S3 user-metadata."""
try:
value.encode("ascii")
return value
except UnicodeEncodeError:
return json.dumps({"__b64__": True, "v": value.encode("utf-8").hex()})


def _unsafe_meta(value: str) -> str:
"""Inverse of `_safe_meta`."""
if value.startswith("{") and "__b64__" in value:
try:
payload = json.loads(value)
if isinstance(payload, dict) and payload.get("__b64__"):
return bytes.fromhex(payload["v"]).decode("utf-8")
except (ValueError, KeyError):
pass
return value
Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

api docs PRs that change docs python PRs that change python files python-tests PRs that change python tests Root services PRs that change app services

Projects

None yet

2 participants