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})
|
||||
|
||||
|
||||
Reference in New Issue
Block a user