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

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
52 changes: 52 additions & 0 deletions photomap/backend/config.py
Original file line number Diff line number Diff line change
Expand Up @@ -81,6 +81,18 @@ class Config(BaseModel):
locationiq_api_key: str | None = Field(
default=None, description="LocationIQ API key for map services"
)
invokeai_url: str | None = Field(
default=None,
description="Base URL of a running InvokeAI backend, e.g. http://localhost:9090",
)
invokeai_username: str | None = Field(
default=None,
description="Username for authenticating against InvokeAI (future use)",
)
invokeai_password: str | None = Field(
default=None,
description="Password for authenticating against InvokeAI (future use)",
)

@field_validator("config_version")
@classmethod
Expand Down Expand Up @@ -108,6 +120,9 @@ def to_dict(self) -> dict[str, Any]:
"config_version": self.config_version,
"albums": {key: album.to_dict() for key, album in self.albums.items()},
"locationiq_api_key": self.locationiq_api_key,
"invokeai_url": self.invokeai_url,
"invokeai_username": self.invokeai_username,
"invokeai_password": self.invokeai_password,
}


Expand Down Expand Up @@ -163,6 +178,40 @@ def set_locationiq_api_key(self, api_key: str | None) -> None:
# Clear cache after saving to ensure fresh reads
self._config = None

def get_invokeai_settings(self) -> dict[str, str | None]:
"""Return the configured InvokeAI connection settings."""
config = self.load_config()
return {
"url": config.invokeai_url,
"username": config.invokeai_username,
"password": config.invokeai_password,
}

def set_invokeai_settings(
self,
url: str | None = None,
username: str | None = None,
password: str | None = None,
) -> None:
"""Update the InvokeAI connection settings.

Empty strings are normalized to None so that the fields can be cleared.
"""
config = self.load_config()

def _clean(value: str | None) -> str | None:
if value is None:
return None
value = value.strip()
return value or None

config.invokeai_url = _clean(url)
config.invokeai_username = _clean(username)
config.invokeai_password = _clean(password)
self._config = config
self.save_config()
self._config = None

def load_config(self) -> Config:
"""Load configuration from YAML file."""
if self._config is None:
Expand All @@ -186,6 +235,9 @@ def load_config(self) -> Config:
config_version=config_data.get("config_version", "1.0.0"),
albums=albums,
locationiq_api_key=config_data.get("locationiq_api_key"),
invokeai_url=config_data.get("invokeai_url"),
invokeai_username=config_data.get("invokeai_username"),
invokeai_password=config_data.get("invokeai_password"),
)

except Exception as e:
Expand Down
6 changes: 5 additions & 1 deletion photomap/backend/metadata_formatting.py
Original file line number Diff line number Diff line change
Expand Up @@ -44,7 +44,11 @@ def format_metadata(
or "generation_mode" in metadata
or "canvas_v2_metadata" in metadata
):
return format_invoke_metadata(result, metadata)
config_manager = get_config_manager()
invokeai_url = config_manager.get_invokeai_settings().get("url")
return format_invoke_metadata(
result, metadata, show_recall_buttons=bool(invokeai_url)
)
else:
config_manager = get_config_manager()
api_key = config_manager.get_locationiq_api_key()
Expand Down
103 changes: 103 additions & 0 deletions photomap/backend/metadata_modules/invoke/invoke_metadata_view.py
Original file line number Diff line number Diff line change
Expand Up @@ -253,6 +253,109 @@ def _canvas_control_layers(
)
return result

# ---- recall payload --------------------------------------------------

def to_recall_payload(self, include_seed: bool = True) -> dict:
"""Build a payload suitable for InvokeAI's ``/api/v1/recall/{queue_id}``.

The returned dict follows the schema documented in the InvokeAI
``RECALL_PARAMETERS`` docs. Fields that the current metadata does not
carry are simply omitted, so that the receiving InvokeAI backend only
overwrites values it has been given.

``include_seed`` toggles whether the random seed is included. The UI
uses ``False`` for the "remix" action so that the receiving InvokeAI
backend re-randomizes the generation.
"""
m = self.metadata
payload: dict = {}

positive = self.positive_prompt
if positive:
payload["positive_prompt"] = positive
negative = self.negative_prompt
if negative:
payload["negative_prompt"] = negative

model_name = self.model_name
if model_name:
payload["model"] = model_name

if include_seed and self.seed is not None:
payload["seed"] = int(self.seed)

# Numeric / scalar fields — only v3 and v5 carry these.
if not isinstance(m, GenerationMetadata2):
for attr, key in (
("steps", "steps"),
("cfg_scale", "cfg_scale"),
("cfg_rescale_multiplier", "cfg_rescale_multiplier"),
("guidance", "guidance"),
("width", "width"),
("height", "height"),
("scheduler", "scheduler"),
("clip_skip", "clip_skip"),
("seamless_x", "seamless_x"),
("seamless_y", "seamless_y"),
):
value = getattr(m, attr, None)
if value is not None:
payload[key] = value

# v5 carries refiner fields too.
if isinstance(m, GenerationMetadata5):
for attr, key in (
("refiner_cfg_scale", "refiner_cfg_scale"),
("refiner_steps", "refiner_steps"),
("refiner_start", "refiner_denoise_start"),
("refiner_positive_aesthetic_score", "refiner_positive_aesthetic_score"),
("refiner_negative_aesthetic_score", "refiner_negative_aesthetic_score"),
("strength", "denoise_strength"),
):
value = getattr(m, attr, None)
if value is not None:
payload[key] = value

