From 145b105b821bc83074126af4986c1c3d78f67b09 Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 26 Oct 2026 17:48:30 -0700 Subject: [PATCH] fix(lora): show linked LoraManager videos as videos (#355) _enrich_images marks every image without is_video by its extension, so an .mp4 preview outside the output folders shows the Video preview label and plays in the inspector. Tests cover serving the sidecar and example-image folders only while the integration is on, and a filesystem root as the sidecar folder adding nothing. --- py/api/__init__.py | 12 ++++- tests/test_api_images.py | 109 +++++++++++++++++++++++++++++++++++++++ 2 files changed, 120 insertions(+), 1 deletion(-) diff --git a/py/api/__init__.py b/py/api/__init__.py index 12619d58..ba47bbbd 100644 --- a/py/api/__init__.py +++ b/py/api/__init__.py @@ -750,7 +750,12 @@ class PromptManagerAPI( # ── Shared utilities used by multiple mixins ────────────────────── def _enrich_images(self, images): - """Add url and thumbnail_url to a flat list of image dicts.""" + """Add url and thumbnail_url to a flat list of image dicts. + + Each dict without ``is_video`` also gets ``is_video`` and + ``media_type`` from its extension (#355: a linked LoraManager video + outside the output folders is shown as a video, not a picture). + """ from urllib.parse import quote as url_quote output_dirs = self._get_all_output_dirs() @@ -760,6 +765,11 @@ class PromptManagerAPI( if not image_path_str: continue + if "is_video" not in image: + video = is_video_path(image_path_str) + image["is_video"] = video + image["media_type"] = "video" if video else "image" + img_path = Path(image_path_str) # Set fallback url via image ID diff --git a/tests/test_api_images.py b/tests/test_api_images.py index 20e61d39..b0b9e353 100644 --- a/tests/test_api_images.py +++ b/tests/test_api_images.py @@ -1396,6 +1396,115 @@ class TestLoraManagerPathAllowList(ImageRouteCoverageCase): resolve.assert_called_once_with(configured) +class TestLoraManagerMediaServing(ImageRouteCoverageCase): + """#355: LoraManager's sidecar and example-image folders are served while + the integration is on, only through the folder guard.""" + + async def setUpAsync(self): + await super().setUpAsync() + from py.config import IntegrationConfig + + self._lm_tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self._lm_tmp.cleanup) + root = Path(os.path.realpath(self._lm_tmp.name)) + custom_nodes = root / "custom_nodes" + self.lm = custom_nodes / "ComfyUI-Lora-Manager" + (self.lm / "py").mkdir(parents=True) + (self.lm / "__init__.py").write_text("") + self.settings = root / "settings" + self.settings.mkdir() + self.sidecars = root / "sidecars" + self.examples = root / "examples" + self.cache = root / "cache" + for folder in (self.sidecars, self.examples, self.cache): + folder.mkdir() + self.write_settings(str(self.sidecars)) + self.enabled = patch.object(IntegrationConfig, "LORA_MANAGER_ENABLED", True) + for item in ( + self.enabled, + patch.object(IntegrationConfig, "LORA_MANAGER_PATH", str(self.lm)), + patch( + "py.lora_utils.custom_nodes_directories", return_value=[custom_nodes] + ), + patch("py.lora_utils.default_lora_roots", return_value=[]), + patch("py.lora_utils.get_lora_image_cache_dir", return_value=self.cache), + patch.dict(os.environ, {"LORA_MANAGER_SETTINGS_DIR": str(self.settings)}), + ): + item.start() + self.addCleanup(item.stop) + + def write_settings(self, sidecar_root): + (self.settings / "settings.json").write_text( + json.dumps( + { + "sidecar_storage_mode": "centralized", + "sidecar_storage_path": sidecar_root, + "example_images_path": str(self.examples), + "civitai_api_key": "never-served", + } + ) + ) + + async def _status(self, image_id): + resp = await self.client.request( + "GET", f"/prompt_manager/images/{image_id}/file" + ) + await resp.read() # release the served file before the folder goes + return resp.status + + async def test_sidecar_and_example_media_are_served_while_enabled(self): + preview = self.sidecars / "loras-0a1b2c3d" / "neon.webp" + preview.parent.mkdir() + preview.write_bytes(b"RIFF") + example = self.examples / "ab12" / "1.mp4" + example.parent.mkdir() + example.write_bytes(b"video") + ids = [self._link_image(preview, "a"), self._link_image(example, "b")] + + for image_id in ids: + self.assertEqual(await self._status(image_id), 200) + self.enabled.stop() + for image_id in ids: + self.assertEqual(await self._status(image_id), 403) + self.enabled.start() + + async def test_a_filesystem_root_as_sidecar_folder_adds_nothing(self): + self.write_settings(os.path.abspath(os.sep)) + dirs = self.api._lora_image_dirs() + # An unusable layout: the default LoRA roots (none here) and the cache. + self.assertEqual(dirs, [self.cache]) + + async def test_the_layout_folders_are_the_served_ones(self): + dirs = self.api._lora_image_dirs() + self.assertEqual( + dirs, + [ + Path(os.path.normcase(str(self.sidecars))), + Path(os.path.normcase(str(self.examples))), + self.cache, + ], + ) + + +class TestEnrichMarksVideos(ImageAPITestCase): + """#355: a linked video outside the output folders is marked as video.""" + + async def test_enrich_sets_is_video_and_media_type(self): + elsewhere = Path(tempfile.gettempdir()) / "lora-media" + images = [ + {"id": 1, "image_path": str(elsewhere / "a.MP4")}, + {"id": 2, "image_path": str(elsewhere / "b.png")}, + {"id": 3, "image_path": str(elsewhere / "c.mp4"), "is_video": False}, + {"id": 4, "image_path": ""}, + ] + self.api._enrich_images(images) + self.assertEqual( + [(i.get("is_video"), i.get("media_type")) for i in images], + [(True, "video"), (False, "image"), (False, None), (None, None)], + ) + self.assertEqual(images[0]["url"], "/prompt_manager/images/1/file") + + class TestLinkImageRelativePaths(ImageAPITestCase): """POST /images/link accepts the path forms the API hands out."""