Support re-import for recipes without a source URL

Recipes imported by drag & drop / file-picker record no source_path and
were rejected by re-import. Fall back to the recipe's own saved image,
which still carries the original embedded generation metadata.

Re-import now re-parses that original metadata instead of the appended
recipe JSON block, so parser upgrades produce fresh results. The
already-optimized preview image is kept verbatim: only its WebP EXIF
chunk is rewritten in place to replace the recipe metadata block, and
the recipe JSON is rewritten with the new analysis plus carried-over
user edits.
This commit is contained in:
Will Miao
2026-08-31 10:01:18 +08:00
parent 2a3c632dc5
commit 39e7c1376c
9 changed files with 537 additions and 41 deletions
+79 -1
View File
@@ -197,6 +197,7 @@ class StubAnalysisService:
self.upload_calls: List[bytes] = []
self.remote_calls: List[Optional[str]] = []
self.local_calls: List[Optional[str]] = []
self.local_ignore_recipe_metadata_calls: List[bool] = []
self.result = SimpleNamespace(payload={"loras": []}, status=200)
self._recipe_parser_factory: Any = None
StubAnalysisService.instances.append(self)
@@ -218,11 +219,16 @@ class StubAnalysisService:
return self.result
async def analyze_local_image(
self, *, file_path: Optional[str], recipe_scanner
self,
*,
file_path: Optional[str],
recipe_scanner,
ignore_recipe_metadata: bool = False,
) -> SimpleNamespace: # noqa: D401
if self.raise_for_local:
raise self.raise_for_local
self.local_calls.append(file_path)
self.local_ignore_recipe_metadata_calls.append(ignore_recipe_metadata)
return self.result
async def analyze_widget_metadata(self, *, recipe_scanner) -> SimpleNamespace:
@@ -257,6 +263,7 @@ class StubPersistenceService:
extension=None,
recipe_id=None,
target_dir=None,
skip_optimize=False,
) -> SimpleNamespace: # noqa: D401
self.save_calls.append(
{
@@ -269,6 +276,7 @@ class StubPersistenceService:
"extension": extension,
"recipe_id": recipe_id,
"target_dir": target_dir,
"skip_optimize": skip_optimize,
}
)
return self.save_result
@@ -2168,3 +2176,73 @@ async def test_checkpoint_mark_hash_invalid_route_requires_recipe_id(
json={},
)
assert response.status == 400
async def test_reimport_without_source_path_falls_back_to_recipe_file(
monkeypatch, tmp_path: Path
) -> None:
"""Drag & drop imports record no source_path; re-import must fall back to
the recipe's own saved image and re-parse ignoring the recipe metadata."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
recipe_file = harness.tmp_dir / "recipes" / "rec1.webp"
recipe_file.parent.mkdir(parents=True, exist_ok=True)
recipe_file.write_bytes(b"fake-image")
harness.scanner.recipes["rec1"] = {
"id": "rec1",
"title": "Old title",
"file_path": str(recipe_file),
"tags": ["tag1"],
# no source_path on purpose
}
harness.analysis.result = SimpleNamespace(
payload={
"success": True,
"recipe_id": "new-rec",
"loras": [],
},
status=200,
)
harness.persistence.save_result = SimpleNamespace(
payload={"success": True, "recipe_id": "new-rec"}, status=200
)
response = await harness.client.post("/api/lm/recipe/rec1/reimport")
payload = await response.json()
assert response.status == 200
assert payload["success"] is True
assert payload["old_recipe_id"] == "rec1"
assert payload["recipe_id"] == "new-rec"
# Local analysis is used on the saved image, ignoring recipe metadata.
assert harness.analysis.local_calls == [str(recipe_file)]
assert harness.analysis.local_ignore_recipe_metadata_calls == [True]
# The old recipe is deleted after the fresh save.
assert harness.persistence.delete_calls == ["rec1"]
# The already-optimized preview image must be stored verbatim.
assert harness.persistence.save_calls[-1]["skip_optimize"] is True
assert harness.persistence.save_calls[-1]["image_bytes"] == b"fake-image"
# User edits (title, tags) are carried over to the new recipe.
assert harness.persistence.update_calls[-1]["recipe_id"] == "new-rec"
assert harness.persistence.update_calls[-1]["updates"]["title"] == "Old title"
assert harness.persistence.update_calls[-1]["updates"]["tags"] == ["tag1"]
async def test_reimport_without_any_source_returns_400(
monkeypatch, tmp_path: Path
) -> None:
"""Recipes with neither source_path nor an accessible image cannot re-import."""
async with recipe_harness(monkeypatch, tmp_path) as harness:
harness.scanner.recipes["rec2"] = {
"id": "rec2",
"title": "No source",
"file_path": str(harness.tmp_dir / "recipes" / "missing.webp"),
}
response = await harness.client.post("/api/lm/recipe/rec2/reimport")
payload = await response.json()
assert response.status == 400
assert payload["success"] is False
assert harness.analysis.local_calls == []
assert harness.persistence.delete_calls == []
+36 -1
View File
@@ -3,7 +3,7 @@ from typing import Any, Dict
import pytest
from py.recipes.parsers.recipe_format import RecipeFormatParser
from py.recipes.parsers.recipe_format import RecipeFormatParser, strip_recipe_metadata
from py.config import config
@@ -425,3 +425,38 @@ async def test_recipe_format_parser_sha256_less_cache_item_no_keyerror(monkeypat
lora_entry = result["loras"][0]
assert lora_entry["existsLocally"] is False
assert lora_entry["localPath"] is None
def test_strip_recipe_metadata_removes_appended_marker():
original = (
"masterpiece, best quality\n"
"Negative prompt: lowres\n"
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 123, "
"Size: 512x768, Model hash: abc123, Model: foo_v1, Clip skip: 2\n"
' Recipe metadata: {"title": "Saved", "loras": []}'
)
stripped = strip_recipe_metadata(original)
assert "Recipe metadata:" not in stripped
assert stripped.startswith("masterpiece, best quality")
assert "Steps: 20" in stripped
assert '{"title": "Saved"}' not in stripped
def test_strip_recipe_metadata_returns_input_without_marker():
text = "Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1"
assert strip_recipe_metadata(text) == text
def test_strip_recipe_metadata_empty_when_only_marker():
text = ' Recipe metadata: {"title": "Saved"}'
assert strip_recipe_metadata(text) == ""
def test_strip_recipe_metadata_handles_multiline_json_marker():
original = (
"Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1\n"
' Recipe metadata: {"title": "Saved", "loras": [{"name": "a", "hash": "h"}]}'
)
stripped = strip_recipe_metadata(original)
assert stripped == "Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1"
+196 -2
View File
@@ -32,8 +32,8 @@ class DummyExifUtils:
self.optimized_calls += 1
return image_data, ".webp"
def append_recipe_metadata(self, image_path, recipe_data):
self.appended = (image_path, recipe_data)
def append_recipe_metadata(self, image_path, recipe_data, pixel_preserving=False):
self.appended = (image_path, recipe_data, pixel_preserving)
def extract_image_metadata(self, path):
return {}
@@ -87,6 +87,87 @@ async def test_save_recipe_video_bypasses_optimization(tmp_path):
assert exif_utils.appended is None, "Metadata embedding should be bypassed for video"
@pytest.mark.asyncio
async def test_save_recipe_skip_optimize_preserves_image_bytes(tmp_path):
"""Local re-import sources are already-optimized recipe images; saving them
must keep the bytes verbatim instead of re-compressing, while the recipe
metadata block is still embedded via a pixel-preserving EXIF update."""
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root / "recipes")
async def add_recipe(self, recipe_data):
return None
async def find_recipes_by_fingerprint(self, fingerprint):
return []
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
image_bytes = b"\x89PNG-not-optimized-again"
result = await service.save_recipe(
recipe_scanner=scanner,
image_bytes=image_bytes,
image_base64=None,
name="Re-imported",
tags=[],
metadata={"gen_params": {"steps": 20}, "base_model": "SDXL", "loras": []},
extension=".webp",
skip_optimize=True,
)
assert result.payload["image_path"].endswith(".webp")
assert Path(result.payload["image_path"]).read_bytes() == image_bytes
assert exif_utils.optimized_calls == 0, "Optimization should be bypassed"
# Metadata is still embedded, but through the pixel-preserving path.
assert exif_utils.appended is not None
assert exif_utils.appended[2] is True
@pytest.mark.asyncio
async def test_save_recipe_skip_optimize_default_optimizes(tmp_path):
"""Normal saves must keep optimizing; only re-import opts out."""
exif_utils = DummyExifUtils()
class DummyScanner:
def __init__(self, root):
self.recipes_dir = str(root / "recipes")
async def add_recipe(self, recipe_data):
return None
async def find_recipes_by_fingerprint(self, fingerprint):
return []
scanner = DummyScanner(tmp_path)
service = RecipePersistenceService(
exif_utils=exif_utils,
card_preview_width=512,
logger=logging.getLogger("test"),
)
await service.save_recipe(
recipe_scanner=scanner,
image_bytes=b"raw-image",
image_base64=None,
name="Normal",
tags=[],
metadata={"gen_params": {"steps": 20}, "base_model": "SDXL", "loras": []},
extension=".webp",
)
assert exif_utils.optimized_calls == 1
assert exif_utils.appended is not None
assert exif_utils.appended[2] is False
@pytest.mark.asyncio
async def test_analyze_remote_image_download_failure_cleans_temp(tmp_path, monkeypatch):
exif_utils = DummyExifUtils()
@@ -1979,3 +2060,116 @@ async def test_mark_checkpoint_hash_invalid_can_clear_flag(tmp_path):
assert result.payload["hash_invalid"] is False
assert result.payload["updated_checkpoint"]["hashInvalid"] is False
@pytest.mark.asyncio
async def test_analyze_local_image_ignore_recipe_metadata_strips_marker(tmp_path):
"""Re-import must re-parse the original embedded metadata, not the
recipe JSON block appended on save."""
original = (
"masterpiece, best quality\n"
"Negative prompt: lowres\n"
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 1, "
"Size: 512x768, Model hash: abc123, Model: foo_v1, Clip skip: 2\n"
' Recipe metadata: {"title": "Saved", "loras": [], "gen_params": {}}'
)
class SpyFactory:
def __init__(self):
self.received = None
def create_parser(self, metadata):
self.received = metadata
return _AutomaticMetadataSpyParser()
class _AutomaticMetadataSpyParser:
async def parse_metadata(self, user_comment, recipe_scanner=None, civitai_client=None):
return {"loras": [], "base_model": "Illustrious", "gen_params": {"seed": 1}}
class DummyScanner:
async def find_recipes_by_fingerprint(self, fingerprint):
return []
image_path = tmp_path / "rec.webp"
image_path.write_bytes(b"fake-image")
factory = SpyFactory()
service = _make_analysis_service(factory, _exif_utils_returning(original))
result = await service.analyze_local_image(
file_path=str(image_path),
recipe_scanner=DummyScanner(),
ignore_recipe_metadata=True,
)
# The parser must receive the original A1111 text without the appended
# recipe metadata block, so it re-parses rather than reusing the snapshot.
assert factory.received is not None
assert "Recipe metadata:" not in factory.received
assert factory.received.startswith("masterpiece, best quality")
assert '{"title": "Saved"}' not in factory.received
assert result.payload["parser"] == "_AutomaticMetadataSpyParser"
@pytest.mark.asyncio
async def test_analyze_local_image_ignore_recipe_metadata_only_marker(tmp_path):
"""An image carrying only the recipe metadata block (no original embedded
metadata) cannot be re-imported; report it instead of reusing the block."""
original = 'Recipe metadata: {"title": "Saved", "loras": []}'
class NeverFactory:
def create_parser(self, metadata):
raise AssertionError("Parser must not run on stripped metadata")
image_path = tmp_path / "rec.webp"
image_path.write_bytes(b"fake-image")
service = _make_analysis_service(NeverFactory(), _exif_utils_returning(original))
result = await service.analyze_local_image(
file_path=str(image_path),
recipe_scanner=SimpleNamespace(),
ignore_recipe_metadata=True,
)
assert "error" in result.payload
assert result.payload["diagnostics"]["reason"] == "only_recipe_metadata"
@pytest.mark.asyncio
async def test_analyze_local_image_default_keeps_recipe_metadata_behavior(tmp_path):
"""Normal import path keeps preferring the recipe metadata block."""
original = (
"Steps: 20, Sampler: DPM++ 2M Karras, Seed: 1\n"
' Recipe metadata: {"title": "Saved", "loras": [], "gen_params": {}}'
)
class SpyFactory:
def __init__(self):
self.received = None
def create_parser(self, metadata):
self.received = metadata
return _AutomaticMetadataSpyParser()
class _AutomaticMetadataSpyParser:
async def parse_metadata(self, user_comment, recipe_scanner=None, civitai_client=None):
return {"loras": [], "base_model": "Illustrious", "gen_params": {"seed": 1}}
class DummyScanner:
async def find_recipes_by_fingerprint(self, fingerprint):
return []
image_path = tmp_path / "rec.webp"
image_path.write_bytes(b"fake-image")
factory = SpyFactory()
service = _make_analysis_service(factory, _exif_utils_returning(original))
result = await service.analyze_local_image(
file_path=str(image_path),
recipe_scanner=DummyScanner(),
)
assert factory.received == original
assert "Recipe metadata:" in factory.received
+63
View File
@@ -65,6 +65,69 @@ def test_append_recipe_metadata_includes_checkpoint(monkeypatch, tmp_path):
assert payload["base_model"] == "Illustrious"
def test_append_recipe_metadata_pixel_preserving_webp(tmp_path):
"""pixel_preserving=True must rewrite only the EXIF chunk of a WebP,
leaving the pixel chunks byte-identical and the old block replaced."""
img = Image.new("RGB", (64, 48), (120, 30, 200))
original_params = (
"masterpiece, best quality\n"
"Negative prompt: lowres\n"
"Steps: 20, Sampler: DPM++ 2M Karras, CFG scale: 7, Seed: 1, "
"Size: 512x768, Model hash: abc123, Model: foo_v1, Clip skip: 2\n"
' Recipe metadata: {"title": "Old", "loras": []}'
)
exif = piexif.dump(
{
"0th": {},
"Exif": {
piexif.ExifIFD.UserComment: b"UNICODE\x00"
+ original_params.encode("utf-16be")
},
}
)
image_path = tmp_path / "recipe.webp"
img.save(str(image_path), format="WEBP", exif=exif, quality=85)
with open(image_path, "rb") as fh:
before = fh.read()
new_recipe = {
"title": "New",
"base_model": "SDXL",
"loras": [],
"gen_params": {"steps": 25},
}
ExifUtils.append_recipe_metadata(
str(image_path), new_recipe, pixel_preserving=True
)
with open(image_path, "rb") as fh:
after = fh.read()
def chunks(data: bytes) -> Dict[bytes, bytes]:
pos, result = 12, {}
while pos + 8 <= len(data):
fourcc = data[pos : pos + 4]
size = int.from_bytes(data[pos + 4 : pos + 8], "little")
result[fourcc] = data[pos + 8 : pos + 8 + size]
pos += 8 + size + (size % 2)
return result
before_chunks = chunks(before)
after_chunks = chunks(after)
for fourcc, payload in before_chunks.items():
if fourcc == b"EXIF":
assert after_chunks[fourcc] != payload, "EXIF must be replaced"
else:
assert after_chunks[fourcc] == payload, f"{fourcc} was re-encoded"
# The appended block is updated; the original parameters stay in front.
metadata = ExifUtils.extract_image_metadata(str(image_path))
assert "Steps: 20" in metadata
assert 'Recipe metadata: {"title": "New"' in metadata
assert '"title": "Old"' not in metadata
def test_optimize_image_preserves_workflow_when_converting_png_to_webp(tmp_path):
image_path = tmp_path / "source.png"
png_info = PngImagePlugin.PngInfo()