From 1e7931ef450aa31549c8baed132058851fe4900f Mon Sep 17 00:00:00 2001 From: Vito Sansevero Date: Mon, 26 Oct 2026 19:43:14 -0700 Subject: [PATCH] fix(lora): show LoRA-linked media on the Library page and in the inspector (#355) Library previews, the positive detail and the per-prompt images page list a prompt's linked media under the output folders plus, while the LoRA integration is on, LoraManager's guarded folders and the download cache (_media_roots; output root indices keep their meaning). That media keeps its rooted identity, so Info and the media viewer open it; the path-based serve route answers for those roots only with a file the database records (either path spelling), never serves or makes a preview there, and output requests never read LoraManager's layout. Such media is marked linked_only: no preview file, no favorite star, no Tag action. Show in folder accepts those roots; the download cache is named, not created. --- py/api/images.py | 78 +++++++- py/api/library.py | 44 ++++- .../workspace-system-integrations.spec.mjs | 29 +++ tests/test_api_library.py | 170 ++++++++++++++++++ web/js/workspace/image-card.mjs | 7 +- 5 files changed, 311 insertions(+), 17 deletions(-) diff --git a/py/api/images.py b/py/api/images.py index 00e14a06..5327f8b7 100644 --- a/py/api/images.py +++ b/py/api/images.py @@ -2295,6 +2295,25 @@ class ImageRoutesMixin: {"success": False, "error": _safe_error(e)}, status=500 ) + def _media_roots(self, outputs=None): + """The roots a prompt's linked media is listed and inspected under. + + The output folders first (``outputs``, else ``_get_all_output_dirs``), + so every output ``root_index`` keeps its meaning, then, while the + LoRA integration is on, the guarded LoraManager folders and the + CivitAI download cache (``_lora_image_dirs``, #355). Media under those + is linked-only: ``serve_output_image`` serves a file under them only + when the database records it, and no preview file is made there. + """ + roots = list(self._get_all_output_dirs() if outputs is None else outputs) + seen = {_canonical(root) for root in roots} + for extra in self._lora_image_dirs(): + key = _canonical(extra) + if key not in seen: + seen.add(key) + roots.append(Path(extra)) + return roots + def _lora_image_dirs(self): """Extra directories served when the LoRA integration is enabled. @@ -2330,7 +2349,8 @@ class ImageRoutesMixin: layout = lora_manager_layout("") dirs = [Path(d) for d in layout.media_roots] - dirs.append(Path(get_lora_image_cache_dir())) + # Named, not made: listing and serving never create the cache. + dirs.append(Path(get_lora_image_cache_dir(create=False))) return dirs except Exception: # LoRA integration is optional; never widen access on failure. @@ -2361,9 +2381,21 @@ class ImageRoutesMixin: return [] async def serve_output_image(self, request): - """Serve a media file from the ComfyUI output folder(s).""" + """Serve a media file from the ComfyUI output folder(s). + + With ``?root=N`` past the output folders (a LoRA-linked root, + ``_media_roots``, #355) only a file the database records is served, + and no preview is served or made there. + """ try: - roots = list(self._get_all_output_dirs()) + outputs = list(self._get_all_output_dirs()) + root_param = request.query.get("root") + roots = outputs + if root_param is not None and re.fullmatch(r"[0-9]+", root_param): + if int(root_param) >= len(outputs): + # Only a LoRA-linked request reads LoraManager's layout; + # output media (and its previews) never waits on it. + roots = await self._run_in_executor(self._media_roots, outputs) scope_error = self._gallery_scope_error(request.query, roots) if scope_error is not None: return scope_error @@ -2378,9 +2410,11 @@ class ImageRoutesMixin: ): return _forbidden("Only media files can be served") - output_dirs = self._select_root(roots, request.query.get("root")) + output_dirs = self._select_root(roots, root_param) if not output_dirs: return _forbidden("No allowed image directories configured") + if len(outputs) <= int(root_param or 0): + return await self._serve_linked_only(output_dirs[0], filepath) for output_path in output_dirs: image_path = output_path / filepath @@ -2412,6 +2446,32 @@ class ImageRoutesMixin: {"success": False, "error": _safe_error(e)}, status=500 ) + async def _serve_linked_only(self, root, filepath): + """A file under a LoRA-linked root, served only when it is recorded.""" + if _is_thumbnail_cache_path(filepath): + return _forbidden() + image_path = root / filepath + if not _is_within(image_path, root): + return _forbidden() + if not image_path.is_file(): + return web.json_response( + {"success": False, "error": "Image file not found"}, status=404 + ) + + def recorded() -> bool: + # Imported local media is stored resolved; downloaded CivitAI + # images keep the cache folder's own spelling (a symlinked or + # junctioned data folder), so either names the record. + spellings = dict.fromkeys([str(image_path), os.path.realpath(image_path)]) + return any( + self.db.get_prompt_id_for_image(spelling) is not None + for spelling in spellings + ) + + if not await self._run_in_executor(recorded): + return _forbidden() + return self._file_response(image_path) + async def rescan_gallery(self, request): """Rewalk every output root now and answer with the new listing. @@ -3266,7 +3326,8 @@ class ImageRoutesMixin: if error is not None: return error index, scope, relative = target - roots = list(self._get_all_output_dirs()) + # A LoRA-linked root too (#355): Show in folder only opens a folder. + roots = await self._run_in_executor(self._media_roots) error = self._gallery_scope_error( {"root_scope": scope, "root": str(index)}, roots ) @@ -3393,8 +3454,11 @@ class ImageRoutesMixin: return set() async def inspect_image(self, request): - """Read prompt evidence for one explicitly rooted physical media file.""" - roots = list(self._get_all_output_dirs()) + """Read prompt evidence for one explicitly rooted physical media file. + + The roots include LoRA-linked folders (``_media_roots``, #355), so the + inspector opens a LoRA import's media; it only reads them.""" + roots = await self._run_in_executor(self._media_roots) scope_error = self._gallery_scope_error(request.query, roots) if scope_error is not None: return scope_error diff --git a/py/api/library.py b/py/api/library.py index ba9e4587..2e3dc4b5 100644 --- a/py/api/library.py +++ b/py/api/library.py @@ -234,9 +234,22 @@ class LibraryRoutesMixin: ) def _library_media( - self, records: list[dict], roots: list[Path], *, linked: bool = True + self, + records: list[dict], + roots: list[Path], + *, + linked: bool = True, + outputs: int | None = None, ) -> list[dict]: + """Workspace descriptors for a prompt's linked media under ``roots``. + + Roots from index ``outputs`` on are linked-only (LoRA media, #355): + their media keeps its rooted identity, but gets no preview file (the + card loads the original; a video shows its label), no favorite star + and is marked ``linked_only`` (no Tag action). + """ page = [] + linked_only = set() for record in records: try: raw_path = Path(record["image_path"]) @@ -250,10 +263,25 @@ class LibraryRoutesMixin: canonical_root = _canonical(root) if path.is_relative_to(canonical_root): page.append((path, 0, canonical_root, index)) + if outputs is not None and index >= outputs: + relative = path.relative_to(canonical_root).as_posix() + linked_only.add((index, relative)) break except (OSError, ValueError, RuntimeError): continue - return self._format_workspace_page(page, linked=linked) + images = self._format_workspace_page(page, linked=linked) + for image in images: + if (image["root_index"], image["path"]) in linked_only: + image["thumbnail_url"] = None + image["thumbnail_ready"] = False + image["linked_only"] = True + image.pop("favorite", None) + return images + + def _library_roots(self) -> tuple[list[Path], int]: + """``(roots, number of output roots)`` for a prompt's linked media.""" + outputs = list(self._get_all_output_dirs()) + return self._media_roots(outputs), len(outputs) @pair_response async def get_library_options(self, request: web.Request) -> web.Response: @@ -294,13 +322,13 @@ class LibraryRoutesMixin: return result, records result, records = await self._run_in_executor(read) - roots = list(self._get_all_output_dirs()) def previews() -> None: + roots, outputs = self._library_roots() for parent in result["items"]: # Previews belong to their parent; a per-parent link read is N+1. parent["previews"] = self._library_media( - records[parent["id"]], roots, linked=False + records[parent["id"]], roots, linked=False, outputs=outputs ) await self._run_in_executor(previews) @@ -342,9 +370,9 @@ class LibraryRoutesMixin: result, records = await self._run_in_executor(read) if result is None: return _missing() - roots = list(self._get_all_output_dirs()) + roots, outputs = await self._run_in_executor(self._library_roots) result["positive"]["previews"] = await self._run_in_executor( - self._library_media, records, roots + lambda: self._library_media(records, roots, outputs=outputs) ) return web.json_response({"success": True, **result}) @@ -358,7 +386,7 @@ class LibraryRoutesMixin: archive = _archive_scope(request) limit = _page_limit(request) cursor = request.query.get("cursor") - roots = list(self._get_all_output_dirs()) + roots, outputs = await self._run_in_executor(self._library_roots) canonical_roots = [str(_canonical(root)) for root in roots] def read() -> dict | None: @@ -391,7 +419,7 @@ class LibraryRoutesMixin: if result is None: return _missing() result["images"] = await self._run_in_executor( - self._library_media, result["images"], roots + lambda: self._library_media(result["images"], roots, outputs=outputs) ) return web.json_response({"success": True, **result}) diff --git a/tests/browser/workspace-system-integrations.spec.mjs b/tests/browser/workspace-system-integrations.spec.mjs index 16787aeb..1d5ad388 100644 --- a/tests/browser/workspace-system-integrations.spec.mjs +++ b/tests/browser/workspace-system-integrations.spec.mjs @@ -410,6 +410,35 @@ test('unusable LoraManager settings: the review says why and offers only Close ( expect(await serverJson(request, '/__test__/integrations/state')).toEqual(before); }); +test('imported LoRA previews show on the Library card and open in the inspector and viewer (#355 G6)', async ({ page, request }) => { + await lora(request, { loras: 1 }); + await openIntegrations(page); + await enable(page); + await layoutOf(page).getByRole('button', { name: 'Review import', exact: true }).click(); + await dialog(page).getByRole('button', { name: 'Start import', exact: true }).click(); + await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/); + expect((await ended(request, page.url().split('/').pop())).state).toBe('finished'); + await page.goto(`${workspace}#/library`); + const card = page.locator('#workspace-main article.prompt-card').filter({ hasText: 'Style 01' }); + const cover = card.locator('.card-art img'); + await expect(cover).toHaveAttribute('src', /\/prompt_manager\/images\/serve\/style_01\.png\?root=\d+&root_scope=[0-9a-f]{64}$/); + await expect.poll(() => cover.evaluate(node => node.complete && node.naturalWidth)).toBeGreaterThan(0); + await expect(card.locator('.card-art')).not.toContainText('No available image previews'); + // The prompt's gallery: Info inspects the file and the viewer opens it; no Tag action or star for LoRA media. + await card.getByRole('button', { name: 'Explore history', exact: true }).click(); + const item = page.getByRole('region', { name: 'Shared gallery', exact: true }).locator('article[data-image-key]').first(); + await expect(item.getByRole('button', { name: 'Tag or auto-tag style_01.png', exact: true })).toHaveCount(0); + await expect(item.locator('.star-button')).toHaveCount(0); + await item.getByRole('button', { name: 'Inspect style_01.png', exact: true }).click(); + const inspector = page.getByRole('complementary', { name: 'Selected image details', exact: true }); + await expect(inspector).toContainText('style_01.png'); + await item.getByRole('button', { name: 'Open style_01.png', exact: true }).click(); + await expect(page.locator('dialog[open] .viewer-canvas img')).toBeVisible(); + await expect.poll(() => page.locator('dialog[open] .viewer-canvas img').evaluate(node => node.complete && node.naturalWidth)) + .toBeGreaterThan(0); + await page.keyboard.press('Escape'); +}); + test('a failed import keeps old imports and its job says so', async ({ page, request }) => { await lora(request, { loras: 2, previous: 2 }); await openIntegrations(page); diff --git a/tests/test_api_library.py b/tests/test_api_library.py index 80597a58..c5568bb8 100644 --- a/tests/test_api_library.py +++ b/tests/test_api_library.py @@ -1,9 +1,12 @@ """Native library endpoints use bounded cursors and rooted media identities.""" import json +import os import unittest from unittest.mock import patch +from utils.hashing import generate_prompt_hash + try: from test_api_images import ImageAPITestCase, make_png except ImportError: @@ -978,6 +981,173 @@ class TestLibraryAPI(ImageAPITestCase): self.assertNotIn("literal", json.dumps(result)) +class TestLibraryLoraMedia(ImageAPITestCase): + """#355 (G6): media a LoRA import linked outside the output folders shows + on the Library page and opens in the inspector while the integration is on.""" + + async def setUpAsync(self): + await super().setUpAsync() + import tempfile + from pathlib import Path + + self._lora_tmp = tempfile.TemporaryDirectory(ignore_cleanup_errors=True) + self.addCleanup(self._lora_tmp.cleanup) + self.lora_dir = Path(self._lora_tmp.name).resolve() / "sidecars" + self.lora_dir.mkdir() + self.lora_on = True + self.api._lora_image_dirs = lambda: [self.lora_dir] if self.lora_on else [] + + async def get(self, path="", **params): + response = await self.client.get( + "/prompt_manager/library" + path, params=params + ) + return response, await response.json() + + def _imported(self): + """A LoRA prompt linked like the import links it: the cover first.""" + still = make_png(self.lora_dir / "abc" / "cover.png") + video = self.lora_dir / "abc" / "clip.mp4" + video.write_bytes(b"video") + self.api.db.replace_category_prompts( + "lora-manager", + [ + { + "name": "neon", + "text": "neon street", + "hash": generate_prompt_hash("neon street"), + "tags": ["lora-manager"], + "images": [str(still), str(video)], + } + ], + ) + positive = self.api.db.search_prompts(category="lora-manager")[0]["id"] + ids = { + os.path.basename(row["image_path"]): row["id"] + for row in self.api.db.get_prompt_images(positive) + } + return positive, ids + + async def test_list_previews_show_linked_lora_media_cover_first(self): + positive, _ids = self._imported() + response, result = await self.get() + self.assertEqual(response.status, 200, result) + item = next(i for i in result["items"] if i["id"] == positive) + previews = item["previews"] + self.assertEqual([p["filename"] for p in previews], ["cover.png", "clip.mp4"]) + # The media keeps the rooted identity every workspace view checks; no + # preview is made inside LoraManager's folders, so the card loads the + # original and a video shows its label. + self.assertEqual({p["root_index"] for p in previews}, {1}) + self.assertTrue( + all( + p["url"].startswith("/prompt_manager/images/serve/abc/") + for p in previews + ) + ) + self.assertEqual([p["thumbnail_url"] for p in previews], [None, None]) + self.assertEqual([p["is_video"] for p in previews], [False, True]) + self.assertEqual([p["linked_only"] for p in previews], [True, True]) + self.assertNotIn(str(self.lora_dir), json.dumps(result)) + file = await self.client.get(previews[0]["url"]) + self.assertEqual(file.status, 200) + await file.read() + self.assertFalse((self.lora_dir / "thumbnails").exists()) + + async def test_detail_and_images_page_list_lora_media(self): + positive, _ids = self._imported() + make_png(self.output_dir / "out.png") + _, detail = await self.get(f"/{positive}") + self.assertEqual( + [p["filename"] for p in detail["positive"]["previews"]], + ["cover.png", "clip.mp4"], + ) + _, page = await self.get(f"/{positive}/images") + self.assertEqual( + [i["filename"] for i in page["images"]], ["cover.png", "clip.mp4"] + ) + # No favorite star: favorites belong to output listings. + self.assertNotIn("favorite", page["images"][0]) + self.assertTrue(page["images"][0]["linked_only"]) + + async def test_inspector_reads_lora_media_by_its_root(self): + positive, _ids = self._imported() + _, page = await self.get(f"/{positive}/images") + image = page["images"][0] + response = await self.client.get( + "/prompt_manager/images/inspect/" + image["path"], + params={ + "root": str(image["root_index"]), + "root_scope": image["root_scope"], + }, + ) + self.assertEqual(response.status, 200, await response.text()) + + async def test_only_recorded_lora_media_is_served_by_path(self): + self._imported() + make_png(self.lora_dir / "abc" / "unlinked.png") + served = await self.client.get( + "/prompt_manager/images/serve/abc/cover.png", params={"root": "1"} + ) + self.assertEqual(served.status, 200) + await served.read() + unlinked = await self.client.get( + "/prompt_manager/images/serve/abc/unlinked.png", params={"root": "1"} + ) + self.assertEqual(unlinked.status, 403) + preview = await self.client.get( + "/prompt_manager/images/serve/thumbnails/abc/cover_thumb.webp", + params={"root": "1"}, + ) + self.assertEqual(preview.status, 403) + self.assertFalse((self.lora_dir / "thumbnails").exists()) + # Without a root, only the output folders are searched. + unrooted = await self.client.get("/prompt_manager/images/serve/abc/cover.png") + self.assertEqual(unrooted.status, 404) + + async def test_a_download_recorded_through_a_symlinked_cache_is_served(self): + from pathlib import Path + + real = self.lora_dir.parent / "real-cache" + real.mkdir() + link = self.lora_dir.parent / "cache-link" + try: + os.symlink(real, link, target_is_directory=True) + except (OSError, NotImplementedError): + self.skipTest("symlinks are not available here") + self.api._lora_image_dirs = lambda: [link] + image = make_png(real / "neon" / "a1b2.jpg") + stored = str(link / "neon" / "a1b2.jpg") + positive = self._save_prompt("downloaded") + self.api.db.link_image_to_prompt(positive, stored) + _, page = await self.get(f"/{positive}/images") + self.assertEqual([i["filename"] for i in page["images"]], [image.name]) + served = await self.client.get(page["images"][0]["url"]) + self.assertEqual(served.status, 200) + await served.read() + self.assertTrue(Path(stored).is_file()) + + async def test_a_malformed_root_is_refused_not_an_error(self): + response = await self.client.get( + "/prompt_manager/images/serve/abc/cover.png", params={"root": "\u00b2"} + ) + self.assertEqual(response.status, 403) + + async def test_lora_media_is_hidden_while_the_integration_is_off(self): + positive, _ids = self._imported() + self.lora_on = False + _, result = await self.get() + item = next(i for i in result["items"] if i["id"] == positive) + self.assertEqual(item["previews"], []) + response = await self.client.get( + "/prompt_manager/images/inspect/abc/cover.png", params={"root": "1"} + ) + self.assertEqual(response.status, 400) + served = await self.client.get( + "/prompt_manager/images/serve/abc/cover.png", params={"root": "1"} + ) + self.assertEqual(served.status, 403) + + class TestNameCursorBudget(unittest.TestCase): def test_lru_byte_budget_scope_and_oversize_keys_are_bounded(self): from py.api.library import _NameCursorStore diff --git a/web/js/workspace/image-card.mjs b/web/js/workspace/image-card.mjs index 4058fe7f..53a7663f 100644 --- a/web/js/workspace/image-card.mjs +++ b/web/js/workspace/image-card.mjs @@ -50,7 +50,8 @@ export function cardMedia(image, lazy = false) { /** * One card for `image`. `checked` (a boolean) adds the D8 selection checkbox, `favoriteBusy` paints a pending star * (D7, shown wherever the image reports `favorite`), `media: false` leaves the open button empty for a file that - * cannot be served, and `showInfo: false` hides the title and file line (D97). + * cannot be served, and `showInfo: false` hides the title and file line (D97). A `linked_only` image (LoRA media in + * LoraManager's folders, #355) has no Tag action. */ export function createImageCard(image, { checked = null, favoriteBusy = false, media = true, showInfo = true } = {}) { const card = el('article', undefined, 'media-card'); @@ -87,7 +88,9 @@ export function createImageCard(image, { checked = null, favoriteBusy = false, m const inspectButton = createButton('', 'icon-btn'); inspectButton.append(createWorkspaceIcon('info')); inspectButton.setAttribute('aria-label', `Inspect ${image.filename}`); - actions.append(inspectButton, createTagButton(image)); + actions.append(inspectButton); + // #355: LoRA-linked media lives in LoraManager's folders; tags are kept for output files only. + if (image.linked_only !== true) actions.append(createTagButton(image)); info.append(text, actions); card.append(art, info); return card;