Skip to content

Commit b5865fe

Browse files
Merge branch 'main' into main
2 parents 701e9dc + 59a8c0d commit b5865fe

File tree

156 files changed

+4963
-1868
lines changed

Some content is hidden

Large Commits have some content hidden by default. Use the searchbox below for content that may be hidden.

156 files changed

+4963
-1868
lines changed

.github/CODEOWNERS

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -1,12 +1,12 @@
11
# continuous integration
2-
/.github/workflows/ @lstein @blessedcoolant @hipsterusername @ebr
2+
/.github/workflows/ @lstein @blessedcoolant @hipsterusername @ebr @jazzhaiku
33

44
# documentation
55
/docs/ @lstein @blessedcoolant @hipsterusername @Millu
66
/mkdocs.yml @lstein @blessedcoolant @hipsterusername @Millu
77

88
# nodes
9-
/invokeai/app/ @Kyle0654 @blessedcoolant @psychedelicious @brandonrising @hipsterusername
9+
/invokeai/app/ @Kyle0654 @blessedcoolant @psychedelicious @brandonrising @hipsterusername @jazzhaiku
1010

1111
# installation and configuration
1212
/pyproject.toml @lstein @blessedcoolant @hipsterusername
@@ -22,7 +22,7 @@
2222
/invokeai/backend @blessedcoolant @psychedelicious @lstein @maryhipp @hipsterusername
2323

2424
# generation, model management, postprocessing
25-
/invokeai/backend @damian0815 @lstein @blessedcoolant @gregghelt2 @StAlKeR7779 @brandonrising @ryanjdick @hipsterusername
25+
/invokeai/backend @damian0815 @lstein @blessedcoolant @gregghelt2 @StAlKeR7779 @brandonrising @ryanjdick @hipsterusername @jazzhaiku
2626

2727
# front ends
2828
/invokeai/frontend/CLI @lstein @hipsterusername

.github/workflows/python-checks.yml

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -62,7 +62,7 @@ jobs:
6262

6363
- name: install ruff
6464
if: ${{ steps.changed-files.outputs.python_any_changed == 'true' || inputs.always_run == true }}
65-
run: pip install ruff==0.6.0
65+
run: pip install ruff==0.9.9
6666
shell: bash
6767

6868
- name: ruff check

docker/Dockerfile

Lines changed: 33 additions & 17 deletions
Original file line numberDiff line numberDiff line change
@@ -13,48 +13,63 @@ RUN --mount=type=cache,target=/var/cache/apt,sharing=locked \
1313
git
1414

1515
# Install `uv` for package management
16-
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
16+
COPY --from=ghcr.io/astral-sh/uv:0.6.0 /uv /uvx /bin/
1717

1818
ENV VIRTUAL_ENV=/opt/venv
1919
ENV PATH="$VIRTUAL_ENV/bin:$PATH"
2020
ENV INVOKEAI_SRC=/opt/invokeai
2121
ENV PYTHON_VERSION=3.11
22+
ENV UV_PYTHON=3.11
2223
ENV UV_COMPILE_BYTECODE=1
2324
ENV UV_LINK_MODE=copy
25+
ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV"
26+
ENV UV_INDEX="https://download.pytorch.org/whl/cu124"
2427

2528
ARG GPU_DRIVER=cuda
26-
ARG TARGETPLATFORM="linux/amd64"
2729
# unused but available
2830
ARG BUILDPLATFORM
2931

3032
# Switch to the `ubuntu` user to work around dependency issues with uv-installed python
3133
RUN mkdir -p ${VIRTUAL_ENV} && \
3234
mkdir -p ${INVOKEAI_SRC} && \
33-
chmod -R a+w /opt
35+
chmod -R a+w /opt && \
36+
mkdir ~ubuntu/.cache && chown ubuntu: ~ubuntu/.cache
3437
USER ubuntu
3538

36-
# Install python and create the venv
37-
RUN uv python install ${PYTHON_VERSION} && \
38-
uv venv --relocatable --prompt "invoke" --python ${PYTHON_VERSION} ${VIRTUAL_ENV}
39+
# Install python
40+
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
41+
uv python install ${PYTHON_VERSION}
3942

4043
WORKDIR ${INVOKEAI_SRC}
41-
COPY invokeai ./invokeai
42-
COPY pyproject.toml ./
4344

