Skip to content

Commit 8a6493c

Browse files
authored
Merge branch 'master' into lstein/feature/invoke-board-selector
2 parents 61e7811 + ff216bb commit 8a6493c

5 files changed

Lines changed: 201 additions & 22 deletions

File tree

photomap/backend/metadata_formatting.py

Lines changed: 25 additions & 13 deletions
Original file line numberDiff line numberDiff line change
@@ -9,7 +9,12 @@
99
from pathlib import Path
1010

1111
from .config import get_config_manager
12-
from .metadata_modules import SlideSummary, format_exif_metadata, format_invoke_metadata
12+
from .metadata_modules import (
13+
SlideSummary,
14+
format_exif_metadata,
15+
format_invoke_metadata,
16+
use_ref_button_html,
17+
)
1318

1419
logger = logging.getLogger(__name__)
1520

@@ -33,23 +38,30 @@ def format_metadata(
3338
index=index,
3439
total=total_slides,
3540
)
36-
if not metadata:
37-
result.description = "<i>No metadata available.</i>"
38-
return result
3941

40-
# This is a fragile heuristic. Better to infer the type of metadata when the embeddings are
41-
# created, but this is a quick fix to avoid breaking existing metadata.
42-
if (
42+
config_manager = get_config_manager()
43+
invokeai_configured = bool(config_manager.get_invokeai_settings().get("url"))
44+
45+
# The "Use as Ref Image" button only needs an image to upload — it works
46+
# for any file regardless of metadata. The full Recall/Remix group, on the
47+
# other hand, requires recallable Invoke generation parameters and is
48+
# rendered by ``format_invoke_metadata`` itself.
49+
is_invoke_metadata = bool(metadata) and (
4350
"app_version" in metadata
4451
or "generation_mode" in metadata
4552
or "canvas_v2_metadata" in metadata
46-
):
47-
config_manager = get_config_manager()
48-
invokeai_url = config_manager.get_invokeai_settings().get("url")
53+
)
54+
55+
if not metadata:
56+
result.description = "<i>No metadata available.</i>"
57+
elif is_invoke_metadata:
4958
return format_invoke_metadata(
50-
result, metadata, show_recall_buttons=bool(invokeai_url)
59+
result, metadata, show_recall_buttons=invokeai_configured
5160
)
5261
else:
53-
config_manager = get_config_manager()
5462
api_key = config_manager.get_locationiq_api_key()
55-
return format_exif_metadata(result, metadata, api_key)
63+
result = format_exif_metadata(result, metadata, api_key)
64+
65+
if invokeai_configured:
66+
result.description = (result.description or "") + use_ref_button_html()
67+
return result
Lines changed: 2 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -1,10 +1,11 @@
11
from .exif_formatter import format_exif_metadata
2-
from .invoke_formatter import format_invoke_metadata
2+
from .invoke_formatter import format_invoke_metadata, use_ref_button_html
33
from .slide_summary import SlideSummary
44

55
# re-export the format_invoke_metadata and format_exif_metadata functions
66
__all__ = [
77
"SlideSummary",
88
"format_invoke_metadata",
99
"format_exif_metadata",
10+
"use_ref_button_html",
1011
]

photomap/backend/metadata_modules/invoke_formatter.py

Lines changed: 31 additions & 7 deletions
Original file line numberDiff line numberDiff line change
@@ -75,6 +75,15 @@
7575
)
7676

7777

78+
_USE_REF_BUTTON_HTML = (
79+
'<button type="button" class="invoke-recall-btn" data-recall-mode="use_ref" '
80+
'title="Upload this image to InvokeAI and use it as a reference image">'
81+
f'{_USE_REF_SVG}<span class="invoke-recall-label">Use as Ref Image</span>'
82+
'<span class="invoke-recall-status" aria-live="polite"></span>'
83+
"</button>"
84+
)
85+
86+
7887
def _recall_buttons_html() -> str:
7988
"""Render the recall / remix / use-ref button group shown at the bottom of the drawer."""
8089
return (
@@ -89,11 +98,22 @@ def _recall_buttons_html() -> str:
8998
f'{_RECALL_SVG}<span class="invoke-recall-label">Recall</span>'
9099
'<span class="invoke-recall-status" aria-live="polite"></span>'
91100
"</button>"
92-
'<button type="button" class="invoke-recall-btn" data-recall-mode="use_ref" '
93-
'title="Upload this image to InvokeAI and use it as a reference image">'
94-
f'{_USE_REF_SVG}<span class="invoke-recall-label">Use as Ref Image</span>'
95-
'<span class="invoke-recall-status" aria-live="polite"></span>'
96-
"</button>"
101+
f"{_USE_REF_BUTTON_HTML}"
102+
"</div>"
103+
)
104+
105+
106+
def use_ref_button_html() -> str:
107+
"""Render the standalone "Use as Ref Image" button.
108+
109+
The Recall and Remix buttons need recallable InvokeAI generation parameters
110+
in the image metadata, but "Use as Ref Image" only needs the image itself —
111+
so it is appended to non-Invoke metadata views as well, whenever an
112+
InvokeAI backend is configured.
113+
"""
114+
return (
115+
'<div class="invoke-recall-controls" data-invoke-recall="1">'
116+
f"{_USE_REF_BUTTON_HTML}"
97117
"</div>"
98118
)
99119

