Files
MajoorWaldi-ComfyUI-Majoor-…/tests/features/test_metadata_service_impl.py
T
MajoorWaldi fbd1d182e8 fix(dnd): include avif images in ComfyUI metadata stripping
- Updated the _STRIPPABLE_EXTS set to include the .avif extension
- This change ensures that AVIF images are considered for ComfyUI metadata stripping during the drag-and-drop operation
2026-04-06 18:28:58 +02:00

349 lines
15 KiB
Python

import asyncio
import pytest
from mjr_am_backend.features.metadata import metadata_service_impl as m
from mjr_am_backend.shared import ErrorCode, Result
class _Exif:
def __init__(self, single=None, batch=None):
self.single = single if single is not None else Result.Ok({})
self.batch = batch if batch is not None else {}
async def aread(self, _path):
return self.single
def read(self, _path):
return self.single
async def aread_batch(self, _paths):
return self.batch
class _FF:
def __init__(self, single=None, batch=None):
self.single = single if single is not None else Result.Ok({})
self.batch = batch if batch is not None else {}
async def aread(self, _path):
return self.single
def read(self, _path):
return self.single
async def aread_batch(self, _paths):
return self.batch
def read_batch(self, _paths):
return self.batch
class _Settings:
def __init__(self, mode="auto", prefs=None, explode=False):
self.mode = mode
self.prefs = prefs if prefs is not None else {"image": True, "media": True}
self.explode = explode
async def get_probe_backend(self):
return self.mode
async def get_metadata_fallback_prefs(self):
if self.explode:
raise RuntimeError("x")
return self.prefs
def _svc(exif=None, ff=None, settings=None):
return m.MetadataService(exif or _Exif(), ff or _FF(), settings or _Settings())
def test_top_level_param_helpers():
assert m._clean_model_name("C:/m/model.safetensors") == "model"
assert m._clean_model_name("") is None
out = m._build_geninfo_from_parameters({"prompt": "p", "steps": "20", "model": "a.ckpt"})
assert out and out["steps"]["value"] == 20
assert m._build_geninfo_from_parameters({"x": 1}) == {}
merged = m._merge_parsed_params({"parameters": "x"})
assert isinstance(merged, dict)
assert m._has_any_parameter_signal(None, "") is False
d = {}
m._apply_prompt_fields(d, "p", "n")
m._apply_sampler_fields(d, "euler", "normal")
m._apply_numeric_fields(d, "20", "7.5", "1")
m._apply_size_field(d, "512", "768")
m._apply_checkpoint_fields(d, "x.safetensors")
assert "positive" in d and "size" in d and "checkpoint" in d
def test_media_pipeline_helpers():
g = {"1": {"class_type": "LoadVideo"}, "2": {"class_type": "VHS_VideoCombine"}}
assert m._looks_like_media_pipeline(g) is True
assert m._looks_like_media_pipeline({"1": {"class_type": "KSampler"}}) is False
types = m._collect_prompt_graph_types(g)
assert "loadvideo" in types
assert m._has_generation_sampler(["ksampler"]) is True
assert m._classify_media_nodes(types)[0] is True
assert m._should_parse_geninfo({"prompt": {"1": {}}, "workflow": None}) is True
def test_group_and_batch_targets(monkeypatch):
monkeypatch.setattr(m, "classify_file", lambda p: "image" if p.endswith(".png") else ("video" if p.endswith(".mp4") else ("audio" if p.endswith(".mp3") else "unknown")))
monkeypatch.setattr(m.os.path, "exists", lambda p: not p.endswith("missing"))
i, v, a, o = m._group_existing_paths(["a.png", "b.mp4", "c.mp3", "d.bin", "missing"])
assert i and v and a and o
monkeypatch.setattr(m, "pick_probe_backend", lambda p, settings_override=None: ["exiftool", "ffprobe"] if p.endswith(".mp4") else ["exiftool"])
ex, ff = m._build_batch_probe_targets(["a.png", "b.mp4", "b.mp4"], "auto")
assert ex == ["a.png", "b.mp4"]
assert ff == ["b.mp4"]
def test_batch_tool_data_and_resolution_helpers():
mp = {"a": Result.Ok({"x": 1}), "b": Result.Err("E", "x")}
assert m._batch_tool_data(mp, "a") == {"x": 1}
assert m._batch_tool_data(mp, "b") is None
payload = {"resolution": (512, 768)}
m._expand_resolution_scalars(payload)
assert payload["width"] == 512 and payload["height"] == 768
assert m._coerce_resolution_pair((1, 2)) == (1, 2)
assert m._coerce_resolution_pair(1) is None
assert m._coerce_dimension_value("5") == 5
@pytest.mark.asyncio
async def test_probe_mode_and_backends(monkeypatch):
s = _svc(settings=_Settings(mode="ffprobe"))
assert await s._resolve_probe_mode("AUTO") == "auto"
assert await s._resolve_probe_mode("bad") == "ffprobe"
monkeypatch.setattr(m, "pick_probe_backend", lambda p, settings_override=None: ["exiftool"])
mode, backends = await s._probe_backends("a.png", None)
assert mode == "ffprobe" and backends == ["exiftool"]
@pytest.mark.asyncio
async def test_resolve_fallback_prefs():
s1 = _svc(settings=_Settings(prefs={"image": False, "media": True}))
assert await s1._resolve_fallback_prefs() == (False, True)
s2 = _svc(settings=_Settings(explode=True))
assert await s2._resolve_fallback_prefs() == (True, True)
@pytest.mark.asyncio
async def test_read_with_transient_retry(monkeypatch):
s = _svc()
seq = [Result.Err("E", "x"), Result.Ok({"a": 1})]
async def _once():
return seq.pop(0)
monkeypatch.setattr(s, "_is_transient_metadata_read_error", lambda result, fp: True)
out = await s._read_with_transient_retry("a.png", _once)
assert out.ok
@pytest.mark.asyncio
async def test_enrich_with_geninfo_async(monkeypatch):
s = _svc()
monkeypatch.setattr(m, "parse_geninfo_from_prompt", lambda *args, **kwargs: Result.Ok({"g": 1}))
c = {"prompt": {"1": {}}, "workflow": {"nodes": []}}
await s._enrich_with_geninfo_async(c)
assert c["geninfo"] == {"g": 1}
monkeypatch.setattr(m, "parse_geninfo_from_prompt", lambda *args, **kwargs: Result.Err("E", "x"))
monkeypatch.setattr(m, "registry_build_geninfo_from_parameters", lambda combined: None)
monkeypatch.setattr(m, "registry_looks_like_media_pipeline", lambda p: True)
c2 = {"prompt": {"1": {}}, "workflow": {"nodes": []}}
await s._enrich_with_geninfo_async(c2)
assert c2["geninfo"] == {}
assert c2["geninfo_status"]["kind"] == "media_pipeline"
@pytest.mark.asyncio
async def test_get_metadata_not_found(tmp_path):
s = _svc()
out = await s.get_metadata(str(tmp_path / "missing.png"))
assert out.code == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_get_metadata_impl_dispatch(monkeypatch, tmp_path):
p = tmp_path / "a.bin"
p.write_text("x")
s = _svc()
monkeypatch.setattr(m, "classify_file", lambda _p: "image")
monkeypatch.setattr(s, "_probe_backends", lambda *args, **kwargs: asyncio.sleep(0, result=("auto", ["exiftool"])))
monkeypatch.setattr(s, "_extract_image_metadata", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"k": 1})))
out1 = await s._get_metadata_impl(str(p))
assert out1.ok
monkeypatch.setattr(m, "classify_file", lambda _p: "video")
monkeypatch.setattr(s, "_extract_video_metadata", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"k": 2})))
out2 = await s._get_metadata_impl(str(p))
assert out2.ok
monkeypatch.setattr(m, "classify_file", lambda _p: "audio")
monkeypatch.setattr(s, "_extract_audio_metadata", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"k": 3})))
out3 = await s._get_metadata_impl(str(p))
assert out3.ok
monkeypatch.setattr(m, "classify_file", lambda _p: "unknown")
out4 = await s._get_metadata_impl(str(p))
assert out4.code == ErrorCode.UNSUPPORTED
@pytest.mark.asyncio
async def test_workflow_only_paths(monkeypatch, tmp_path):
p = tmp_path / "a.png"
p.write_text("x")
s = _svc()
monkeypatch.setattr(m, "classify_file", lambda _p: "image")
monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True)))
monkeypatch.setattr(s, "_read_workflow_only_exif", lambda **kwargs: asyncio.sleep(0, result={"a": 1}))
monkeypatch.setattr(s, "_extract_workflow_only_payload", lambda *args, **kwargs: Result.Ok({"workflow": {"w": 1}, "prompt": {"p": 1}, "quality": "partial"}))
out = await s.get_workflow_only(str(p))
assert out.ok and out.data["quality"] == "partial"
out_nf = await s.get_workflow_only(str(tmp_path / "missing.png"))
assert out_nf.code == ErrorCode.NOT_FOUND
@pytest.mark.asyncio
async def test_read_workflow_only_exif_fallback(monkeypatch):
s = _svc()
monkeypatch.setattr(s, "_exif_read", lambda _p: asyncio.sleep(0, result=Result.Err("E", "x")))
monkeypatch.setattr(m, "read_image_exif_like", lambda _p: {"fallback": 1})
out = await s._read_workflow_only_exif(file_path="a.png", kind="image", image_fallback_enabled=True, scan_id=None)
assert out == {"fallback": 1}
@pytest.mark.asyncio
async def test_image_video_audio_extractors(monkeypatch, tmp_path):
p_img = tmp_path / "a.png"
p_img.write_text("x")
p_vid = tmp_path / "a.mp4"
p_vid.write_text("x")
p_aud = tmp_path / "a.mp3"
p_aud.write_text("x")
s = _svc()
monkeypatch.setattr(s, "_resolve_image_exif_data", lambda *args, **kwargs: asyncio.sleep(0, result={"x": 1}))
monkeypatch.setattr(s, "_extract_image_by_extension", lambda fp, ext, ex: Result.Ok({"quality": "partial", "workflow": None, "prompt": None}))
monkeypatch.setattr(s, "_build_image_metadata_payload", lambda *args, **kwargs: {"workflow": None, "prompt": None})
monkeypatch.setattr(s, "_enrich_with_geninfo_async", lambda combined: asyncio.sleep(0))
out_img = await s._extract_image_metadata(str(p_img))
assert out_img.ok
monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True)))
monkeypatch.setattr(s, "_read_video_exif_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1)))
monkeypatch.setattr(s, "_read_video_ffprobe_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1)))
monkeypatch.setattr(m, "extract_video_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial", "resolution": (100, 200)}))
monkeypatch.setattr(s, "_enrich_with_geninfo_async", lambda combined: asyncio.sleep(0))
out_vid = await s._extract_video_metadata(str(p_vid))
assert out_vid.ok
monkeypatch.setattr(s, "_read_audio_exif_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1)))
monkeypatch.setattr(s, "_read_audio_ffprobe_if_allowed", lambda *args, **kwargs: asyncio.sleep(0, result=({}, 0.1)))
monkeypatch.setattr(s, "_maybe_audio_ffprobe_fallback", lambda fp, data: asyncio.sleep(0, result=data))
monkeypatch.setattr(m, "extract_audio_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial"}))
monkeypatch.setattr(s, "_finalize_audio_metadata_ok", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"ok": 1})))
out_aud = await s._extract_audio_metadata(str(p_aud))
assert out_aud.ok
@pytest.mark.asyncio
async def test_process_image_batch_item_supports_avif(monkeypatch, tmp_path):
p_avif = tmp_path / "a.avif"
p_avif.write_text("x")
s = _svc()
monkeypatch.setattr(m, "extract_avif_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial", "workflow": {"w": 1}, "prompt": None}))
monkeypatch.setattr(s, "_get_file_info", lambda _path: {"path": _path})
monkeypatch.setattr(s, "_normalize_visual_dimensions", lambda *args, **kwargs: None)
monkeypatch.setattr(s, "_finalize_batch_ok", lambda combined, metadata_result: asyncio.sleep(0, result=Result.Ok(combined, quality=metadata_result.meta.get("quality", "none"))))
out = await s._process_image_batch_item(str(p_avif), {"x": 1}, image_fallback_enabled=False)
assert out.ok
assert out.data["workflow"] == {"w": 1}
@pytest.mark.asyncio
async def test_audio_fallback_only_and_reads(monkeypatch, tmp_path):
p = tmp_path / "a.mp3"
p.write_text("x")
s = _svc()
monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True)))
monkeypatch.setattr(m, "read_media_probe_like", lambda _p: {"d": 1})
monkeypatch.setattr(m, "extract_audio_metadata", lambda *args, **kwargs: Result.Ok({"quality": "partial"}))
out = await s._extract_audio_metadata_fallback_only(str(p))
assert out.ok
monkeypatch.setattr(s, "_exif_read", lambda _p: asyncio.sleep(0, result=Result.Err("E", "x")))
monkeypatch.setattr(s, "_ffprobe_read", lambda _p: asyncio.sleep(0, result=Result.Err("E", "x")))
ex, _ = await s._read_audio_exif_if_allowed(str(p), None, True)
ff, _ = await s._read_audio_ffprobe_if_allowed(str(p), None, True)
assert ex is None and ff is None
@pytest.mark.asyncio
async def test_batch_impl_and_fill_helpers(monkeypatch, tmp_path):
p_img = tmp_path / "a.png"
p_vid = tmp_path / "a.mp4"
p_aud = tmp_path / "a.mp3"
p_oth = tmp_path / "a.bin"
for p in (p_img, p_vid, p_aud, p_oth):
p.write_text("x")
s = _svc(exif=_Exif(batch={str(p_img): Result.Ok({"ex": 1})}), ff=_FF(batch={str(p_vid): Result.Ok({"ff": 1}), str(p_aud): Result.Ok({"ff": 2})}))
monkeypatch.setattr(m, "registry_group_existing_paths", lambda fps: ([str(p_img)], [str(p_vid)], [str(p_aud)], [str(p_oth)]))
monkeypatch.setattr(s, "_resolve_probe_mode", lambda ov: asyncio.sleep(0, result="auto"))
monkeypatch.setattr(s, "_resolve_fallback_prefs", lambda: asyncio.sleep(0, result=(True, True)))
monkeypatch.setattr(m, "registry_build_batch_probe_targets", lambda paths, mode: (paths, paths))
monkeypatch.setattr(s, "_process_image_batch_item", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"i": 1})))
monkeypatch.setattr(s, "_process_video_batch_item", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"v": 1})))
monkeypatch.setattr(s, "_process_audio_batch_item", lambda *args, **kwargs: asyncio.sleep(0, result=Result.Ok({"a": 1})))
monkeypatch.setattr(s, "_fill_other_batch_results", lambda results, others: results.update({o: Result.Ok({"o": 1}) for o in others}))
out = await s._get_metadata_batch_impl([str(p_img), str(p_vid), str(p_aud), str(p_oth)])
assert all(k in out for k in (str(p_img), str(p_vid), str(p_aud), str(p_oth)))
def test_dimension_helpers():
svc = _svc()
w, h = svc._fill_dims_from_resolution({"resolution": (1, 2)}, None, None, lambda x: int(x))
assert (w, h) == (1, 2)
ff = {"video_stream": {"width": "3", "height": "4"}}
w2, h2 = svc._fill_dims_from_ffprobe(ff, None, None, lambda x: int(x))
assert (w2, h2) == (3, 4)
ex = {"Image:ImageWidth": "5", "Image:ImageHeight": "6"}
w3, h3 = svc._fill_dims_from_exif(ex, None, None, lambda x: int(x))
assert (w3, h3) == (5, 6)
assert svc._pick_first_coerced(ex, ("bad", "Image:ImageWidth"), lambda x: int(x) if x else None) == 5
def test_proxy_methods(monkeypatch, tmp_path):
svc = _svc()
monkeypatch.setattr(m, "retry_extract_rating_tags_only", lambda exif, path, logger, scan_id=None: Result.Ok({"rating": 5}))
assert svc.extract_rating_tags_only("a").ok
p = tmp_path / "a.bin"
p.write_text("x")
monkeypatch.setattr(m, "retry_fill_other_batch_results", lambda results, paths, get_info: results.update({p: Result.Ok(get_info(p)) for p in paths}))
r = {}
svc._fill_other_batch_results(r, [str(p)])
assert str(p) in r