44-
# Editable mode helps use the same image for development:
45-
# the local working copy can be bind-mounted into the image
46-
# at path defined by ${INVOKEAI_SRC}
45+
# Install project's dependencies as a separate layer so they aren't rebuilt every commit.
46+
# bind-mount instead of copy to defer adding sources to the image until next layer.
47+
#
4748
# NOTE: there are no pytorch builds for arm64 + cuda, only cpu
4849
# x86_64/CUDA is the default
4950
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
51+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
52+
--mount=type=bind,source=invokeai/version,target=invokeai/version \
53+
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then \
54+
UV_INDEX="https://download.pytorch.org/whl/cpu"; \
55+
elif [ "$GPU_DRIVER" = "rocm" ]; then \
56+
UV_INDEX="https://download.pytorch.org/whl/rocm6.1"; \
57+
fi && \
58+
uv sync --no-install-project
59+
60+
# Now that the bulk of the dependencies have been installed, copy in the project files that change more frequently.
61+
COPY invokeai invokeai
62+
COPY pyproject.toml .
63+
64+
RUN --mount=type=cache,target=/home/ubuntu/.cache/uv,uid=1000,gid=1000 \
65+
--mount=type=bind,source=pyproject.toml,target=pyproject.toml \
5066
if [ "$TARGETPLATFORM" = "linux/arm64" ] || [ "$GPU_DRIVER" = "cpu" ]; then \
51-
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cpu"; \
67+
UV_INDEX="https://download.pytorch.org/whl/cpu"; \
5268
elif [ "$GPU_DRIVER" = "rocm" ]; then \
53-
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/rocm6.1"; \
54-
else \
55-
extra_index_url_arg="--extra-index-url https://download.pytorch.org/whl/cu124"; \
69+
UV_INDEX="https://download.pytorch.org/whl/rocm6.1"; \
5670
fi && \
57-
uv pip install --python ${PYTHON_VERSION} $extra_index_url_arg -e "."
71+
uv sync
72+
5873

5974
#### Build the Web UI ------------------------------------
6075

@@ -98,6 +113,7 @@ RUN apt update && apt install -y --no-install-recommends \
98113

99114
ENV INVOKEAI_SRC=/opt/invokeai
100115
ENV VIRTUAL_ENV=/opt/venv
116+
ENV UV_PROJECT_ENVIRONMENT="$VIRTUAL_ENV"
101117
ENV PYTHON_VERSION=3.11
102118
ENV INVOKEAI_ROOT=/invokeai
103119
ENV INVOKEAI_HOST=0.0.0.0
@@ -109,7 +125,7 @@ ENV CONTAINER_GID=${CONTAINER_GID:-1000}
109125
# Install `uv` for package management
110126
# and install python for the ubuntu user (expected to exist on ubuntu >=24.x)
111127
# this is too tiny to optimize with multi-stage builds, but maybe we'll come back to it
112-
COPY --from=ghcr.io/astral-sh/uv:0.5.5 /uv /uvx /bin/
128+
COPY --from=ghcr.io/astral-sh/uv:0.6.0 /uv /uvx /bin/
113129
USER ubuntu
114130
RUN uv python install ${PYTHON_VERSION}
115131
USER root

invokeai/app/api/dependencies.py

