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.
This commit is contained in:
+71
-7
@@ -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
|
||||
|
||||
+36
-8
@@ -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})
|
||||
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user