From 792ababc97810835dbfa2e346e50b3400b3a53c0 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Wed, 4 Feb 2026 17:42:20 -0500 Subject: [PATCH 1/7] refactor(metadata): start to replace ad-hoc metadata parsing with structured parsing via pydantic --- .../{invoke => invoke-DELETE}/__init__.py | 0 .../invoke_metadata_abc.py | 0 .../{invoke => invoke-DELETE}/legacy.py | 0 .../{invoke => invoke-DELETE}/v3.py | 0 .../{invoke => invoke-DELETE}/v5.py | 0 .../invoke/canvas2metadata.py | 192 ++++++++++++++++++ .../invoke/invoke2metadata.py | 36 ++++ .../invoke/invoke3metadata.py | 42 ++++ .../invoke/invoke5metadata.py | 62 ++++++ .../invoke/parse_invoke_metadata.py | 154 ++++++++++++++ 10 files changed, 486 insertions(+) rename photomap/backend/metadata_modules/{invoke => invoke-DELETE}/__init__.py (100%) rename photomap/backend/metadata_modules/{invoke => invoke-DELETE}/invoke_metadata_abc.py (100%) rename photomap/backend/metadata_modules/{invoke => invoke-DELETE}/legacy.py (100%) rename photomap/backend/metadata_modules/{invoke => invoke-DELETE}/v3.py (100%) rename photomap/backend/metadata_modules/{invoke => invoke-DELETE}/v5.py (100%) create mode 100644 photomap/backend/metadata_modules/invoke/canvas2metadata.py create mode 100644 photomap/backend/metadata_modules/invoke/invoke2metadata.py create mode 100644 photomap/backend/metadata_modules/invoke/invoke3metadata.py create mode 100644 photomap/backend/metadata_modules/invoke/invoke5metadata.py create mode 100644 photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py diff --git a/photomap/backend/metadata_modules/invoke/__init__.py b/photomap/backend/metadata_modules/invoke-DELETE/__init__.py similarity index 100% rename from photomap/backend/metadata_modules/invoke/__init__.py rename to photomap/backend/metadata_modules/invoke-DELETE/__init__.py diff --git a/photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py b/photomap/backend/metadata_modules/invoke-DELETE/invoke_metadata_abc.py similarity index 100% rename from photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py rename to photomap/backend/metadata_modules/invoke-DELETE/invoke_metadata_abc.py diff --git a/photomap/backend/metadata_modules/invoke/legacy.py b/photomap/backend/metadata_modules/invoke-DELETE/legacy.py similarity index 100% rename from photomap/backend/metadata_modules/invoke/legacy.py rename to photomap/backend/metadata_modules/invoke-DELETE/legacy.py diff --git a/photomap/backend/metadata_modules/invoke/v3.py b/photomap/backend/metadata_modules/invoke-DELETE/v3.py similarity index 100% rename from photomap/backend/metadata_modules/invoke/v3.py rename to photomap/backend/metadata_modules/invoke-DELETE/v3.py diff --git a/photomap/backend/metadata_modules/invoke/v5.py b/photomap/backend/metadata_modules/invoke-DELETE/v5.py similarity index 100% rename from photomap/backend/metadata_modules/invoke/v5.py rename to photomap/backend/metadata_modules/invoke-DELETE/v5.py diff --git a/photomap/backend/metadata_modules/invoke/canvas2metadata.py b/photomap/backend/metadata_modules/invoke/canvas2metadata.py new file mode 100644 index 00000000..919c729a --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/canvas2metadata.py @@ -0,0 +1,192 @@ +from typing import Annotated, Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, Field + + +class Clip(BaseModel): + height: float + width: float + x: float + y: float + + +class Color(BaseModel): + b: int + g: int + r: int + + +class Fill(BaseModel): + color: Color + style: str + + +class Model(BaseModel): + base: str + hash: str + key: str + name: str + type: str + + +class ImageData(BaseModel): + image_type: Literal["dataURL"] = Field(default="dataURL", alias="type") + data_url: str = Field(alias="dataURL") + height: int + width: int + + class Config: + populate_by_name = True + + +class ImageFile(BaseModel): + image_type: Literal["file"] = Field(default="file", alias="type") + image_name: str + height: int + width: int + + class Config: + populate_by_name = True + + +ImageUnion = Annotated[ + Union[ImageFile, ImageData], + Field(discriminator="image_type"), +] + + +class Object(BaseModel): + id: str + image: Optional[ImageUnion] = None + type: str + + +class Position(BaseModel): + x: int | float + y: int | float + + +class Inpaintmask(BaseModel): + fill: Fill + id: str + is_enabled: bool = Field(alias="isEnabled") + is_locked: bool = Field(alias="isLocked") + name: Optional[Any] + objects: List[Object] + opacity: int + position: Position + type: str + + class Config: + populate_by_name = True + + +class Rasterlayer(BaseModel): + id: str + is_enabled: bool = Field(alias="isEnabled") + is_locked: bool = Field(alias="isLocked") + name: Optional[Any] + objects: List[Object] + opacity: int + position: Position + type: str + + class Config: + populate_by_name = True + + +class Ipadapter(BaseModel): + begin_end_step_pct: Optional[List[int | float]] = Field( + None, alias="beginEndStepPct" + ) + clip_vision_model: Optional[str] = Field(None, alias="clipVisionModel") + image: ImageUnion + model: Model + type: str + method: Optional[str] = None + weight: Optional[float] = None + + class Config: + populate_by_name = True + + +class ReferenceImage(BaseModel): + id: str + ip_adapter: Ipadapter = Field(alias="ipAdapter") + is_enabled: Optional[bool] = Field(None, alias="isEnabled") + is_locked: Optional[bool] = Field(None, alias="isLocked") + name: Optional[Any] = None + type: Optional[str] = None + + class Config: + populate_by_name = True + + +class Controladapter(BaseModel): + begin_end_step_pct: List[int | float] = Field(alias="beginEndStepPct") + control_mode: Optional[str] = Field(None, alias="controlMode") + model: Model + type: str + weight: float + + class Config: + populate_by_name = True + + +class Controllayer(BaseModel): + control_adapter: Controladapter = Field(alias="controlAdapter") + id: str + is_enabled: bool = Field(alias="isEnabled") + is_locked: bool = Field(alias="isLocked") + name: Optional[Any] + objects: List[Object] + opacity: int + position: Position + type: str + with_transparency_effect: bool = Field(alias="withTransparencyEffect") + + class Config: + populate_by_name = True + + +class RegionalGuidance(BaseModel): + auto_negative: bool = Field(alias="autoNegative") + fill: Fill + id: str + is_enabled: bool = Field(alias="isEnabled") + is_locked: bool = Field(alias="isLocked") + name: Optional[Any] + negative_prompt: Optional[Any] = Field(None, alias="negativePrompt") + objects: List[Object] + opacity: float + position: Position + positive_prompt: Optional[Any] = Field(None, alias="positivePrompt") + reference_images: List[ReferenceImage] = Field(alias="referenceImages") + type: str + + class Config: + populate_by_name = True + + +class CanvasV2Metadata(BaseModel): + raster_layers: Optional[List[Rasterlayer]] = Field(None, alias="rasterLayers") + control_layers: Optional[List[Controllayer]] = Field(None, alias="controlLayers") + inpaint_masks: Optional[List[Inpaintmask]] = Field(None, alias="inpaintMasks") + reference_images: Optional[List[ReferenceImage]] = Field( + None, alias="referenceImages" + ) + regional_guidance: Optional[List[RegionalGuidance]] = Field( + None, alias="regionalGuidance" + ) + + class Config: + populate_by_name = True + + +class GenerationMetadataCanvas(BaseModel): + metadata_version: Literal["canvas"] + canvas_v2_metadata: CanvasV2Metadata + model: Optional[Model] = None + negative_prompt: Optional[str] = None + positive_prompt: Optional[str] = None + seed: Optional[int] = None diff --git a/photomap/backend/metadata_modules/invoke/invoke2metadata.py b/photomap/backend/metadata_modules/invoke/invoke2metadata.py new file mode 100644 index 00000000..6c2cb894 --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/invoke2metadata.py @@ -0,0 +1,36 @@ +from typing import Any, List, Literal, Optional + +from pydantic import BaseModel + + +class Prompt(BaseModel): + prompt: str + weight: float + + +class Image(BaseModel): + cfg_scale: float + height: int + hires_fix: Optional[bool] = None + perlin: Optional[int | float] = None + postprocessing: Optional[Any] + prompt: str | List[Prompt] + sampler: str + seamless: Optional[bool] = None + seed: int + steps: int + threshold: Optional[int | float] = None + type: str + variations: Optional[List[Any]] = None + width: int + + +class GenerationMetadata2(BaseModel): + metadata_version: Literal[2] + app_id: str + app_version: str + image: Optional[Image] = None + images: Optional[List[Image]] = None + model: str + model_hash: str + model_weights: Optional[str] = None diff --git a/photomap/backend/metadata_modules/invoke/invoke3metadata.py b/photomap/backend/metadata_modules/invoke/invoke3metadata.py new file mode 100644 index 00000000..1d01e946 --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/invoke3metadata.py @@ -0,0 +1,42 @@ +from typing import Literal, Optional + +from pydantic import BaseModel, Field + + +# Pydantic classes for version 3 +class Model(BaseModel): + base_model: Optional[str] = Field(alias="base", default=None) + model_name: str = Field(alias="name") + model_type: Optional[str] = Field(alias="type", default=None) + + class Config: + populate_by_name = True + + +class Vae(BaseModel): + base_model: str = Field(alias="base") + model_name: str = Field(alias="name") + + class Config: + populate_by_name = True + + +# All fields are optional because of various glitches and exceptions in v3 metadata +class GenerationMetadata3(BaseModel): + metadata_version: Literal[3] + generation_mode: Optional[str] = None + model: Optional[Model] = None + positive_prompt: Optional[str] = None + positive_style_prompt: Optional[str] = None + negative_prompt: Optional[str] = None + negative_style_prompt: Optional[str] = None + height: Optional[int] = None + width: Optional[int] = None + rand_device: Optional[str] = None + scheduler: Optional[str] = None + seed: Optional[int] = None + steps: Optional[int] = None + vae: Optional[Vae] = None + cfg_rescale_multiplier: Optional[float] = None + cfg_scale: Optional[float] = None + esrgan_model: Optional[str] = None diff --git a/photomap/backend/metadata_modules/invoke/invoke5metadata.py b/photomap/backend/metadata_modules/invoke/invoke5metadata.py new file mode 100644 index 00000000..5bdae33c --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/invoke5metadata.py @@ -0,0 +1,62 @@ +from typing import Any, List, Literal, Optional + +from pydantic import BaseModel + + +# Pydantic classes for version 5 +class Model(BaseModel): + base: str + hash: str + key: str + name: str + type: str + + +class ClipEmbedModel(BaseModel): + base: str + hash: str + key: str + name: str + type: str + + +class T5Encoder(BaseModel): + base: str + hash: str + key: str + name: str + type: str + + +class Vae(BaseModel): + base: str + hash: str + key: str + name: str + type: str + + +class Lora(BaseModel): + model: Model + weight: float + + +# Empirically, pretty much all fields are optional in v5! +class GenerationMetadata5(BaseModel): + metadata_version: Literal[5] + app_version: str + model: Optional[Model] = None + generation_mode: Optional[str] = None + height: Optional[int] = None + width: Optional[int] = None + positive_prompt: Optional[str] = None + scheduler: Optional[str] = None + seed: Optional[int] = None + steps: Optional[int] = None + guidance: Optional[int | float] = None + ref_images: Optional[List[Any]] = None + loras: Optional[List[Lora]] = None + t5_encoder: Optional[T5Encoder] = None + vae: Optional[Vae] = None + clip_embed_model: Optional[ClipEmbedModel] = None + dype_preset: Optional[str] = None diff --git a/photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py b/photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py new file mode 100644 index 00000000..7629acc8 --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py @@ -0,0 +1,154 @@ +import json +import os +import sys +from pathlib import Path +from typing import Annotated, Any, Union + +from canvas2metadata import GenerationMetadataCanvas +from invoke2metadata import GenerationMetadata2 +from invoke3metadata import GenerationMetadata3 +from invoke5metadata import GenerationMetadata5 +from PIL import Image +from pydantic import Field, TypeAdapter + +GenerationMetadata = Annotated[ + Union[ + GenerationMetadata2, + GenerationMetadata3, + GenerationMetadata5, + GenerationMetadataCanvas, + ], + Field(discriminator="metadata_version"), +] + + +def add_image_type_discriminator(image: dict[str, Any]) -> None: + """Add image_type discriminator to an image object based on its fields.""" + if "dataURL" in image: + image["image_type"] = "dataURL" + elif "image_name" in image: + image["image_type"] = "file" + + +def preprocess_canvas_metadata(json_data: dict[str, Any]) -> dict[str, Any]: + """Preprocess canvas metadata to add type discriminators to image objects.""" + if "canvas_v2_metadata" not in json_data: + return json_data + + canvas_metadata = json_data["canvas_v2_metadata"] + + def process_image_in_dict(obj: dict[str, Any], key: str = "image") -> None: + """Add type discriminator to an image object if it exists.""" + if key in obj and obj[key]: + add_image_type_discriminator(obj[key]) + + def process_objects(objects: list[dict[str, Any]]) -> None: + """Process a list of objects that may contain images.""" + for obj in objects: + process_image_in_dict(obj) + + def process_reference_images(ref_images: list[dict[str, Any]]) -> None: + """Process reference images with ipAdapter.""" + for ref_image in ref_images: + if "ipAdapter" in ref_image and ref_image["ipAdapter"]: + process_image_in_dict(ref_image["ipAdapter"]) + + # Process layers with objects (rasterLayers, inpaintMasks, controlLayers) + for layer_key in ["rasterLayers", "inpaintMasks", "controlLayers"]: + if layer_key in canvas_metadata and canvas_metadata[layer_key]: + for layer in canvas_metadata[layer_key]: + if "objects" in layer and layer["objects"]: + process_objects(layer["objects"]) + + # Process top-level referenceImages + if "referenceImages" in canvas_metadata and canvas_metadata["referenceImages"]: + process_reference_images(canvas_metadata["referenceImages"]) + + # Process regionalGuidance with objects and referenceImages + if "regionalGuidance" in canvas_metadata and canvas_metadata["regionalGuidance"]: + for region in canvas_metadata["regionalGuidance"]: + if "objects" in region and region["objects"]: + process_objects(region["objects"]) + if "referenceImages" in region and region["referenceImages"]: + process_reference_images(region["referenceImages"]) + + return json_data + + +def parse_generation_metadata(json_data: dict[str, Any]) -> GenerationMetadata: + if "metadata_version" not in json_data: + if "canvas_v2_metadata" in json_data: + json_data = {"metadata_version": "canvas", **json_data} + elif "app_version" in json_data: + if any( + json_data["app_version"].startswith(x) for x in ["v1.", "2.", "v2."] + ): + json_data = {"metadata_version": 2, **json_data} + elif json_data["app_version"].startswith("3."): + if "model" in json_data and isinstance(json_data["model"], str): + json_data = {"metadata_version": 2, **json_data} + else: + json_data = {"metadata_version": 3, **json_data} + else: + json_data = {"metadata_version": 5, **json_data} + elif "model_weights" in json_data: + # v2 metadata has model_weights field + json_data = {"metadata_version": 2, **json_data} + else: + json_data = {"metadata_version": 3, **json_data} + + # Preprocess canvas metadata to add image type discriminators + json_data = preprocess_canvas_metadata(json_data) + + return TypeAdapter(GenerationMetadata).validate_python(json_data) + + +def traverse_directory(directory: str) -> None: + """ + Traverse the specified directory to find PNG files and parse their + generation metadata from embedded JSON. + + Args: + directory: Path to the directory to traverse + """ + dir_path = Path(directory) + + for file_path in dir_path.rglob("*.png"): + parse_file(file_path) + + +def parse_file(file_path: Path) -> None: + """ + Retrieve the metadata from the PNG file and parse its + generation metadata from embedded JSON. + + Args: + file_path: Path to the file to parse + """ + try: + metadata_tags = ["invokeai_metadata", "Sd-metadata", "sd-metadata"] + metadata_dict = None + with Image.open(file_path) as img: + for tag in metadata_tags: + if tag in img.info: + metadata_json = img.info[tag] + metadata_dict = json.loads(metadata_json) + generation_metadata = parse_generation_metadata(metadata_dict) + print(f"## File: {file_path}") + print(generation_metadata.model_dump_json(indent=4)) + except Exception as e: + print(f"Error processing file {file_path}: {e}", file=sys.stderr) + if metadata_dict is not None: + print(f"Raw data = {json.dumps(metadata_dict, indent=4)}", file=sys.stderr) + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "." + + if os.path.isfile(path): + parse_file(Path(path)) + elif os.path.isdir(path): + traverse_directory(path) + else: + print(f"Error: '{path}' is not a valid directory", file=sys.stderr) + sys.exit(1) From d367ff7443e880b9cf59a71552c3066bdf7c7872 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Thu, 5 Feb 2026 23:58:53 -0500 Subject: [PATCH 2/7] feature(backend): continue implementation of pydantic parsing of invoke metadata --- photomap/backend/metadata_modules/__init__.py | 19 +- .../invoke-DELETE/__init__.py | 19 +- .../invoke/canvas2metadata.py | 1 + .../invoke/invoke5metadata.py | 28 +- .../invoke/parse_invoke_metadata.py | 154 --------- .../metadata_modules/invokemetadata.py | 317 ++++++++++++++++++ 6 files changed, 362 insertions(+), 176 deletions(-) delete mode 100644 photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py create mode 100644 photomap/backend/metadata_modules/invokemetadata.py diff --git a/photomap/backend/metadata_modules/__init__.py b/photomap/backend/metadata_modules/__init__.py index 646cadd5..beb89062 100644 --- a/photomap/backend/metadata_modules/__init__.py +++ b/photomap/backend/metadata_modules/__init__.py @@ -1,11 +1,10 @@ +# from .exif_formatter import format_exif_metadata +# from .invoke_formatter import format_invoke_metadata +# from .slide_summary import SlideSummary -from .exif_formatter import format_exif_metadata -from .invoke_formatter import format_invoke_metadata -from .slide_summary import SlideSummary - -# re-export the format_invoke_metadata and format_exif_metadata functions -__all__ = [ - "SlideSummary", - "format_invoke_metadata", - "format_exif_metadata", -] +# # re-export the format_invoke_metadata and format_exif_metadata functions +# __all__ = [ +# "SlideSummary", +# "format_invoke_metadata", +# "format_exif_metadata", +# ] diff --git a/photomap/backend/metadata_modules/invoke-DELETE/__init__.py b/photomap/backend/metadata_modules/invoke-DELETE/__init__.py index a962bdea..63cfea4b 100644 --- a/photomap/backend/metadata_modules/invoke-DELETE/__init__.py +++ b/photomap/backend/metadata_modules/invoke-DELETE/__init__.py @@ -1,11 +1,10 @@ +# from .legacy import InvokeLegacyMetadata +# from .v3 import Invoke3Metadata +# from .v5 import Invoke5Metadata -from .legacy import InvokeLegacyMetadata -from .v3 import Invoke3Metadata -from .v5 import Invoke5Metadata - -# reexport the main classes -__all__ = [ - "InvokeLegacyMetadata", - "Invoke3Metadata", - "Invoke5Metadata", -] +# # reexport the main classes +# __all__ = [ +# "InvokeLegacyMetadata", +# "Invoke3Metadata", +# "Invoke5Metadata", +# ] diff --git a/photomap/backend/metadata_modules/invoke/canvas2metadata.py b/photomap/backend/metadata_modules/invoke/canvas2metadata.py index 919c729a..a536792f 100644 --- a/photomap/backend/metadata_modules/invoke/canvas2metadata.py +++ b/photomap/backend/metadata_modules/invoke/canvas2metadata.py @@ -105,6 +105,7 @@ class Ipadapter(BaseModel): type: str method: Optional[str] = None weight: Optional[float] = None + image_influence: Optional[str] = Field(None, alias="imageInfluence") class Config: populate_by_name = True diff --git a/photomap/backend/metadata_modules/invoke/invoke5metadata.py b/photomap/backend/metadata_modules/invoke/invoke5metadata.py index 5bdae33c..b6a72b0a 100644 --- a/photomap/backend/metadata_modules/invoke/invoke5metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke5metadata.py @@ -1,6 +1,6 @@ from typing import Any, List, Literal, Optional -from pydantic import BaseModel +from pydantic import BaseModel, Field # Pydantic classes for version 5 @@ -41,6 +41,29 @@ class Lora(BaseModel): weight: float +class Image(BaseModel): + image_name: str + width: Optional[int] + height: Optional[int] + + +class RefImageConfig(BaseModel): + type: str + image: Image + model: Optional[Model] = None + beginEndStepPct: Optional[List[float]] = None + method: Optional[str] = None + clipVisionModel: Optional[str] = None + weight: Optional[float] = None + image_influence: Optional[str] = Field(default=None, alias="imageInfluence") + + +class RefImage(BaseModel): + id: str + isEnabled: bool + config: RefImageConfig + + # Empirically, pretty much all fields are optional in v5! class GenerationMetadata5(BaseModel): metadata_version: Literal[5] @@ -50,11 +73,12 @@ class GenerationMetadata5(BaseModel): height: Optional[int] = None width: Optional[int] = None positive_prompt: Optional[str] = None + negative_prompt: Optional[str] = None scheduler: Optional[str] = None seed: Optional[int] = None steps: Optional[int] = None guidance: Optional[int | float] = None - ref_images: Optional[List[Any]] = None + ref_images: Optional[List[RefImage]] = None loras: Optional[List[Lora]] = None t5_encoder: Optional[T5Encoder] = None vae: Optional[Vae] = None diff --git a/photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py b/photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py deleted file mode 100644 index 7629acc8..00000000 --- a/photomap/backend/metadata_modules/invoke/parse_invoke_metadata.py +++ /dev/null @@ -1,154 +0,0 @@ -import json -import os -import sys -from pathlib import Path -from typing import Annotated, Any, Union - -from canvas2metadata import GenerationMetadataCanvas -from invoke2metadata import GenerationMetadata2 -from invoke3metadata import GenerationMetadata3 -from invoke5metadata import GenerationMetadata5 -from PIL import Image -from pydantic import Field, TypeAdapter - -GenerationMetadata = Annotated[ - Union[ - GenerationMetadata2, - GenerationMetadata3, - GenerationMetadata5, - GenerationMetadataCanvas, - ], - Field(discriminator="metadata_version"), -] - - -def add_image_type_discriminator(image: dict[str, Any]) -> None: - """Add image_type discriminator to an image object based on its fields.""" - if "dataURL" in image: - image["image_type"] = "dataURL" - elif "image_name" in image: - image["image_type"] = "file" - - -def preprocess_canvas_metadata(json_data: dict[str, Any]) -> dict[str, Any]: - """Preprocess canvas metadata to add type discriminators to image objects.""" - if "canvas_v2_metadata" not in json_data: - return json_data - - canvas_metadata = json_data["canvas_v2_metadata"] - - def process_image_in_dict(obj: dict[str, Any], key: str = "image") -> None: - """Add type discriminator to an image object if it exists.""" - if key in obj and obj[key]: - add_image_type_discriminator(obj[key]) - - def process_objects(objects: list[dict[str, Any]]) -> None: - """Process a list of objects that may contain images.""" - for obj in objects: - process_image_in_dict(obj) - - def process_reference_images(ref_images: list[dict[str, Any]]) -> None: - """Process reference images with ipAdapter.""" - for ref_image in ref_images: - if "ipAdapter" in ref_image and ref_image["ipAdapter"]: - process_image_in_dict(ref_image["ipAdapter"]) - - # Process layers with objects (rasterLayers, inpaintMasks, controlLayers) - for layer_key in ["rasterLayers", "inpaintMasks", "controlLayers"]: - if layer_key in canvas_metadata and canvas_metadata[layer_key]: - for layer in canvas_metadata[layer_key]: - if "objects" in layer and layer["objects"]: - process_objects(layer["objects"]) - - # Process top-level referenceImages - if "referenceImages" in canvas_metadata and canvas_metadata["referenceImages"]: - process_reference_images(canvas_metadata["referenceImages"]) - - # Process regionalGuidance with objects and referenceImages - if "regionalGuidance" in canvas_metadata and canvas_metadata["regionalGuidance"]: - for region in canvas_metadata["regionalGuidance"]: - if "objects" in region and region["objects"]: - process_objects(region["objects"]) - if "referenceImages" in region and region["referenceImages"]: - process_reference_images(region["referenceImages"]) - - return json_data - - -def parse_generation_metadata(json_data: dict[str, Any]) -> GenerationMetadata: - if "metadata_version" not in json_data: - if "canvas_v2_metadata" in json_data: - json_data = {"metadata_version": "canvas", **json_data} - elif "app_version" in json_data: - if any( - json_data["app_version"].startswith(x) for x in ["v1.", "2.", "v2."] - ): - json_data = {"metadata_version": 2, **json_data} - elif json_data["app_version"].startswith("3."): - if "model" in json_data and isinstance(json_data["model"], str): - json_data = {"metadata_version": 2, **json_data} - else: - json_data = {"metadata_version": 3, **json_data} - else: - json_data = {"metadata_version": 5, **json_data} - elif "model_weights" in json_data: - # v2 metadata has model_weights field - json_data = {"metadata_version": 2, **json_data} - else: - json_data = {"metadata_version": 3, **json_data} - - # Preprocess canvas metadata to add image type discriminators - json_data = preprocess_canvas_metadata(json_data) - - return TypeAdapter(GenerationMetadata).validate_python(json_data) - - -def traverse_directory(directory: str) -> None: - """ - Traverse the specified directory to find PNG files and parse their - generation metadata from embedded JSON. - - Args: - directory: Path to the directory to traverse - """ - dir_path = Path(directory) - - for file_path in dir_path.rglob("*.png"): - parse_file(file_path) - - -def parse_file(file_path: Path) -> None: - """ - Retrieve the metadata from the PNG file and parse its - generation metadata from embedded JSON. - - Args: - file_path: Path to the file to parse - """ - try: - metadata_tags = ["invokeai_metadata", "Sd-metadata", "sd-metadata"] - metadata_dict = None - with Image.open(file_path) as img: - for tag in metadata_tags: - if tag in img.info: - metadata_json = img.info[tag] - metadata_dict = json.loads(metadata_json) - generation_metadata = parse_generation_metadata(metadata_dict) - print(f"## File: {file_path}") - print(generation_metadata.model_dump_json(indent=4)) - except Exception as e: - print(f"Error processing file {file_path}: {e}", file=sys.stderr) - if metadata_dict is not None: - print(f"Raw data = {json.dumps(metadata_dict, indent=4)}", file=sys.stderr) - - -if __name__ == "__main__": - path = sys.argv[1] if len(sys.argv) > 1 else "." - - if os.path.isfile(path): - parse_file(Path(path)) - elif os.path.isdir(path): - traverse_directory(path) - else: - print(f"Error: '{path}' is not a valid directory", file=sys.stderr) - sys.exit(1) diff --git a/photomap/backend/metadata_modules/invokemetadata.py b/photomap/backend/metadata_modules/invokemetadata.py new file mode 100644 index 00000000..e5725e69 --- /dev/null +++ b/photomap/backend/metadata_modules/invokemetadata.py @@ -0,0 +1,317 @@ +""" +Wrapper for GenerationMetadata +""" + +from typing import Annotated, Any, List, Optional, Union + +from pydantic import Field, TypeAdapter + +from .invoke.canvas2metadata import GenerationMetadataCanvas +from .invoke.invoke2metadata import GenerationMetadata2 +from .invoke.invoke3metadata import GenerationMetadata3 +from .invoke.invoke5metadata import ( + GenerationMetadata5, + Image, + Model, + RefImage, + RefImageConfig, +) + +GenerationMetadata = Annotated[ + Union[ + GenerationMetadata2, + GenerationMetadata3, + GenerationMetadata5, + GenerationMetadataCanvas, + ], + Field(discriminator="metadata_version"), +] + + +class GenerationMetadataAdapter: + def __init__(self): + self.adapter = TypeAdapter(GenerationMetadata) + self.metadata = None + + def parse(self, json_data: dict[str, Any]) -> GenerationMetadata: + """ + Parse JSON data into a GenerationMetadata object. + + :param json_data: Dictionary containing metadata + :type json_data: dict[str, Any] + :return: Parsed generation metadata + :rtype: GenerationMetadata + """ + if "metadata_version" not in json_data: + if "canvas_v2_metadata" in json_data: + json_data = {"metadata_version": "canvas", **json_data} + elif "app_version" in json_data: + if any( + json_data["app_version"].startswith(x) for x in ["v1.", "2.", "v2."] + ): + json_data = {"metadata_version": 2, **json_data} + elif json_data["app_version"].startswith("3."): + if "model" in json_data and isinstance(json_data["model"], str): + json_data = {"metadata_version": 2, **json_data} + else: + json_data = {"metadata_version": 3, **json_data} + else: + json_data = {"metadata_version": 5, **json_data} + elif "model_weights" in json_data: + # v2 metadata has model_weights field + json_data = {"metadata_version": 2, **json_data} + else: + json_data = {"metadata_version": 3, **json_data} + + # Normalize ref_images + if "ref_images" in json_data and json_data["ref_images"]: + json_data["ref_images"] = self._normalize_ref_images( + json_data["ref_images"] + ) + + # Preprocess canvas metadata to add image type discriminators + json_data = self._preprocess_canvas_metadata(json_data) + + self.metadata = self.adapter.validate_python(json_data) + return self.metadata + + @property + def positive_prompt(self) -> Optional[str]: + if self.metadata is None: + return None + if hasattr(self.metadata, "positive_prompt"): + return getattr(self.metadata, "positive_prompt") + if ( + hasattr(self.metadata, "image") + and self.metadata.image + and hasattr(self.metadata.image, "prompt") + ): + return getattr(self.metadata.image, "prompt") + return None + + @property + def negative_prompt(self) -> Optional[str]: + if self.metadata is None: + return None + if hasattr(self.metadata, "negative_prompt"): + return self.metadata.negative_prompt + return None + + @property + def model_name(self) -> Optional[str]: + if self.metadata is None: + return None + if hasattr(self.metadata.model, "name"): + return self.metadata.model.name + else: + return self.metadata.model + + @property + def seed(self) -> Optional[int]: + if self.metadata is None: + return None + if hasattr(self.metadata, "seed"): + return self.metadata.seed + if ( + hasattr(self.metadata, "image") + and self.metadata.image + and hasattr(self.metadata.image, "seed") + ): + return self.metadata.image.seed + return None + + @property + def steps(self) -> Optional[int]: + if self.metadata is None: + return None + if hasattr(self.metadata, "steps"): + return self.metadata.steps + if ( + hasattr(self.metadata, "image") + and self.metadata.image + and hasattr(self.metadata.image, "steps") + ): + return self.metadata.image.steps + return None + + @property + def height(self) -> Optional[int]: + if self.metadata is None: + return None + if hasattr(self.metadata, "height"): + return self.metadata.height + if ( + hasattr(self.metadata, "image") + and self.metadata.image + and hasattr(self.metadata.image, "height") + ): + return self.metadata.image.height + return None + + @property + def width(self) -> Optional[int]: + if self.metadata is None: + return None + if hasattr(self.metadata, "width"): + return self.metadata.width + if ( + hasattr(self.metadata, "image") + and self.metadata.image + and hasattr(self.metadata.image, "width") + ): + return self.metadata.image.width + return None + + @property + def ref_images(self) -> Optional[List[RefImage]]: + if self.metadata is None: + return None + if hasattr(self.metadata, "ref_images"): + return self.metadata.ref_images + if ( + hasattr(self.metadata, "canvas_v2_metadata") + and self.metadata.canvas_v2_metadata + ): + if self.metadata.canvas_v2_metadata.reference_images: + return [ + RefImage( + isEnabled=ri.is_enabled, + id=ri.id, + config=RefImageConfig( + type=ri.ip_adapter.type, + image=Image( + image_name=ri.ip_adapter.image.image_name, + width=( + ri.ip_adapter.image.width + if hasattr(ri.ip_adapter.image, "width") + else None + ), + height=( + ri.ip_adapter.image.height + if hasattr(ri.ip_adapter.image, "height") + else None + ), + ), + model=( + Model( + base=ri.ip_adapter.model.base, + hash=ri.ip_adapter.model.hash, + key=ri.ip_adapter.model.key, + name=ri.ip_adapter.model.name, + type=ri.ip_adapter.model.type, + ) + ), + weight=( + ri.ip_adapter.weight + if hasattr(ri, "ip_adapter") + and ri.ip_adapter + and hasattr(ri.ip_adapter, "weight") + else None + ), + image_influence=( + ri.ip_adapter.image_influence + if hasattr(ri, "ip_adapter") + and ri.ip_adapter + and hasattr(ri.ip_adapter, "image_influence") + else None + ), + method=( + ri.ip_adapter.method + if hasattr(ri, "ip_adapter") + and ri.ip_adapter + and hasattr(ri.ip_adapter, "method") + else None + ), + ), + ) + for ri in self.metadata.canvas_v2_metadata.reference_images + ] + return None + + def _preprocess_canvas_metadata(self, json_data: dict[str, Any]) -> dict[str, Any]: + """Preprocess canvas metadata to add type discriminators to image objects.""" + if "canvas_v2_metadata" not in json_data: + return json_data + + canvas_metadata = json_data["canvas_v2_metadata"] + + def add_image_type_discriminator(image: dict[str, Any]) -> None: + """Add image_type discriminator to an image object based on its fields.""" + if "dataURL" in image: + image["image_type"] = "dataURL" + elif "image_name" in image: + image["image_type"] = "file" + + def process_image_in_dict(obj: dict[str, Any], key: str = "image") -> None: + """Add type discriminator to an image object if it exists.""" + if key in obj and obj[key]: + add_image_type_discriminator(obj[key]) + + def process_objects(objects: list[dict[str, Any]]) -> None: + """Process a list of objects that may contain images.""" + for obj in objects: + process_image_in_dict(obj) + + def process_reference_images(ref_images: list[dict[str, Any]]) -> None: + """Process reference images with ipAdapter.""" + for ref_image in ref_images: + if "ipAdapter" in ref_image and ref_image["ipAdapter"]: + process_image_in_dict(ref_image["ipAdapter"]) + + # Process layers with objects (rasterLayers, inpaintMasks, controlLayers) + for layer_key in ["rasterLayers", "inpaintMasks", "controlLayers"]: + if layer_key in canvas_metadata and canvas_metadata[layer_key]: + for layer in canvas_metadata[layer_key]: + if "objects" in layer and layer["objects"]: + process_objects(layer["objects"]) + + # Process top-level referenceImages + if "referenceImages" in canvas_metadata and canvas_metadata["referenceImages"]: + process_reference_images(canvas_metadata["referenceImages"]) + + # Process regionalGuidance with objects and referenceImages + if ( + "regionalGuidance" in canvas_metadata + and canvas_metadata["regionalGuidance"] + ): + for region in canvas_metadata["regionalGuidance"]: + if "objects" in region and region["objects"]: + process_objects(region["objects"]) + if "referenceImages" in region and region["referenceImages"]: + process_reference_images(region["referenceImages"]) + + return json_data + + def _normalize_ref_images(self, ref_images: Any) -> list[dict[str, Any]]: + """ + Normalize ref_images structure. + + Handles both flat lists and nested lists (list of lists). + Flattens nested image structure from config.image.original.image to config.image. + + :param ref_images: Raw ref_images data (may be list or list of lists) + :type ref_images: Any + :return: Normalized flat list of reference images + :rtype: list[dict[str, Any]] + """ + if not isinstance(ref_images, list) or len(ref_images) == 0: + return ref_images + + # Flatten if it's a list of lists + if isinstance(ref_images[0], list): + ref_images = ref_images[0] + + # Normalize nested image structure in ref_images config + for ref_image in ref_images: + if ( + "config" in ref_image + and "image" in ref_image["config"] + and isinstance(ref_image["config"]["image"], dict) + ): + image_obj = ref_image["config"]["image"] + # If image has ["original"]["image"] nesting, flatten it + if "original" in image_obj and isinstance(image_obj["original"], dict): + if "image" in image_obj["original"]: + ref_image["config"]["image"] = image_obj["original"]["image"] + + return ref_images From 98f50d718cb484915761ef68457739fd782d8547 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 8 Feb 2026 12:56:27 -0500 Subject: [PATCH 3/7] refactor(metadata): add support for v5 metadata and v2 canbas --- .../invoke/canvas2metadata.py | 212 ++++++----------- .../invoke/common_metadata_elements.py | 175 ++++++++++++++ .../invoke/invoke2metadata.py | 3 +- .../invoke/invoke3metadata.py | 54 +++-- .../invoke/invoke5metadata.py | 203 +++++++++++++---- .../invoke/invoke_metadata_abc.py | 164 +++++++++++++ .../metadata_modules/invokemetadata.py | 215 +----------------- 7 files changed, 614 insertions(+), 412 deletions(-) create mode 100644 photomap/backend/metadata_modules/invoke/common_metadata_elements.py create mode 100644 photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py diff --git a/photomap/backend/metadata_modules/invoke/canvas2metadata.py b/photomap/backend/metadata_modules/invoke/canvas2metadata.py index a536792f..219ff7a2 100644 --- a/photomap/backend/metadata_modules/invoke/canvas2metadata.py +++ b/photomap/backend/metadata_modules/invoke/canvas2metadata.py @@ -1,6 +1,20 @@ from typing import Annotated, Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator + +from photomap.backend.metadata_modules.invoke.common_metadata_elements import ( + ClipEmbedModel, + ControlAdapter, + Fill, + Lora, + Model, + Object, + Position, + ReferenceImage, + RegionalGuidance, + T5Encoder, + tag_reference_images, +) class Clip(BaseModel): @@ -10,63 +24,8 @@ class Clip(BaseModel): y: float -class Color(BaseModel): - b: int - g: int - r: int - - -class Fill(BaseModel): - color: Color - style: str - - -class Model(BaseModel): - base: str - hash: str - key: str - name: str - type: str - - -class ImageData(BaseModel): - image_type: Literal["dataURL"] = Field(default="dataURL", alias="type") - data_url: str = Field(alias="dataURL") - height: int - width: int - - class Config: - populate_by_name = True - - -class ImageFile(BaseModel): - image_type: Literal["file"] = Field(default="file", alias="type") - image_name: str - height: int - width: int - - class Config: - populate_by_name = True - - -ImageUnion = Annotated[ - Union[ImageFile, ImageData], - Field(discriminator="image_type"), -] - - -class Object(BaseModel): - id: str - image: Optional[ImageUnion] = None - type: str - - -class Position(BaseModel): - x: int | float - y: int | float - - class Inpaintmask(BaseModel): + model_config = ConfigDict(populate_by_name=True) fill: Fill id: str is_enabled: bool = Field(alias="isEnabled") @@ -77,11 +36,9 @@ class Inpaintmask(BaseModel): position: Position type: str - class Config: - populate_by_name = True - class Rasterlayer(BaseModel): + model_config = ConfigDict(populate_by_name=True) id: str is_enabled: bool = Field(alias="isEnabled") is_locked: bool = Field(alias="isLocked") @@ -91,51 +48,10 @@ class Rasterlayer(BaseModel): position: Position type: str - class Config: - populate_by_name = True - - -class Ipadapter(BaseModel): - begin_end_step_pct: Optional[List[int | float]] = Field( - None, alias="beginEndStepPct" - ) - clip_vision_model: Optional[str] = Field(None, alias="clipVisionModel") - image: ImageUnion - model: Model - type: str - method: Optional[str] = None - weight: Optional[float] = None - image_influence: Optional[str] = Field(None, alias="imageInfluence") - - class Config: - populate_by_name = True - -class ReferenceImage(BaseModel): - id: str - ip_adapter: Ipadapter = Field(alias="ipAdapter") - is_enabled: Optional[bool] = Field(None, alias="isEnabled") - is_locked: Optional[bool] = Field(None, alias="isLocked") - name: Optional[Any] = None - type: Optional[str] = None - - class Config: - populate_by_name = True - - -class Controladapter(BaseModel): - begin_end_step_pct: List[int | float] = Field(alias="beginEndStepPct") - control_mode: Optional[str] = Field(None, alias="controlMode") - model: Model - type: str - weight: float - - class Config: - populate_by_name = True - - -class Controllayer(BaseModel): - control_adapter: Controladapter = Field(alias="controlAdapter") +class ControlLayer(BaseModel): + model_config = ConfigDict(populate_by_name=True) + control_adapter: ControlAdapter = Field(alias="controlAdapter") id: str is_enabled: bool = Field(alias="isEnabled") is_locked: bool = Field(alias="isLocked") @@ -146,32 +62,11 @@ class Controllayer(BaseModel): type: str with_transparency_effect: bool = Field(alias="withTransparencyEffect") - class Config: - populate_by_name = True - - -class RegionalGuidance(BaseModel): - auto_negative: bool = Field(alias="autoNegative") - fill: Fill - id: str - is_enabled: bool = Field(alias="isEnabled") - is_locked: bool = Field(alias="isLocked") - name: Optional[Any] - negative_prompt: Optional[Any] = Field(None, alias="negativePrompt") - objects: List[Object] - opacity: float - position: Position - positive_prompt: Optional[Any] = Field(None, alias="positivePrompt") - reference_images: List[ReferenceImage] = Field(alias="referenceImages") - type: str - - class Config: - populate_by_name = True - class CanvasV2Metadata(BaseModel): + model_config = ConfigDict(populate_by_name=True) raster_layers: Optional[List[Rasterlayer]] = Field(None, alias="rasterLayers") - control_layers: Optional[List[Controllayer]] = Field(None, alias="controlLayers") + control_layers: Optional[List[ControlLayer]] = Field(None, alias="controlLayers") inpaint_masks: Optional[List[Inpaintmask]] = Field(None, alias="inpaintMasks") reference_images: Optional[List[ReferenceImage]] = Field( None, alias="referenceImages" @@ -180,14 +75,55 @@ class CanvasV2Metadata(BaseModel): None, alias="regionalGuidance" ) - class Config: - populate_by_name = True - - -class GenerationMetadataCanvas(BaseModel): - metadata_version: Literal["canvas"] - canvas_v2_metadata: CanvasV2Metadata - model: Optional[Model] = None - negative_prompt: Optional[str] = None - positive_prompt: Optional[str] = None - seed: Optional[int] = None + @model_validator(mode="before") + @classmethod + def _preprocess_canvas_metadata( + cls, canvas_metadata: Dict[str, Any] + ) -> Dict[str, Any]: + """Preprocess canvas metadata to add type discriminators to image objects.""" + + def process_image_in_dict(obj: dict[str, Any], key: str = "image") -> None: + """Add type discriminator to an image object if it exists.""" + if key in obj and obj[key]: + tag_reference_images(obj[key]) + + def process_objects(objects: list[dict[str, Any]]) -> None: + """Process a list of objects that may contain images.""" + for obj in objects: + process_image_in_dict(obj) + + def process_reference_images(ref_images: list[dict[str, Any]]) -> None: + """Process reference images with ipAdapter.""" + for ref_image in ref_images: + if "ipAdapter" in ref_image and ref_image["ipAdapter"]: + process_image_in_dict(ref_image["ipAdapter"]) + + # Process layers with objects (rasterLayers, inpaintMasks, controlLayers) + for layer_key in ["rasterLayers", "inpaintMasks", "controlLayers"]: + if layer_key in canvas_metadata and canvas_metadata[layer_key]: + for layer in canvas_metadata[layer_key]: + if "objects" in layer and layer["objects"]: + process_objects(layer["objects"]) + + # Process top-level referenceImages + if "referenceImages" in canvas_metadata and canvas_metadata["referenceImages"]: + process_reference_images(canvas_metadata["referenceImages"]) + + # Process regionalGuidance with objects and referenceImages + if ( + "regionalGuidance" in canvas_metadata + and canvas_metadata["regionalGuidance"] + ): + for region in canvas_metadata["regionalGuidance"]: + if "objects" in region and region["objects"]: + process_objects(region["objects"]) + if "referenceImages" in region and region["referenceImages"]: + process_reference_images(region["referenceImages"]) + + return canvas_metadata + + @model_serializer(mode="wrap") + def serialize_model(self, serializer, info): + """Exclude None values when serializing.""" + data = serializer(self) + return {k: v for k, v in data.items() if v is not None} diff --git a/photomap/backend/metadata_modules/invoke/common_metadata_elements.py b/photomap/backend/metadata_modules/invoke/common_metadata_elements.py new file mode 100644 index 00000000..8210fcb7 --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/common_metadata_elements.py @@ -0,0 +1,175 @@ +import sys +from typing import Annotated, Any, Dict, List, Literal, Optional, Union + +from pydantic import BaseModel, ConfigDict, Field, model_validator + + +class Color(BaseModel): + b: int + g: int + r: int + + +class Model(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + name: str = Field(alias="model_name") + base: str = Field(default="unknown", alias="base_model") + hash: Optional[str] = None + key: Optional[str] = None + type: str = Field(default="main", alias="model_type") + + +class T5Encoder(Model): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class ClipEmbedModel(Model): + model_config = ConfigDict(extra="allow", populate_by_name=True) + + +class ControlAdapter(BaseModel): + model_config = ConfigDict(populate_by_name=True) + begin_end_step_pct: List[int | float] = Field(alias="beginEndStepPct") + control_mode: Optional[str] = Field(None, alias="controlMode") + model: Model + type: str + weight: float + + @model_validator(mode="before") + @classmethod + def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: + """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" + return fixup_step_percentages(json_data) + + +class ImageData(BaseModel): + model_config = ConfigDict(populate_by_name=True) + type: Literal["dataURL"] = Field(default="dataURL", alias="image_type") + data_url: str = Field(alias="dataURL") + height: Optional[int] = None + width: Optional[int] = None + + +class ImageFile(BaseModel): + model_config = ConfigDict(populate_by_name=True) + type: Literal["file"] = Field(default="file", alias="image_type") + name: str = Field(alias="image_name") + height: Optional[int] = None + width: Optional[int] = None + + +Image = Annotated[ + Union[ImageFile, ImageData], + Field(discriminator="type"), +] + + +class Lora(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) + model: Model = Field(alias="lora") + weight: float + + +class IPAdapter(BaseModel): + model_config = ConfigDict(populate_by_name=True) + begin_end_step_pct: Optional[List[int | float]] = Field( + None, alias="beginEndStepPct" + ) + model: Model + image: Image + type: Optional[str] = None + method: Optional[str] = None + weight: Optional[float] = None + image_influence: Optional[str] = Field(None, alias="imageInfluence") + + @model_validator(mode="before") + @classmethod + def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: + """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" + return fixup_step_percentages(json_data) + + @model_validator(mode="before") + @classmethod + def tag_reference_images(cls, data: Dict[str, Any]) -> Dict[str, Any]: + """Tag reference images with a discriminator for proper parsing.""" + if "image" in data and isinstance(data["image"], dict): + tag_reference_images(data["image"]) + return data + + @model_validator(mode="before") + @classmethod + def consolidate_model_aliases(cls, data): + """Consolidate model aliases to ensure the model field is populated correctly.""" + for key in ["clip_vision_model", "ip_adapter_model", "t2i_adapter_model"]: + if "model" not in data and key in data: + data["model"] = data[key] + break + return data + + +class ReferenceImage(BaseModel): + model_config = ConfigDict(populate_by_name=True) + id: str + ip_adapter: IPAdapter = Field(alias="ipAdapter") + is_enabled: Optional[bool] = Field(None, alias="isEnabled") + is_locked: Optional[bool] = Field(None, alias="isLocked") + name: Optional[Any] = None + type: Optional[str] = None + + +class Fill(BaseModel): + color: Color + style: str + + +class Position(BaseModel): + x: int | float + y: int | float + + +class Object(BaseModel): + id: str + image: Optional[Image] = None + type: str + + +class RegionalGuidance(BaseModel): + model_config = ConfigDict(populate_by_name=True) + auto_negative: bool = Field(alias="autoNegative") + fill: Fill + id: str + is_enabled: bool = Field(alias="isEnabled") + is_locked: bool = Field(alias="isLocked") + name: Optional[Any] + positive_prompt: Optional[str] = Field(None, alias="positivePrompt") + negative_prompt: Optional[str] = Field(None, alias="negativePrompt") + objects: List[Object] + opacity: float + position: Position + reference_images: List[ReferenceImage] = Field(alias="referenceImages") + type: str + + +def tag_reference_images(image: Dict[str, Any]) -> None: + """Mutates the input image dict to add an "image_type" field based on whether it has a "dataURL" or "image_name" field.""" + if "dataURL" in image: + image["type"] = "dataURL" + elif "image_name" in image: + image["type"] = "file" + elif "name" in image: + image["type"] = "file" + + +def fixup_step_percentages(json_data: Dict[str, Any]) -> Dict[str, Any]: + """ + Helper function to convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, + and ensure the values are in a list. + """ + for key in ["begin_step_percent", "end_step_percent"]: + if key in json_data: + value = json_data.pop(key) + if isinstance(value, (int, float)): + json_data.setdefault("beginEndStepPct", []).append(value) + elif isinstance(value, list): + json_data.setdefault("beginEndStepPct", []).extend(value) + return json_data diff --git a/photomap/backend/metadata_modules/invoke/invoke2metadata.py b/photomap/backend/metadata_modules/invoke/invoke2metadata.py index 6c2cb894..f6f85d3d 100644 --- a/photomap/backend/metadata_modules/invoke/invoke2metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke2metadata.py @@ -1,6 +1,6 @@ from typing import Any, List, Literal, Optional -from pydantic import BaseModel +from pydantic import BaseModel, ConfigDict class Prompt(BaseModel): @@ -26,6 +26,7 @@ class Image(BaseModel): class GenerationMetadata2(BaseModel): + model_config = ConfigDict(extra="forbid") metadata_version: Literal[2] app_id: str app_version: str diff --git a/photomap/backend/metadata_modules/invoke/invoke3metadata.py b/photomap/backend/metadata_modules/invoke/invoke3metadata.py index 1d01e946..54f122ab 100644 --- a/photomap/backend/metadata_modules/invoke/invoke3metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke3metadata.py @@ -1,29 +1,25 @@ -from typing import Literal, Optional +from typing import List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator +from photomap.backend.metadata_modules.invoke.common_metadata_elements import ( + ControlAdapter, + IPAdapter, + Lora, + Model, + tag_reference_images, +) -# Pydantic classes for version 3 -class Model(BaseModel): - base_model: Optional[str] = Field(alias="base", default=None) - model_name: str = Field(alias="name") - model_type: Optional[str] = Field(alias="type", default=None) - class Config: - populate_by_name = True - - -class Vae(BaseModel): - base_model: str = Field(alias="base") - model_name: str = Field(alias="name") - - class Config: - populate_by_name = True +class T2IAdapter(IPAdapter): + pass # All fields are optional because of various glitches and exceptions in v3 metadata class GenerationMetadata3(BaseModel): + model_config = ConfigDict(extra="forbid") metadata_version: Literal[3] + app_version: str = Field(default="3.X.X") generation_mode: Optional[str] = None model: Optional[Model] = None positive_prompt: Optional[str] = None @@ -36,7 +32,29 @@ class GenerationMetadata3(BaseModel): scheduler: Optional[str] = None seed: Optional[int] = None steps: Optional[int] = None - vae: Optional[Vae] = None + vae: Optional[Model] = None + ip_adapters: Optional[List[IPAdapter]] = Field(default=None, alias="ipAdapters") + t2iAdapters: Optional[List[T2IAdapter]] = None + loras: Optional[List[Lora]] = None + controlnets: Optional[List[ControlAdapter]] = None cfg_rescale_multiplier: Optional[float] = None cfg_scale: Optional[float] = None esrgan_model: Optional[str] = None + clip_skip: Optional[int] = None + seamless_x: Optional[bool] = None + seamless_y: Optional[bool] = None + + @model_validator(mode="before") + def tag_reference_images(cls, data): + """Tag reference images with a discriminator for proper parsing.""" + if "ipAdapters" in data and isinstance(data["ipAdapters"], list): + for ref_image in data["ipAdapters"]: + if image := ref_image.get("image"): + tag_reference_images(image) + return data + + @model_serializer(mode="wrap") + def serialize_model(self, serializer, info): + """Exclude None values when serializing.""" + data = serializer(self) + return {k: v for k, v in data.items() if v is not None} diff --git a/photomap/backend/metadata_modules/invoke/invoke5metadata.py b/photomap/backend/metadata_modules/invoke/invoke5metadata.py index b6a72b0a..1625ebf0 100644 --- a/photomap/backend/metadata_modules/invoke/invoke5metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke5metadata.py @@ -1,50 +1,49 @@ -from typing import Any, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, Field +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator - -# Pydantic classes for version 5 -class Model(BaseModel): - base: str - hash: str - key: str - name: str - type: str +from photomap.backend.metadata_modules.invoke.canvas2metadata import CanvasV2Metadata +from photomap.backend.metadata_modules.invoke.common_metadata_elements import ( + ClipEmbedModel, + Image, + IPAdapter, + Lora, + Model, + RegionalGuidance, + T5Encoder, + fixup_step_percentages, + tag_reference_images, +) -class ClipEmbedModel(BaseModel): - base: str - hash: str - key: str - name: str +class ControlLayer(BaseModel): + id: str type: str + is_enabled: bool = Field(alias="isEnabled") + is_selected: bool = Field(alias="isSelected") + control_adapter: Optional[IPAdapter] = Field(default=None, alias="ipAdapter") -class T5Encoder(BaseModel): - base: str - hash: str - key: str - name: str - type: str - +class ControlLayers(BaseModel): + version: int | float + layers: List[ControlLayer] -class Vae(BaseModel): - base: str - hash: str - key: str - name: str - type: str - - -class Lora(BaseModel): - model: Model - weight: float +class ControlNet(BaseModel): + image: Image + model: Model = Field(alias="control_model") + weight: Optional[float] = Field(alias="control_weight") + begin_end_step_pct: Optional[List[int | float]] = Field( + None, alias="beginEndStepPct" + ) + control_mode: str + resize_mode: str -class Image(BaseModel): - image_name: str - width: Optional[int] - height: Optional[int] + @model_validator(mode="before") + @classmethod + def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: + """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" + return fixup_step_percentages(json_data) class RefImageConfig(BaseModel): @@ -63,24 +62,144 @@ class RefImage(BaseModel): isEnabled: bool config: RefImageConfig + @model_validator(mode="before") + @classmethod + def tag_reference_images(cls, data): + """Tag reference images with a discriminator for proper parsing.""" + if "config" in data and isinstance(data["config"], dict): + config = data["config"] + if "image" in config and isinstance(config["image"], dict): + tag_reference_images(config["image"]) + return data + # Empirically, pretty much all fields are optional in v5! class GenerationMetadata5(BaseModel): + model_config = ConfigDict(extra="forbid", populate_by_name=True) metadata_version: Literal[5] - app_version: str - model: Optional[Model] = None + app_version: Optional[str] = Field(default="5.X.X") + model: Optional[Model | str] = Field(default=None, alias="Model") generation_mode: Optional[str] = None height: Optional[int] = None width: Optional[int] = None - positive_prompt: Optional[str] = None + positive_prompt: Optional[str] = Field(default=None, alias="Positive Prompt") + positive_style_prompt: Optional[str] = None negative_prompt: Optional[str] = None + negative_style_prompt: Optional[str] = None scheduler: Optional[str] = None seed: Optional[int] = None - steps: Optional[int] = None + steps: Optional[int] = Field(default=None, alias="Steps") guidance: Optional[int | float] = None ref_images: Optional[List[RefImage]] = None + control_layers: Optional[ControlLayers] = Field(default=None) loras: Optional[List[Lora]] = None + regions: Optional[List[RegionalGuidance]] = None t5_encoder: Optional[T5Encoder] = None - vae: Optional[Vae] = None + qwen3_encoder: Optional[Model] = None + qwen3_source: Optional[Model] = None + vae: Optional[Model] = None clip_embed_model: Optional[ClipEmbedModel] = None dype_preset: Optional[str] = None + rand_device: Optional[str] = None + cfg_scale: Optional[float] = None + cfg_rescale_multiplier: Optional[float] = None + seamless_x: Optional[bool] = None + seamless_y: Optional[bool] = None + upscale_model: Optional[Model] = None + upscale_initial_image: Optional[Image] = None + upscale_scale: Optional[float] = None + creativity: Optional[float] = None + structure: Optional[float] = None + tile_size: Optional[int] = None + tile_overlap: Optional[int] = None + clip_skip: Optional[int] = None + canvas_v2_metadata: Optional[CanvasV2Metadata] = None + # These fields appear in some ZiT images + seed_variance_strength: Optional[float] = None + seed_variance_enabled: Optional[bool] = Field( + default=None, alias="z_image_seed_variance_enabled" + ) + seed_variance_randomize_percentage: Optional[int] = Field( + default=None, alias="z_image_seed_variance_randomize_percentage" + ) + # These fields appear in some Flux.1 images + dype_scale: Optional[float] = None + dype_exponent: Optional[float] = None + # These fields appear in some sdxl images + strength: Optional[float] = None + init_image: Optional[str] = None + hrf_enabled: Optional[bool] = None + hrf_method: Optional[str] = None + hrf_strength: Optional[float] = None + refiner_cfg_scale: Optional[float] = None + refiner_steps: Optional[int] = None + refiner_scheduler: Optional[str] = None + refiner_positive_aesthetic_score: Optional[float] = None + refiner_negative_aesthetic_score: Optional[float] = None + refiner_start: Optional[float] = None + + @model_validator(mode="before") + @classmethod + def normalize_field_names(cls, data): + """Normalize alternative field name variations before validation.""" + if isinstance(data, dict): + # Map alternative names to canonical field names + aliases = { + "Seed variance strength": "seed_variance_strength", + "z_image_seed_variance_strength": "seed_variance_strength", + "z_image_seed_variance_randomize_percentage": "seed_variance_randomize_percentage", + "z_image_seed_variance_randomize_percent": "seed_variance_randomize_percentage", + "z_image_seed_variance_enabled": "seed_variance_enabled", + } + for alt_name, canonical_name in aliases.items(): + if alt_name in data and canonical_name not in data: + data[canonical_name] = data.pop(alt_name) + + return data + + @model_validator(mode="before") + def tag_reference_images(cls, data): + # NOTE: MOVE THIS TO THE PROPER IMAGE VALIDATOR + """Tag reference images with a discriminator for proper parsing.""" + if "upscale_initial_image" in data and isinstance( + data["upscale_initial_image"], dict + ): + tag_reference_images(data["upscale_initial_image"]) + if "controlnets" in data and isinstance(data["controlnets"], list): + for controlnet in data["controlnets"]: + if isinstance(controlnet, dict) and "image" in controlnet: + tag_reference_images(controlnet["image"]) + return data + + @model_validator(mode="before") + def fixup_controlnets(cls, data: Dict[str, Any]) -> Dict[str, Any]: + """ " + Massage the legacy controlnet format into the new control_layers format + """ + if "controlnets" in data and isinstance(data["controlnets"], list): + layers = [] + for cn in data["controlnets"]: + layer = { + "id": cn.get("id", ""), + "type": "controlnet", + "isEnabled": cn.get("isEnabled", True), + "isSelected": cn.get("isSelected", False), + "ipAdapter": { + "image": cn.get("image"), + "model": cn.get("control_model"), + "weight": cn.get("control_weight"), + "beginEndStepPct": cn.get("beginEndStepPct"), + "control_mode": cn.get("control_mode"), + "resize_mode": cn.get("resize_mode"), + }, + } + layers.append(layer) + data["control_layers"] = {"version": 1, "layers": layers} + del data["controlnets"] + return data + + @model_serializer(mode="wrap") + def serialize_model(self, serializer, info): + """Exclude None values when serializing.""" + data = serializer(self) + return {k: v for k, v in data.items() if v is not None} diff --git a/photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py b/photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py new file mode 100644 index 00000000..92796e5d --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py @@ -0,0 +1,164 @@ +""" +Abstract base class for Invoke metadata modules. +This class is used to define the interface for formatting metadata from Invoke modules. +""" + +from abc import ABC, abstractmethod +from typing import Annotated, Any, List, Literal, Optional, Union + +from pydantic import BaseModel, Field + + +# some common types used across multiple modules +class Model(BaseModel): + name: str + base: Optional[str] = None + hash: Optional[str] = None + key: Optional[str] = None + type: Optional[str] = None + + +class Lora(BaseModel): + model: Model + weight: float + is_enabled: bool = True + + +class IPAdapter(BaseModel): + model_name: str + image_name: Optional[str] = None + image_data: Optional[str] = None + weight: Optional[float] = None + image_influence: Optional[str] = None + method: Optional[str] = None + begin_step_percent: Optional[float] = None + end_step_percent: Optional[float] = None + + +class ControlNet(BaseModel): + model_name: str + image_name: Optional[str] = None + image_data: Optional[str] = None + weight: Optional[float] = None + image_influence: Optional[str] = None + begin_step_percent: Optional[float] = None + end_step_percent: Optional[float] = None + control_mode: Optional[str] = None + + +# abstract base class properties +class InvokeMetadataModule(ABC): + @property + @abstractmethod + def metadata_version(self) -> int: + pass + + @property + @abstractmethod + def positive_prompt(self) -> str: + pass + + @property + @abstractmethod + def negative_prompt(self) -> Optional[str]: + pass + + @property + @abstractmethod + def model(self) -> Optional[Model]: + pass + + @property + @abstractmethod + def refiner_model(self) -> Optional[Model]: + pass + + @property + @abstractmethod + def vae_model(self) -> Optional[Model]: + pass + + @property + @abstractmethod + def scheduler(self) -> Optional[str]: + pass + + @property + @abstractmethod + def steps(self) -> Optional[int]: + pass + + @property + @abstractmethod + def refiner_steps(self) -> Optional[int]: + pass + + @property + @abstractmethod + def cfg_scale(self) -> Optional[float]: + pass + + @property + @abstractmethod + def cfg_rescale_multiplier(self) -> Optional[float]: + pass + + @property + @abstractmethod + def refiner_cfg_scale(self) -> Optional[float]: + pass + + @property + @abstractmethod + def guidance(self) -> Optional[int | float]: + pass + + @property + @abstractmethod + def width(self) -> Optional[int]: + pass + + @property + @abstractmethod + def height(self) -> Optional[int]: + pass + + @property + @abstractmethod + def seed(self) -> Optional[int]: + pass + + @property + @abstractmethod + def denoise_strength(self) -> Optional[float]: + pass + + @property + @abstractmethod + def refiner_denoise_start(self) -> Optional[float]: + pass + + @property + @abstractmethod + def clip_skip(self) -> Optional[int]: + pass + + @property + @abstractmethod + def seamless_x(self) -> Optional[bool]: + pass + + @property + @abstractmethod + def seamless_y(self) -> Optional[bool]: + pass + + @property + @abstractmethod + def refiner_positive_aesthetic_score(self) -> Optional[float]: + pass + + @property + @abstractmethod + def refiner_negative_aesthetic_score(self) -> Optional[float]: + pass diff --git a/photomap/backend/metadata_modules/invokemetadata.py b/photomap/backend/metadata_modules/invokemetadata.py index e5725e69..5d582661 100644 --- a/photomap/backend/metadata_modules/invokemetadata.py +++ b/photomap/backend/metadata_modules/invokemetadata.py @@ -6,7 +6,6 @@ from pydantic import Field, TypeAdapter -from .invoke.canvas2metadata import GenerationMetadataCanvas from .invoke.invoke2metadata import GenerationMetadata2 from .invoke.invoke3metadata import GenerationMetadata3 from .invoke.invoke5metadata import ( @@ -22,7 +21,6 @@ GenerationMetadata2, GenerationMetadata3, GenerationMetadata5, - GenerationMetadataCanvas, ], Field(discriminator="metadata_version"), ] @@ -44,7 +42,7 @@ def parse(self, json_data: dict[str, Any]) -> GenerationMetadata: """ if "metadata_version" not in json_data: if "canvas_v2_metadata" in json_data: - json_data = {"metadata_version": "canvas", **json_data} + json_data = {"metadata_version": 5, **json_data} elif "app_version" in json_data: if any( json_data["app_version"].startswith(x) for x in ["v1.", "2.", "v2."] @@ -63,225 +61,16 @@ def parse(self, json_data: dict[str, Any]) -> GenerationMetadata: else: json_data = {"metadata_version": 3, **json_data} + # MOVE THIS CODE TO THE APPROPRIATE MODEL VALIDATOR # Normalize ref_images if "ref_images" in json_data and json_data["ref_images"]: json_data["ref_images"] = self._normalize_ref_images( json_data["ref_images"] ) - # Preprocess canvas metadata to add image type discriminators - json_data = self._preprocess_canvas_metadata(json_data) - self.metadata = self.adapter.validate_python(json_data) return self.metadata - @property - def positive_prompt(self) -> Optional[str]: - if self.metadata is None: - return None - if hasattr(self.metadata, "positive_prompt"): - return getattr(self.metadata, "positive_prompt") - if ( - hasattr(self.metadata, "image") - and self.metadata.image - and hasattr(self.metadata.image, "prompt") - ): - return getattr(self.metadata.image, "prompt") - return None - - @property - def negative_prompt(self) -> Optional[str]: - if self.metadata is None: - return None - if hasattr(self.metadata, "negative_prompt"): - return self.metadata.negative_prompt - return None - - @property - def model_name(self) -> Optional[str]: - if self.metadata is None: - return None - if hasattr(self.metadata.model, "name"): - return self.metadata.model.name - else: - return self.metadata.model - - @property - def seed(self) -> Optional[int]: - if self.metadata is None: - return None - if hasattr(self.metadata, "seed"): - return self.metadata.seed - if ( - hasattr(self.metadata, "image") - and self.metadata.image - and hasattr(self.metadata.image, "seed") - ): - return self.metadata.image.seed - return None - - @property - def steps(self) -> Optional[int]: - if self.metadata is None: - return None - if hasattr(self.metadata, "steps"): - return self.metadata.steps - if ( - hasattr(self.metadata, "image") - and self.metadata.image - and hasattr(self.metadata.image, "steps") - ): - return self.metadata.image.steps - return None - - @property - def height(self) -> Optional[int]: - if self.metadata is None: - return None - if hasattr(self.metadata, "height"): - return self.metadata.height - if ( - hasattr(self.metadata, "image") - and self.metadata.image - and hasattr(self.metadata.image, "height") - ): - return self.metadata.image.height - return None - - @property - def width(self) -> Optional[int]: - if self.metadata is None: - return None - if hasattr(self.metadata, "width"): - return self.metadata.width - if ( - hasattr(self.metadata, "image") - and self.metadata.image - and hasattr(self.metadata.image, "width") - ): - return self.metadata.image.width - return None - - @property - def ref_images(self) -> Optional[List[RefImage]]: - if self.metadata is None: - return None - if hasattr(self.metadata, "ref_images"): - return self.metadata.ref_images - if ( - hasattr(self.metadata, "canvas_v2_metadata") - and self.metadata.canvas_v2_metadata - ): - if self.metadata.canvas_v2_metadata.reference_images: - return [ - RefImage( - isEnabled=ri.is_enabled, - id=ri.id, - config=RefImageConfig( - type=ri.ip_adapter.type, - image=Image( - image_name=ri.ip_adapter.image.image_name, - width=( - ri.ip_adapter.image.width - if hasattr(ri.ip_adapter.image, "width") - else None - ), - height=( - ri.ip_adapter.image.height - if hasattr(ri.ip_adapter.image, "height") - else None - ), - ), - model=( - Model( - base=ri.ip_adapter.model.base, - hash=ri.ip_adapter.model.hash, - key=ri.ip_adapter.model.key, - name=ri.ip_adapter.model.name, - type=ri.ip_adapter.model.type, - ) - ), - weight=( - ri.ip_adapter.weight - if hasattr(ri, "ip_adapter") - and ri.ip_adapter - and hasattr(ri.ip_adapter, "weight") - else None - ), - image_influence=( - ri.ip_adapter.image_influence - if hasattr(ri, "ip_adapter") - and ri.ip_adapter - and hasattr(ri.ip_adapter, "image_influence") - else None - ), - method=( - ri.ip_adapter.method - if hasattr(ri, "ip_adapter") - and ri.ip_adapter - and hasattr(ri.ip_adapter, "method") - else None - ), - ), - ) - for ri in self.metadata.canvas_v2_metadata.reference_images - ] - return None - - def _preprocess_canvas_metadata(self, json_data: dict[str, Any]) -> dict[str, Any]: - """Preprocess canvas metadata to add type discriminators to image objects.""" - if "canvas_v2_metadata" not in json_data: - return json_data - - canvas_metadata = json_data["canvas_v2_metadata"] - - def add_image_type_discriminator(image: dict[str, Any]) -> None: - """Add image_type discriminator to an image object based on its fields.""" - if "dataURL" in image: - image["image_type"] = "dataURL" - elif "image_name" in image: - image["image_type"] = "file" - - def process_image_in_dict(obj: dict[str, Any], key: str = "image") -> None: - """Add type discriminator to an image object if it exists.""" - if key in obj and obj[key]: - add_image_type_discriminator(obj[key]) - - def process_objects(objects: list[dict[str, Any]]) -> None: - """Process a list of objects that may contain images.""" - for obj in objects: - process_image_in_dict(obj) - - def process_reference_images(ref_images: list[dict[str, Any]]) -> None: - """Process reference images with ipAdapter.""" - for ref_image in ref_images: - if "ipAdapter" in ref_image and ref_image["ipAdapter"]: - process_image_in_dict(ref_image["ipAdapter"]) - - # Process layers with objects (rasterLayers, inpaintMasks, controlLayers) - for layer_key in ["rasterLayers", "inpaintMasks", "controlLayers"]: - if layer_key in canvas_metadata and canvas_metadata[layer_key]: - for layer in canvas_metadata[layer_key]: - if "objects" in layer and layer["objects"]: - process_objects(layer["objects"]) - - # Process top-level referenceImages - if "referenceImages" in canvas_metadata and canvas_metadata["referenceImages"]: - process_reference_images(canvas_metadata["referenceImages"]) - - # Process regionalGuidance with objects and referenceImages - if ( - "regionalGuidance" in canvas_metadata - and canvas_metadata["regionalGuidance"] - ): - for region in canvas_metadata["regionalGuidance"]: - if "objects" in region and region["objects"]: - process_objects(region["objects"]) - if "referenceImages" in region and region["referenceImages"]: - process_reference_images(region["referenceImages"]) - - return json_data - def _normalize_ref_images(self, ref_images: Any) -> list[dict[str, Any]]: """ Normalize ref_images structure. From 6ec4c92ebb8c0c9d8bddbf6c91c276b25f354c2e Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 8 Feb 2026 15:57:02 -0500 Subject: [PATCH 4/7] fix(metadata): change opacity from int to float --- .../invoke/canvas2metadata.py | 6 +- .../metadata_tests/dump_invoke_metadata.py | 54 ++++++++ scripts/metadata_tests/json2pydantic.py | 115 ++++++++++++++++++ .../metadata_tests/parse_invoke_metadata.py | 66 ++++++++++ .../parse_invoke_metadata_from_file.py | 84 +++++++++++++ 5 files changed, 320 insertions(+), 5 deletions(-) create mode 100755 scripts/metadata_tests/dump_invoke_metadata.py create mode 100755 scripts/metadata_tests/json2pydantic.py create mode 100755 scripts/metadata_tests/parse_invoke_metadata.py create mode 100755 scripts/metadata_tests/parse_invoke_metadata_from_file.py diff --git a/photomap/backend/metadata_modules/invoke/canvas2metadata.py b/photomap/backend/metadata_modules/invoke/canvas2metadata.py index 219ff7a2..21fe687d 100644 --- a/photomap/backend/metadata_modules/invoke/canvas2metadata.py +++ b/photomap/backend/metadata_modules/invoke/canvas2metadata.py @@ -3,16 +3,12 @@ from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator from photomap.backend.metadata_modules.invoke.common_metadata_elements import ( - ClipEmbedModel, ControlAdapter, Fill, - Lora, - Model, Object, Position, ReferenceImage, RegionalGuidance, - T5Encoder, tag_reference_images, ) @@ -32,7 +28,7 @@ class Inpaintmask(BaseModel): is_locked: bool = Field(alias="isLocked") name: Optional[Any] objects: List[Object] - opacity: int + opacity: float position: Position type: str diff --git a/scripts/metadata_tests/dump_invoke_metadata.py b/scripts/metadata_tests/dump_invoke_metadata.py new file mode 100755 index 00000000..9e35da7c --- /dev/null +++ b/scripts/metadata_tests/dump_invoke_metadata.py @@ -0,0 +1,54 @@ +#!/usr/bin/env python3 +# +import json +import os +import sys +from pathlib import Path +from typing import Annotated, Any, Union + +from PIL import Image + + +def traverse_directory(directory: str) -> None: + """ + Traverse the specified directory to find PNG files and parse their + generation metadata from embedded JSON. + + Args: + directory: Path to the directory to traverse + """ + dir_path = Path(directory) + + for file_path in dir_path.rglob("*.png"): + parse_file(file_path) + + +def parse_file(file_path: Path) -> None: + """ + Retrieve the metadata from the PNG file and parse its + generation metadata from embedded JSON. + + Args: + file_path: Path to the file to parse + q""" + metadata_dict = {} + metadata_tags = ["invokeai_metadata", "Sd-metadata", "sd-metadata"] + with Image.open(file_path) as img: + for tag in metadata_tags: + if tag in img.info: + metadata_json = img.info[tag] + metadata_dict = json.loads(metadata_json) + print(f"## {file_path}") + print(json.dumps(metadata_dict, indent=4)) + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "." + + if os.path.isfile(path): + parse_file(Path(path)) + elif os.path.isdir(path): + traverse_directory(path) + else: + print(f"Error: '{path}' is not a valid directory", file=sys.stderr) + sys.exit(1) diff --git a/scripts/metadata_tests/json2pydantic.py b/scripts/metadata_tests/json2pydantic.py new file mode 100755 index 00000000..2a0a22cb --- /dev/null +++ b/scripts/metadata_tests/json2pydantic.py @@ -0,0 +1,115 @@ +#!/usr/bin/env python3 +import json +import sys +from collections import defaultdict +from pathlib import Path +from typing import Any, Dict, Set + + +def json_to_pydantic_models( + json_data: Dict[str, Any], model_name: str = "Model" +) -> str: + """ + Convert a JSON object to Pydantic model class definitions. + + Args: + json_data: Dictionary containing the JSON structure + model_name: Name for the root model class + + Returns: + String containing Pydantic model class definitions + """ + models: Dict[str, Dict[str, str]] = {} + imports: Set[str] = {"from pydantic import BaseModel"} + + def infer_type(value: Any) -> str: + """Infer Python type from a JSON value.""" + if value is None: + return "Optional[Any]" + elif isinstance(value, bool): + return "bool" + elif isinstance(value, int): + return "int" + elif isinstance(value, float): + return "float" + elif isinstance(value, str): + return "str" + elif isinstance(value, list): + if value: + # Analyze first element to determine list type + item_type = infer_type(value[0]) + return f"List[{item_type}]" + return "List[Any]" + elif isinstance(value, dict): + return "Dict[str, Any]" + return "Any" + + def process_object(obj: Dict[str, Any], class_name: str) -> None: + """Recursively process JSON objects and create model classes.""" + fields: Dict[str, str] = {} + + for key, value in obj.items(): + if isinstance(value, dict): + # Create nested model + nested_class_name = "".join( + word.capitalize() for word in key.split("_") + ) + process_object(value, nested_class_name) + fields[key] = nested_class_name + elif isinstance(value, list) and value and isinstance(value[0], dict): + # List of objects - create model for first item + item_class_name = "".join( + word.capitalize() for word in key.rstrip("s").split("_") + ) + process_object(value[0], item_class_name) + fields[key] = f"List[{item_class_name}]" + else: + fields[key] = infer_type(value) + + models[class_name] = fields + + # Process the root object + process_object(json_data, model_name) + + # Add Optional import if needed + if any( + "Optional" in field_type + for fields in models.values() + for field_type in fields.values() + ): + imports.add("from typing import Optional") + + # Add List, Dict, Any imports if needed + all_types = "".join(str(fields) for fields in models.values()) + if "List[" in all_types: + imports.add("from typing import List") + if "Dict[" in all_types: + imports.add("from typing import Dict") + if "Any" in all_types: + imports.add("from typing import Any") + + # Generate output + output = "\n".join(sorted(imports)) + "\n\n" + + # Output models in dependency order (nested first) + for class_name in sorted(models.keys()): + fields = models[class_name] + output += f"class {class_name}(BaseModel):\n" + for field_name, field_type in sorted(fields.items()): + output += f" {field_name}: {field_type}\n" + output += "\n" + + return output + + +# Example usage +if __name__ == "__main__": + # read JSON from a file passed on the command line + if len(sys.argv) != 2: + print("Usage: python json2pydantic.py ") + sys.exit(1) + json_file_path = Path(sys.argv[1]) + with json_file_path.open("r", encoding="utf-8") as f: + example_json = json.load(f) + pydantic_code = json_to_pydantic_models(example_json, "GenerationMetadata") + print(pydantic_code) diff --git a/scripts/metadata_tests/parse_invoke_metadata.py b/scripts/metadata_tests/parse_invoke_metadata.py new file mode 100755 index 00000000..4d09f124 --- /dev/null +++ b/scripts/metadata_tests/parse_invoke_metadata.py @@ -0,0 +1,66 @@ +#!/usr/bin/env python3 +import json +import os +import sys +from pathlib import Path +from typing import Annotated, Any, Union + +from PIL import Image +from pydantic import Field, TypeAdapter + +from photomap.backend.metadata_modules.invokemetadata import ( + GenerationMetadata, + GenerationMetadataAdapter, +) + + +def traverse_directory(directory: str) -> None: + """ + Traverse the specified directory to find PNG files and parse their + generation metadata from embedded JSON. + + Args: + directory: Path to the directory to traverse + """ + dir_path = Path(directory) + + for file_path in dir_path.rglob("*.png"): + parse_file(file_path) + + +def parse_file(file_path: Path) -> None: + """ + Retrieve the metadata from the PNG file and parse its + generation metadata from embedded JSON. + + Args: + file_path: Path to the file to parse +q """ + metadata_dict = {} + try: + metadata_tags = ["invokeai_metadata", "Sd-metadata", "sd-metadata"] + metadata_adapter = GenerationMetadataAdapter() + with Image.open(file_path) as img: + for tag in metadata_tags: + if tag in img.info: + metadata_json = img.info[tag] + metadata_dict = json.loads(metadata_json) + generation_metadata = metadata_adapter.parse(metadata_dict) + print(f"## File: {file_path}") + print(generation_metadata.model_dump_json(indent=4)) + except Exception as e: + print(f"Error processing file {file_path}: {e}", file=sys.stderr) + if metadata_dict is not None: + print(f"Raw data = {json.dumps(metadata_dict, indent=4)}", file=sys.stderr) + + +if __name__ == "__main__": + path = sys.argv[1] if len(sys.argv) > 1 else "." + + if os.path.isfile(path): + parse_file(Path(path)) + elif os.path.isdir(path): + traverse_directory(path) + else: + print(f"Error: '{path}' is not a valid directory", file=sys.stderr) + sys.exit(1) diff --git a/scripts/metadata_tests/parse_invoke_metadata_from_file.py b/scripts/metadata_tests/parse_invoke_metadata_from_file.py new file mode 100755 index 00000000..414ec531 --- /dev/null +++ b/scripts/metadata_tests/parse_invoke_metadata_from_file.py @@ -0,0 +1,84 @@ +#!/usr/bin/env python3 +import argparse +import json +import os +import sys +from itertools import groupby +from pathlib import Path +from typing import Annotated, Any, Generator, Union + +from PIL import Image +from pydantic import Field, TypeAdapter + +from photomap.backend.metadata_modules.invokemetadata import GenerationMetadataAdapter + + +def read_records(file_path: Path) -> Generator[tuple[str, dict[str, Any]], None, None]: + """ + Read records from a file where each record starts with ## filename. + + Args: + file_path: Path to the file containing records + + Yields: + Tuple of (filename, json_data) for each record + """ + with open(file_path) as f: + filename = "" + for is_header, group in groupby(f, key=lambda line: line.startswith("##")): + if is_header: + filename = next(group).strip()[2:].strip() + else: + try: + json_data = json.loads("".join(group)) + yield filename, json_data + except json.JSONDecodeError as e: + print(f"Error parsing JSON for {filename}: {e}", file=sys.stderr) + + +def parse_file(file_path: Path, print_parse: bool = False) -> None: + """ + Retrieve the metadata from the PNG file and parse its + generation metadata from embedded JSON. + + Args: + file_path: Path to the file to parse + print_parse: If True, pretty-print serialized metadata on successful parse + """ + metadata_dict = {} + metadata_adapter = GenerationMetadataAdapter() + for filename, metadata_dict in read_records(file_path): + try: + generation_metadata = metadata_adapter.parse(metadata_dict) + print(f"## {filename}: successfully parsed") + if print_parse: + print(generation_metadata.model_dump_json(indent=4)) + except BrokenPipeError: + raise + except Exception as e: + print(f"## {filename}: parse failed: {e}", file=sys.stderr) + if metadata_dict is not None: + print( + f"Raw data = {json.dumps(metadata_dict, indent=4)}", file=sys.stderr + ) + + +if __name__ == "__main__": + parser = argparse.ArgumentParser( + description="Parse Invoke generation metadata from a file" + ) + parser.add_argument("file", help="Path to the file to parse") + parser.add_argument( + "--print_parse", + action="store_true", + help="Pretty-print serialized metadata on successful parse", + ) + + args = parser.parse_args() + path = args.file + + if os.path.isfile(path): + parse_file(Path(path), print_parse=args.print_parse) + else: + print(f"Error: '{path}' is not a valid file", file=sys.stderr) + sys.exit(1) From 7394b8413628b6927c67a5bb680d294ca5edb9ff Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 8 Feb 2026 15:58:55 -0500 Subject: [PATCH 5/7] fix(metadata): change more opacity to float --- photomap/backend/metadata_modules/invoke/canvas2metadata.py | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/photomap/backend/metadata_modules/invoke/canvas2metadata.py b/photomap/backend/metadata_modules/invoke/canvas2metadata.py index 21fe687d..9f9934b6 100644 --- a/photomap/backend/metadata_modules/invoke/canvas2metadata.py +++ b/photomap/backend/metadata_modules/invoke/canvas2metadata.py @@ -40,7 +40,7 @@ class Rasterlayer(BaseModel): is_locked: bool = Field(alias="isLocked") name: Optional[Any] objects: List[Object] - opacity: int + opacity: float position: Position type: str @@ -53,7 +53,7 @@ class ControlLayer(BaseModel): is_locked: bool = Field(alias="isLocked") name: Optional[Any] objects: List[Object] - opacity: int + opacity: float position: Position type: str with_transparency_effect: bool = Field(alias="withTransparencyEffect") From e6da64476e7948e7f91462a9667294d19be3f4df Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sun, 8 Feb 2026 20:05:29 -0500 Subject: [PATCH 6/7] fix(metadata): handle version 3 postprocessed images --- .../invoke/common_metadata_elements.py | 49 ++++++---- .../invoke/invoke2metadata.py | 49 +++++++++- .../invoke/invoke3metadata.py | 92 ++++++++++++++++++- 3 files changed, 165 insertions(+), 25 deletions(-) diff --git a/photomap/backend/metadata_modules/invoke/common_metadata_elements.py b/photomap/backend/metadata_modules/invoke/common_metadata_elements.py index 8210fcb7..f91bf9de 100644 --- a/photomap/backend/metadata_modules/invoke/common_metadata_elements.py +++ b/photomap/backend/metadata_modules/invoke/common_metadata_elements.py @@ -1,7 +1,7 @@ import sys from typing import Annotated, Any, Dict, List, Literal, Optional, Union -from pydantic import BaseModel, ConfigDict, Field, model_validator +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator class Color(BaseModel): @@ -13,11 +13,17 @@ class Color(BaseModel): class Model(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) name: str = Field(alias="model_name") - base: str = Field(default="unknown", alias="base_model") + base: Optional[str] = Field(default=None, alias="base_model") hash: Optional[str] = None key: Optional[str] = None type: str = Field(default="main", alias="model_type") + @model_serializer(mode="wrap") + def serialize_model(self, serializer, info): + """Exclude None values when serializing.""" + data = serializer(self) + return {k: v for k, v in data.items() if v is not None} + class T5Encoder(Model): model_config = ConfigDict(extra="allow", populate_by_name=True) @@ -27,21 +33,6 @@ class ClipEmbedModel(Model): model_config = ConfigDict(extra="allow", populate_by_name=True) -class ControlAdapter(BaseModel): - model_config = ConfigDict(populate_by_name=True) - begin_end_step_pct: List[int | float] = Field(alias="beginEndStepPct") - control_mode: Optional[str] = Field(None, alias="controlMode") - model: Model - type: str - weight: float - - @model_validator(mode="before") - @classmethod - def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: - """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" - return fixup_step_percentages(json_data) - - class ImageData(BaseModel): model_config = ConfigDict(populate_by_name=True) type: Literal["dataURL"] = Field(default="dataURL", alias="image_type") @@ -64,6 +55,30 @@ class ImageFile(BaseModel): ] +class ControlAdapter(BaseModel): + model_config = ConfigDict(populate_by_name=True) + image: Image + begin_end_step_pct: List[int | float] = Field(alias="beginEndStepPct") + control_mode: Optional[str] = Field(None, alias="controlMode") + model: Model = Field(alias="control_model") + type: Optional[str] = Field(default=None) + weight: float = Field(alias="control_weight") + + @model_validator(mode="before") + @classmethod + def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: + """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" + return fixup_step_percentages(json_data) + + @model_validator(mode="before") + @classmethod + def tag_reference_images(cls, data: Dict[str, Any]) -> Dict[str, Any]: + """Tag reference images with a discriminator for proper parsing.""" + if "image" in data and isinstance(data["image"], dict): + tag_reference_images(data["image"]) + return data + + class Lora(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) model: Model = Field(alias="lora") diff --git a/photomap/backend/metadata_modules/invoke/invoke2metadata.py b/photomap/backend/metadata_modules/invoke/invoke2metadata.py index f6f85d3d..afa00f64 100644 --- a/photomap/backend/metadata_modules/invoke/invoke2metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke2metadata.py @@ -1,6 +1,8 @@ -from typing import Any, List, Literal, Optional +from typing import Any, Dict, List, Literal, Optional -from pydantic import BaseModel, ConfigDict +from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator + +from photomap.backend.metadata_modules.invoke.common_metadata_elements import Model class Prompt(BaseModel): @@ -8,6 +10,11 @@ class Prompt(BaseModel): weight: float +class ImageVariation(BaseModel): + seed: int + weight: float + + class Image(BaseModel): cfg_scale: float height: int @@ -21,17 +28,51 @@ class Image(BaseModel): steps: int threshold: Optional[int | float] = None type: str - variations: Optional[List[Any]] = None + variations: Optional[List[ImageVariation]] = None width: int +class ModelListElement(BaseModel): + model: Model + status: str + description: str + + class GenerationMetadata2(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", populate_by_name=True) metadata_version: Literal[2] app_id: str + model_id: Optional[str] = None app_version: str image: Optional[Image] = None images: Optional[List[Image]] = None model: str model_hash: str model_weights: Optional[str] = None + # This appears in a few old images, but is not well structured. + # We structure it a bit in the model validator. + model_list: Optional[List[ModelListElement]] = None + + @model_validator(mode="before") + @classmethod + def validate_model_id(cls, data: Dict[str, Any]) -> Dict[str, Any]: + """Munge the model_list into a more compatible structure""" + if "model_list" in data and isinstance(data["model_list"], dict): + model_list = [] + for model_entry_key, model_entry_value in data["model_list"].items(): + new_model_entry = {} + new_model_entry["model"] = Model(model_name=model_entry_key) + if isinstance(model_entry_value, dict): + new_model_entry["status"] = model_entry_value.get("status", "") + new_model_entry["description"] = model_entry_value.get( + "description", "" + ) + model_list.append(new_model_entry) + data["model_list"] = model_list + return data + + @model_serializer(mode="wrap") + def serialize_model(self, serializer, info): + """Exclude None values when serializing.""" + data = serializer(self) + return {k: v for k, v in data.items() if v is not None} diff --git a/photomap/backend/metadata_modules/invoke/invoke3metadata.py b/photomap/backend/metadata_modules/invoke/invoke3metadata.py index 54f122ab..76782f86 100644 --- a/photomap/backend/metadata_modules/invoke/invoke3metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke3metadata.py @@ -2,6 +2,7 @@ from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator +from photomap.backend.metadata_modules.invoke.canvas2metadata import Clip from photomap.backend.metadata_modules.invoke.common_metadata_elements import ( ControlAdapter, IPAdapter, @@ -15,13 +16,34 @@ class T2IAdapter(IPAdapter): pass -# All fields are optional because of various glitches and exceptions in v3 metadata +class CanvasObject(BaseModel): + kind: str + layer: str + tool: Optional[str] = None + stroke_width: Optional[int] = Field(default=None, alias="strokeWidth") + x: Optional[int] = None + y: Optional[int] = None + width: Optional[int] = None + height: Optional[int] = None + image_name: Optional[str] = Field(default=None, alias="imageName") + points: Optional[List[float]] = None + clip: Optional[Clip] = None + + +class PostProcessing(BaseModel): + type: str + orig_path: Optional[List[str]] = None + orig_hash: Optional[str] = None + scale: Optional[float] = None + strength: Optional[float] = None + + +# Most fields are optional because of various glitches and exceptions in v3 metadata class GenerationMetadata3(BaseModel): - model_config = ConfigDict(extra="forbid") + model_config = ConfigDict(extra="forbid", populate_by_name=True) metadata_version: Literal[3] - app_version: str = Field(default="3.X.X") + app_version: Optional[str] = Field(default="3.X.X", alias="imported_app_version") generation_mode: Optional[str] = None - model: Optional[Model] = None positive_prompt: Optional[str] = None positive_style_prompt: Optional[str] = None negative_prompt: Optional[str] = None @@ -32,6 +54,10 @@ class GenerationMetadata3(BaseModel): scheduler: Optional[str] = None seed: Optional[int] = None steps: Optional[int] = None + strength: Optional[float] = None + init_image: Optional[str] = None + post_processing: Optional[List[PostProcessing]] = None + model: Optional[Model] = None vae: Optional[Model] = None ip_adapters: Optional[List[IPAdapter]] = Field(default=None, alias="ipAdapters") t2iAdapters: Optional[List[T2IAdapter]] = None @@ -43,8 +69,57 @@ class GenerationMetadata3(BaseModel): clip_skip: Optional[int] = None seamless_x: Optional[bool] = None seamless_y: Optional[bool] = None + # These fields appear in some app_version 3 images + refiner_model: Optional[Model] = None + refiner_cfg_scale: Optional[float] = None + refiner_steps: Optional[int] = None + refiner_scheduler: Optional[str] = None + refiner_positive_aesthetic_score: Optional[float] = Field( + default=None, alias="refiner_positive_aesthetic_store" + ) + refiner_negative_aesthetic_score: Optional[float] = Field( + default=None, alias="refiner_negative_aesthetic_store" + ) + refiner_start: Optional[float] = None + # A few examples of these + hrf_enabled: Optional[bool] = None + hrf_method: Optional[str] = None + hrf_strength: Optional[float] = None + hrf_width: Optional[int] = None + hrf_height: Optional[int] = None + # One example of this found! + canvas_objects: Optional[List[CanvasObject]] = Field( + default=None, alias="_canvas_objects" + ) + + @model_validator(mode="before") + @classmethod + def fixup_orphan_images(cls, data: dict) -> dict: + """ + Fix up any orphaned reference images by sticking them into a postprocessing model. + """ + if ( + "image" in data + and isinstance(data["image"], dict) + and "postprocessing" in data["image"] + ): + post_processing_list = [] + for post_processing_entry in data["image"]["postprocessing"]: + post_processing_list.append( + PostProcessing( + orig_path=post_processing_entry.get("orig_path"), + orig_hash=post_processing_entry.get("orig_hash"), + type=post_processing_entry.get("type"), + strength=post_processing_entry.get("strength"), + scale=post_processing_entry.get("scale"), + ) + ) + data.pop("image") + data["post_processing"] = post_processing_list + return data @model_validator(mode="before") + @classmethod def tag_reference_images(cls, data): """Tag reference images with a discriminator for proper parsing.""" if "ipAdapters" in data and isinstance(data["ipAdapters"], list): @@ -53,6 +128,15 @@ def tag_reference_images(cls, data): tag_reference_images(image) return data + @model_validator(mode="before") + @classmethod + def fixup_aesthetic_score(cls, json_data: dict) -> dict: + """Replace the refiner_aesthetic_store and refiner_aesthetic_score fields with refiner_positive_aesthetic_score.""" + for key in ["refiner_positive_aesthetic_store", "refiner_aesthetic_store"]: + if key in json_data: + json_data["refiner_positive_aesthetic_score"] = json_data.pop(key) + return json_data + @model_serializer(mode="wrap") def serialize_model(self, serializer, info): """Exclude None values when serializing.""" From b0084b79fefd61eb3cf906945ad9d763d76160f1 Mon Sep 17 00:00:00 2001 From: Lincoln Stein Date: Sat, 11 Apr 2026 17:16:42 -0400 Subject: [PATCH 7/7] refactor(metadata): finish wiring invoke formatter to pydantic union The invoke metadata refactor left `invoke_formatter.py` pointing at classes that no longer existed, so opening the drawer on an InvokeAI-generated image would raise ImportError. This change completes the wiring and locks in the behavior with regression tests. - Add `InvokeMetadataView`, a version-agnostic facade over the `GenerationMetadata2/3/5` discriminated union, so the formatter doesn't sprinkle `isinstance` checks across extraction logic. - Rewrite `invoke_formatter.py` as a thin HTML renderer on top of the view, preserving the existing drawer table layout. - Move `_normalize_ref_images` out of `GenerationMetadataAdapter.parse` and into `GenerationMetadata5` as a `@model_validator(mode="before")`, where it belongs. - Re-enable `metadata_modules/__init__.py` re-exports that had been commented out during the refactor. - Delete the now-unused `invoke/invoke_metadata_abc.py`. - Drawer HTML tweaks: suppress empty negative-prompt row; keep positive prompt row but drop copy icon when empty; per-column suppression in tuple tables; fall back to `1.0` when a surviving weight column has gaps; surface Flux Redux `imageInfluence` (e.g. "Medium") in the weight column when no numeric weight is present. - Add 33 regression tests in `tests/backend/test_invoke_metadata.py` covering view-level extraction and end-to-end HTML rendering for v2/v3/v5 (both `ref_images` and `canvas_v2_metadata` paths). - Run ruff across `invoke/` to modernize `typing.Optional`/`Union`/ `List`/`Dict` to PEP 604 / PEP 585 forms. Co-Authored-By: Claude Opus 4.6 (1M context) --- CLAUDE.md | 80 ++ photomap/backend/metadata_modules/__init__.py | 18 +- .../invoke/canvas2metadata.py | 28 +- .../invoke/common_metadata_elements.py | 69 +- .../invoke/invoke2metadata.py | 30 +- .../invoke/invoke3metadata.py | 108 +-- .../invoke/invoke5metadata.py | 172 ++-- .../invoke/invoke_metadata_abc.py | 164 ---- .../invoke/invoke_metadata_view.py | 274 ++++++ .../metadata_modules/invoke_formatter.py | 231 +++-- .../metadata_modules/invokemetadata.py | 57 +- tests/backend/test_invoke_metadata.py | 819 ++++++++++++++++++ 12 files changed, 1546 insertions(+), 504 deletions(-) create mode 100644 CLAUDE.md delete mode 100644 photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py create mode 100644 photomap/backend/metadata_modules/invoke/invoke_metadata_view.py create mode 100644 tests/backend/test_invoke_metadata.py diff --git a/CLAUDE.md b/CLAUDE.md new file mode 100644 index 00000000..0cf18a89 --- /dev/null +++ b/CLAUDE.md @@ -0,0 +1,80 @@ +# CLAUDE.md + +This file provides guidance to Claude Code (claude.ai/code) when working with code in this repository. + +## Project + +PhotoMapAI is a local-first image browser for large photo collections. It uses CLIP embeddings to power semantic text/image search and builds a UMAP "semantic map" that clusters images by content. The backend is FastAPI; the frontend is vanilla ES6 modules (no framework) using Swiper.js and Plotly.js. All processing is local — nothing is sent to external services. + +## Common Commands + +```bash +# Install for development (Python 3.10–3.13) +pip install -e .[testing,development] +npm install + +# Run the server (entry point defined in pyproject.toml) +start_photomap # http://localhost:8050 + +# Tests +make test # runs npm test + pytest +pytest tests # backend only +pytest tests/backend/test_search.py::test_text_search # single test +npm test # frontend Jest only +NODE_OPTIONS='--experimental-vm-modules' jest tests/frontend/search.test.js # single JS test + +# Linting / formatting (CI enforces both) +make lint # runs backend-lint + frontend-lint +ruff check photomap tests --fix +npm run lint:fix +npm run format # prettier write +npm run format:check # CI check + +# Docs +make docs # mkdocs serve on :8000 +``` + +Ruff is configured for line-length 120, target py310, rules E/W/F/I/UP/B (see pyproject.toml). Jest runs in jsdom with experimental ESM (the project is `"type": "module"`). + +## Architecture + +### Backend layout (`photomap/backend/`) + +- `photomap_server.py` — FastAPI app entry point. Wires up routers, mounts `/static` and Jinja2 templates, and defines the top-level `/` route. `start_photomap` from `pyproject.toml` runs `main()` here. +- `routers/` — one router per API surface: `album`, `search`, `umap`, `index`, `curation`, `filetree`, `upgrade`. Routers are included in `photomap_server.py`; `curation_router` is mounted with an explicit `/api/curation` prefix while the others set their own prefixes. +- `config.py` — YAML-backed album config. Access via the `get_config_manager()` singleton (lru_cached). `Album` is a Pydantic model that expands `~` in image paths. Config lives in a platformdirs user config directory. +- `embeddings.py` — CLIP embedding generation and persistence (`.npz`). +- `imagetool.py` — shared CLI entry point for `index_images`, `update_images`, `search_images`, `search_text`, `find_duplicate_images` (all registered as scripts in `pyproject.toml`). +- `metadata_extraction.py` / `metadata_formatting.py` — pulls EXIF + generator metadata (InvokeAI) out of images and formats for the UI. + +### Metadata subsystem (`photomap/backend/metadata_modules/`) + +This is the area under active refactor (current branch: `lstein/feature/refactor-invoke-metadata`). InvokeAI writes several incompatible metadata schemas into PNG tEXt chunks; the parser must auto-detect and upgrade. + +- `invokemetadata.py` defines `GenerationMetadata` as a Pydantic `Annotated[Union[…], Field(discriminator="metadata_version")]` over `GenerationMetadata2`, `GenerationMetadata3`, and `GenerationMetadata5`. `GenerationMetadataAdapter.parse()` inspects fields like `canvas_v2_metadata`, `app_version`, and `model_weights` to inject the correct `metadata_version` when the source JSON predates the discriminator. +- `invoke/` holds the per-version schemas: `invoke2metadata.py`, `invoke3metadata.py`, `invoke5metadata.py`, plus `canvas2metadata.py` and `common_metadata_elements.py` for shared types. `invoke_metadata_view.py` is the version-agnostic facade consumed by `invoke_formatter.py`. When adding support for a new InvokeAI version, add a new `invokeNmetadata.py`, extend the Union in `invokemetadata.py`, teach `parse()` how to recognize legacy payloads that lack a `metadata_version` field, and extend `InvokeMetadataView`'s `isinstance` dispatch. +- `invoke_formatter.py` / `exif_formatter.py` render parsed metadata for the drawer UI; `slide_summary.py` produces the compact slideshow caption. +- `invoke-DELETE/` is a holdover from the refactor — leave it alone unless cleaning up. + +### Frontend layout (`photomap/frontend/`) + +- `static/javascript/` — one ES6 module per feature. No build step; modules are served directly and imported from `main.js` / `index.js`. +- `state.js` is the centralized application state. Prefer extending it over adding new globals. +- `events.js` owns global keyboard shortcuts; register new ones there rather than scattering listeners. +- `localStorage` is used for persisted user preferences, `sessionStorage` for per-navigation state. +- `templates/` — Jinja2 templates rendered by FastAPI. + +### Tests + +- `tests/backend/` — pytest. `conftest.py` + `fixtures.py` set up shared fixtures (test images in `tests/backend/test_images/`). Use the FastAPI `TestClient` for router tests; see `test_search.py`, `test_albums.py`, `test_curation.py` as templates. +- `tests/frontend/` — Jest with jsdom. `setup.js` provides DOM fixtures. See `tests/frontend/README.md` for setup notes. + +## Conventions to follow + +From `.github/copilot-instructions.md` — the parts that actually affect how you write code here: + +- **Python:** type hints on public functions, `pathlib.Path` (not `os.path`) for file operations, f-strings, imports ordered stdlib → third-party → local (`photomap` is first-party to isort). Code must pass `ruff check photomap tests`. +- **Pinned quirk:** `setuptools<67` is intentional — avoids a deprecation warning from the CLIP dependency. Don't "fix" it. +- **New API endpoints:** add/extend a router under `photomap/backend/routers/`, use Pydantic models for request/response, include the router in `photomap_server.py`, add a `tests/backend/test_.py`. +- **New frontend features:** create a module in `static/javascript/`, wire shared state through `state.js`, register shortcuts in `events.js`, add a Jest test. +- **JavaScript:** ES6 modules only, `const`/`let`, must pass `npm run lint` and `npm run format:check`. diff --git a/photomap/backend/metadata_modules/__init__.py b/photomap/backend/metadata_modules/__init__.py index beb89062..033f6e89 100644 --- a/photomap/backend/metadata_modules/__init__.py +++ b/photomap/backend/metadata_modules/__init__.py @@ -1,10 +1,10 @@ -# from .exif_formatter import format_exif_metadata -# from .invoke_formatter import format_invoke_metadata -# from .slide_summary import SlideSummary +from .exif_formatter import format_exif_metadata +from .invoke_formatter import format_invoke_metadata +from .slide_summary import SlideSummary -# # re-export the format_invoke_metadata and format_exif_metadata functions -# __all__ = [ -# "SlideSummary", -# "format_invoke_metadata", -# "format_exif_metadata", -# ] +# re-export the format_invoke_metadata and format_exif_metadata functions +__all__ = [ + "SlideSummary", + "format_invoke_metadata", + "format_exif_metadata", +] diff --git a/photomap/backend/metadata_modules/invoke/canvas2metadata.py b/photomap/backend/metadata_modules/invoke/canvas2metadata.py index 9f9934b6..a2b92a19 100644 --- a/photomap/backend/metadata_modules/invoke/canvas2metadata.py +++ b/photomap/backend/metadata_modules/invoke/canvas2metadata.py @@ -1,4 +1,4 @@ -from typing import Annotated, Any, Dict, List, Literal, Optional, Union +from typing import Any from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator @@ -26,8 +26,8 @@ class Inpaintmask(BaseModel): id: str is_enabled: bool = Field(alias="isEnabled") is_locked: bool = Field(alias="isLocked") - name: Optional[Any] - objects: List[Object] + name: Any | None + objects: list[Object] opacity: float position: Position type: str @@ -38,8 +38,8 @@ class Rasterlayer(BaseModel): id: str is_enabled: bool = Field(alias="isEnabled") is_locked: bool = Field(alias="isLocked") - name: Optional[Any] - objects: List[Object] + name: Any | None + objects: list[Object] opacity: float position: Position type: str @@ -51,8 +51,8 @@ class ControlLayer(BaseModel): id: str is_enabled: bool = Field(alias="isEnabled") is_locked: bool = Field(alias="isLocked") - name: Optional[Any] - objects: List[Object] + name: Any | None + objects: list[Object] opacity: float position: Position type: str @@ -61,21 +61,21 @@ class ControlLayer(BaseModel): class CanvasV2Metadata(BaseModel): model_config = ConfigDict(populate_by_name=True) - raster_layers: Optional[List[Rasterlayer]] = Field(None, alias="rasterLayers") - control_layers: Optional[List[ControlLayer]] = Field(None, alias="controlLayers") - inpaint_masks: Optional[List[Inpaintmask]] = Field(None, alias="inpaintMasks") - reference_images: Optional[List[ReferenceImage]] = Field( + raster_layers: list[Rasterlayer] | None = Field(None, alias="rasterLayers") + control_layers: list[ControlLayer] | None = Field(None, alias="controlLayers") + inpaint_masks: list[Inpaintmask] | None = Field(None, alias="inpaintMasks") + reference_images: list[ReferenceImage] | None = Field( None, alias="referenceImages" ) - regional_guidance: Optional[List[RegionalGuidance]] = Field( + regional_guidance: list[RegionalGuidance] | None = Field( None, alias="regionalGuidance" ) @model_validator(mode="before") @classmethod def _preprocess_canvas_metadata( - cls, canvas_metadata: Dict[str, Any] - ) -> Dict[str, Any]: + cls, canvas_metadata: dict[str, Any] + ) -> dict[str, Any]: """Preprocess canvas metadata to add type discriminators to image objects.""" def process_image_in_dict(obj: dict[str, Any], key: str = "image") -> None: diff --git a/photomap/backend/metadata_modules/invoke/common_metadata_elements.py b/photomap/backend/metadata_modules/invoke/common_metadata_elements.py index f91bf9de..b4f74a4c 100644 --- a/photomap/backend/metadata_modules/invoke/common_metadata_elements.py +++ b/photomap/backend/metadata_modules/invoke/common_metadata_elements.py @@ -1,5 +1,4 @@ -import sys -from typing import Annotated, Any, Dict, List, Literal, Optional, Union +from typing import Annotated, Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator @@ -13,9 +12,9 @@ class Color(BaseModel): class Model(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) name: str = Field(alias="model_name") - base: Optional[str] = Field(default=None, alias="base_model") - hash: Optional[str] = None - key: Optional[str] = None + base: str | None = Field(default=None, alias="base_model") + hash: str | None = None + key: str | None = None type: str = Field(default="main", alias="model_type") @model_serializer(mode="wrap") @@ -37,20 +36,20 @@ class ImageData(BaseModel): model_config = ConfigDict(populate_by_name=True) type: Literal["dataURL"] = Field(default="dataURL", alias="image_type") data_url: str = Field(alias="dataURL") - height: Optional[int] = None - width: Optional[int] = None + height: int | None = None + width: int | None = None class ImageFile(BaseModel): model_config = ConfigDict(populate_by_name=True) type: Literal["file"] = Field(default="file", alias="image_type") name: str = Field(alias="image_name") - height: Optional[int] = None - width: Optional[int] = None + height: int | None = None + width: int | None = None Image = Annotated[ - Union[ImageFile, ImageData], + ImageFile | ImageData, Field(discriminator="type"), ] @@ -58,21 +57,21 @@ class ImageFile(BaseModel): class ControlAdapter(BaseModel): model_config = ConfigDict(populate_by_name=True) image: Image - begin_end_step_pct: List[int | float] = Field(alias="beginEndStepPct") - control_mode: Optional[str] = Field(None, alias="controlMode") + begin_end_step_pct: list[int | float] = Field(alias="beginEndStepPct") + control_mode: str | None = Field(None, alias="controlMode") model: Model = Field(alias="control_model") - type: Optional[str] = Field(default=None) + type: str | None = Field(default=None) weight: float = Field(alias="control_weight") @model_validator(mode="before") @classmethod - def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: + def fixup_step_percentages(cls, json_data: dict[str, Any]) -> dict[str, Any]: """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" return fixup_step_percentages(json_data) @model_validator(mode="before") @classmethod - def tag_reference_images(cls, data: Dict[str, Any]) -> Dict[str, Any]: + def tag_reference_images(cls, data: dict[str, Any]) -> dict[str, Any]: """Tag reference images with a discriminator for proper parsing.""" if "image" in data and isinstance(data["image"], dict): tag_reference_images(data["image"]) @@ -87,25 +86,25 @@ class Lora(BaseModel): class IPAdapter(BaseModel): model_config = ConfigDict(populate_by_name=True) - begin_end_step_pct: Optional[List[int | float]] = Field( + begin_end_step_pct: list[int | float] | None = Field( None, alias="beginEndStepPct" ) model: Model image: Image - type: Optional[str] = None - method: Optional[str] = None - weight: Optional[float] = None - image_influence: Optional[str] = Field(None, alias="imageInfluence") + type: str | None = None + method: str | None = None + weight: float | None = None + image_influence: str | None = Field(None, alias="imageInfluence") @model_validator(mode="before") @classmethod - def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: + def fixup_step_percentages(cls, json_data: dict[str, Any]) -> dict[str, Any]: """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" return fixup_step_percentages(json_data) @model_validator(mode="before") @classmethod - def tag_reference_images(cls, data: Dict[str, Any]) -> Dict[str, Any]: + def tag_reference_images(cls, data: dict[str, Any]) -> dict[str, Any]: """Tag reference images with a discriminator for proper parsing.""" if "image" in data and isinstance(data["image"], dict): tag_reference_images(data["image"]) @@ -126,10 +125,10 @@ class ReferenceImage(BaseModel): model_config = ConfigDict(populate_by_name=True) id: str ip_adapter: IPAdapter = Field(alias="ipAdapter") - is_enabled: Optional[bool] = Field(None, alias="isEnabled") - is_locked: Optional[bool] = Field(None, alias="isLocked") - name: Optional[Any] = None - type: Optional[str] = None + is_enabled: bool | None = Field(None, alias="isEnabled") + is_locked: bool | None = Field(None, alias="isLocked") + name: Any | None = None + type: str | None = None class Fill(BaseModel): @@ -144,7 +143,7 @@ class Position(BaseModel): class Object(BaseModel): id: str - image: Optional[Image] = None + image: Image | None = None type: str @@ -155,17 +154,17 @@ class RegionalGuidance(BaseModel): id: str is_enabled: bool = Field(alias="isEnabled") is_locked: bool = Field(alias="isLocked") - name: Optional[Any] - positive_prompt: Optional[str] = Field(None, alias="positivePrompt") - negative_prompt: Optional[str] = Field(None, alias="negativePrompt") - objects: List[Object] + name: Any | None + positive_prompt: str | None = Field(None, alias="positivePrompt") + negative_prompt: str | None = Field(None, alias="negativePrompt") + objects: list[Object] opacity: float position: Position - reference_images: List[ReferenceImage] = Field(alias="referenceImages") + reference_images: list[ReferenceImage] = Field(alias="referenceImages") type: str -def tag_reference_images(image: Dict[str, Any]) -> None: +def tag_reference_images(image: dict[str, Any]) -> None: """Mutates the input image dict to add an "image_type" field based on whether it has a "dataURL" or "image_name" field.""" if "dataURL" in image: image["type"] = "dataURL" @@ -175,7 +174,7 @@ def tag_reference_images(image: Dict[str, Any]) -> None: image["type"] = "file" -def fixup_step_percentages(json_data: Dict[str, Any]) -> Dict[str, Any]: +def fixup_step_percentages(json_data: dict[str, Any]) -> dict[str, Any]: """ Helper function to convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list. @@ -183,7 +182,7 @@ def fixup_step_percentages(json_data: Dict[str, Any]) -> Dict[str, Any]: for key in ["begin_step_percent", "end_step_percent"]: if key in json_data: value = json_data.pop(key) - if isinstance(value, (int, float)): + if isinstance(value, int | float): json_data.setdefault("beginEndStepPct", []).append(value) elif isinstance(value, list): json_data.setdefault("beginEndStepPct", []).extend(value) diff --git a/photomap/backend/metadata_modules/invoke/invoke2metadata.py b/photomap/backend/metadata_modules/invoke/invoke2metadata.py index afa00f64..bce2961f 100644 --- a/photomap/backend/metadata_modules/invoke/invoke2metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke2metadata.py @@ -1,6 +1,6 @@ -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal -from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator +from pydantic import BaseModel, ConfigDict, model_serializer, model_validator from photomap.backend.metadata_modules.invoke.common_metadata_elements import Model @@ -18,17 +18,17 @@ class ImageVariation(BaseModel): class Image(BaseModel): cfg_scale: float height: int - hires_fix: Optional[bool] = None - perlin: Optional[int | float] = None - postprocessing: Optional[Any] - prompt: str | List[Prompt] + hires_fix: bool | None = None + perlin: int | float | None = None + postprocessing: Any | None + prompt: str | list[Prompt] sampler: str - seamless: Optional[bool] = None + seamless: bool | None = None seed: int steps: int - threshold: Optional[int | float] = None + threshold: int | float | None = None type: str - variations: Optional[List[ImageVariation]] = None + variations: list[ImageVariation] | None = None width: int @@ -42,20 +42,20 @@ class GenerationMetadata2(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) metadata_version: Literal[2] app_id: str - model_id: Optional[str] = None + model_id: str | None = None app_version: str - image: Optional[Image] = None - images: Optional[List[Image]] = None + image: Image | None = None + images: list[Image] | None = None model: str model_hash: str - model_weights: Optional[str] = None + model_weights: str | None = None # This appears in a few old images, but is not well structured. # We structure it a bit in the model validator. - model_list: Optional[List[ModelListElement]] = None + model_list: list[ModelListElement] | None = None @model_validator(mode="before") @classmethod - def validate_model_id(cls, data: Dict[str, Any]) -> Dict[str, Any]: + def validate_model_id(cls, data: dict[str, Any]) -> dict[str, Any]: """Munge the model_list into a more compatible structure""" if "model_list" in data and isinstance(data["model_list"], dict): model_list = [] diff --git a/photomap/backend/metadata_modules/invoke/invoke3metadata.py b/photomap/backend/metadata_modules/invoke/invoke3metadata.py index 76782f86..7e33499b 100644 --- a/photomap/backend/metadata_modules/invoke/invoke3metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke3metadata.py @@ -1,4 +1,4 @@ -from typing import List, Literal, Optional +from typing import Literal from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator @@ -19,76 +19,76 @@ class T2IAdapter(IPAdapter): class CanvasObject(BaseModel): kind: str layer: str - tool: Optional[str] = None - stroke_width: Optional[int] = Field(default=None, alias="strokeWidth") - x: Optional[int] = None - y: Optional[int] = None - width: Optional[int] = None - height: Optional[int] = None - image_name: Optional[str] = Field(default=None, alias="imageName") - points: Optional[List[float]] = None - clip: Optional[Clip] = None + tool: str | None = None + stroke_width: int | None = Field(default=None, alias="strokeWidth") + x: int | None = None + y: int | None = None + width: int | None = None + height: int | None = None + image_name: str | None = Field(default=None, alias="imageName") + points: list[float] | None = None + clip: Clip | None = None class PostProcessing(BaseModel): type: str - orig_path: Optional[List[str]] = None - orig_hash: Optional[str] = None - scale: Optional[float] = None - strength: Optional[float] = None + orig_path: list[str] | None = None + orig_hash: str | None = None + scale: float | None = None + strength: float | None = None # Most fields are optional because of various glitches and exceptions in v3 metadata class GenerationMetadata3(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) metadata_version: Literal[3] - app_version: Optional[str] = Field(default="3.X.X", alias="imported_app_version") - generation_mode: Optional[str] = None - positive_prompt: Optional[str] = None - positive_style_prompt: Optional[str] = None - negative_prompt: Optional[str] = None - negative_style_prompt: Optional[str] = None - height: Optional[int] = None - width: Optional[int] = None - rand_device: Optional[str] = None - scheduler: Optional[str] = None - seed: Optional[int] = None - steps: Optional[int] = None - strength: Optional[float] = None - init_image: Optional[str] = None - post_processing: Optional[List[PostProcessing]] = None - model: Optional[Model] = None - vae: Optional[Model] = None - ip_adapters: Optional[List[IPAdapter]] = Field(default=None, alias="ipAdapters") - t2iAdapters: Optional[List[T2IAdapter]] = None - loras: Optional[List[Lora]] = None - controlnets: Optional[List[ControlAdapter]] = None - cfg_rescale_multiplier: Optional[float] = None - cfg_scale: Optional[float] = None - esrgan_model: Optional[str] = None - clip_skip: Optional[int] = None - seamless_x: Optional[bool] = None - seamless_y: Optional[bool] = None + app_version: str | None = Field(default="3.X.X", alias="imported_app_version") + generation_mode: str | None = None + positive_prompt: str | None = None + positive_style_prompt: str | None = None + negative_prompt: str | None = None + negative_style_prompt: str | None = None + height: int | None = None + width: int | None = None + rand_device: str | None = None + scheduler: str | None = None + seed: int | None = None + steps: int | None = None + strength: float | None = None + init_image: str | None = None + post_processing: list[PostProcessing] | None = None + model: Model | None = None + vae: Model | None = None + ip_adapters: list[IPAdapter] | None = Field(default=None, alias="ipAdapters") + t2iAdapters: list[T2IAdapter] | None = None + loras: list[Lora] | None = None + controlnets: list[ControlAdapter] | None = None + cfg_rescale_multiplier: float | None = None + cfg_scale: float | None = None + esrgan_model: str | None = None + clip_skip: int | None = None + seamless_x: bool | None = None + seamless_y: bool | None = None # These fields appear in some app_version 3 images - refiner_model: Optional[Model] = None - refiner_cfg_scale: Optional[float] = None - refiner_steps: Optional[int] = None - refiner_scheduler: Optional[str] = None - refiner_positive_aesthetic_score: Optional[float] = Field( + refiner_model: Model | None = None + refiner_cfg_scale: float | None = None + refiner_steps: int | None = None + refiner_scheduler: str | None = None + refiner_positive_aesthetic_score: float | None = Field( default=None, alias="refiner_positive_aesthetic_store" ) - refiner_negative_aesthetic_score: Optional[float] = Field( + refiner_negative_aesthetic_score: float | None = Field( default=None, alias="refiner_negative_aesthetic_store" ) - refiner_start: Optional[float] = None + refiner_start: float | None = None # A few examples of these - hrf_enabled: Optional[bool] = None - hrf_method: Optional[str] = None - hrf_strength: Optional[float] = None - hrf_width: Optional[int] = None - hrf_height: Optional[int] = None + hrf_enabled: bool | None = None + hrf_method: str | None = None + hrf_strength: float | None = None + hrf_width: int | None = None + hrf_height: int | None = None # One example of this found! - canvas_objects: Optional[List[CanvasObject]] = Field( + canvas_objects: list[CanvasObject] | None = Field( default=None, alias="_canvas_objects" ) diff --git a/photomap/backend/metadata_modules/invoke/invoke5metadata.py b/photomap/backend/metadata_modules/invoke/invoke5metadata.py index 1625ebf0..add790db 100644 --- a/photomap/backend/metadata_modules/invoke/invoke5metadata.py +++ b/photomap/backend/metadata_modules/invoke/invoke5metadata.py @@ -1,4 +1,4 @@ -from typing import Any, Dict, List, Literal, Optional +from typing import Any, Literal from pydantic import BaseModel, ConfigDict, Field, model_serializer, model_validator @@ -21,19 +21,19 @@ class ControlLayer(BaseModel): type: str is_enabled: bool = Field(alias="isEnabled") is_selected: bool = Field(alias="isSelected") - control_adapter: Optional[IPAdapter] = Field(default=None, alias="ipAdapter") + control_adapter: IPAdapter | None = Field(default=None, alias="ipAdapter") class ControlLayers(BaseModel): version: int | float - layers: List[ControlLayer] + layers: list[ControlLayer] class ControlNet(BaseModel): image: Image model: Model = Field(alias="control_model") - weight: Optional[float] = Field(alias="control_weight") - begin_end_step_pct: Optional[List[int | float]] = Field( + weight: float | None = Field(alias="control_weight") + begin_end_step_pct: list[int | float] | None = Field( None, alias="beginEndStepPct" ) control_mode: str @@ -41,7 +41,7 @@ class ControlNet(BaseModel): @model_validator(mode="before") @classmethod - def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: + def fixup_step_percentages(cls, json_data: dict[str, Any]) -> dict[str, Any]: """Convert begin_step_percent and end_step_percent to beginEndStepPct if they exist, and ensure the values are in a list.""" return fixup_step_percentages(json_data) @@ -49,12 +49,12 @@ def fixup_step_percentages(cls, json_data: Dict[str, Any]) -> Dict[str, Any]: class RefImageConfig(BaseModel): type: str image: Image - model: Optional[Model] = None - beginEndStepPct: Optional[List[float]] = None - method: Optional[str] = None - clipVisionModel: Optional[str] = None - weight: Optional[float] = None - image_influence: Optional[str] = Field(default=None, alias="imageInfluence") + model: Model | None = None + beginEndStepPct: list[float] | None = None + method: str | None = None + clipVisionModel: str | None = None + weight: float | None = None + image_influence: str | None = Field(default=None, alias="imageInfluence") class RefImage(BaseModel): @@ -77,66 +77,106 @@ def tag_reference_images(cls, data): class GenerationMetadata5(BaseModel): model_config = ConfigDict(extra="forbid", populate_by_name=True) metadata_version: Literal[5] - app_version: Optional[str] = Field(default="5.X.X") - model: Optional[Model | str] = Field(default=None, alias="Model") - generation_mode: Optional[str] = None - height: Optional[int] = None - width: Optional[int] = None - positive_prompt: Optional[str] = Field(default=None, alias="Positive Prompt") - positive_style_prompt: Optional[str] = None - negative_prompt: Optional[str] = None - negative_style_prompt: Optional[str] = None - scheduler: Optional[str] = None - seed: Optional[int] = None - steps: Optional[int] = Field(default=None, alias="Steps") - guidance: Optional[int | float] = None - ref_images: Optional[List[RefImage]] = None - control_layers: Optional[ControlLayers] = Field(default=None) - loras: Optional[List[Lora]] = None - regions: Optional[List[RegionalGuidance]] = None - t5_encoder: Optional[T5Encoder] = None - qwen3_encoder: Optional[Model] = None - qwen3_source: Optional[Model] = None - vae: Optional[Model] = None - clip_embed_model: Optional[ClipEmbedModel] = None - dype_preset: Optional[str] = None - rand_device: Optional[str] = None - cfg_scale: Optional[float] = None - cfg_rescale_multiplier: Optional[float] = None - seamless_x: Optional[bool] = None - seamless_y: Optional[bool] = None - upscale_model: Optional[Model] = None - upscale_initial_image: Optional[Image] = None - upscale_scale: Optional[float] = None - creativity: Optional[float] = None - structure: Optional[float] = None - tile_size: Optional[int] = None - tile_overlap: Optional[int] = None - clip_skip: Optional[int] = None - canvas_v2_metadata: Optional[CanvasV2Metadata] = None + app_version: str | None = Field(default="5.X.X") + model: Model | str | None = Field(default=None, alias="Model") + generation_mode: str | None = None + height: int | None = None + width: int | None = None + positive_prompt: str | None = Field(default=None, alias="Positive Prompt") + positive_style_prompt: str | None = None + negative_prompt: str | None = None + negative_style_prompt: str | None = None + scheduler: str | None = None + seed: int | None = None + steps: int | None = Field(default=None, alias="Steps") + guidance: int | float | None = None + ref_images: list[RefImage] | None = None + control_layers: ControlLayers | None = Field(default=None) + loras: list[Lora] | None = None + regions: list[RegionalGuidance] | None = None + t5_encoder: T5Encoder | None = None + qwen3_encoder: Model | None = None + qwen3_source: Model | None = None + vae: Model | None = None + clip_embed_model: ClipEmbedModel | None = None + dype_preset: str | None = None + rand_device: str | None = None + cfg_scale: float | None = None + cfg_rescale_multiplier: float | None = None + seamless_x: bool | None = None + seamless_y: bool | None = None + upscale_model: Model | None = None + upscale_initial_image: Image | None = None + upscale_scale: float | None = None + creativity: float | None = None + structure: float | None = None + tile_size: int | None = None + tile_overlap: int | None = None + clip_skip: int | None = None + canvas_v2_metadata: CanvasV2Metadata | None = None # These fields appear in some ZiT images - seed_variance_strength: Optional[float] = None - seed_variance_enabled: Optional[bool] = Field( + seed_variance_strength: float | None = None + seed_variance_enabled: bool | None = Field( default=None, alias="z_image_seed_variance_enabled" ) - seed_variance_randomize_percentage: Optional[int] = Field( + seed_variance_randomize_percentage: int | None = Field( default=None, alias="z_image_seed_variance_randomize_percentage" ) # These fields appear in some Flux.1 images - dype_scale: Optional[float] = None - dype_exponent: Optional[float] = None + dype_scale: float | None = None + dype_exponent: float | None = None # These fields appear in some sdxl images - strength: Optional[float] = None - init_image: Optional[str] = None - hrf_enabled: Optional[bool] = None - hrf_method: Optional[str] = None - hrf_strength: Optional[float] = None - refiner_cfg_scale: Optional[float] = None - refiner_steps: Optional[int] = None - refiner_scheduler: Optional[str] = None - refiner_positive_aesthetic_score: Optional[float] = None - refiner_negative_aesthetic_score: Optional[float] = None - refiner_start: Optional[float] = None + strength: float | None = None + init_image: str | None = None + hrf_enabled: bool | None = None + hrf_method: str | None = None + hrf_strength: float | None = None + refiner_cfg_scale: float | None = None + refiner_steps: int | None = None + refiner_scheduler: str | None = None + refiner_positive_aesthetic_score: float | None = None + refiner_negative_aesthetic_score: float | None = None + refiner_start: float | None = None + + @model_validator(mode="before") + @classmethod + def normalize_ref_images(cls, data): + """Flatten legacy ``ref_images`` structures before validation. + + Two historical quirks are handled here: + + * In some versions, ``ref_images`` was serialized as a list of lists. + Flatten the outer wrapper to a plain list. + * Earlier metadata nested the image under + ``config.image.original.image``; collapse that down to + ``config.image``. + """ + if not isinstance(data, dict): + return data + ref_images = data.get("ref_images") + if not isinstance(ref_images, list) or not ref_images: + return data + + # Flatten list-of-lists wrapper + if isinstance(ref_images[0], list): + ref_images = ref_images[0] + + # Unwrap nested ``config.image.original.image`` structures + for ref_image in ref_images: + if not isinstance(ref_image, dict): + continue + config = ref_image.get("config") + if not isinstance(config, dict): + continue + image_obj = config.get("image") + if not isinstance(image_obj, dict): + continue + original = image_obj.get("original") + if isinstance(original, dict) and "image" in original: + config["image"] = original["image"] + + data["ref_images"] = ref_images + return data @model_validator(mode="before") @classmethod @@ -172,7 +212,7 @@ def tag_reference_images(cls, data): return data @model_validator(mode="before") - def fixup_controlnets(cls, data: Dict[str, Any]) -> Dict[str, Any]: + def fixup_controlnets(cls, data: dict[str, Any]) -> dict[str, Any]: """ " Massage the legacy controlnet format into the new control_layers format """ diff --git a/photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py b/photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py deleted file mode 100644 index 92796e5d..00000000 --- a/photomap/backend/metadata_modules/invoke/invoke_metadata_abc.py +++ /dev/null @@ -1,164 +0,0 @@ -""" -Abstract base class for Invoke metadata modules. -This class is used to define the interface for formatting metadata from Invoke modules. -""" - -from abc import ABC, abstractmethod -from typing import Annotated, Any, List, Literal, Optional, Union - -from pydantic import BaseModel, Field - - -# some common types used across multiple modules -class Model(BaseModel): - name: str - base: Optional[str] = None - hash: Optional[str] = None - key: Optional[str] = None - type: Optional[str] = None - - -class Lora(BaseModel): - model: Model - weight: float - is_enabled: bool = True - - -class IPAdapter(BaseModel): - model_name: str - image_name: Optional[str] = None - image_data: Optional[str] = None - weight: Optional[float] = None - image_influence: Optional[str] = None - method: Optional[str] = None - begin_step_percent: Optional[float] = None - end_step_percent: Optional[float] = None - - -class ControlNet(BaseModel): - model_name: str - image_name: Optional[str] = None - image_data: Optional[str] = None - weight: Optional[float] = None - image_influence: Optional[str] = None - begin_step_percent: Optional[float] = None - end_step_percent: Optional[float] = None - control_mode: Optional[str] = None - - -# abstract base class properties -class InvokeMetadataModule(ABC): - @property - @abstractmethod - def metadata_version(self) -> int: - pass - - @property - @abstractmethod - def positive_prompt(self) -> str: - pass - - @property - @abstractmethod - def negative_prompt(self) -> Optional[str]: - pass - - @property - @abstractmethod - def model(self) -> Optional[Model]: - pass - - @property - @abstractmethod - def refiner_model(self) -> Optional[Model]: - pass - - @property - @abstractmethod - def vae_model(self) -> Optional[Model]: - pass - - @property - @abstractmethod - def scheduler(self) -> Optional[str]: - pass - - @property - @abstractmethod - def steps(self) -> Optional[int]: - pass - - @property - @abstractmethod - def refiner_steps(self) -> Optional[int]: - pass - - @property - @abstractmethod - def cfg_scale(self) -> Optional[float]: - pass - - @property - @abstractmethod - def cfg_rescale_multiplier(self) -> Optional[float]: - pass - - @property - @abstractmethod - def refiner_cfg_scale(self) -> Optional[float]: - pass - - @property - @abstractmethod - def guidance(self) -> Optional[int | float]: - pass - - @property - @abstractmethod - def width(self) -> Optional[int]: - pass - - @property - @abstractmethod - def height(self) -> Optional[int]: - pass - - @property - @abstractmethod - def seed(self) -> Optional[int]: - pass - - @property - @abstractmethod - def denoise_strength(self) -> Optional[float]: - pass - - @property - @abstractmethod - def refiner_denoise_start(self) -> Optional[float]: - pass - - @property - @abstractmethod - def clip_skip(self) -> Optional[int]: - pass - - @property - @abstractmethod - def seamless_x(self) -> Optional[bool]: - pass - - @property - @abstractmethod - def seamless_y(self) -> Optional[bool]: - pass - - @property - @abstractmethod - def refiner_positive_aesthetic_score(self) -> Optional[float]: - pass - - @property - @abstractmethod - def refiner_negative_aesthetic_score(self) -> Optional[float]: - pass diff --git a/photomap/backend/metadata_modules/invoke/invoke_metadata_view.py b/photomap/backend/metadata_modules/invoke/invoke_metadata_view.py new file mode 100644 index 00000000..8bb2d8d2 --- /dev/null +++ b/photomap/backend/metadata_modules/invoke/invoke_metadata_view.py @@ -0,0 +1,274 @@ +""" +Version-agnostic view over a parsed GenerationMetadata object. + +The new invoke metadata system uses a Pydantic discriminated union over +``GenerationMetadata2`` / ``GenerationMetadata3`` / ``GenerationMetadata5``. +Each version stores the same logical pieces of information (prompt, model, +seed, LoRAs, reference images, control layers, raster images) in structurally +different places. Rather than sprinkling ``isinstance`` checks throughout the +formatter, this module centralizes the version-specific extraction rules in a +single ``InvokeMetadataView`` class that exposes a flat, stable interface for +the formatter to consume. +""" + +from collections import namedtuple + +from .canvas2metadata import CanvasV2Metadata +from .common_metadata_elements import ( + ControlAdapter, + ImageFile, + IPAdapter, +) +from .common_metadata_elements import Image as CommonImage +from .invoke2metadata import GenerationMetadata2 +from .invoke2metadata import Prompt as V2Prompt +from .invoke3metadata import GenerationMetadata3 +from .invoke5metadata import ControlLayer as V5ControlLayer +from .invoke5metadata import GenerationMetadata5, RefImage + +LoraTuple = namedtuple("LoraTuple", ["model_name", "weight"]) +ReferenceImageTuple = namedtuple( + "ReferenceImageTuple", ["model_name", "image_name", "weight"] +) +ControlLayerTuple = namedtuple( + "ControlLayerTuple", ["model_name", "image_name", "weight"] +) + +GenerationMetadataT = GenerationMetadata2 | GenerationMetadata3 | GenerationMetadata5 + + +def _image_name(image: CommonImage | None) -> str: + """Return the stored image name for an Image, or '' if unavailable. + + The discriminated ``Image`` union can be either an ``ImageFile`` (which + carries a ``name``) or an ``ImageData`` (inline data URL, no name). + """ + if isinstance(image, ImageFile): + return image.name or "" + return "" + + +def _effective_weight( + weight: float | None, image_influence: str | None +) -> float | str | None: + """Return the value to display in the weight column of a tuple table. + + Most InvokeAI IP-adapter-style reference images carry a numeric + ``weight``. Flux Redux adapters, however, don't use a numeric weight — + they use a categorical ``image_influence`` field with values like + ``"Low"``, ``"Medium"``, ``"High"``. When ``weight`` is absent we fall + back to ``image_influence`` so the column remains meaningful. + """ + if weight is not None: + return weight + return image_influence + + +class InvokeMetadataView: + """Read-only facade over a parsed InvokeAI ``GenerationMetadata``. + + Attributes expose exactly the fields required by ``invoke_formatter`` so + that the formatter stays ignorant of the underlying version layout. + """ + + def __init__(self, metadata: GenerationMetadataT) -> None: + self.metadata = metadata + + # ---- prompts --------------------------------------------------------- + + @property + def positive_prompt(self) -> str: + m = self.metadata + if isinstance(m, GenerationMetadata2): + if m.image is None: + return "" + prompt = m.image.prompt + if isinstance(prompt, list): + if not prompt: + return "" + first = prompt[0] + if isinstance(first, V2Prompt): + return first.prompt or "" + return "" + return prompt or "" + return m.positive_prompt or "" + + @property + def negative_prompt(self) -> str: + m = self.metadata + if isinstance(m, GenerationMetadata2): + return "" + return m.negative_prompt or "" + + # ---- model / seed ---------------------------------------------------- + + @property + def model_name(self) -> str: + m = self.metadata + if isinstance(m, GenerationMetadata2): + return m.model_weights or m.model or "" + if m.model is None: + return "" + if isinstance(m.model, str): + return m.model + return m.model.name or "" + + @property + def seed(self) -> int | None: + m = self.metadata + if isinstance(m, GenerationMetadata2): + return m.image.seed if m.image is not None else None + return m.seed + + # ---- loras ----------------------------------------------------------- + + @property + def loras(self) -> list[LoraTuple]: + m = self.metadata + if isinstance(m, GenerationMetadata2): + return [] + if not m.loras: + return [] + return [ + LoraTuple(model_name=lora.model.name, weight=lora.weight) for lora in m.loras + ] + + # ---- reference images (IPAdapter) ------------------------------------ + + @property + def reference_images(self) -> list[ReferenceImageTuple]: + m = self.metadata + if isinstance(m, GenerationMetadata2): + return [] + if isinstance(m, GenerationMetadata3): + return [ + self._ipadapter_to_tuple(ipa) + for ipa in (m.ip_adapters or []) + ] + # v5 + if m.ref_images: + return [self._refimage_to_tuple(r) for r in m.ref_images if r.isEnabled] + if m.canvas_v2_metadata is not None: + return self._canvas_reference_images(m.canvas_v2_metadata) + return [] + + @staticmethod + def _ipadapter_to_tuple(ipa: IPAdapter) -> ReferenceImageTuple: + return ReferenceImageTuple( + model_name=ipa.model.name if ipa.model else "", + image_name=_image_name(ipa.image), + weight=_effective_weight(ipa.weight, ipa.image_influence), + ) + + @staticmethod + def _refimage_to_tuple(ref: RefImage) -> ReferenceImageTuple: + cfg = ref.config + model_name = cfg.model.name if cfg.model else "" + return ReferenceImageTuple( + model_name=model_name, + image_name=_image_name(cfg.image), + weight=_effective_weight(cfg.weight, cfg.image_influence), + ) + + @staticmethod + def _canvas_reference_images( + canvas: CanvasV2Metadata, + ) -> list[ReferenceImageTuple]: + result: list[ReferenceImageTuple] = [] + for ref in canvas.reference_images or []: + if ref.is_enabled is False: + continue + ipa = ref.ip_adapter + result.append( + ReferenceImageTuple( + model_name=ipa.model.name if ipa.model else "", + image_name=_image_name(ipa.image), + weight=_effective_weight(ipa.weight, ipa.image_influence), + ) + ) + return result + + # ---- control layers -------------------------------------------------- + + @property + def control_layers(self) -> list[ControlLayerTuple]: + m = self.metadata + if isinstance(m, GenerationMetadata2): + return [] + if isinstance(m, GenerationMetadata3): + return [ + self._control_adapter_to_tuple(ca) + for ca in (m.controlnets or []) + ] + # v5 — prefer top-level control_layers, fall back to canvas_v2_metadata + if m.control_layers is not None and m.control_layers.layers: + return [ + self._v5_control_layer_to_tuple(layer) + for layer in m.control_layers.layers + if layer.is_enabled and layer.control_adapter is not None + ] + if m.canvas_v2_metadata is not None: + return self._canvas_control_layers(m.canvas_v2_metadata) + return [] + + @staticmethod + def _control_adapter_to_tuple(ca: ControlAdapter) -> ControlLayerTuple: + return ControlLayerTuple( + model_name=ca.model.name if ca.model else "", + image_name=_image_name(ca.image), + weight=ca.weight, + ) + + @staticmethod + def _v5_control_layer_to_tuple(layer: V5ControlLayer) -> ControlLayerTuple: + ca = layer.control_adapter + assert ca is not None # caller filters this + return ControlLayerTuple( + model_name=ca.model.name if ca.model else "", + image_name=_image_name(ca.image), + weight=_effective_weight(ca.weight, ca.image_influence), + ) + + @staticmethod + def _canvas_control_layers( + canvas: CanvasV2Metadata, + ) -> list[ControlLayerTuple]: + result: list[ControlLayerTuple] = [] + for layer in canvas.control_layers or []: + if not layer.is_enabled: + continue + ca = layer.control_adapter + image_names = [ + _image_name(obj.image) + for obj in layer.objects + if obj.image is not None + ] + image_names = [n for n in image_names if n] + result.append( + ControlLayerTuple( + model_name=ca.model.name if ca.model else "", + image_name=", ".join(image_names), + weight=ca.weight, + ) + ) + return result + + # ---- raster images --------------------------------------------------- + + @property + def raster_images(self) -> list[str]: + m = self.metadata + if not isinstance(m, GenerationMetadata5): + return [] + canvas = m.canvas_v2_metadata + if canvas is None or not canvas.raster_layers: + return [] + result: list[str] = [] + for layer in canvas.raster_layers: + if not layer.is_enabled: + continue + for obj in layer.objects: + name = _image_name(obj.image) if obj.image is not None else "" + if name: + result.append(name) + return result diff --git a/photomap/backend/metadata_modules/invoke_formatter.py b/photomap/backend/metadata_modules/invoke_formatter.py index bdc54473..cc313d83 100644 --- a/photomap/backend/metadata_modules/invoke_formatter.py +++ b/photomap/backend/metadata_modules/invoke_formatter.py @@ -1,134 +1,179 @@ """ backend.metadata_modules.invoke_formatter -Format metadata from invoke module, including human-readable tags. -Returns an HTML representation of the metadata. +Format InvokeAI generation metadata as HTML for the metadata drawer. + +This module is a thin HTML renderer over :class:`InvokeMetadataView`, which +provides a version-agnostic view over the Pydantic discriminated union of +v2 / v3 / v5 InvokeAI metadata. The formatter itself does not know anything +about the underlying metadata version layout. """ import logging +from collections.abc import Iterable from datetime import datetime from pathlib import Path -from typing import Any -from .invoke import Invoke3Metadata, Invoke5Metadata, InvokeLegacyMetadata +from pydantic import ValidationError + +from .invoke.invoke_metadata_view import ( + ControlLayerTuple, + InvokeMetadataView, + LoraTuple, + ReferenceImageTuple, +) +from .invokemetadata import GenerationMetadataAdapter from .slide_summary import SlideSummary logger = logging.getLogger(__name__) -def format_invoke_metadata(slide_data: SlideSummary, metadata: dict) -> SlideSummary: - """ - Format invoke metadata dictionary into an HTML string. +_COPY_SVG = ( + '' + '' + '" + "" +) - Args: - slide_data: SlideSummary containing the file name and path. - metadata (dict): Metadata dictionary containing invoke attributes. - Returns: - SlideSummary: structured metadata appropriate for an image with invoke data. +def format_invoke_metadata(slide_data: SlideSummary, metadata: dict) -> SlideSummary: + """Render InvokeAI metadata into an HTML table on ``slide_data.description``. + + Also populates ``slide_data.reference_images`` with the image names of any + reference images, control layer images, and raster images referenced by + the metadata — these are used by the drawer to render thumbnails. """ if not metadata: slide_data.description = "No invoke metadata available." return slide_data - # If all the values are simple scalars, then we're just going to return a table of key value pairs - # This is the case when an image was generated by Invoke using a workflow that manually - # collects values. + # Images produced by custom Invoke workflows sometimes carry a flat dict + # of hand-picked scalars. Render those as a plain key/value table. if all( isinstance(value, str | int | float | bool | type(None)) for value in metadata.values() ): - html = "" - for key, value in metadata.items(): - html += f"" - html += "" - slide_data.description = html + slide_data.description = _scalar_table(metadata) return slide_data - # pick the appropriate metadata class based on tags in the raw data - extractor_class = ( - Invoke5Metadata - if "canvas_v2_metadata" in metadata or "ref_images" in metadata - else ( - Invoke3Metadata - if "generation_mode" in metadata - else InvokeLegacyMetadata if "app_version" in metadata else None - ) - ) - - # get modification time for the file - modification_time = None - if mtime := ( - Path(slide_data.filepath).stat().st_mtime if slide_data.filepath else None - ): - modification_time = datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") - - if not extractor_class: + try: + parsed = GenerationMetadataAdapter().parse(metadata) + except ValidationError as exc: + logger.warning("Failed to parse invoke metadata: %s", exc) slide_data.description = "Unknown invoke metadata format." return slide_data - extractor = extractor_class(raw_metadata=metadata) - positive_prompt = extractor.get_prompts().positive_prompt - negative_prompt = extractor.get_prompts().negative_prompt - model = extractor.get_model() - seed = extractor.get_seed() - loras = _format_list(extractor.get_loras()) - reference_images = extractor.get_reference_images() - reference_image_table = _format_list(reference_images) if reference_images else None - raster_images = extractor.get_raster_images() - control_layers = extractor.get_control_layers() - control_layer_table = _format_list(control_layers) if control_layers else None - - copy_svg = ( - '' - '' - '' - "" - ) + view = InvokeMetadataView(parsed) + + modification_time = _format_mtime(slide_data.filepath) + positive_prompt = view.positive_prompt + negative_prompt = view.negative_prompt + model = view.model_name + seed = view.seed + loras = view.loras + reference_images = view.reference_images + control_layers = view.control_layers + raster_images = view.raster_images - html = "" + rows: list[str] = [] if modification_time: - html += f"" - if positive_prompt: - html += f'' + rows.append(f"") + rows.append( + f'" + ) if negative_prompt: - html += f'' + rows.append( + f'" + ) if model: - html += f"" + rows.append(f"") if seed is not None: - html += f'' - if loras: - html += f"" + rows.append( + f'' + ) + if loras and (lora_html := _tuple_table(loras)): + rows.append(f"") if raster_images: - html += f"" - if reference_image_table: - html += f"" - if control_layer_table: - html += f"" - html += "" - - slide_data.description = html + rows.append( + f"Raster Images{', '.join(raster_images)}" + ) + if reference_images and (ref_html := _tuple_table(reference_images)): + rows.append(f"Reference Images{ref_html}") + if control_layers and (ctrl_html := _tuple_table(control_layers)): + rows.append(f"Control Layers{ctrl_html}") + + slide_data.description = "" + "".join(rows) + "" slide_data.reference_images = [ - x.image_name for x in reference_images + control_layers - ] - slide_data.reference_images.extend(raster_images) + ri.image_name for ri in reference_images if ri.image_name + ] + [ + cl.image_name for cl in control_layers if cl.image_name + ] + list(raster_images) return slide_data -def _format_list(tuples: list[Any]) -> str | None: - """ - Format a list of tuples into an HTML table. - Args: - tuples (list): List of tuples, such as the Lora tuple defined in invoke_metadata_abc. - Returns: - str: HTML representation of the loras. +def _scalar_table(metadata: dict) -> str: + rows = "".join( + f"{key}{value}" for key, value in metadata.items() + ) + return f"{rows}" + + +def _format_mtime(filepath: str | None) -> str | None: + if not filepath: + return None + try: + mtime = Path(filepath).stat().st_mtime + except (OSError, ValueError): + return None + return datetime.fromtimestamp(mtime).strftime("%Y-%m-%d %H:%M:%S") + + +def _tuple_table( + tuples: Iterable[LoraTuple | ReferenceImageTuple | ControlLayerTuple], +) -> str: + """Render a list of named tuples as a compact HTML table. + + Suppression rules: + + * **Row** — a row whose every field is empty (``None`` or ``""``) is dropped. + * **Column** — a column that is empty across *all* surviving rows is + dropped entirely, so a table of reference images with no IP adapter + model names, for example, renders without a model column at all. + * **Weight fallback** — when the ``weight`` column survives because at + least one row carries a weight, rows that are missing a weight are + rendered as ``1.0`` (the effective default InvokeAI uses when the + field is absent) rather than as a ragged empty cell. """ - if not tuples: - return - - html = "" - for tuple in tuples: - row = "".join([f"" for item in tuple]) - html += f"{row}" - html += "
{item}
" - return html + rows_data = [ + tup + for tup in tuples + if not all(v is None or v == "" for v in tup) + ] + if not rows_data: + return "" + + fields = rows_data[0]._fields + keep_column = [ + any(tup[idx] is not None and tup[idx] != "" for tup in rows_data) + for idx in range(len(fields)) + ] + + html_rows: list[str] = [] + for tup in rows_data: + cells: list[str] = [] + for idx, field_name in enumerate(fields): + if not keep_column[idx]: + continue + value = tup[idx] + if field_name == "weight" and (value is None or value == ""): + value = 1.0 + elif value is None: + value = "" + cells.append(f"{value}") + html_rows.append(f"{''.join(cells)}") + + return "" + "".join(html_rows) + "
" diff --git a/photomap/backend/metadata_modules/invokemetadata.py b/photomap/backend/metadata_modules/invokemetadata.py index 5d582661..4ed57dd0 100644 --- a/photomap/backend/metadata_modules/invokemetadata.py +++ b/photomap/backend/metadata_modules/invokemetadata.py @@ -2,26 +2,16 @@ Wrapper for GenerationMetadata """ -from typing import Annotated, Any, List, Optional, Union +from typing import Annotated, Any from pydantic import Field, TypeAdapter from .invoke.invoke2metadata import GenerationMetadata2 from .invoke.invoke3metadata import GenerationMetadata3 -from .invoke.invoke5metadata import ( - GenerationMetadata5, - Image, - Model, - RefImage, - RefImageConfig, -) +from .invoke.invoke5metadata import GenerationMetadata5 GenerationMetadata = Annotated[ - Union[ - GenerationMetadata2, - GenerationMetadata3, - GenerationMetadata5, - ], + GenerationMetadata2 | GenerationMetadata3 | GenerationMetadata5, Field(discriminator="metadata_version"), ] @@ -61,46 +51,5 @@ def parse(self, json_data: dict[str, Any]) -> GenerationMetadata: else: json_data = {"metadata_version": 3, **json_data} - # MOVE THIS CODE TO THE APPROPRIATE MODEL VALIDATOR - # Normalize ref_images - if "ref_images" in json_data and json_data["ref_images"]: - json_data["ref_images"] = self._normalize_ref_images( - json_data["ref_images"] - ) - self.metadata = self.adapter.validate_python(json_data) return self.metadata - - def _normalize_ref_images(self, ref_images: Any) -> list[dict[str, Any]]: - """ - Normalize ref_images structure. - - Handles both flat lists and nested lists (list of lists). - Flattens nested image structure from config.image.original.image to config.image. - - :param ref_images: Raw ref_images data (may be list or list of lists) - :type ref_images: Any - :return: Normalized flat list of reference images - :rtype: list[dict[str, Any]] - """ - if not isinstance(ref_images, list) or len(ref_images) == 0: - return ref_images - - # Flatten if it's a list of lists - if isinstance(ref_images[0], list): - ref_images = ref_images[0] - - # Normalize nested image structure in ref_images config - for ref_image in ref_images: - if ( - "config" in ref_image - and "image" in ref_image["config"] - and isinstance(ref_image["config"]["image"], dict) - ): - image_obj = ref_image["config"]["image"] - # If image has ["original"]["image"] nesting, flatten it - if "original" in image_obj and isinstance(image_obj["original"], dict): - if "image" in image_obj["original"]: - ref_image["config"]["image"] = image_obj["original"]["image"] - - return ref_images diff --git a/tests/backend/test_invoke_metadata.py b/tests/backend/test_invoke_metadata.py new file mode 100644 index 00000000..78aff1ba --- /dev/null +++ b/tests/backend/test_invoke_metadata.py @@ -0,0 +1,819 @@ +"""Regression tests for the refactored InvokeAI metadata pipeline. + +Two layers are covered: + +1. ``InvokeMetadataView`` — the version-agnostic wrapper that mediates + between the Pydantic discriminated union (v2 / v3 / v5) and the + ``invoke_formatter``. Each logical field (prompts, model, seed, loras, + reference images, control layers, raster images) is asserted against + every metadata version that meaningfully carries it. +2. ``format_invoke_metadata`` — the HTML renderer consumed by + ``metadata-drawer``. Tests assert the rendered HTML contains the + expected table rows and that ``slide_data.reference_images`` is + populated for downstream consumers. +""" + +from __future__ import annotations + +import pytest + +from photomap.backend.metadata_modules.invoke.invoke_metadata_view import ( + ControlLayerTuple, + InvokeMetadataView, + LoraTuple, + ReferenceImageTuple, +) +from photomap.backend.metadata_modules.invoke_formatter import format_invoke_metadata +from photomap.backend.metadata_modules.invokemetadata import GenerationMetadataAdapter +from photomap.backend.metadata_modules.slide_summary import SlideSummary + +# --------------------------------------------------------------------------- +# Fixtures +# --------------------------------------------------------------------------- + + +def _slide() -> SlideSummary: + return SlideSummary(filename="sample.png", filepath="") + + +def _view(data: dict) -> InvokeMetadataView: + return InvokeMetadataView(GenerationMetadataAdapter().parse(data)) + + +@pytest.fixture +def v2_scalar_prompt_metadata() -> dict: + """Legacy InvokeAI v2 with a scalar prompt and model_weights.""" + return { + "app_id": "invoke-ai/InvokeAI-Stable-Diffusion", + "app_version": "2.3.5", + "model": "stable_diffusion_v1_5", + "model_weights": "stable-diffusion-1.5", + "model_hash": "abc123", + "image": { + "prompt": "a mountain landscape", + "seed": 1234, + "cfg_scale": 7.5, + "height": 512, + "width": 768, + "sampler": "k_euler_a", + "steps": 20, + "type": "txt2img", + "postprocessing": None, + }, + } + + +@pytest.fixture +def v2_list_prompt_metadata() -> dict: + """Very old InvokeAI v2 with the list-of-weighted-prompts variant.""" + return { + "app_id": "invoke-ai/InvokeAI-Stable-Diffusion", + "app_version": "2.2.0", + "model": "legacy_model", + "model_hash": "xyz", + "image": { + "prompt": [{"prompt": "old style prompt", "weight": 1.0}], + "seed": 999, + "cfg_scale": 7.0, + "height": 512, + "width": 512, + "sampler": "k_lms", + "steps": 10, + "type": "txt2img", + "postprocessing": None, + }, + } + + +@pytest.fixture +def v3_metadata() -> dict: + """InvokeAI v3 with LoRAs, IP adapters, and controlnets.""" + return { + "metadata_version": 3, + "app_version": "3.5.0", + "generation_mode": "txt2img", + "positive_prompt": "a cat riding a skateboard", + "negative_prompt": "blurry, low quality", + "seed": 42, + "model": {"model_name": "dreamshaper", "base_model": "sd-1"}, + "loras": [ + {"lora": {"model_name": "detail_lora"}, "weight": 0.8}, + {"lora": {"model_name": "style_lora"}, "weight": 0.5}, + ], + "ipAdapters": [ + { + "ip_adapter_model": {"model_name": "ip_adapter_sd15"}, + "image": {"image_name": "ref.png"}, + "weight": 0.5, + } + ], + "controlnets": [ + { + "control_model": {"model_name": "canny_sd15"}, + "image": {"image_name": "control.png"}, + "control_weight": 0.7, + "beginEndStepPct": [0.0, 1.0], + "control_mode": "balanced", + } + ], + } + + +@pytest.fixture +def v5_ref_images_metadata() -> dict: + """Modern InvokeAI v5 using the ``ref_images`` field.""" + return { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "a dog in a hat", + "negative_prompt": "ugly, deformed", + "seed": 100, + "model": {"name": "flux-schnell", "base": "flux"}, + "loras": [{"lora": {"name": "dog_lora"}, "weight": 0.6}], + "ref_images": [ + { + "id": "r1", + "isEnabled": True, + "config": { + "type": "ipAdapter", + "model": {"name": "ipa-flux"}, + "image": {"image_name": "ref1.png"}, + "weight": 0.7, + }, + }, + { + "id": "r2", + "isEnabled": False, # should be filtered out + "config": { + "type": "ipAdapter", + "model": {"name": "ipa-flux"}, + "image": {"image_name": "ref2.png"}, + "weight": 0.3, + }, + }, + ], + "control_layers": { + "version": 1, + "layers": [ + { + "id": "cl1", + "type": "control_layer", + "isEnabled": True, + "isSelected": False, + "ipAdapter": { + "model": {"name": "canny_flux"}, + "image": {"image_name": "edges.png"}, + "weight": 0.9, + }, + }, + { + "id": "cl2", + "type": "control_layer", + "isEnabled": False, # should be filtered out + "isSelected": False, + "ipAdapter": { + "model": {"name": "depth_flux"}, + "image": {"image_name": "depth.png"}, + "weight": 0.5, + }, + }, + ], + }, + } + + +@pytest.fixture +def v5_canvas_metadata() -> dict: + """InvokeAI v5 using the older ``canvas_v2_metadata`` layout.""" + return { + "app_version": "5.0.0", + "positive_prompt": "a bird over the ocean", + "negative_prompt": "blurry", + "seed": 7, + "model": {"name": "sdxl", "base": "sdxl"}, + "canvas_v2_metadata": { + "rasterLayers": [ + { + "id": "rl1", + "isEnabled": True, + "isLocked": False, + "name": None, + "objects": [ + {"id": "o1", "type": "image", + "image": {"image_name": "raster1.png"}}, + {"id": "o2", "type": "image", + "image": {"image_name": "raster2.png"}}, + ], + "opacity": 1.0, + "position": {"x": 0, "y": 0}, + "type": "raster_layer", + }, + { + "id": "rl2", + "isEnabled": False, # should be filtered out + "isLocked": False, + "name": None, + "objects": [ + {"id": "o3", "type": "image", + "image": {"image_name": "raster_disabled.png"}}, + ], + "opacity": 1.0, + "position": {"x": 0, "y": 0}, + "type": "raster_layer", + }, + ], + "referenceImages": [ + { + "id": "ri1", + "isEnabled": True, + "isLocked": False, + "name": None, + "ipAdapter": { + "model": {"name": "ipa-sdxl"}, + "image": {"image_name": "ip_ref.png"}, + "weight": 0.5, + }, + }, + ], + "controlLayers": [ + { + "id": "cl1", + "isEnabled": True, + "isLocked": False, + "name": None, + "objects": [ + {"id": "co1", "type": "image", + "image": {"image_name": "cl_img.png"}}, + ], + "opacity": 1.0, + "position": {"x": 0, "y": 0}, + "type": "control_layer", + "withTransparencyEffect": False, + "controlAdapter": { + "control_model": {"name": "canny_sdxl"}, + "image": {"image_name": "cl_img.png"}, + "control_weight": 0.6, + "beginEndStepPct": [0.0, 1.0], + "controlMode": "balanced", + }, + }, + ], + }, + } + + +# --------------------------------------------------------------------------- +# InvokeMetadataView — v2 +# --------------------------------------------------------------------------- + + +class TestInvokeMetadataViewV2: + def test_scalar_prompt(self, v2_scalar_prompt_metadata): + view = _view(v2_scalar_prompt_metadata) + assert view.positive_prompt == "a mountain landscape" + assert view.negative_prompt == "" + # model_weights takes precedence over plain model + assert view.model_name == "stable-diffusion-1.5" + assert view.seed == 1234 + + def test_list_prompt_takes_first_entry(self, v2_list_prompt_metadata): + view = _view(v2_list_prompt_metadata) + assert view.positive_prompt == "old style prompt" + assert view.negative_prompt == "" + # no model_weights — falls back to model + assert view.model_name == "legacy_model" + assert view.seed == 999 + + def test_empty_collections(self, v2_scalar_prompt_metadata): + view = _view(v2_scalar_prompt_metadata) + assert view.loras == [] + assert view.reference_images == [] + assert view.control_layers == [] + assert view.raster_images == [] + + +# --------------------------------------------------------------------------- +# InvokeMetadataView — v3 +# --------------------------------------------------------------------------- + + +class TestInvokeMetadataViewV3: + def test_prompts_model_seed(self, v3_metadata): + view = _view(v3_metadata) + assert view.positive_prompt == "a cat riding a skateboard" + assert view.negative_prompt == "blurry, low quality" + assert view.model_name == "dreamshaper" + assert view.seed == 42 + + def test_loras(self, v3_metadata): + view = _view(v3_metadata) + assert view.loras == [ + LoraTuple(model_name="detail_lora", weight=0.8), + LoraTuple(model_name="style_lora", weight=0.5), + ] + + def test_reference_images_from_ip_adapters(self, v3_metadata): + view = _view(v3_metadata) + assert view.reference_images == [ + ReferenceImageTuple( + model_name="ip_adapter_sd15", + image_name="ref.png", + weight=0.5, + ) + ] + + def test_control_layers_from_controlnets(self, v3_metadata): + view = _view(v3_metadata) + assert view.control_layers == [ + ControlLayerTuple( + model_name="canny_sd15", + image_name="control.png", + weight=0.7, + ) + ] + + def test_no_raster_images(self, v3_metadata): + view = _view(v3_metadata) + assert view.raster_images == [] + + +# --------------------------------------------------------------------------- +# InvokeMetadataView — v5 (ref_images path) +# --------------------------------------------------------------------------- + + +class TestInvokeMetadataViewV5RefImages: + def test_prompts_model_seed(self, v5_ref_images_metadata): + view = _view(v5_ref_images_metadata) + assert view.positive_prompt == "a dog in a hat" + assert view.negative_prompt == "ugly, deformed" + assert view.model_name == "flux-schnell" + assert view.seed == 100 + + def test_loras(self, v5_ref_images_metadata): + view = _view(v5_ref_images_metadata) + assert view.loras == [LoraTuple(model_name="dog_lora", weight=0.6)] + + def test_ref_images_skips_disabled(self, v5_ref_images_metadata): + view = _view(v5_ref_images_metadata) + assert view.reference_images == [ + ReferenceImageTuple( + model_name="ipa-flux", image_name="ref1.png", weight=0.7 + ) + ] + + def test_control_layers_skips_disabled(self, v5_ref_images_metadata): + view = _view(v5_ref_images_metadata) + assert view.control_layers == [ + ControlLayerTuple( + model_name="canny_flux", image_name="edges.png", weight=0.9 + ) + ] + + def test_no_raster_images_without_canvas(self, v5_ref_images_metadata): + view = _view(v5_ref_images_metadata) + assert view.raster_images == [] + + +# --------------------------------------------------------------------------- +# InvokeMetadataView — v5 (canvas_v2_metadata path) +# --------------------------------------------------------------------------- + + +class TestInvokeMetadataViewV5Canvas: + def test_prompts_model_seed(self, v5_canvas_metadata): + view = _view(v5_canvas_metadata) + assert view.positive_prompt == "a bird over the ocean" + assert view.negative_prompt == "blurry" + assert view.model_name == "sdxl" + assert view.seed == 7 + + def test_reference_images_from_canvas(self, v5_canvas_metadata): + view = _view(v5_canvas_metadata) + assert view.reference_images == [ + ReferenceImageTuple( + model_name="ipa-sdxl", image_name="ip_ref.png", weight=0.5 + ) + ] + + def test_control_layers_join_object_images(self, v5_canvas_metadata): + view = _view(v5_canvas_metadata) + assert view.control_layers == [ + ControlLayerTuple( + model_name="canny_sdxl", image_name="cl_img.png", weight=0.6 + ) + ] + + def test_raster_images_skip_disabled_layers(self, v5_canvas_metadata): + view = _view(v5_canvas_metadata) + assert view.raster_images == ["raster1.png", "raster2.png"] + + +# --------------------------------------------------------------------------- +# format_invoke_metadata — end-to-end HTML rendering +# --------------------------------------------------------------------------- + + +class TestFormatInvokeMetadata: + def test_empty_metadata_returns_placeholder(self): + slide = _slide() + result = format_invoke_metadata(slide, {}) + assert "No invoke metadata available" in result.description + + def test_scalar_only_metadata_uses_key_value_table(self): + slide = _slide() + metadata = { + "Custom Field": "value", + "Seed": 1234, + "Steps": 20, + } + result = format_invoke_metadata(slide, metadata) + assert "" in result.description + assert "" in result.description + assert "" in result.description + + def test_v2_renders_prompt_model_seed(self, v2_scalar_prompt_metadata): + slide = _slide() + result = format_invoke_metadata(slide, v2_scalar_prompt_metadata) + html = result.description + assert "" in html + assert "Positive Prompt" in html + assert "a mountain landscape" in html + assert "stable-diffusion-1.5" in html + assert "1234" in html + # No loras/controls/references in v2 + assert "Loras" not in html + assert "Reference Images" not in html + assert "Control Layers" not in html + assert result.reference_images == [] + + def test_v3_renders_all_sections(self, v3_metadata): + slide = _slide() + result = format_invoke_metadata(slide, v3_metadata) + html = result.description + for snippet in [ + "Positive Prompt", + "a cat riding a skateboard", + "Negative Prompt", + "blurry, low quality", + "", + "", + "42", + "Loras", + "detail_lora", + "style_lora", + "Reference Images", + "ip_adapter_sd15", + "ref.png", + "Control Layers", + "canny_sd15", + "control.png", + ]: + assert snippet in html, f"missing {snippet!r} in rendered HTML" + # Reference images collected for downstream thumbnail rendering + assert "ref.png" in result.reference_images + assert "control.png" in result.reference_images + + def test_v5_ref_images_path(self, v5_ref_images_metadata): + slide = _slide() + result = format_invoke_metadata(slide, v5_ref_images_metadata) + html = result.description + assert "a dog in a hat" in html + assert "flux-schnell" in html + assert "ipa-flux" in html + assert "ref1.png" in html + # disabled ref image must not appear + assert "ref2.png" not in html + # control layer (enabled) rendered; disabled one skipped + assert "canny_flux" in html + assert "depth_flux" not in html + assert "ref1.png" in result.reference_images + assert "edges.png" in result.reference_images + + def test_v5_canvas_path_raster_images(self, v5_canvas_metadata): + slide = _slide() + result = format_invoke_metadata(slide, v5_canvas_metadata) + html = result.description + assert "Raster Images" in html + assert "raster1.png" in html + assert "raster2.png" in html + # Disabled raster layer filtered out + assert "raster_disabled.png" not in html + # Canvas reference image + control layer rows present + assert "ipa-sdxl" in html + assert "ip_ref.png" in html + assert "canny_sdxl" in html + # reference_images is ref + control layer image names, plus raster + assert "ip_ref.png" in result.reference_images + assert "cl_img.png" in result.reference_images + assert "raster1.png" in result.reference_images + assert "raster2.png" in result.reference_images + + +# --------------------------------------------------------------------------- +# Empty-field handling in the rendered HTML +# --------------------------------------------------------------------------- + + +class TestFormatInvokeMetadataEmptyFields: + """Tweaks to how the formatter handles empty / missing field values.""" + + def test_empty_negative_prompt_suppresses_row(self): + """A blank negative_prompt should not produce a Negative Prompt row.""" + metadata = { + "metadata_version": 3, + "app_version": "3.5.0", + "positive_prompt": "anything", + "negative_prompt": "", + "seed": 1, + "model": {"model_name": "m"}, + } + html = format_invoke_metadata(_slide(), metadata).description + assert "Negative Prompt" not in html + + def test_empty_positive_prompt_suppresses_copy_icon_only(self): + """Blank positive_prompt keeps the row but drops the copy icon.""" + metadata = { + "metadata_version": 3, + "app_version": "3.5.0", + "positive_prompt": "", + "negative_prompt": "dont want this", + "seed": 1, + "model": {"model_name": "m"}, + } + html = format_invoke_metadata(_slide(), metadata).description + # Row is still present so the drawer always shows a positive prompt slot + assert "" in html + # But the copy icon (identifiable by its class) must NOT be on that row + # Extract just the Positive Prompt row to check + start = html.index("") + end = html.index("", start) + positive_row = html[start:end] + assert "copy-icon" not in positive_row + # The Negative Prompt row does still carry a copy icon (sanity check + # that we haven't accidentally dropped icons everywhere) + assert "Negative Prompt" in html + neg_start = html.index("") + neg_end = html.index("", neg_start) + assert "copy-icon" in html[neg_start:neg_end] + + def test_reference_image_row_omits_empty_model_and_weight(self): + """Tuple cells for missing model / weight should be dropped.""" + metadata = { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "x", + "seed": 1, + "model": {"name": "m"}, + "ref_images": [ + { + "id": "r1", + "isEnabled": True, + "config": { + "type": "ipAdapter", + # no model, no weight + "image": {"image_name": "only_image.png"}, + }, + } + ], + } + html = format_invoke_metadata(_slide(), metadata).description + assert "Reference Images" in html + assert "only_image.png" in html + # Extract the inner tuple-table row and assert it has exactly one " in tuple_table + + def test_flux_redux_image_influence_shown_in_weight_column(self): + """Flux Redux reference images don't carry a numeric weight — they + use an ``imageInfluence`` categorical like "Medium". That value + should surface in the weight column when no numeric weight exists. + """ + metadata = { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "x", + "seed": 1, + "model": {"name": "m"}, + "ref_images": [ + { + "id": "r1", + "isEnabled": True, + "config": { + "type": "ipAdapter", + "model": {"name": "flux_redux"}, + "image": {"image_name": "redux.png"}, + "imageInfluence": "Medium", + # no numeric weight + }, + } + ], + } + html = format_invoke_metadata(_slide(), metadata).description + start = html.index("") + end = html.index("
", start) + tuple_table = html[start:end] + assert "Medium" in tuple_table + # And the formatter must NOT have replaced "Medium" with 1.0 + assert "1.0" not in tuple_table + + def test_flux_redux_image_influence_v3_ip_adapters(self): + """Same fallback applies to v3-style ipAdapters entries.""" + metadata = { + "metadata_version": 3, + "app_version": "3.5.0", + "positive_prompt": "x", + "seed": 1, + "model": {"model_name": "m"}, + "ipAdapters": [ + { + "ip_adapter_model": {"model_name": "flux_redux"}, + "image": {"image_name": "redux.png"}, + "imageInfluence": "High", + # no numeric weight + } + ], + } + html = format_invoke_metadata(_slide(), metadata).description + assert "High" in html + assert "flux_redux" in html + + def test_numeric_weight_wins_over_image_influence(self): + """When both weight and imageInfluence are present, the numeric + weight takes precedence — it's the more specific signal. + """ + metadata = { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "x", + "seed": 1, + "model": {"name": "m"}, + "ref_images": [ + { + "id": "r1", + "isEnabled": True, + "config": { + "type": "ipAdapter", + "model": {"name": "ipa"}, + "image": {"image_name": "ref.png"}, + "weight": 0.6, + "imageInfluence": "Medium", + }, + } + ], + } + html = format_invoke_metadata(_slide(), metadata).description + assert "0.6" in html + assert "Medium" not in html + + def test_mixed_weight_column_defaults_missing_weights_to_1_0(self): + """Regression for the ragged-row bug: when some ref_images have an + explicit weight and others don't, every row renders with a weight + cell and missing weights default to 1.0 instead of leaving a blank + column on the first row. + """ + metadata = { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "x", + "seed": 1, + "model": {"name": "m"}, + "ref_images": [ + { + "id": "r1", + "isEnabled": True, + "config": { + "type": "ipAdapter", + "image": {"image_name": "first.png"}, + # no weight — should render as 1.0 in a surviving column + }, + }, + { + "id": "r2", + "isEnabled": True, + "config": { + "type": "ipAdapter", + "image": {"image_name": "second.png"}, + "weight": 0.7, + }, + }, + ], + } + html = format_invoke_metadata(_slide(), metadata).description + start = html.index("") + end = html.index("
", start) + tuple_table = html[start:end] + # model_name column is empty on every row → dropped. + # Two surviving columns (image, weight) × two rows → 4 s total. + assert tuple_table.count("") == 2 + assert tuple_table.count("") == 4 + assert "first.png" in tuple_table + assert "second.png" in tuple_table + assert "1.0" in tuple_table + assert "0.7" in tuple_table + + def test_ref_images_list_of_lists_is_flattened(self): + """Some legacy metadata wraps ref_images in an outer list. The v5 + model validator should flatten it before parsing. + """ + metadata = { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "x", + "seed": 1, + "model": {"name": "m"}, + "ref_images": [ + [ + { + "id": "r1", + "isEnabled": True, + "config": { + "type": "ipAdapter", + "model": {"name": "ipa"}, + "image": {"image_name": "nested.png"}, + "weight": 0.5, + }, + }, + ] + ], + } + html = format_invoke_metadata(_slide(), metadata).description + assert "nested.png" in html + assert "ipa" in html + + def test_ref_images_unwrap_nested_original_image(self): + """Old metadata may nest the image under ``config.image.original.image``. + The v5 validator should collapse that to ``config.image`` before parsing. + """ + metadata = { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "x", + "seed": 1, + "model": {"name": "m"}, + "ref_images": [ + { + "id": "r1", + "isEnabled": True, + "config": { + "type": "ipAdapter", + "model": {"name": "ipa"}, + "image": { + "original": {"image": {"image_name": "deep.png"}} + }, + "weight": 0.5, + }, + } + ], + } + html = format_invoke_metadata(_slide(), metadata).description + assert "deep.png" in html + + def test_control_layer_row_skipped_when_fully_empty(self): + """A row with every cell empty should be dropped from the tuple table.""" + metadata = { + "metadata_version": 5, + "app_version": "5.6.0", + "positive_prompt": "x", + "seed": 1, + "model": {"name": "m"}, + "control_layers": { + "version": 1, + "layers": [ + { + "id": "cl1", + "type": "control_layer", + "isEnabled": True, + "isSelected": False, + "ipAdapter": { + # model name empty, image name empty, no weight + "model": {"name": ""}, + "image": {"image_name": ""}, + }, + }, + { + "id": "cl2", + "type": "control_layer", + "isEnabled": True, + "isSelected": False, + "ipAdapter": { + "model": {"name": "canny"}, + "image": {"image_name": "e.png"}, + "weight": 0.5, + }, + }, + ], + }, + } + html = format_invoke_metadata(_slide(), metadata).description + # The inner tuple table should contain exactly one — the + # "all empty" layer is dropped and only the "canny" layer survives. + start = html.index("") + end = html.index("
", start) + tuple_table = html[start:end] + assert tuple_table.count("") == 1 + assert "canny" in tuple_table + assert "e.png" in tuple_table