diff --git a/docs/contributing/INVOCATIONS.md b/docs/contributing/INVOCATIONS.md index b34a2f25ac..be5d9d0805 100644 --- a/docs/contributing/INVOCATIONS.md +++ b/docs/contributing/INVOCATIONS.md @@ -244,8 +244,12 @@ copy-paste the template above. We can use the `@invocation` decorator to provide some additional info to the UI, like a custom title, tags and category. +We also encourage providing a version. This must be a +[semver](https://semver.org/) version string ("$MAJOR.$MINOR.$PATCH"). The UI +will let users know if their workflow is using a mismatched version of the node. + ```python -@invocation("resize", title="My Resizer", tags=["resize", "image"], category="My Invocations") +@invocation("resize", title="My Resizer", tags=["resize", "image"], category="My Invocations", version="1.0.0") class ResizeInvocation(BaseInvocation): """Resizes an image""" @@ -279,8 +283,6 @@ take a look a at our [contributing nodes overview](contributingNodes). ## Advanced ---> - ### Custom Output Types Like with custom inputs, sometimes you might find yourself needing custom diff --git a/invokeai/app/invocations/baseinvocation.py b/invokeai/app/invocations/baseinvocation.py index ccc2b4d05f..65a8734690 100644 --- a/invokeai/app/invocations/baseinvocation.py +++ b/invokeai/app/invocations/baseinvocation.py @@ -26,11 +26,16 @@ from typing import ( from pydantic import BaseModel, Field, validator from pydantic.fields import Undefined, ModelField from pydantic.typing import NoArgAnyCallable +import semver if TYPE_CHECKING: from ..services.invocation_services import InvocationServices +class InvalidVersionError(ValueError): + pass + + class FieldDescriptions: denoising_start = "When to start denoising, expressed a percentage of total steps" denoising_end = "When to stop denoising, expressed a percentage of total steps" @@ -401,6 +406,9 @@ class UIConfigBase(BaseModel): tags: Optional[list[str]] = Field(default_factory=None, description="The node's tags") title: Optional[str] = Field(default=None, description="The node's display name") category: Optional[str] = Field(default=None, description="The node's category") + version: Optional[str] = Field( + default=None, description='The node\'s version. Should be a valid semver string e.g. "1.0.0" or "3.8.13".' + ) class InvocationContext: @@ -499,6 +507,8 @@ class BaseInvocation(ABC, BaseModel): schema["tags"] = uiconfig.tags if uiconfig and hasattr(uiconfig, "category"): schema["category"] = uiconfig.category + if uiconfig and hasattr(uiconfig, "version"): + schema["version"] = uiconfig.version if "required" not in schema or not isinstance(schema["required"], list): schema["required"] = list() schema["required"].extend(["type", "id"]) @@ -567,7 +577,11 @@ GenericBaseInvocation = TypeVar("GenericBaseInvocation", bound=BaseInvocation) def invocation( - invocation_type: str, title: Optional[str] = None, tags: Optional[list[str]] = None, category: Optional[str] = None + invocation_type: str, + title: Optional[str] = None, + tags: Optional[list[str]] = None, + category: Optional[str] = None, + version: Optional[str] = None, ) -> Callable[[Type[GenericBaseInvocation]], Type[GenericBaseInvocation]]: """ Adds metadata to an invocation. @@ -594,6 +608,12 @@ def invocation( cls.UIConfig.tags = tags if category is not None: cls.UIConfig.category = category + if version is not None: + try: + semver.Version.parse(version) + except ValueError as e: + raise InvalidVersionError(f'Invalid version string for node "{invocation_type}": "{version}"') from e + cls.UIConfig.version = version # Add the invocation type to the pydantic model of the invocation invocation_type_annotation = Literal[invocation_type] # type: ignore diff --git a/invokeai/app/invocations/collections.py b/invokeai/app/invocations/collections.py index 979f2e43b7..2814a9c3ca 100644 --- a/invokeai/app/invocations/collections.py +++ b/invokeai/app/invocations/collections.py @@ -10,7 +10,9 @@ from invokeai.app.util.misc import SEED_MAX, get_random_seed from .baseinvocation import BaseInvocation, InputField, InvocationContext, invocation -@invocation("range", title="Integer Range", tags=["collection", "integer", "range"], category="collections") +@invocation( + "range", title="Integer Range", tags=["collection", "integer", "range"], category="collections", version="1.0.0" +) class RangeInvocation(BaseInvocation): """Creates a range of numbers from start to stop with step""" @@ -33,6 +35,7 @@ class RangeInvocation(BaseInvocation): title="Integer Range of Size", tags=["collection", "integer", "size", "range"], category="collections", + version="1.0.0", ) class RangeOfSizeInvocation(BaseInvocation): """Creates a range from start to start + size with step""" @@ -50,6 +53,7 @@ class RangeOfSizeInvocation(BaseInvocation): title="Random Range", tags=["range", "integer", "random", "collection"], category="collections", + version="1.0.0", ) class RandomRangeInvocation(BaseInvocation): """Creates a collection of random numbers""" diff --git a/invokeai/app/invocations/compel.py b/invokeai/app/invocations/compel.py index 563d8d97fd..4557c57820 100644 --- a/invokeai/app/invocations/compel.py +++ b/invokeai/app/invocations/compel.py @@ -44,7 +44,7 @@ class ConditioningFieldData: # PerpNeg = "perp_neg" -@invocation("compel", title="Prompt", tags=["prompt", "compel"], category="conditioning") +@invocation("compel", title="Prompt", tags=["prompt", "compel"], category="conditioning", version="1.0.0") class CompelInvocation(BaseInvocation): """Parse prompt using compel package to conditioning.""" @@ -267,6 +267,7 @@ class SDXLPromptInvocationBase: title="SDXL Prompt", tags=["sdxl", "compel", "prompt"], category="conditioning", + version="1.0.0", ) class SDXLCompelPromptInvocation(BaseInvocation, SDXLPromptInvocationBase): """Parse prompt using compel package to conditioning.""" @@ -351,6 +352,7 @@ class SDXLCompelPromptInvocation(BaseInvocation, SDXLPromptInvocationBase): title="SDXL Refiner Prompt", tags=["sdxl", "compel", "prompt"], category="conditioning", + version="1.0.0", ) class SDXLRefinerCompelPromptInvocation(BaseInvocation, SDXLPromptInvocationBase): """Parse prompt using compel package to conditioning.""" @@ -403,7 +405,7 @@ class ClipSkipInvocationOutput(BaseInvocationOutput): clip: ClipField = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP") -@invocation("clip_skip", title="CLIP Skip", tags=["clipskip", "clip", "skip"], category="conditioning") +@invocation("clip_skip", title="CLIP Skip", tags=["clipskip", "clip", "skip"], category="conditioning", version="1.0.0") class ClipSkipInvocation(BaseInvocation): """Skip layers in clip text_encoder model.""" diff --git a/invokeai/app/invocations/controlnet_image_processors.py b/invokeai/app/invocations/controlnet_image_processors.py index 272afb3a4c..2c2eab9155 100644 --- a/invokeai/app/invocations/controlnet_image_processors.py +++ b/invokeai/app/invocations/controlnet_image_processors.py @@ -95,7 +95,7 @@ class ControlOutput(BaseInvocationOutput): control: ControlField = OutputField(description=FieldDescriptions.control) -@invocation("controlnet", title="ControlNet", tags=["controlnet"], category="controlnet") +@invocation("controlnet", title="ControlNet", tags=["controlnet"], category="controlnet", version="1.0.0") class ControlNetInvocation(BaseInvocation): """Collects ControlNet info to pass to other nodes""" @@ -127,7 +127,9 @@ class ControlNetInvocation(BaseInvocation): ) -@invocation("image_processor", title="Base Image Processor", tags=["controlnet"], category="controlnet") +@invocation( + "image_processor", title="Base Image Processor", tags=["controlnet"], category="controlnet", version="1.0.0" +) class ImageProcessorInvocation(BaseInvocation): """Base class for invocations that preprocess images for ControlNet""" @@ -171,6 +173,7 @@ class ImageProcessorInvocation(BaseInvocation): title="Canny Processor", tags=["controlnet", "canny"], category="controlnet", + version="1.0.0", ) class CannyImageProcessorInvocation(ImageProcessorInvocation): """Canny edge detection for ControlNet""" @@ -193,6 +196,7 @@ class CannyImageProcessorInvocation(ImageProcessorInvocation): title="HED (softedge) Processor", tags=["controlnet", "hed", "softedge"], category="controlnet", + version="1.0.0", ) class HedImageProcessorInvocation(ImageProcessorInvocation): """Applies HED edge detection to image""" @@ -221,6 +225,7 @@ class HedImageProcessorInvocation(ImageProcessorInvocation): title="Lineart Processor", tags=["controlnet", "lineart"], category="controlnet", + version="1.0.0", ) class LineartImageProcessorInvocation(ImageProcessorInvocation): """Applies line art processing to image""" @@ -242,6 +247,7 @@ class LineartImageProcessorInvocation(ImageProcessorInvocation): title="Lineart Anime Processor", tags=["controlnet", "lineart", "anime"], category="controlnet", + version="1.0.0", ) class LineartAnimeImageProcessorInvocation(ImageProcessorInvocation): """Applies line art anime processing to image""" @@ -264,6 +270,7 @@ class LineartAnimeImageProcessorInvocation(ImageProcessorInvocation): title="Openpose Processor", tags=["controlnet", "openpose", "pose"], category="controlnet", + version="1.0.0", ) class OpenposeImageProcessorInvocation(ImageProcessorInvocation): """Applies Openpose processing to image""" @@ -288,6 +295,7 @@ class OpenposeImageProcessorInvocation(ImageProcessorInvocation): title="Midas Depth Processor", tags=["controlnet", "midas"], category="controlnet", + version="1.0.0", ) class MidasDepthImageProcessorInvocation(ImageProcessorInvocation): """Applies Midas depth processing to image""" @@ -314,6 +322,7 @@ class MidasDepthImageProcessorInvocation(ImageProcessorInvocation): title="Normal BAE Processor", tags=["controlnet"], category="controlnet", + version="1.0.0", ) class NormalbaeImageProcessorInvocation(ImageProcessorInvocation): """Applies NormalBae processing to image""" @@ -329,7 +338,9 @@ class NormalbaeImageProcessorInvocation(ImageProcessorInvocation): return processed_image -@invocation("mlsd_image_processor", title="MLSD Processor", tags=["controlnet", "mlsd"], category="controlnet") +@invocation( + "mlsd_image_processor", title="MLSD Processor", tags=["controlnet", "mlsd"], category="controlnet", version="1.0.0" +) class MlsdImageProcessorInvocation(ImageProcessorInvocation): """Applies MLSD processing to image""" @@ -350,7 +361,9 @@ class MlsdImageProcessorInvocation(ImageProcessorInvocation): return processed_image -@invocation("pidi_image_processor", title="PIDI Processor", tags=["controlnet", "pidi"], category="controlnet") +@invocation( + "pidi_image_processor", title="PIDI Processor", tags=["controlnet", "pidi"], category="controlnet", version="1.0.0" +) class PidiImageProcessorInvocation(ImageProcessorInvocation): """Applies PIDI processing to image""" @@ -376,6 +389,7 @@ class PidiImageProcessorInvocation(ImageProcessorInvocation): title="Content Shuffle Processor", tags=["controlnet", "contentshuffle"], category="controlnet", + version="1.0.0", ) class ContentShuffleImageProcessorInvocation(ImageProcessorInvocation): """Applies content shuffle processing to image""" @@ -405,6 +419,7 @@ class ContentShuffleImageProcessorInvocation(ImageProcessorInvocation): title="Zoe (Depth) Processor", tags=["controlnet", "zoe", "depth"], category="controlnet", + version="1.0.0", ) class ZoeDepthImageProcessorInvocation(ImageProcessorInvocation): """Applies Zoe depth processing to image""" @@ -420,6 +435,7 @@ class ZoeDepthImageProcessorInvocation(ImageProcessorInvocation): title="Mediapipe Face Processor", tags=["controlnet", "mediapipe", "face"], category="controlnet", + version="1.0.0", ) class MediapipeFaceProcessorInvocation(ImageProcessorInvocation): """Applies mediapipe face processing to image""" @@ -442,6 +458,7 @@ class MediapipeFaceProcessorInvocation(ImageProcessorInvocation): title="Leres (Depth) Processor", tags=["controlnet", "leres", "depth"], category="controlnet", + version="1.0.0", ) class LeresImageProcessorInvocation(ImageProcessorInvocation): """Applies leres processing to image""" @@ -470,6 +487,7 @@ class LeresImageProcessorInvocation(ImageProcessorInvocation): title="Tile Resample Processor", tags=["controlnet", "tile"], category="controlnet", + version="1.0.0", ) class TileResamplerProcessorInvocation(ImageProcessorInvocation): """Tile resampler processor""" @@ -509,6 +527,7 @@ class TileResamplerProcessorInvocation(ImageProcessorInvocation): title="Segment Anything Processor", tags=["controlnet", "segmentanything"], category="controlnet", + version="1.0.0", ) class SegmentAnythingProcessorInvocation(ImageProcessorInvocation): """Applies segment anything processing to image""" diff --git a/invokeai/app/invocations/cv.py b/invokeai/app/invocations/cv.py index 40d8867aa1..411aff8234 100644 --- a/invokeai/app/invocations/cv.py +++ b/invokeai/app/invocations/cv.py @@ -10,12 +10,7 @@ from invokeai.app.models.image import ImageCategory, ResourceOrigin from .baseinvocation import BaseInvocation, InputField, InvocationContext, invocation -@invocation( - "cv_inpaint", - title="OpenCV Inpaint", - tags=["opencv", "inpaint"], - category="inpaint", -) +@invocation("cv_inpaint", title="OpenCV Inpaint", tags=["opencv", "inpaint"], category="inpaint", version="1.0.0") class CvInpaintInvocation(BaseInvocation): """Simple inpaint using opencv.""" diff --git a/invokeai/app/invocations/image.py b/invokeai/app/invocations/image.py index 5eeead7db2..b6f7cc405b 100644 --- a/invokeai/app/invocations/image.py +++ b/invokeai/app/invocations/image.py @@ -16,7 +16,7 @@ from ..models.image import ImageCategory, ResourceOrigin from .baseinvocation import BaseInvocation, FieldDescriptions, InputField, InvocationContext, invocation -@invocation("show_image", title="Show Image", tags=["image"], category="image") +@invocation("show_image", title="Show Image", tags=["image"], category="image", version="1.0.0") class ShowImageInvocation(BaseInvocation): """Displays a provided image using the OS image viewer, and passes it forward in the pipeline.""" @@ -36,7 +36,7 @@ class ShowImageInvocation(BaseInvocation): ) -@invocation("blank_image", title="Blank Image", tags=["image"], category="image") +@invocation("blank_image", title="Blank Image", tags=["image"], category="image", version="1.0.0") class BlankImageInvocation(BaseInvocation): """Creates a blank image and forwards it to the pipeline""" @@ -65,7 +65,7 @@ class BlankImageInvocation(BaseInvocation): ) -@invocation("img_crop", title="Crop Image", tags=["image", "crop"], category="image") +@invocation("img_crop", title="Crop Image", tags=["image", "crop"], category="image", version="1.0.0") class ImageCropInvocation(BaseInvocation): """Crops an image to a specified box. The box can be outside of the image.""" @@ -98,7 +98,7 @@ class ImageCropInvocation(BaseInvocation): ) -@invocation("img_paste", title="Paste Image", tags=["image", "paste"], category="image") +@invocation("img_paste", title="Paste Image", tags=["image", "paste"], category="image", version="1.0.0") class ImagePasteInvocation(BaseInvocation): """Pastes an image into another image.""" @@ -146,7 +146,7 @@ class ImagePasteInvocation(BaseInvocation): ) -@invocation("tomask", title="Mask from Alpha", tags=["image", "mask"], category="image") +@invocation("tomask", title="Mask from Alpha", tags=["image", "mask"], category="image", version="1.0.0") class MaskFromAlphaInvocation(BaseInvocation): """Extracts the alpha channel of an image as a mask.""" @@ -177,7 +177,7 @@ class MaskFromAlphaInvocation(BaseInvocation): ) -@invocation("img_mul", title="Multiply Images", tags=["image", "multiply"], category="image") +@invocation("img_mul", title="Multiply Images", tags=["image", "multiply"], category="image", version="1.0.0") class ImageMultiplyInvocation(BaseInvocation): """Multiplies two images together using `PIL.ImageChops.multiply()`.""" @@ -210,7 +210,7 @@ class ImageMultiplyInvocation(BaseInvocation): IMAGE_CHANNELS = Literal["A", "R", "G", "B"] -@invocation("img_chan", title="Extract Image Channel", tags=["image", "channel"], category="image") +@invocation("img_chan", title="Extract Image Channel", tags=["image", "channel"], category="image", version="1.0.0") class ImageChannelInvocation(BaseInvocation): """Gets a channel from an image.""" @@ -242,7 +242,7 @@ class ImageChannelInvocation(BaseInvocation): IMAGE_MODES = Literal["L", "RGB", "RGBA", "CMYK", "YCbCr", "LAB", "HSV", "I", "F"] -@invocation("img_conv", title="Convert Image Mode", tags=["image", "convert"], category="image") +@invocation("img_conv", title="Convert Image Mode", tags=["image", "convert"], category="image", version="1.0.0") class ImageConvertInvocation(BaseInvocation): """Converts an image to a different mode.""" @@ -271,7 +271,7 @@ class ImageConvertInvocation(BaseInvocation): ) -@invocation("img_blur", title="Blur Image", tags=["image", "blur"], category="image") +@invocation("img_blur", title="Blur Image", tags=["image", "blur"], category="image", version="1.0.0") class ImageBlurInvocation(BaseInvocation): """Blurs an image""" @@ -325,7 +325,7 @@ PIL_RESAMPLING_MAP = { } -@invocation("img_resize", title="Resize Image", tags=["image", "resize"], category="image") +@invocation("img_resize", title="Resize Image", tags=["image", "resize"], category="image", version="1.0.0") class ImageResizeInvocation(BaseInvocation): """Resizes an image to specific dimensions""" @@ -365,7 +365,7 @@ class ImageResizeInvocation(BaseInvocation): ) -@invocation("img_scale", title="Scale Image", tags=["image", "scale"], category="image") +@invocation("img_scale", title="Scale Image", tags=["image", "scale"], category="image", version="1.0.0") class ImageScaleInvocation(BaseInvocation): """Scales an image by a factor""" @@ -406,7 +406,7 @@ class ImageScaleInvocation(BaseInvocation): ) -@invocation("img_lerp", title="Lerp Image", tags=["image", "lerp"], category="image") +@invocation("img_lerp", title="Lerp Image", tags=["image", "lerp"], category="image", version="1.0.0") class ImageLerpInvocation(BaseInvocation): """Linear interpolation of all pixels of an image""" @@ -439,7 +439,7 @@ class ImageLerpInvocation(BaseInvocation): ) -@invocation("img_ilerp", title="Inverse Lerp Image", tags=["image", "ilerp"], category="image") +@invocation("img_ilerp", title="Inverse Lerp Image", tags=["image", "ilerp"], category="image", version="1.0.0") class ImageInverseLerpInvocation(BaseInvocation): """Inverse linear interpolation of all pixels of an image""" @@ -472,7 +472,7 @@ class ImageInverseLerpInvocation(BaseInvocation): ) -@invocation("img_nsfw", title="Blur NSFW Image", tags=["image", "nsfw"], category="image") +@invocation("img_nsfw", title="Blur NSFW Image", tags=["image", "nsfw"], category="image", version="1.0.0") class ImageNSFWBlurInvocation(BaseInvocation): """Add blur to NSFW-flagged images""" @@ -517,7 +517,9 @@ class ImageNSFWBlurInvocation(BaseInvocation): return caution.resize((caution.width // 2, caution.height // 2)) -@invocation("img_watermark", title="Add Invisible Watermark", tags=["image", "watermark"], category="image") +@invocation( + "img_watermark", title="Add Invisible Watermark", tags=["image", "watermark"], category="image", version="1.0.0" +) class ImageWatermarkInvocation(BaseInvocation): """Add an invisible watermark to an image""" @@ -548,7 +550,7 @@ class ImageWatermarkInvocation(BaseInvocation): ) -@invocation("mask_edge", title="Mask Edge", tags=["image", "mask", "inpaint"], category="image") +@invocation("mask_edge", title="Mask Edge", tags=["image", "mask", "inpaint"], category="image", version="1.0.0") class MaskEdgeInvocation(BaseInvocation): """Applies an edge mask to an image""" @@ -593,7 +595,9 @@ class MaskEdgeInvocation(BaseInvocation): ) -@invocation("mask_combine", title="Combine Masks", tags=["image", "mask", "multiply"], category="image") +@invocation( + "mask_combine", title="Combine Masks", tags=["image", "mask", "multiply"], category="image", version="1.0.0" +) class MaskCombineInvocation(BaseInvocation): """Combine two masks together by multiplying them using `PIL.ImageChops.multiply()`.""" @@ -623,7 +627,7 @@ class MaskCombineInvocation(BaseInvocation): ) -@invocation("color_correct", title="Color Correct", tags=["image", "color"], category="image") +@invocation("color_correct", title="Color Correct", tags=["image", "color"], category="image", version="1.0.0") class ColorCorrectInvocation(BaseInvocation): """ Shifts the colors of a target image to match the reference image, optionally @@ -728,7 +732,7 @@ class ColorCorrectInvocation(BaseInvocation): ) -@invocation("img_hue_adjust", title="Adjust Image Hue", tags=["image", "hue"], category="image") +@invocation("img_hue_adjust", title="Adjust Image Hue", tags=["image", "hue"], category="image", version="1.0.0") class ImageHueAdjustmentInvocation(BaseInvocation): """Adjusts the Hue of an image.""" @@ -774,6 +778,7 @@ class ImageHueAdjustmentInvocation(BaseInvocation): title="Adjust Image Luminosity", tags=["image", "luminosity", "hsl"], category="image", + version="1.0.0", ) class ImageLuminosityAdjustmentInvocation(BaseInvocation): """Adjusts the Luminosity (Value) of an image.""" @@ -826,6 +831,7 @@ class ImageLuminosityAdjustmentInvocation(BaseInvocation): title="Adjust Image Saturation", tags=["image", "saturation", "hsl"], category="image", + version="1.0.0", ) class ImageSaturationAdjustmentInvocation(BaseInvocation): """Adjusts the Saturation of an image.""" diff --git a/invokeai/app/invocations/infill.py b/invokeai/app/invocations/infill.py index 438c56e312..fa322e7864 100644 --- a/invokeai/app/invocations/infill.py +++ b/invokeai/app/invocations/infill.py @@ -116,7 +116,7 @@ def tile_fill_missing(im: Image.Image, tile_size: int = 16, seed: Optional[int] return si -@invocation("infill_rgba", title="Solid Color Infill", tags=["image", "inpaint"], category="inpaint") +@invocation("infill_rgba", title="Solid Color Infill", tags=["image", "inpaint"], category="inpaint", version="1.0.0") class InfillColorInvocation(BaseInvocation): """Infills transparent areas of an image with a solid color""" @@ -151,7 +151,7 @@ class InfillColorInvocation(BaseInvocation): ) -@invocation("infill_tile", title="Tile Infill", tags=["image", "inpaint"], category="inpaint") +@invocation("infill_tile", title="Tile Infill", tags=["image", "inpaint"], category="inpaint", version="1.0.0") class InfillTileInvocation(BaseInvocation): """Infills transparent areas of an image with tiles of the image""" @@ -187,7 +187,9 @@ class InfillTileInvocation(BaseInvocation): ) -@invocation("infill_patchmatch", title="PatchMatch Infill", tags=["image", "inpaint"], category="inpaint") +@invocation( + "infill_patchmatch", title="PatchMatch Infill", tags=["image", "inpaint"], category="inpaint", version="1.0.0" +) class InfillPatchMatchInvocation(BaseInvocation): """Infills transparent areas of an image using the PatchMatch algorithm""" @@ -218,7 +220,7 @@ class InfillPatchMatchInvocation(BaseInvocation): ) -@invocation("infill_lama", title="LaMa Infill", tags=["image", "inpaint"], category="inpaint") +@invocation("infill_lama", title="LaMa Infill", tags=["image", "inpaint"], category="inpaint", version="1.0.0") class LaMaInfillInvocation(BaseInvocation): """Infills transparent areas of an image using the LaMa model""" diff --git a/invokeai/app/invocations/latent.py b/invokeai/app/invocations/latent.py index c0e53e4e12..8fde088b36 100644 --- a/invokeai/app/invocations/latent.py +++ b/invokeai/app/invocations/latent.py @@ -74,7 +74,7 @@ class SchedulerOutput(BaseInvocationOutput): scheduler: SAMPLER_NAME_VALUES = OutputField(description=FieldDescriptions.scheduler, ui_type=UIType.Scheduler) -@invocation("scheduler", title="Scheduler", tags=["scheduler"], category="latents") +@invocation("scheduler", title="Scheduler", tags=["scheduler"], category="latents", version="1.0.0") class SchedulerInvocation(BaseInvocation): """Selects a scheduler.""" @@ -86,7 +86,9 @@ class SchedulerInvocation(BaseInvocation): return SchedulerOutput(scheduler=self.scheduler) -@invocation("create_denoise_mask", title="Create Denoise Mask", tags=["mask", "denoise"], category="latents") +@invocation( + "create_denoise_mask", title="Create Denoise Mask", tags=["mask", "denoise"], category="latents", version="1.0.0" +) class CreateDenoiseMaskInvocation(BaseInvocation): """Creates mask for denoising model run.""" @@ -186,6 +188,7 @@ def get_scheduler( title="Denoise Latents", tags=["latents", "denoise", "txt2img", "t2i", "t2l", "img2img", "i2i", "l2l"], category="latents", + version="1.0.0", ) class DenoiseLatentsInvocation(BaseInvocation): """Denoises noisy latents to decodable images""" @@ -544,7 +547,9 @@ class DenoiseLatentsInvocation(BaseInvocation): return build_latents_output(latents_name=name, latents=result_latents, seed=seed) -@invocation("l2i", title="Latents to Image", tags=["latents", "image", "vae", "l2i"], category="latents") +@invocation( + "l2i", title="Latents to Image", tags=["latents", "image", "vae", "l2i"], category="latents", version="1.0.0" +) class LatentsToImageInvocation(BaseInvocation): """Generates an image from latents.""" @@ -641,7 +646,7 @@ class LatentsToImageInvocation(BaseInvocation): LATENTS_INTERPOLATION_MODE = Literal["nearest", "linear", "bilinear", "bicubic", "trilinear", "area", "nearest-exact"] -@invocation("lresize", title="Resize Latents", tags=["latents", "resize"], category="latents") +@invocation("lresize", title="Resize Latents", tags=["latents", "resize"], category="latents", version="1.0.0") class ResizeLatentsInvocation(BaseInvocation): """Resizes latents to explicit width/height (in pixels). Provided dimensions are floor-divided by 8.""" @@ -685,7 +690,7 @@ class ResizeLatentsInvocation(BaseInvocation): return build_latents_output(latents_name=name, latents=resized_latents, seed=self.latents.seed) -@invocation("lscale", title="Scale Latents", tags=["latents", "resize"], category="latents") +@invocation("lscale", title="Scale Latents", tags=["latents", "resize"], category="latents", version="1.0.0") class ScaleLatentsInvocation(BaseInvocation): """Scales latents by a given factor.""" @@ -721,7 +726,9 @@ class ScaleLatentsInvocation(BaseInvocation): return build_latents_output(latents_name=name, latents=resized_latents, seed=self.latents.seed) -@invocation("i2l", title="Image to Latents", tags=["latents", "image", "vae", "i2l"], category="latents") +@invocation( + "i2l", title="Image to Latents", tags=["latents", "image", "vae", "i2l"], category="latents", version="1.0.0" +) class ImageToLatentsInvocation(BaseInvocation): """Encodes an image into latents.""" @@ -801,7 +808,7 @@ class ImageToLatentsInvocation(BaseInvocation): return build_latents_output(latents_name=name, latents=latents, seed=None) -@invocation("lblend", title="Blend Latents", tags=["latents", "blend"], category="latents") +@invocation("lblend", title="Blend Latents", tags=["latents", "blend"], category="latents", version="1.0.0") class BlendLatentsInvocation(BaseInvocation): """Blend two latents using a given alpha. Latents must have same size.""" diff --git a/invokeai/app/invocations/math.py b/invokeai/app/invocations/math.py index 2a8dc12b28..0bc8b7b950 100644 --- a/invokeai/app/invocations/math.py +++ b/invokeai/app/invocations/math.py @@ -7,7 +7,7 @@ from invokeai.app.invocations.primitives import IntegerOutput from .baseinvocation import BaseInvocation, FieldDescriptions, InputField, InvocationContext, invocation -@invocation("add", title="Add Integers", tags=["math", "add"], category="math") +@invocation("add", title="Add Integers", tags=["math", "add"], category="math", version="1.0.0") class AddInvocation(BaseInvocation): """Adds two numbers""" @@ -18,7 +18,7 @@ class AddInvocation(BaseInvocation): return IntegerOutput(value=self.a + self.b) -@invocation("sub", title="Subtract Integers", tags=["math", "subtract"], category="math") +@invocation("sub", title="Subtract Integers", tags=["math", "subtract"], category="math", version="1.0.0") class SubtractInvocation(BaseInvocation): """Subtracts two numbers""" @@ -29,7 +29,7 @@ class SubtractInvocation(BaseInvocation): return IntegerOutput(value=self.a - self.b) -@invocation("mul", title="Multiply Integers", tags=["math", "multiply"], category="math") +@invocation("mul", title="Multiply Integers", tags=["math", "multiply"], category="math", version="1.0.0") class MultiplyInvocation(BaseInvocation): """Multiplies two numbers""" @@ -40,7 +40,7 @@ class MultiplyInvocation(BaseInvocation): return IntegerOutput(value=self.a * self.b) -@invocation("div", title="Divide Integers", tags=["math", "divide"], category="math") +@invocation("div", title="Divide Integers", tags=["math", "divide"], category="math", version="1.0.0") class DivideInvocation(BaseInvocation): """Divides two numbers""" @@ -51,7 +51,7 @@ class DivideInvocation(BaseInvocation): return IntegerOutput(value=int(self.a / self.b)) -@invocation("rand_int", title="Random Integer", tags=["math", "random"], category="math") +@invocation("rand_int", title="Random Integer", tags=["math", "random"], category="math", version="1.0.0") class RandomIntInvocation(BaseInvocation): """Outputs a single random integer.""" diff --git a/invokeai/app/invocations/metadata.py b/invokeai/app/invocations/metadata.py index 9c028a2dc1..39fa3beba0 100644 --- a/invokeai/app/invocations/metadata.py +++ b/invokeai/app/invocations/metadata.py @@ -98,7 +98,9 @@ class MetadataAccumulatorOutput(BaseInvocationOutput): metadata: CoreMetadata = OutputField(description="The core metadata for the image") -@invocation("metadata_accumulator", title="Metadata Accumulator", tags=["metadata"], category="metadata") +@invocation( + "metadata_accumulator", title="Metadata Accumulator", tags=["metadata"], category="metadata", version="1.0.0" +) class MetadataAccumulatorInvocation(BaseInvocation): """Outputs a Core Metadata Object""" diff --git a/invokeai/app/invocations/model.py b/invokeai/app/invocations/model.py index 5a1073df0a..571cb2e730 100644 --- a/invokeai/app/invocations/model.py +++ b/invokeai/app/invocations/model.py @@ -73,7 +73,7 @@ class LoRAModelField(BaseModel): base_model: BaseModelType = Field(description="Base model") -@invocation("main_model_loader", title="Main Model", tags=["model"], category="model") +@invocation("main_model_loader", title="Main Model", tags=["model"], category="model", version="1.0.0") class MainModelLoaderInvocation(BaseInvocation): """Loads a main model, outputting its submodels.""" @@ -173,7 +173,7 @@ class LoraLoaderOutput(BaseInvocationOutput): clip: Optional[ClipField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP") -@invocation("lora_loader", title="LoRA", tags=["model"], category="model") +@invocation("lora_loader", title="LoRA", tags=["model"], category="model", version="1.0.0") class LoraLoaderInvocation(BaseInvocation): """Apply selected lora to unet and text_encoder.""" @@ -244,7 +244,7 @@ class SDXLLoraLoaderOutput(BaseInvocationOutput): clip2: Optional[ClipField] = OutputField(default=None, description=FieldDescriptions.clip, title="CLIP 2") -@invocation("sdxl_lora_loader", title="SDXL LoRA", tags=["lora", "model"], category="model") +@invocation("sdxl_lora_loader", title="SDXL LoRA", tags=["lora", "model"], category="model", version="1.0.0") class SDXLLoraLoaderInvocation(BaseInvocation): """Apply selected lora to unet and text_encoder.""" @@ -338,7 +338,7 @@ class VaeLoaderOutput(BaseInvocationOutput): vae: VaeField = OutputField(description=FieldDescriptions.vae, title="VAE") -@invocation("vae_loader", title="VAE", tags=["vae", "model"], category="model") +@invocation("vae_loader", title="VAE", tags=["vae", "model"], category="model", version="1.0.0") class VaeLoaderInvocation(BaseInvocation): """Loads a VAE model, outputting a VaeLoaderOutput""" @@ -376,7 +376,7 @@ class SeamlessModeOutput(BaseInvocationOutput): vae: Optional[VaeField] = OutputField(description=FieldDescriptions.vae, title="VAE") -@invocation("seamless", title="Seamless", tags=["seamless", "model"], category="model") +@invocation("seamless", title="Seamless", tags=["seamless", "model"], category="model", version="1.0.0") class SeamlessModeInvocation(BaseInvocation): """Applies the seamless transformation to the Model UNet and VAE.""" diff --git a/invokeai/app/invocations/noise.py b/invokeai/app/invocations/noise.py index 1f1d9fe3ce..c46747aa89 100644 --- a/invokeai/app/invocations/noise.py +++ b/invokeai/app/invocations/noise.py @@ -78,7 +78,7 @@ def build_noise_output(latents_name: str, latents: torch.Tensor, seed: int): ) -@invocation("noise", title="Noise", tags=["latents", "noise"], category="latents") +@invocation("noise", title="Noise", tags=["latents", "noise"], category="latents", version="1.0.0") class NoiseInvocation(BaseInvocation): """Generates latent noise.""" diff --git a/invokeai/app/invocations/onnx.py b/invokeai/app/invocations/onnx.py index b61ea2da99..d346a5f58f 100644 --- a/invokeai/app/invocations/onnx.py +++ b/invokeai/app/invocations/onnx.py @@ -56,7 +56,7 @@ ORT_TO_NP_TYPE = { PRECISION_VALUES = Literal[tuple(list(ORT_TO_NP_TYPE.keys()))] -@invocation("prompt_onnx", title="ONNX Prompt (Raw)", tags=["prompt", "onnx"], category="conditioning") +@invocation("prompt_onnx", title="ONNX Prompt (Raw)", tags=["prompt", "onnx"], category="conditioning", version="1.0.0") class ONNXPromptInvocation(BaseInvocation): prompt: str = InputField(default="", description=FieldDescriptions.raw_prompt, ui_component=UIComponent.Textarea) clip: ClipField = InputField(description=FieldDescriptions.clip, input=Input.Connection) @@ -143,6 +143,7 @@ class ONNXPromptInvocation(BaseInvocation): title="ONNX Text to Latents", tags=["latents", "inference", "txt2img", "onnx"], category="latents", + version="1.0.0", ) class ONNXTextToLatentsInvocation(BaseInvocation): """Generates latents from conditionings.""" @@ -319,6 +320,7 @@ class ONNXTextToLatentsInvocation(BaseInvocation): title="ONNX Latents to Image", tags=["latents", "image", "vae", "onnx"], category="image", + version="1.0.0", ) class ONNXLatentsToImageInvocation(BaseInvocation): """Generates an image from latents.""" @@ -403,7 +405,7 @@ class OnnxModelField(BaseModel): model_type: ModelType = Field(description="Model Type") -@invocation("onnx_model_loader", title="ONNX Main Model", tags=["onnx", "model"], category="model") +@invocation("onnx_model_loader", title="ONNX Main Model", tags=["onnx", "model"], category="model", version="1.0.0") class OnnxModelLoaderInvocation(BaseInvocation): """Loads a main model, outputting its submodels.""" diff --git a/invokeai/app/invocations/param_easing.py b/invokeai/app/invocations/param_easing.py index 1b3c0dc09e..9cfe447372 100644 --- a/invokeai/app/invocations/param_easing.py +++ b/invokeai/app/invocations/param_easing.py @@ -45,7 +45,7 @@ from invokeai.app.invocations.primitives import FloatCollectionOutput from .baseinvocation import BaseInvocation, InputField, InvocationContext, invocation -@invocation("float_range", title="Float Range", tags=["math", "range"], category="math") +@invocation("float_range", title="Float Range", tags=["math", "range"], category="math", version="1.0.0") class FloatLinearRangeInvocation(BaseInvocation): """Creates a range""" @@ -96,7 +96,7 @@ EASING_FUNCTION_KEYS = Literal[tuple(list(EASING_FUNCTIONS_MAP.keys()))] # actually I think for now could just use CollectionOutput (which is list[Any] -@invocation("step_param_easing", title="Step Param Easing", tags=["step", "easing"], category="step") +@invocation("step_param_easing", title="Step Param Easing", tags=["step", "easing"], category="step", version="1.0.0") class StepParamEasingInvocation(BaseInvocation): """Experimental per-step parameter easing for denoising steps""" diff --git a/invokeai/app/invocations/primitives.py b/invokeai/app/invocations/primitives.py index fdadc4b31b..93cf29f7d6 100644 --- a/invokeai/app/invocations/primitives.py +++ b/invokeai/app/invocations/primitives.py @@ -44,7 +44,9 @@ class BooleanCollectionOutput(BaseInvocationOutput): ) -@invocation("boolean", title="Boolean Primitive", tags=["primitives", "boolean"], category="primitives") +@invocation( + "boolean", title="Boolean Primitive", tags=["primitives", "boolean"], category="primitives", version="1.0.0" +) class BooleanInvocation(BaseInvocation): """A boolean primitive value""" @@ -59,6 +61,7 @@ class BooleanInvocation(BaseInvocation): title="Boolean Collection Primitive", tags=["primitives", "boolean", "collection"], category="primitives", + version="1.0.0", ) class BooleanCollectionInvocation(BaseInvocation): """A collection of boolean primitive values""" @@ -90,7 +93,9 @@ class IntegerCollectionOutput(BaseInvocationOutput): ) -@invocation("integer", title="Integer Primitive", tags=["primitives", "integer"], category="primitives") +@invocation( + "integer", title="Integer Primitive", tags=["primitives", "integer"], category="primitives", version="1.0.0" +) class IntegerInvocation(BaseInvocation): """An integer primitive value""" @@ -105,6 +110,7 @@ class IntegerInvocation(BaseInvocation): title="Integer Collection Primitive", tags=["primitives", "integer", "collection"], category="primitives", + version="1.0.0", ) class IntegerCollectionInvocation(BaseInvocation): """A collection of integer primitive values""" @@ -136,7 +142,7 @@ class FloatCollectionOutput(BaseInvocationOutput): ) -@invocation("float", title="Float Primitive", tags=["primitives", "float"], category="primitives") +@invocation("float", title="Float Primitive", tags=["primitives", "float"], category="primitives", version="1.0.0") class FloatInvocation(BaseInvocation): """A float primitive value""" @@ -151,6 +157,7 @@ class FloatInvocation(BaseInvocation): title="Float Collection Primitive", tags=["primitives", "float", "collection"], category="primitives", + version="1.0.0", ) class FloatCollectionInvocation(BaseInvocation): """A collection of float primitive values""" @@ -182,7 +189,7 @@ class StringCollectionOutput(BaseInvocationOutput): ) -@invocation("string", title="String Primitive", tags=["primitives", "string"], category="primitives") +@invocation("string", title="String Primitive", tags=["primitives", "string"], category="primitives", version="1.0.0") class StringInvocation(BaseInvocation): """A string primitive value""" @@ -197,6 +204,7 @@ class StringInvocation(BaseInvocation): title="String Collection Primitive", tags=["primitives", "string", "collection"], category="primitives", + version="1.0.0", ) class StringCollectionInvocation(BaseInvocation): """A collection of string primitive values""" @@ -236,7 +244,7 @@ class ImageCollectionOutput(BaseInvocationOutput): ) -@invocation("image", title="Image Primitive", tags=["primitives", "image"], category="primitives") +@invocation("image", title="Image Primitive", tags=["primitives", "image"], category="primitives", version="1.0.0") class ImageInvocation(BaseInvocation): """An image primitive value""" @@ -257,6 +265,7 @@ class ImageInvocation(BaseInvocation): title="Image Collection Primitive", tags=["primitives", "image", "collection"], category="primitives", + version="1.0.0", ) class ImageCollectionInvocation(BaseInvocation): """A collection of image primitive values""" @@ -318,7 +327,9 @@ class LatentsCollectionOutput(BaseInvocationOutput): ) -@invocation("latents", title="Latents Primitive", tags=["primitives", "latents"], category="primitives") +@invocation( + "latents", title="Latents Primitive", tags=["primitives", "latents"], category="primitives", version="1.0.0" +) class LatentsInvocation(BaseInvocation): """A latents tensor primitive value""" @@ -335,6 +346,7 @@ class LatentsInvocation(BaseInvocation): title="Latents Collection Primitive", tags=["primitives", "latents", "collection"], category="primitives", + version="1.0.0", ) class LatentsCollectionInvocation(BaseInvocation): """A collection of latents tensor primitive values""" @@ -388,7 +400,7 @@ class ColorCollectionOutput(BaseInvocationOutput): ) -@invocation("color", title="Color Primitive", tags=["primitives", "color"], category="primitives") +@invocation("color", title="Color Primitive", tags=["primitives", "color"], category="primitives", version="1.0.0") class ColorInvocation(BaseInvocation): """A color primitive value""" @@ -430,6 +442,7 @@ class ConditioningCollectionOutput(BaseInvocationOutput): title="Conditioning Primitive", tags=["primitives", "conditioning"], category="primitives", + version="1.0.0", ) class ConditioningInvocation(BaseInvocation): """A conditioning tensor primitive value""" @@ -445,6 +458,7 @@ class ConditioningInvocation(BaseInvocation): title="Conditioning Collection Primitive", tags=["primitives", "conditioning", "collection"], category="primitives", + version="1.0.0", ) class ConditioningCollectionInvocation(BaseInvocation): """A collection of conditioning tensor primitive values""" diff --git a/invokeai/app/invocations/prompt.py b/invokeai/app/invocations/prompt.py index c42deeaa2c..69ce1dba49 100644 --- a/invokeai/app/invocations/prompt.py +++ b/invokeai/app/invocations/prompt.py @@ -10,7 +10,7 @@ from invokeai.app.invocations.primitives import StringCollectionOutput from .baseinvocation import BaseInvocation, InputField, InvocationContext, UIComponent, invocation -@invocation("dynamic_prompt", title="Dynamic Prompt", tags=["prompt", "collection"], category="prompt") +@invocation("dynamic_prompt", title="Dynamic Prompt", tags=["prompt", "collection"], category="prompt", version="1.0.0") class DynamicPromptInvocation(BaseInvocation): """Parses a prompt using adieyal/dynamicprompts' random or combinatorial generator""" @@ -29,7 +29,7 @@ class DynamicPromptInvocation(BaseInvocation): return StringCollectionOutput(collection=prompts) -@invocation("prompt_from_file", title="Prompts from File", tags=["prompt", "file"], category="prompt") +@invocation("prompt_from_file", title="Prompts from File", tags=["prompt", "file"], category="prompt", version="1.0.0") class PromptsFromFileInvocation(BaseInvocation): """Loads prompts from a text file""" diff --git a/invokeai/app/invocations/sdxl.py b/invokeai/app/invocations/sdxl.py index 288858a173..de4ea604b4 100644 --- a/invokeai/app/invocations/sdxl.py +++ b/invokeai/app/invocations/sdxl.py @@ -33,7 +33,7 @@ class SDXLRefinerModelLoaderOutput(BaseInvocationOutput): vae: VaeField = OutputField(description=FieldDescriptions.vae, title="VAE") -@invocation("sdxl_model_loader", title="SDXL Main Model", tags=["model", "sdxl"], category="model") +@invocation("sdxl_model_loader", title="SDXL Main Model", tags=["model", "sdxl"], category="model", version="1.0.0") class SDXLModelLoaderInvocation(BaseInvocation): """Loads an sdxl base model, outputting its submodels.""" @@ -119,6 +119,7 @@ class SDXLModelLoaderInvocation(BaseInvocation): title="SDXL Refiner Model", tags=["model", "sdxl", "refiner"], category="model", + version="1.0.0", ) class SDXLRefinerModelLoaderInvocation(BaseInvocation): """Loads an sdxl refiner model, outputting its submodels.""" diff --git a/invokeai/app/invocations/upscale.py b/invokeai/app/invocations/upscale.py index e9fb3f9963..7dca6d9f21 100644 --- a/invokeai/app/invocations/upscale.py +++ b/invokeai/app/invocations/upscale.py @@ -23,7 +23,7 @@ ESRGAN_MODELS = Literal[ ] -@invocation("esrgan", title="Upscale (RealESRGAN)", tags=["esrgan", "upscale"], category="esrgan") +@invocation("esrgan", title="Upscale (RealESRGAN)", tags=["esrgan", "upscale"], category="esrgan", version="1.0.0") class ESRGANInvocation(BaseInvocation): """Upscales an image using RealESRGAN.""" diff --git a/invokeai/frontend/web/package.json b/invokeai/frontend/web/package.json index cc1e17cf51..9a45dd89a5 100644 --- a/invokeai/frontend/web/package.json +++ b/invokeai/frontend/web/package.json @@ -75,6 +75,7 @@ "@reduxjs/toolkit": "^1.9.5", "@roarr/browser-log-writer": "^1.1.5", "@stevebel/png": "^1.5.1", + "compare-versions": "^6.1.0", "dateformat": "^5.0.3", "formik": "^2.4.3", "framer-motion": "^10.16.1", diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts index 4afe023fbb..261edba0af 100644 --- a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/index.ts @@ -84,6 +84,7 @@ import { addUserInvokedCanvasListener } from './listeners/userInvokedCanvas'; import { addUserInvokedImageToImageListener } from './listeners/userInvokedImageToImage'; import { addUserInvokedNodesListener } from './listeners/userInvokedNodes'; import { addUserInvokedTextToImageListener } from './listeners/userInvokedTextToImage'; +import { addWorkflowLoadedListener } from './listeners/workflowLoaded'; export const listenerMiddleware = createListenerMiddleware(); @@ -202,6 +203,9 @@ addBoardIdSelectedListener(); // Node schemas addReceivedOpenAPISchemaListener(); +// Workflows +addWorkflowLoadedListener(); + // DND addImageDroppedListener(); diff --git a/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoaded.ts b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoaded.ts new file mode 100644 index 0000000000..c447720941 --- /dev/null +++ b/invokeai/frontend/web/src/app/store/middleware/listenerMiddleware/listeners/workflowLoaded.ts @@ -0,0 +1,55 @@ +import { logger } from 'app/logging/logger'; +import { workflowLoadRequested } from 'features/nodes/store/actions'; +import { workflowLoaded } from 'features/nodes/store/nodesSlice'; +import { $flow } from 'features/nodes/store/reactFlowInstance'; +import { validateWorkflow } from 'features/nodes/util/validateWorkflow'; +import { addToast } from 'features/system/store/systemSlice'; +import { makeToast } from 'features/system/util/makeToast'; +import { setActiveTab } from 'features/ui/store/uiSlice'; +import { startAppListening } from '..'; + +export const addWorkflowLoadedListener = () => { + startAppListening({ + actionCreator: workflowLoadRequested, + effect: (action, { dispatch, getState }) => { + const log = logger('nodes'); + const workflow = action.payload; + const nodeTemplates = getState().nodes.nodeTemplates; + + const { workflow: validatedWorkflow, errors } = validateWorkflow( + workflow, + nodeTemplates + ); + + dispatch(workflowLoaded(validatedWorkflow)); + + if (!errors.length) { + dispatch( + addToast( + makeToast({ + title: 'Workflow Loaded', + status: 'success', + }) + ) + ); + } else { + dispatch( + addToast( + makeToast({ + title: 'Workflow Loaded with Warnings', + status: 'warning', + }) + ) + ); + errors.forEach(({ message, ...rest }) => { + log.warn(rest, message); + }); + } + + dispatch(setActiveTab('nodes')); + requestAnimationFrame(() => { + $flow.get()?.fitView(); + }); + }, + }); +}; diff --git a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx index 3559679fc4..846cf5a6f0 100644 --- a/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/CurrentImage/CurrentImageButtons.tsx @@ -17,16 +17,13 @@ import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import IAIIconButton from 'common/components/IAIIconButton'; import { DeleteImageButton } from 'features/deleteImageModal/components/DeleteImageButton'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; -import { workflowLoaded } from 'features/nodes/store/nodesSlice'; +import { workflowLoadRequested } from 'features/nodes/store/actions'; import ParamUpscalePopover from 'features/parameters/components/Parameters/Upscale/ParamUpscaleSettings'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { addToast } from 'features/system/store/systemSlice'; -import { makeToast } from 'features/system/util/makeToast'; import { activeTabNameSelector } from 'features/ui/store/uiSelectors'; import { - setActiveTab, setShouldShowImageDetails, setShouldShowProgressInViewer, } from 'features/ui/store/uiSlice'; @@ -124,16 +121,7 @@ const CurrentImageButtons = (props: CurrentImageButtonsProps) => { if (!workflow) { return; } - dispatch(workflowLoaded(workflow)); - dispatch(setActiveTab('nodes')); - dispatch( - addToast( - makeToast({ - title: 'Workflow Loaded', - status: 'success', - }) - ) - ); + dispatch(workflowLoadRequested(workflow)); }, [dispatch, workflow]); const handleClickUseAllParameters = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx index e75a7745bb..90272a3a86 100644 --- a/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx +++ b/invokeai/frontend/web/src/features/gallery/components/ImageContextMenu/SingleSelectionMenuItems.tsx @@ -7,12 +7,9 @@ import { isModalOpenChanged, } from 'features/changeBoardModal/store/slice'; import { imagesToDeleteSelected } from 'features/deleteImageModal/store/slice'; -import { workflowLoaded } from 'features/nodes/store/nodesSlice'; import { useRecallParameters } from 'features/parameters/hooks/useRecallParameters'; import { initialImageSelected } from 'features/parameters/store/actions'; import { useFeatureStatus } from 'features/system/hooks/useFeatureStatus'; -import { addToast } from 'features/system/store/systemSlice'; -import { makeToast } from 'features/system/util/makeToast'; import { useCopyImageToClipboard } from 'features/ui/hooks/useCopyImageToClipboard'; import { setActiveTab } from 'features/ui/store/uiSlice'; import { memo, useCallback } from 'react'; @@ -36,6 +33,7 @@ import { } from 'services/api/endpoints/images'; import { ImageDTO } from 'services/api/types'; import { sentImageToCanvas, sentImageToImg2Img } from '../../store/actions'; +import { workflowLoadRequested } from 'features/nodes/store/actions'; type SingleSelectionMenuItemsProps = { imageDTO: ImageDTO; @@ -102,16 +100,7 @@ const SingleSelectionMenuItems = (props: SingleSelectionMenuItemsProps) => { if (!workflow) { return; } - dispatch(workflowLoaded(workflow)); - dispatch(setActiveTab('nodes')); - dispatch( - addToast( - makeToast({ - title: 'Workflow Loaded', - status: 'success', - }) - ) - ); + dispatch(workflowLoadRequested(workflow)); }, [dispatch, workflow]); const handleSendToImageToImage = useCallback(() => { diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx index e8fb66d074..16af1fe12c 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/Flow.tsx @@ -3,6 +3,7 @@ import { createSelector } from '@reduxjs/toolkit'; import { stateSelector } from 'app/store/store'; import { useAppDispatch, useAppSelector } from 'app/store/storeHooks'; import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { $flow } from 'features/nodes/store/reactFlowInstance'; import { contextMenusClosed } from 'features/ui/store/uiSlice'; import { useCallback } from 'react'; import { useHotkeys } from 'react-hotkeys-hook'; @@ -13,6 +14,7 @@ import { OnConnectStart, OnEdgesChange, OnEdgesDelete, + OnInit, OnMoveEnd, OnNodesChange, OnNodesDelete, @@ -147,6 +149,11 @@ export const Flow = () => { dispatch(contextMenusClosed()); }, [dispatch]); + const onInit: OnInit = useCallback((flow) => { + $flow.set(flow); + flow.fitView(); + }, []); + useHotkeys(['Ctrl+c', 'Meta+c'], (e) => { e.preventDefault(); dispatch(selectionCopied()); @@ -170,6 +177,7 @@ export const Flow = () => { edgeTypes={edgeTypes} nodes={nodes} edges={edges} + onInit={onInit} onNodesChange={onNodesChange} onEdgesChange={onEdgesChange} onEdgesDelete={onEdgesDelete} diff --git a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx index eae05688b5..143785ecfe 100644 --- a/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx +++ b/invokeai/frontend/web/src/features/nodes/components/flow/nodes/Invocation/InvocationNodeNotes.tsx @@ -12,6 +12,7 @@ import { Tooltip, useDisclosure, } from '@chakra-ui/react'; +import { compare } from 'compare-versions'; import { useNodeData } from 'features/nodes/hooks/useNodeData'; import { useNodeLabel } from 'features/nodes/hooks/useNodeLabel'; import { useNodeTemplate } from 'features/nodes/hooks/useNodeTemplate'; @@ -20,6 +21,7 @@ import { isInvocationNodeData } from 'features/nodes/types/types'; import { memo, useMemo } from 'react'; import { FaInfoCircle } from 'react-icons/fa'; import NotesTextarea from './NotesTextarea'; +import { useDoNodeVersionsMatch } from 'features/nodes/hooks/useDoNodeVersionsMatch'; interface Props { nodeId: string; @@ -29,6 +31,7 @@ const InvocationNodeNotes = ({ nodeId }: Props) => { const { isOpen, onOpen, onClose } = useDisclosure(); const label = useNodeLabel(nodeId); const title = useNodeTemplateTitle(nodeId); + const doVersionsMatch = useDoNodeVersionsMatch(nodeId); return ( <> @@ -50,7 +53,11 @@ const InvocationNodeNotes = ({ nodeId }: Props) => { > @@ -92,16 +99,59 @@ const TooltipContent = memo(({ nodeId }: { nodeId: string }) => { return 'Unknown Node'; }, [data, nodeTemplate]); + const versionComponent = useMemo(() => { + if (!isInvocationNodeData(data) || !nodeTemplate) { + return null; + } + + if (!data.version) { + return ( + + Version unknown + + ); + } + + if (!nodeTemplate.version) { + return ( + + Version {data.version} (unknown template) + + ); + } + + if (compare(data.version, nodeTemplate.version, '<')) { + return ( + + Version {data.version} (update node) + + ); + } + + if (compare(data.version, nodeTemplate.version, '>')) { + return ( + + Version {data.version} (update app) + + ); + } + + return Version {data.version}; + }, [data, nodeTemplate]); + if (!isInvocationNodeData(data)) { return Unknown Node; } return ( - {title} + + {title} + {nodeTemplate?.description} + {versionComponent} {data?.notes && {data.notes}} ); diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts b/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts index a88a82e1fc..24982f591e 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts +++ b/invokeai/frontend/web/src/features/nodes/hooks/useBuildNodeData.ts @@ -138,13 +138,14 @@ export const useBuildNodeData = () => { data: { id: nodeId, type, - inputs, - outputs, - isOpen: true, + version: template.version, label: '', notes: '', + isOpen: true, embedWorkflow: false, isIntermediate: true, + inputs, + outputs, }, }; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useDoNodeVersionsMatch.ts b/invokeai/frontend/web/src/features/nodes/hooks/useDoNodeVersionsMatch.ts new file mode 100644 index 0000000000..926c56ac1e --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/hooks/useDoNodeVersionsMatch.ts @@ -0,0 +1,33 @@ +import { createSelector } from '@reduxjs/toolkit'; +import { stateSelector } from 'app/store/store'; +import { useAppSelector } from 'app/store/storeHooks'; +import { defaultSelectorOptions } from 'app/store/util/defaultMemoizeOptions'; +import { compareVersions } from 'compare-versions'; +import { useMemo } from 'react'; +import { isInvocationNode } from '../types/types'; + +export const useDoNodeVersionsMatch = (nodeId: string) => { + const selector = useMemo( + () => + createSelector( + stateSelector, + ({ nodes }) => { + const node = nodes.nodes.find((node) => node.id === nodeId); + if (!isInvocationNode(node)) { + return false; + } + const nodeTemplate = nodes.nodeTemplates[node?.data.type ?? '']; + if (!nodeTemplate?.version || !node.data?.version) { + return false; + } + return compareVersions(nodeTemplate.version, node.data.version) === 0; + }, + defaultSelectorOptions + ), + [nodeId] + ); + + const nodeTemplate = useAppSelector(selector); + + return nodeTemplate; +}; diff --git a/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx b/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx index 97f2cea77b..7f015ac5eb 100644 --- a/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx +++ b/invokeai/frontend/web/src/features/nodes/hooks/useLoadWorkflowFromFile.tsx @@ -2,13 +2,13 @@ import { ListItem, Text, UnorderedList } from '@chakra-ui/react'; import { useLogger } from 'app/logging/useLogger'; import { useAppDispatch } from 'app/store/storeHooks'; import { parseify } from 'common/util/serialize'; -import { workflowLoaded } from 'features/nodes/store/nodesSlice'; -import { zValidatedWorkflow } from 'features/nodes/types/types'; +import { zWorkflow } from 'features/nodes/types/types'; import { addToast } from 'features/system/store/systemSlice'; import { makeToast } from 'features/system/util/makeToast'; import { memo, useCallback } from 'react'; import { ZodError } from 'zod'; import { fromZodError, fromZodIssue } from 'zod-validation-error'; +import { workflowLoadRequested } from '../store/actions'; export const useLoadWorkflowFromFile = () => { const dispatch = useAppDispatch(); @@ -24,7 +24,7 @@ export const useLoadWorkflowFromFile = () => { try { const parsedJSON = JSON.parse(String(rawJSON)); - const result = zValidatedWorkflow.safeParse(parsedJSON); + const result = zWorkflow.safeParse(parsedJSON); if (!result.success) { const { message } = fromZodError(result.error, { @@ -45,32 +45,8 @@ export const useLoadWorkflowFromFile = () => { reader.abort(); return; } - dispatch(workflowLoaded(result.data.workflow)); - if (!result.data.warnings.length) { - dispatch( - addToast( - makeToast({ - title: 'Workflow Loaded', - status: 'success', - }) - ) - ); - reader.abort(); - return; - } - - dispatch( - addToast( - makeToast({ - title: 'Workflow Loaded with Warnings', - status: 'warning', - }) - ) - ); - result.data.warnings.forEach(({ message, ...rest }) => { - logger.warn(rest, message); - }); + dispatch(workflowLoadRequested(result.data)); reader.abort(); } catch { diff --git a/invokeai/frontend/web/src/features/nodes/store/actions.ts b/invokeai/frontend/web/src/features/nodes/store/actions.ts index 2463a1e945..cf7ccf8238 100644 --- a/invokeai/frontend/web/src/features/nodes/store/actions.ts +++ b/invokeai/frontend/web/src/features/nodes/store/actions.ts @@ -1,5 +1,6 @@ import { createAction, isAnyOf } from '@reduxjs/toolkit'; import { Graph } from 'services/api/types'; +import { Workflow } from '../types/types'; export const textToImageGraphBuilt = createAction( 'nodes/textToImageGraphBuilt' @@ -16,3 +17,7 @@ export const isAnyGraphBuilt = isAnyOf( canvasGraphBuilt, nodesGraphBuilt ); + +export const workflowLoadRequested = createAction( + 'nodes/workflowLoadRequested' +); diff --git a/invokeai/frontend/web/src/features/nodes/store/reactFlowInstance.ts b/invokeai/frontend/web/src/features/nodes/store/reactFlowInstance.ts new file mode 100644 index 0000000000..e9094a9310 --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/store/reactFlowInstance.ts @@ -0,0 +1,4 @@ +import { atom } from 'nanostores'; +import { ReactFlowInstance } from 'reactflow'; + +export const $flow = atom(null); diff --git a/invokeai/frontend/web/src/features/nodes/types/types.ts b/invokeai/frontend/web/src/features/nodes/types/types.ts index f7986a5028..402ef4ac7a 100644 --- a/invokeai/frontend/web/src/features/nodes/types/types.ts +++ b/invokeai/frontend/web/src/features/nodes/types/types.ts @@ -52,6 +52,10 @@ export type InvocationTemplate = { * The type of this node's output */ outputType: string; // TODO: generate a union of output types + /** + * The invocation's version. + */ + version?: string; }; export type FieldUIConfig = { @@ -962,6 +966,7 @@ export type InvocationSchemaExtra = { title: string; category?: string; tags?: string[]; + version?: string; properties: Omit< NonNullable & (_InputField | _OutputField), @@ -1095,6 +1100,29 @@ export const zCoreMetadata = z export type CoreMetadata = z.infer; +export const zSemVer = z.string().refine((val) => { + const [major, minor, patch] = val.split('.'); + return ( + major !== undefined && + Number.isInteger(Number(major)) && + minor !== undefined && + Number.isInteger(Number(minor)) && + patch !== undefined && + Number.isInteger(Number(patch)) + ); +}); + +export const zParsedSemver = zSemVer.transform((val) => { + const [major, minor, patch] = val.split('.'); + return { + major: Number(major), + minor: Number(minor), + patch: Number(patch), + }; +}); + +export type SemVer = z.infer; + export const zInvocationNodeData = z.object({ id: z.string().trim().min(1), // no easy way to build this dynamically, and we don't want to anyways, because this will be used @@ -1107,6 +1135,7 @@ export const zInvocationNodeData = z.object({ notes: z.string(), embedWorkflow: z.boolean(), isIntermediate: z.boolean(), + version: zSemVer.optional(), }); // Massage this to get better type safety while developing @@ -1195,20 +1224,6 @@ export const zFieldIdentifier = z.object({ export type FieldIdentifier = z.infer; -export const zSemVer = z.string().refine((val) => { - const [major, minor, patch] = val.split('.'); - return ( - major !== undefined && - minor !== undefined && - patch !== undefined && - Number.isInteger(Number(major)) && - Number.isInteger(Number(minor)) && - Number.isInteger(Number(patch)) - ); -}); - -export type SemVer = z.infer; - export type WorkflowWarning = { message: string; issues: string[]; diff --git a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts index 78e7495481..d8bb189abc 100644 --- a/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts +++ b/invokeai/frontend/web/src/features/nodes/util/parseSchema.ts @@ -73,6 +73,7 @@ export const parseSchema = ( const title = schema.title.replace('Invocation', ''); const tags = schema.tags ?? []; const description = schema.description ?? ''; + const version = schema.version ?? ''; const inputs = reduce( schema.properties, @@ -225,11 +226,12 @@ export const parseSchema = ( const invocation: InvocationTemplate = { title, type, + version, tags, description, + outputType, inputs, outputs, - outputType, }; Object.assign(invocationsAccumulator, { [type]: invocation }); diff --git a/invokeai/frontend/web/src/features/nodes/util/validateWorkflow.ts b/invokeai/frontend/web/src/features/nodes/util/validateWorkflow.ts new file mode 100644 index 0000000000..a3085d516b --- /dev/null +++ b/invokeai/frontend/web/src/features/nodes/util/validateWorkflow.ts @@ -0,0 +1,96 @@ +import { compareVersions } from 'compare-versions'; +import { cloneDeep, keyBy } from 'lodash-es'; +import { + InvocationTemplate, + Workflow, + WorkflowWarning, + isWorkflowInvocationNode, +} from '../types/types'; +import { parseify } from 'common/util/serialize'; + +export const validateWorkflow = ( + workflow: Workflow, + nodeTemplates: Record +) => { + const clone = cloneDeep(workflow); + const { nodes, edges } = clone; + const errors: WorkflowWarning[] = []; + const invocationNodes = nodes.filter(isWorkflowInvocationNode); + const keyedNodes = keyBy(invocationNodes, 'id'); + nodes.forEach((node) => { + if (!isWorkflowInvocationNode(node)) { + return; + } + + const nodeTemplate = nodeTemplates[node.data.type]; + if (!nodeTemplate) { + errors.push({ + message: `Node "${node.data.type}" skipped`, + issues: [`Node type "${node.data.type}" does not exist`], + data: node, + }); + return; + } + + if ( + nodeTemplate.version && + node.data.version && + compareVersions(nodeTemplate.version, node.data.version) !== 0 + ) { + errors.push({ + message: `Node "${node.data.type}" has mismatched version`, + issues: [ + `Node "${node.data.type}" v${node.data.version} may be incompatible with installed v${nodeTemplate.version}`, + ], + data: { node, nodeTemplate: parseify(nodeTemplate) }, + }); + return; + } + }); + edges.forEach((edge, i) => { + const sourceNode = keyedNodes[edge.source]; + const targetNode = keyedNodes[edge.target]; + const issues: string[] = []; + if (!sourceNode) { + issues.push(`Output node ${edge.source} does not exist`); + } else if ( + edge.type === 'default' && + !(edge.sourceHandle in sourceNode.data.outputs) + ) { + issues.push( + `Output field "${edge.source}.${edge.sourceHandle}" does not exist` + ); + } + if (!targetNode) { + issues.push(`Input node ${edge.target} does not exist`); + } else if ( + edge.type === 'default' && + !(edge.targetHandle in targetNode.data.inputs) + ) { + issues.push( + `Input field "${edge.target}.${edge.targetHandle}" does not exist` + ); + } + if (!nodeTemplates[sourceNode?.data.type ?? '__UNKNOWN_NODE_TYPE__']) { + issues.push( + `Source node "${edge.source}" missing template "${sourceNode?.data.type}"` + ); + } + if (!nodeTemplates[targetNode?.data.type ?? '__UNKNOWN_NODE_TYPE__']) { + issues.push( + `Source node "${edge.target}" missing template "${targetNode?.data.type}"` + ); + } + if (issues.length) { + delete edges[i]; + const src = edge.type === 'default' ? edge.sourceHandle : edge.source; + const tgt = edge.type === 'default' ? edge.targetHandle : edge.target; + errors.push({ + message: `Edge "${src} -> ${tgt}" skipped`, + issues, + data: edge, + }); + } + }); + return { workflow: clone, errors }; +}; diff --git a/invokeai/frontend/web/src/services/api/schema.d.ts b/invokeai/frontend/web/src/services/api/schema.d.ts index f48892113d..6e00d1b38b 100644 --- a/invokeai/frontend/web/src/services/api/schema.d.ts +++ b/invokeai/frontend/web/src/services/api/schema.d.ts @@ -6981,6 +6981,11 @@ export type components = { * @description The node's category */ category?: string; + /** + * Version + * @description The node's version. Should be a valid semver string e.g. "1.0.0" or "3.8.13". + */ + version?: string; }; /** * Input @@ -7036,24 +7041,12 @@ export type components = { /** Ui Order */ ui_order?: number; }; - /** - * StableDiffusionOnnxModelFormat - * @description An enumeration. - * @enum {string} - */ - StableDiffusionOnnxModelFormat: "olive" | "onnx"; /** * StableDiffusion1ModelFormat * @description An enumeration. * @enum {string} */ StableDiffusion1ModelFormat: "checkpoint" | "diffusers"; - /** - * ControlNetModelFormat - * @description An enumeration. - * @enum {string} - */ - ControlNetModelFormat: "checkpoint" | "diffusers"; /** * StableDiffusionXLModelFormat * @description An enumeration. @@ -7066,6 +7059,18 @@ export type components = { * @enum {string} */ StableDiffusion2ModelFormat: "checkpoint" | "diffusers"; + /** + * ControlNetModelFormat + * @description An enumeration. + * @enum {string} + */ + ControlNetModelFormat: "checkpoint" | "diffusers"; + /** + * StableDiffusionOnnxModelFormat + * @description An enumeration. + * @enum {string} + */ + StableDiffusionOnnxModelFormat: "olive" | "onnx"; }; responses: never; parameters: never; diff --git a/invokeai/frontend/web/yarn.lock b/invokeai/frontend/web/yarn.lock index 2c3c9ae88f..787c81a756 100644 --- a/invokeai/frontend/web/yarn.lock +++ b/invokeai/frontend/web/yarn.lock @@ -2970,6 +2970,11 @@ commondir@^1.0.1: resolved "https://registry.yarnpkg.com/commondir/-/commondir-1.0.1.tgz#ddd800da0c66127393cca5950ea968a3aaf1253b" integrity sha512-W9pAhw0ja1Edb5GVdIF1mjZw/ASI0AlShXM83UUGe2DVr5TdAPEA1OA8m/g8zWp9x6On7gqufY+FatDbC3MDQg== +compare-versions@^6.1.0: + version "6.1.0" + resolved "https://registry.yarnpkg.com/compare-versions/-/compare-versions-6.1.0.tgz#3f2131e3ae93577df111dba133e6db876ffe127a" + integrity sha512-LNZQXhqUvqUTotpZ00qLSaify3b4VFD588aRr8MKFw4CMUr98ytzCW5wDH5qx/DEY5kCDXcbcRuCqL0szEf2tg== + compute-scroll-into-view@1.0.20: version "1.0.20" resolved "https://registry.yarnpkg.com/compute-scroll-into-view/-/compute-scroll-into-view-1.0.20.tgz#1768b5522d1172754f5d0c9b02de3af6be506a43" diff --git a/pyproject.toml b/pyproject.toml index 129538264d..4b06944b33 100644 --- a/pyproject.toml +++ b/pyproject.toml @@ -74,6 +74,7 @@ dependencies = [ "rich~=13.3", "safetensors==0.3.1", "scikit-image~=0.21.0", + "semver~=3.0.1", "send2trash", "test-tube~=0.7.5", "torch~=2.0.1", diff --git a/tests/nodes/test_node_graph.py b/tests/nodes/test_node_graph.py index 56bf823d14..0e1be8f343 100644 --- a/tests/nodes/test_node_graph.py +++ b/tests/nodes/test_node_graph.py @@ -1,4 +1,10 @@ -from invokeai.app.invocations.baseinvocation import BaseInvocation, BaseInvocationOutput, invocation, invocation_output +from invokeai.app.invocations.baseinvocation import ( + BaseInvocation, + BaseInvocationOutput, + InvalidVersionError, + invocation, + invocation_output, +) from .test_nodes import ( ImageToImageTestInvocation, TextToImageTestInvocation, @@ -616,18 +622,38 @@ def test_invocation_decorator(): title = "Test Invocation" tags = ["first", "second", "third"] category = "category" + version = "1.2.3" - @invocation(invocation_type, title=title, tags=tags, category=category) - class Test(BaseInvocation): + @invocation(invocation_type, title=title, tags=tags, category=category, version=version) + class TestInvocation(BaseInvocation): def invoke(self): pass - schema = Test.schema() + schema = TestInvocation.schema() assert schema.get("title") == title assert schema.get("tags") == tags assert schema.get("category") == category - assert Test(id="1").type == invocation_type # type: ignore (type is dynamically added) + assert schema.get("version") == version + assert TestInvocation(id="1").type == invocation_type # type: ignore (type is dynamically added) + + +def test_invocation_version_must_be_semver(): + invocation_type = "test_invocation" + valid_version = "1.0.0" + invalid_version = "not_semver" + + @invocation(invocation_type, version=valid_version) + class ValidVersionInvocation(BaseInvocation): + def invoke(self): + pass + + with pytest.raises(InvalidVersionError): + + @invocation(invocation_type, version=invalid_version) + class InvalidVersionInvocation(BaseInvocation): + def invoke(self): + pass def test_invocation_output_decorator():