Lines changed: 4 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -36,6 +36,7 @@
3636
from invokeai.app.services.style_preset_records.style_preset_records_sqlite import SqliteStylePresetRecordsStorage
3737
from invokeai.app.services.urls.urls_default import LocalUrlService
3838
from invokeai.app.services.workflow_records.workflow_records_sqlite import SqliteWorkflowRecordsStorage
39+
from invokeai.app.services.workflow_thumbnails.workflow_thumbnails_disk import WorkflowThumbnailFileStorageDisk
3940
from invokeai.backend.stable_diffusion.diffusion.conditioning_data import ConditioningFieldData
4041
from invokeai.backend.util.logging import InvokeAILogger
4142
from invokeai.version.invokeai_version import __version__
@@ -83,6 +84,7 @@ def initialize(
8384

8485
model_images_folder = config.models_path
8586
style_presets_folder = config.style_presets_path
87+
workflow_thumbnails_folder = config.workflow_thumbnails_path
8688

8789
db = init_db(config=config, logger=logger, image_files=image_files)
8890

@@ -120,6 +122,7 @@ def initialize(
120122
workflow_records = SqliteWorkflowRecordsStorage(db=db)
121123
style_preset_records = SqliteStylePresetRecordsStorage(db=db)
122124
style_preset_image_files = StylePresetImageFileStorageDisk(style_presets_folder / "images")
125+
workflow_thumbnails = WorkflowThumbnailFileStorageDisk(workflow_thumbnails_folder)
123126

124127
services = InvocationServices(
125128
board_image_records=board_image_records,
@@ -147,6 +150,7 @@ def initialize(
147150
conditioning=conditioning,
148151
style_preset_records=style_preset_records,
149152
style_preset_image_files=style_preset_image_files,
153+
workflow_thumbnails=workflow_thumbnails,
150154
)
151155

152156
ApiDependencies.invoker = Invoker(services)
Lines changed: 124 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,124 @@
1+
import json
2+
import logging
3+
from dataclasses import dataclass
4+
5+
from PIL import Image
6+
7+
from invokeai.app.services.workflow_records.workflow_records_common import WorkflowWithoutIDValidator
8+
9+
10+
@dataclass
11+
class ExtractedMetadata:
12+
invokeai_metadata: str | None
13+
invokeai_workflow: str | None
14+
invokeai_graph: str | None
15+
16+
17+
def extract_metadata_from_image(
18+
pil_image: Image.Image,
19+
invokeai_metadata_override: str | None,
20+
invokeai_workflow_override: str | None,
21+
invokeai_graph_override: str | None,
22+
logger: logging.Logger,
23+
) -> ExtractedMetadata:
24+
"""
25+
Extracts the "invokeai_metadata", "invokeai_workflow", and "invokeai_graph" data embedded in the PIL Image.
26+
27+
These items are stored as stringified JSON in the image file's metadata, so we need to do some parsing to validate
28+
them. Once parsed, the values are returned as they came (as strings), or None if they are not present or invalid.
29+
30+
In some situations, we may prefer to override the values extracted from the image file with some other values.
31+
32+
For example, when uploading an image via API, the client can optionally provide the metadata directly in the request,
33+
as opposed to embedding it in the image file. In this case, the client-provided metadata will be used instead of the
34+
metadata embedded in the image file.
35+
36+
Args:
37+
pil_image: The PIL Image object.
38+
invokeai_metadata_override: The metadata override provided by the client.
39+
invokeai_workflow_override: The workflow override provided by the client.
40+
invokeai_graph_override: The graph override provided by the client.
41+
logger: The logger to use for debug logging.
42+
43+
Returns:
44+
ExtractedMetadata: The extracted metadata, workflow, and graph.
45+
"""
46+
47+
# The fallback value for metadata is None.
48+
stringified_metadata: str | None = None
49+
50+
# Use the metadata override if provided, else attempt to extract it from the image file.
51+
metadata_raw = invokeai_metadata_override or pil_image.info.get("invokeai_metadata", None)
52+
53+
# If the metadata is present in the image file, we will attempt to parse it as JSON. When we create images,
54+
# we always store metadata as a stringified JSON dict. So, we expect it to be a string here.
55+
if isinstance(metadata_raw, str):
56+
try:
57+
# Must be a JSON string
58+
metadata_parsed = json.loads(metadata_raw)
59+
# Must be a dict
60+
if isinstance(metadata_parsed, dict):
61+
# Looks good, overwrite the fallback value
62+
stringified_metadata = metadata_raw
63+
except Exception as e:
64+
logger.debug(f"Failed to parse metadata for uploaded image, {e}")
65+
pass
66+
67+
# We expect the workflow, if embedded in the image, to be a JSON-stringified WorkflowWithoutID. We will store it
68+
# as a string.
69+
workflow_raw: str | None = invokeai_workflow_override or pil_image.info.get("invokeai_workflow", None)
70+
71+
# The fallback value for workflow is None.
72+
stringified_workflow: str | None = None
73+
74+
# If the workflow is present in the image file, we will attempt to parse it as JSON. When we create images, we
75+
# always store workflows as a stringified JSON WorkflowWithoutID. So, we expect it to be a string here.
76+
if isinstance(workflow_raw, str):
77+
try:
78+
# Validate the workflow JSON before storing it
79+
WorkflowWithoutIDValidator.validate_json(workflow_raw)
80+
# Looks good, overwrite the fallback value
81+
stringified_workflow = workflow_raw
82+
except Exception:
83+
logger.debug("Failed to parse workflow for uploaded image")
84+
pass
85+
86+
# We expect the workflow, if embedded in the image, to be a JSON-stringified Graph. We will store it as a
87+
# string.
88+
graph_raw: str | None = invokeai_graph_override or pil_image.info.get("invokeai_graph", None)
89+
90+
# The fallback value for graph is None.
91+
stringified_graph: str | None = None
92+
93+
# If the graph is present in the image file, we will attempt to parse it as JSON. When we create images, we
94+
# always store graphs as a stringified JSON Graph. So, we expect it to be a string here.
95+
if isinstance(graph_raw, str):
96+
try:
97+
# TODO(psyche): Due to pydantic's handling of None values, it is possible for the graph to fail validation,
98+
# even if it is a direct dump of a valid graph. Node fields in the graph are allowed to have be unset if
99+
# they have incoming connections, but something about the ser/de process cannot adequately handle this.
100+
#
101+
# In lieu of fixing the graph validation, we will just do a simple check here to see if the graph is dict
102+
# with the correct keys. This is not a perfect solution, but it should be good enough for now.
103+
104+
# FIX ME: Validate the graph JSON before storing it
105+
# Graph.model_validate_json(graph_raw)
106+
107+
# Crappy workaround to validate JSON
108+
graph_parsed = json.loads(graph_raw)
109+
if not isinstance(graph_parsed, dict):
110+
raise ValueError("Not a dict")
111+
if not isinstance(graph_parsed.get("nodes", None), dict):
112+
raise ValueError("'nodes' is not a dict")
113+
if not isinstance(graph_parsed.get("edges", None), list):
114+
raise ValueError("'edges' is not a list")
115+
116+
# Looks good, overwrite the fallback value
117+
stringified_graph = graph_raw
118+
except Exception as e:
119+
logger.debug(f"Failed to parse graph for uploaded image, {e}")
120+
pass
121+
122+
return ExtractedMetadata(
123+
invokeai_metadata=stringified_metadata, invokeai_workflow=stringified_workflow, invokeai_graph=stringified_graph
124+
)

invokeai/app/api/routers/images.py

Lines changed: 16 additions & 34 deletions
Original file line numberDiff line numberDiff line change
@@ -6,9 +6,10 @@
66
from fastapi.responses import FileResponse
77
from fastapi.routing import APIRouter
88
from PIL import Image
9-
from pydantic import BaseModel, Field, JsonValue
9+
from pydantic import BaseModel, Field
1010

1111
from invokeai.app.api.dependencies import ApiDependencies
12+
from invokeai.app.api.extract_metadata_from_image import extract_metadata_from_image
1213
from invokeai.app.invocations.fields import MetadataField
1314
from invokeai.app.services.image_records.image_records_common import (
1415
ImageCategory,
@@ -45,18 +46,16 @@ async def upload_image(
4546
board_id: Optional[str] = Query(default=None, description="The board to add this image to, if any"),
4647
session_id: Optional[str] = Query(default=None, description="The session ID associated with this upload, if any"),
4748
crop_visible: Optional[bool] = Query(default=False, description="Whether to crop the image"),
48-
metadata: Optional[JsonValue] = Body(
49-
default=None, description="The metadata to associate with the image", embed=True
49+
metadata: Optional[str] = Body(
50+
default=None,
51+
description="The metadata to associate with the image, must be a stringified JSON dict",
52+
embed=True,
5053
),
5154
) -> ImageDTO:
5255
"""Uploads an image"""
5356
if not file.content_type or not file.content_type.startswith("image"):
5457
raise HTTPException(status_code=415, detail="Not an image")
5558

56-
_metadata = None
57-
_workflow = None
58-
_graph = None
59-
6059
contents = await file.read()
6160
try:
6261
pil_image = Image.open(io.BytesIO(contents))
@@ -67,30 +66,13 @@ async def upload_image(
6766
ApiDependencies.invoker.services.logger.error(traceback.format_exc())
6867
raise HTTPException(status_code=415, detail="Failed to read image")
6968

70-
# TODO: retain non-invokeai metadata on upload?
71-
# attempt to parse metadata from image
72-
metadata_raw = metadata if isinstance(metadata, str) else pil_image.info.get("invokeai_metadata", None)
73-
if isinstance(metadata_raw, str):
74-
_metadata = metadata_raw
75-
else:
76-
ApiDependencies.invoker.services.logger.debug("Failed to parse metadata for uploaded image")
77-
pass
78-
79-
# attempt to parse workflow from image
80-
workflow_raw = pil_image.info.get("invokeai_workflow", None)
81-
if isinstance(workflow_raw, str):
82-
_workflow = workflow_raw
83-
else:
84-
ApiDependencies.invoker.services.logger.debug("Failed to parse workflow for uploaded image")
85-
pass
86-
87-
# attempt to extract graph from image
88-
graph_raw = pil_image.info.get("invokeai_graph", None)
89-
if isinstance(graph_raw, str):
90-
_graph = graph_raw
91-
else:
92-
ApiDependencies.invoker.services.logger.debug("Failed to parse graph for uploaded image")
93-
pass
69+
extracted_metadata = extract_metadata_from_image(
70+
pil_image=pil_image,
71+
invokeai_metadata_override=metadata,
72+
invokeai_workflow_override=None,
73+
invokeai_graph_override=None,
74+
logger=ApiDependencies.invoker.services.logger,
75+
)
9476

9577
try:
9678
image_dto = ApiDependencies.invoker.services.images.create(
@@ -99,9 +81,9 @@ async def upload_image(
9981
image_category=image_category,
10082
session_id=session_id,
10183
board_id=board_id,
102-
metadata=_metadata,
103-
workflow=_workflow,
104-
graph=_graph,
84+
metadata=extracted_metadata.invokeai_metadata,
85+
workflow=extracted_metadata.invokeai_workflow,
86+
graph=extracted_metadata.invokeai_graph,
10587
is_intermediate=is_intermediate,
10688
)
10789

0 commit comments

Comments
 (0)