@@ -119,14 +139,18 @@ def format_invoke_metadata(
119139
isinstance(value, str | int | float | bool | type(None))
120140
for value in metadata.values()
121141
):
122-
slide_data.description = _scalar_table(metadata)
142+
slide_data.description = _scalar_table(metadata) + (
143+
use_ref_button_html() if show_recall_buttons else ""
144+
)
123145
return slide_data
124146

125147
try:
126148
parsed = GenerationMetadataAdapter().parse(metadata)
127149
except ValidationError as exc:
128150
logger.warning("Failed to parse invoke metadata: %s", exc)
129-
slide_data.description = "<i>Unknown invoke metadata format.</i>"
151+
slide_data.description = "<i>Unknown invoke metadata format.</i>" + (
152+
use_ref_button_html() if show_recall_buttons else ""
153+
)
130154
return slide_data
131155

132156
view = InvokeMetadataView(parsed)

tests/backend/test_invoke_metadata.py

Lines changed: 51 additions & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -23,7 +23,10 @@
2323
LoraTuple,
2424
ReferenceImageTuple,
2525
)
26-
from photomap.backend.metadata_modules.invoke_formatter import format_invoke_metadata
26+
from photomap.backend.metadata_modules.invoke_formatter import (
27+
format_invoke_metadata,
28+
use_ref_button_html,
29+
)
2730
from photomap.backend.metadata_modules.invokemetadata import GenerationMetadataAdapter
2831
from photomap.backend.metadata_modules.slide_summary import SlideSummary
2932