loras = [
{"model_name": lora.model_name, "weight": float(lora.weight)}
for lora in self.loras
if lora.model_name
]
if loras:
payload["loras"] = loras

control_layers: list[dict] = []
for layer in self.control_layers:
if not layer.model_name:
continue
entry: dict = {"model_name": layer.model_name}
if layer.image_name:
entry["image_name"] = layer.image_name
if isinstance(layer.weight, int | float):
entry["weight"] = float(layer.weight)
control_layers.append(entry)
if control_layers:
payload["control_layers"] = control_layers

ip_adapters: list[dict] = []
reference_images: list[dict] = []
for ref in self.reference_images:
if ref.model_name:
entry: dict = {"model_name": ref.model_name}
if ref.image_name:
entry["image_name"] = ref.image_name
if isinstance(ref.weight, int | float):
entry["weight"] = float(ref.weight)
ip_adapters.append(entry)
elif ref.image_name:
reference_images.append({"image_name": ref.image_name})
if ip_adapters:
payload["ip_adapters"] = ip_adapters
if reference_images:
payload["reference_images"] = reference_images

return payload

# ---- raster images ---------------------------------------------------

@property
Expand Down
55 changes: 53 additions & 2 deletions photomap/backend/metadata_modules/invoke_formatter.py
Original file line number Diff line number Diff line change
Expand Up @@ -37,8 +37,54 @@
"</svg></span>"
)

# Asterisk icon for the recall button — matches InvokeAI's own recall iconography.
_RECALL_SVG = (
'<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" '
'aria-hidden="true">'
'<path d="M12 2a1 1 0 0 1 1 1v6.382l5.536-2.77a1 1 0 0 1 .894 1.789L13.894 '
'11.17l5.536 2.77a1 1 0 1 1-.894 1.789L13 12.96v6.042a1 1 0 1 1-2 0v-6.042'
'l-5.536 2.77a1 1 0 0 1-.894-1.789l5.536-2.77-5.536-2.768a1 1 0 0 1 .894'
'-1.789L11 9.382V3a1 1 0 0 1 1-1z"/>'
"</svg>"
)

# Two circling arrows — a "refresh / remix" icon matching the reference
# screenshot.
_REMIX_SVG = (
'<svg width="18" height="18" viewBox="0 0 24 24" fill="none" '
'stroke="currentColor" stroke-width="2.2" stroke-linecap="round" '
'stroke-linejoin="round" aria-hidden="true">'
'<polyline points="20 4 20 9 15 9"/>'
'<path d="M20 9A8 8 0 0 0 5.6 6.6"/>'
'<polyline points="4 20 4 15 9 15"/>'
'<path d="M4 15a8 8 0 0 0 14.4 2.4"/>'
"</svg>"
)


def format_invoke_metadata(slide_data: SlideSummary, metadata: dict) -> SlideSummary:
def _recall_buttons_html() -> str:
"""Render the recall / remix button group shown at the bottom of the drawer."""
return (
'<div class="invoke-recall-controls" data-invoke-recall="1">'
'<button type="button" class="invoke-recall-btn" data-recall-mode="recall" '
'title="Recall parameters (including seed) to InvokeAI">'
f'{_RECALL_SVG}<span class="invoke-recall-label">Recall</span>'
'<span class="invoke-recall-status" aria-live="polite"></span>'
"</button>"
'<button type="button" class="invoke-recall-btn" data-recall-mode="remix" '
'title="Remix (recall parameters without the seed) to InvokeAI">'
f'{_REMIX_SVG}<span class="invoke-recall-label">Remix</span>'
'<span class="invoke-recall-status" aria-live="polite"></span>'
"</button>"
"</div>"
)


def format_invoke_metadata(
slide_data: SlideSummary,
metadata: dict,
show_recall_buttons: bool = False,
) -> SlideSummary:
"""Render InvokeAI metadata into an HTML table on ``slide_data.description``.

Also populates ``slide_data.reference_images`` with the image names of any
Expand Down Expand Up @@ -106,7 +152,12 @@ def format_invoke_metadata(slide_data: SlideSummary, metadata: dict) -> SlideSum
if control_layers and (ctrl_html := _tuple_table(control_layers)):
rows.append(f"<tr><th>Control Layers</th><td>{ctrl_html}</td></tr>")

slide_data.description = "<table class='invoke-metadata'>" + "".join(rows) + "</table>"
slide_data.description = (
"<table class='invoke-metadata'>"
+ "".join(rows)
+ "</table>"
+ (_recall_buttons_html() if show_recall_buttons else "")
)
slide_data.reference_images = [
ri.image_name for ri in reference_images if ri.image_name
] + [
Expand Down
2 changes: 2 additions & 0 deletions photomap/backend/photomap_server.py
Original file line number Diff line number Diff line change
Expand Up @@ -23,6 +23,7 @@
from photomap.backend.routers.curation import router as curation_router
from photomap.backend.routers.filetree import filetree_router
from photomap.backend.routers.index import index_router
from photomap.backend.routers.invoke import invoke_router
from photomap.backend.routers.search import search_router
from photomap.backend.routers.umap import umap_router
from photomap.backend.routers.upgrade import upgrade_router
Expand All @@ -42,6 +43,7 @@
album_router,
filetree_router,
upgrade_router,
invoke_router,
]:
app.include_router(router)

Expand Down
Loading
Loading