Skip to content
Open
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
97 changes: 97 additions & 0 deletions invokeai/app/invocations/image.py
Original file line number Diff line number Diff line change
@@ -1,5 +1,6 @@
# Copyright (c) 2022 Kyle Schouviller (https://github.com/kyle0654)

from pathlib import Path
from typing import Literal, Optional

import cv2
Expand Down Expand Up @@ -991,6 +992,102 @@ def invoke(self, context: InvocationContext) -> ImageOutput:
return ImageOutput.build(image_dto)


@invocation(
"save_image_to_file",
title="Save Image (Gallery + File Export)",
tags=["image", "export", "file", "save"],
category="image",
version="1.0.0",
use_cache=False,
)
class SaveImageToFileInvocation(BaseInvocation, WithMetadata, WithBoard):
"""Saves an image to the gallery (like the standard Save Image node) AND additionally exports a copy
to the filesystem with a custom filename.

Filename pattern: {prefix}{uuid}{suffix}.{file_format}
- The UUID is the same UUID used for the gallery entry, so the exported file can be matched to the gallery item.
- The gallery entry itself always uses the plain UUID (prefix/suffix apply only to the exported file on disk).
- Board and Metadata inputs behave exactly like the standard Save Image node.
- The export target is restricted to (subfolders of) the InvokeAI outputs folder — absolute paths are rejected.

Example: prefix="hero_", suffix="_final", file_format="png" → "hero_<uuid>_final.png"
"""

image: ImageField = InputField(description="The image to save and export")
output_directory: str = InputField(
default="",
description=(
"Target subdirectory (relative to the configured InvokeAI outputs folder) for the exported file. "
"Leave empty to use the outputs folder directly. "
"Example: 'my-exports' → <outputs>/my-exports/. Nested paths like 'exports/2026' are allowed. "
"Absolute paths and path traversal ('..') are not allowed for security reasons. "
"The directory is created automatically if it doesn't exist."
),
)
prefix: str = InputField(
default="",
description="Text prepended to the UUID in the exported filename. Example: 'portrait_' → 'portrait_<uuid>.png'",
)
suffix: str = InputField(
default="",
description="Text appended to the UUID (before the extension). Example: '_v2' → '<uuid>_v2.png'",
)
file_format: Literal["png", "jpg", "webp"] = InputField(
default="png",
description="File format for the exported file. PNG is lossless; JPG/WEBP are lossy and respect 'quality'.",
)
quality: int = InputField(
default=95,
ge=1,
le=100,
description="Compression quality for JPG and WEBP (1-100, higher = better quality, larger file). Ignored for PNG.",
)

def invoke(self, context: InvocationContext) -> ImageOutput:
image = context.images.get_pil(self.image.image_name)

image_dto = context.images.save(image=image)

uuid = Path(image_dto.image_name).stem

outputs_path = context.config.get().outputs_path
assert outputs_path is not None

if not self.output_directory:
target_dir = outputs_path
else:
raw_str = self.output_directory
raw = Path(raw_str)
has_windows_drive = len(raw_str) >= 2 and raw_str[0].isalpha() and raw_str[1] == ":"
starts_with_sep = raw_str.startswith("/") or raw_str.startswith("\\")
if raw.is_absolute() or raw.drive or has_windows_drive or starts_with_sep:
raise ValueError(
f"Absolute paths are not allowed in output_directory: {raw_str!r}. "
"Use a path relative to the InvokeAI outputs folder."
)
candidate = (outputs_path / raw).resolve()
outputs_resolved = outputs_path.resolve()
if outputs_resolved != candidate and outputs_resolved not in candidate.parents:
raise ValueError(f"output_directory must stay within the outputs folder: {raw_str!r}")
target_dir = candidate

target_dir.mkdir(parents=True, exist_ok=True)

filename = f"{self.prefix}{uuid}{self.suffix}.{self.file_format}"
target_path = target_dir / filename

if self.file_format == "png":
image.save(target_path, format="PNG")
elif self.file_format == "jpg":
if image.mode in ("RGBA", "LA", "P"):
image = image.convert("RGB")
image.save(target_path, format="JPEG", quality=self.quality)
else:
image.save(target_path, format="WEBP", quality=self.quality)

return ImageOutput.build(image_dto)


@invocation(
"canvas_paste_back",
title="Canvas Paste Back",
Expand Down
Loading
Loading