@@ -895,3 +898,50 @@ def test_buttons_shown_when_enabled(self, v3_metadata):
895898
assert 'data-recall-mode="use_ref"' in html
896899
assert "Use as Ref Image" in html
897900
assert html.count('class="invoke-recall-btn"') == 3
901+
902+
def test_scalar_only_metadata_appends_use_ref_button_when_enabled(self):
903+
"""A flat-scalars custom-workflow image carries no recallable
904+
parameters, but the image itself can still be uploaded as a reference.
905+
"""
906+
metadata = {"Custom Field": "value", "Steps": 20}
907+
html = format_invoke_metadata(
908+
_slide(), metadata, show_recall_buttons=True
909+
).description
910+
assert 'data-recall-mode="use_ref"' in html
911+
# Recall and Remix make no sense without parsed parameters.
912+
assert 'data-recall-mode="recall"' not in html
913+
assert 'data-recall-mode="remix"' not in html
914+
assert html.count('class="invoke-recall-btn"') == 1
915+
916+
def test_scalar_only_metadata_no_buttons_when_disabled(self):
917+
metadata = {"Custom Field": "value"}
918+
html = format_invoke_metadata(_slide(), metadata).description
919+
assert "invoke-recall-controls" not in html
920+
921+
def test_unknown_invoke_format_appends_use_ref_button_when_enabled(self):
922+
"""Payloads that look like Invoke metadata but fail discriminator
923+
validation should still expose the Use-as-Ref button (the file on
924+
disk is fine even if its metadata makes no sense).
925+
"""
926+
# ``metadata_version: 99`` is not a known discriminator, so parsing
927+
# raises ValidationError and we fall into the "unknown format" branch.
928+
# The nested dict also keeps us out of the "flat-scalars" fast path.
929+
metadata = {
930+
"metadata_version": 99,
931+
"app_version": "9.9.9",
932+
"model": {"wat": "not a recognizable model"},
933+
}
934+
html = format_invoke_metadata(
935+
_slide(), metadata, show_recall_buttons=True
936+
).description
937+
assert "Unknown invoke metadata format" in html
938+
assert 'data-recall-mode="use_ref"' in html
939+
assert 'data-recall-mode="recall"' not in html
940+
assert 'data-recall-mode="remix"' not in html
941+
942+
def test_use_ref_button_html_renders_single_button(self):
943+
html = use_ref_button_html()
944+
assert 'class="invoke-recall-controls"' in html
945+
assert 'data-recall-mode="use_ref"' in html
946+
assert "Use as Ref Image" in html
947+
assert html.count('class="invoke-recall-btn"') == 1
Lines changed: 92 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,92 @@
1+
"""Tests for the top-level metadata-formatting dispatcher.
2+
3+
The interesting cases here aren't the renderers themselves (those are covered
4+
in ``test_invoke_metadata.py``) but the orchestration: deciding which renderer
5+
to call and conditionally appending the standalone "Use as Ref Image" button
6+
to the non-Invoke paths whenever an InvokeAI backend is configured.
7+
"""
8+
9+
from __future__ import annotations
10+
11+
from pathlib import Path
12+
13+
import pytest
14+
15+
from photomap.backend.config import get_config_manager
16+
from photomap.backend.metadata_formatting import format_metadata
17+
18+
19+
@pytest.fixture
20+
def clear_invokeai_config():
21+
manager = get_config_manager()
22+
manager.set_invokeai_settings(url=None, username=None, password=None)
23+
yield
24+
manager.set_invokeai_settings(url=None, username=None, password=None)
25+
26+
27+
@pytest.fixture
28+
def with_invokeai_url(clear_invokeai_config):
29+
manager = get_config_manager()
30+
manager.set_invokeai_settings(url="http://localhost:9090")
31+
yield "http://localhost:9090"
32+
manager.set_invokeai_settings(url=None, username=None, password=None)
33+
34+
35+
def _filepath() -> Path:
36+
return Path("/tmp/example.png")
37+
38+
39+
class TestNoMetadata:
40+
def test_renders_placeholder_without_invokeai(self, clear_invokeai_config):
41+
result = format_metadata(_filepath(), {}, 0, 1)
42+
assert "No metadata available" in result.description
43+
assert "invoke-recall-controls" not in result.description
44+
45+
def test_appends_use_ref_button_when_invokeai_configured(self, with_invokeai_url):
46+
result = format_metadata(_filepath(), {}, 0, 1)
47+
assert "No metadata available" in result.description
48+
assert 'data-recall-mode="use_ref"' in result.description
49+
# No Recall/Remix without parameters to recall.
50+
assert 'data-recall-mode="recall"' not in result.description
51+
assert 'data-recall-mode="remix"' not in result.description
52+
53+
54+
class TestExifMetadata:
55+
EXIF = {"Make": "Canon", "Model": "EOS R5", "FNumber": 2.8}
56+
57+
def test_no_button_without_invokeai(self, clear_invokeai_config):
58+
result = format_metadata(_filepath(), self.EXIF, 0, 1)
59+
assert "Canon" in result.description
60+
assert "invoke-recall-controls" not in result.description
61+
62+
def test_use_ref_button_added_when_invokeai_configured(self, with_invokeai_url):
63+
result = format_metadata(_filepath(), self.EXIF, 0, 1)
64+
assert "Canon" in result.description
65+
assert 'data-recall-mode="use_ref"' in result.description
66+
assert 'data-recall-mode="recall"' not in result.description
67+
assert 'data-recall-mode="remix"' not in result.description
68+
69+
70+
class TestInvokeMetadata:
71+
INVOKE = {
72+
"metadata_version": 3,
73+
"app_version": "3.5.0",
74+
"positive_prompt": "anything",
75+
"seed": 1,
76+
"model": {"model_name": "m"},
77+
}
78+
79+
def test_no_buttons_without_invokeai(self, clear_invokeai_config):
80+
result = format_metadata(_filepath(), self.INVOKE, 0, 1)
81+
assert "anything" in result.description
82+
assert "invoke-recall-controls" not in result.description
83+
84+
def test_full_recall_group_when_invokeai_configured(self, with_invokeai_url):
85+
result = format_metadata(_filepath(), self.INVOKE, 0, 1)
86+
assert 'data-recall-mode="recall"' in result.description
87+
assert 'data-recall-mode="remix"' in result.description
88+
assert 'data-recall-mode="use_ref"' in result.description
89+
# The use_ref button should be inside the same control group as the
90+
# recall buttons (one container, three buttons), not a duplicate
91+
# standalone container appended afterwards.
92+
assert result.description.count('class="invoke-recall-controls"') == 1

0 commit comments

Comments
 (0)