* perf(images): refresh the listing when the monitor sees a new output (#348, PR 2) The image monitor now reports every settled output (linked or not, also when linking raised) with its resolved path through set_output_listener. The API's notify_output_settled hops from the monitor's worker thread onto the server's loop (call_soon_threadsafe) and does nothing before the loop is known or after cleanup. mark_root_dirty matches the path against each walk's resolved_root (normcase(realpath), computed in the executor by walk_root, now its fourth value) and marks the root dirty. A dirty root gets one background walk, no sooner than rewalk_interval = min(15, max(5, 5 x last walk's duration)) after the previous walk started (last_started_at / last_duration are recorded on every walk). _schedule_walk is shared with PR 1's delayed stale refresh: one deferred walk per root, never armed while a walk runs (the walk's end re-checks dirty: the trailing walk), at least 0.5 s away, the earlier deadline wins, and every walk start cancels it. A failed walk clears dirty. Rescan semantics are unchanged. * fix(images): keep removed roots and cancelled walks from walking again (#348, PR 2) Review findings on the monitor-triggered walks: - reset_gallery_roots_cache now retires every listing record: its pending timer is cancelled, dirty is cleared, and no mark, timer or trailing walk starts a walk of it until a reader or the warm-up asks for that root again. Before, a timer or trailing walk pending when the roots changed stored a listing for the removed root, and the monitor (which watches the old roots until restart) kept it walking on every new output. - A walk task cancelled outside cleanup_gallery_listings no longer starts a trailing walk while the loop shuts down. - root_contains ignores a Windows extended-length prefix (\\?\ and \\?\UNC\) on either side, which realpath can keep on a file past MAX_PATH. - The REWALK comment states the ~20% GIL share only for walks up to 3 s. * test(images): prove a cancelled walk starts no trailing walk (#348, PR 2) The previous assertions held even with the trailing walk, because that walk finished before they ran; assert the walk number and the scan count. * perf(images): add a settled output to the listing without a walk (#348, PR 2) Live test: each landing in a 20,000-file folder cost a full walk (~0.4 s of GIL) plus a full workspace rebuild, so Images reads during a run took up to 2.1 s. The monitor's report now patches the stored listing instead. - output_settled (the monitor hop's target) slices the file's part below the root in its own case (relative_part; Windows compares folded), and _run_adds places one root's pending files in one executor batch: _settled_entry applies the walk's filters (suffix, thumbnails, depth, symlink by lstat; macOS on-disk spelling), insert_entry puts the (Path, mtime) entry where the newest-first sort does, add_folder copies the folder sets. Stored only when no walk started and no drop happened meanwhile; walked_at is untouched, so the 30 s refresh still walks. - Anything inexact marks the root dirty and walks as before: a running walk, an unsliceable path, a failed lstat, a tie in mtime, the cap, an unexpected error. Rescan, the refresh, the warm-up and invalidation stay full walks; a walk start forgets the pending adds. - The workspace caches follow a patched listing by entry identity (listing_delta): only added values are built, the newest order places them by bisection, the favorites lookup is copied and extended. Synthetic 20,000 files: per landing 252 ms (walk + full build) -> 18 ms (3 ms add + 15 ms warm build). * fix(images): review fixes for adds without a walk (#348, PR 2) - _newest_step steps the cached newest order only when the per-file cache still holds exactly the values this call built. A stale page read racing a newer warm build (both run in executor threads) stored an order labelled with older values than it held, and the next read inserted the new file a second time. It also sorts in full when a roots reset dropped the per-file values meanwhile. - The stored spelling is checked on Windows too (nt._getfinalpathname: realpath keeps the given case when it cannot open the file), and before the name filters, which compare the walk's stored spelling. macOS opens non-blocking, so a FIFO named like an image never holds the executor. - An output in a folder the walk cannot list (no read permission) is not added, as the walk skips it. - Windows-portable unit tests: POSIX paths in the insert tests, and a str.lower fold for the length guard (Windows' normcase keeps lengths). * test(images): reproduce the newest-order race and fix a Windows clock flake (#348, PR 2) - The race test now has the newer build step from the values the cached order was built from, which is the interleaving that inserted a file twice; it fails without the parts check in _newest_step. - test_a_failed_walk_records_its_timing_and_clears_dirty compared two monotonic readings that Windows' ~15.6 ms clock can make equal; the previous start is moved back instead. - TESTING.md: os.access ignores ACLs on Windows.
3762 lines
150 KiB
Python
3762 lines
150 KiB
Python
"""Image and gallery API routes for PromptManager."""
|
|
|
|
import asyncio
|
|
import base64
|
|
import binascii
|
|
import functools
|
|
import hashlib
|
|
import hmac
|
|
import io
|
|
import json
|
|
import os
|
|
import re
|
|
import stat
|
|
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
|
|
import sys
|
|
import time as _time
|
|
import urllib.parse
|
|
from bisect import bisect_left, bisect_right, insort
|
|
from collections.abc import Mapping
|
|
from pathlib import Path
|
|
from typing import Callable, NamedTuple
|
|
|
|
from aiohttp import web
|
|
from PIL import Image, UnidentifiedImageError
|
|
|
|
from .jobs import MAX_OUTCOME_ROWS, THUMBNAILS_JOB_KIND
|
|
from . import gallery_listing
|
|
from .gallery_listing import (
|
|
DISCARDED,
|
|
FAILED,
|
|
MAX_DISCARDED_WALKS,
|
|
WALK_STOPPED,
|
|
WalkOutcome,
|
|
walk_root,
|
|
)
|
|
from . import thumbnail_settings
|
|
from .thumbnail_encode import (
|
|
PREVIEW_HELD,
|
|
EncoderClosed,
|
|
PreviewWriteHeld,
|
|
replace_unless_held,
|
|
run_encoder,
|
|
sweep_stray_temps,
|
|
temp_sibling,
|
|
thumbnail_frame,
|
|
writer_settings,
|
|
write_thumbnail,
|
|
)
|
|
from .prompts import bad_request, parse_page_params, publish_image_paths
|
|
from .prompts import thumbnail_url_for
|
|
from .prompts import safe_error_message as _safe_error
|
|
from .inspection_service import (
|
|
CurrentFileChanged,
|
|
IMAGE_EXTENSIONS as _IMAGE_EXTENSIONS,
|
|
SERVABLE_EXTENSIONS,
|
|
VIDEO_EXTENSIONS,
|
|
inspect_current_file,
|
|
)
|
|
|
|
try:
|
|
from ...database.image_favorites import MAX_KEY_LENGTH
|
|
from ...database.library import display_title
|
|
from ...database.models import normalize_image_path
|
|
from ...utils.card_seed import card_seed, header_params, parsed_positive
|
|
from ...utils.parallel import map_parallel
|
|
from ...utils.json_strict import dumps_strict
|
|
from ...utils.inspection_response import (
|
|
encode_inspection_response,
|
|
library_metadata,
|
|
)
|
|
from ...utils.prompt_inspector import MetadataReadError, read_inspection_file
|
|
from ...utils.file_reveal import (
|
|
OpenerUnavailable,
|
|
RevealGate,
|
|
RevealTargetChanged,
|
|
is_local_request,
|
|
reveal_executor,
|
|
reveal_in_file_manager,
|
|
)
|
|
except ImportError:
|
|
from database.image_favorites import MAX_KEY_LENGTH
|
|
from database.library import display_title
|
|
from database.models import normalize_image_path
|
|
from utils.card_seed import card_seed, header_params, parsed_positive
|
|
from utils.parallel import map_parallel
|
|
from utils.json_strict import dumps_strict
|
|
from utils.inspection_response import (
|
|
encode_inspection_response,
|
|
library_metadata,
|
|
)
|
|
from utils.prompt_inspector import MetadataReadError, read_inspection_file
|
|
from utils.file_reveal import (
|
|
OpenerUnavailable,
|
|
RevealGate,
|
|
RevealTargetChanged,
|
|
is_local_request,
|
|
reveal_executor,
|
|
reveal_in_file_manager,
|
|
)
|
|
|
|
|
|
IMAGE_EXTENSIONS = _IMAGE_EXTENSIONS
|
|
# The header records the card parses (see utils.card_seed.header_params).
|
|
_CARD_RECORDS = ("prompt", "workflow")
|
|
_PNG_SIGNATURE = b"\x89PNG\r\n\x1a\n"
|
|
_LISTING_FAILED = "The image list could not be filtered. Check server logs for details."
|
|
|
|
|
|
def _card_header(path: Path) -> tuple:
|
|
"""``(width, height, records)`` of one image card, every handle closed.
|
|
|
|
A PNG's records come from the inspector's own reader
|
|
(``read_inspection_file``: text after IDAT, the shared byte budgets, and
|
|
the refusal of conflicting duplicate prompt or workflow chunks), so the
|
|
card reads exactly what the inspector reads. When that reader refuses the
|
|
file, the card keeps its dimensions and gets no records. Other formats
|
|
keep Pillow's header ``info``, as the inspector's header read does.
|
|
Raises OSError or ValueError for a file that cannot be read at all.
|
|
"""
|
|
with path.open("rb") as stream:
|
|
if stream.read(len(_PNG_SIGNATURE)) != _PNG_SIGNATURE:
|
|
stream.seek(0)
|
|
with Image.open(stream) as source:
|
|
width, height = source.size
|
|
return width, height, {k: source.info.get(k) for k in _CARD_RECORDS}
|
|
try:
|
|
inspection = read_inspection_file(path)
|
|
except MetadataReadError:
|
|
with path.open("rb") as stream, Image.open(stream) as source:
|
|
width, height = source.size
|
|
return width, height, None
|
|
records = {k: inspection.metadata.get(k) for k in _CARD_RECORDS}
|
|
return inspection.width, inspection.height, records
|
|
|
|
|
|
# The legacy gallery page's thumbnail generation, as Activity lists it (D80).
|
|
THUMBNAIL_GENERATION_TITLE = "Thumbnail generation (gallery page)"
|
|
# Its clear: automatic previews are not written while it runs.
|
|
THUMBNAIL_CLEAR_TITLE = "Thumbnail clear (gallery page)"
|
|
|
|
|
|
class _CardFacts(NamedTuple):
|
|
"""One image file's card facts, derived from its header alone.
|
|
|
|
Cached per gallery scan by ``(path, mtime)`` (see ``_card_facts``); links
|
|
and favorites are read fresh on every page.
|
|
"""
|
|
|
|
width: int | None
|
|
height: int | None
|
|
seed: str | None
|
|
positive_title: str | None # _positive_title of the parsed positive
|
|
|
|
|
|
_NO_CARD_FACTS = _CardFacts(None, None, None, None)
|
|
|
|
|
|
def _unavailable_link() -> dict:
|
|
"""A card link summary when the database could not be read."""
|
|
return {
|
|
"status": "unavailable",
|
|
"positive_id": None,
|
|
"title": None,
|
|
"title_source": None,
|
|
"category": None,
|
|
"rating": None,
|
|
}
|
|
|
|
|
|
def _positive_title(positive: str | None) -> str | None:
|
|
"""The display title of a file's parsed positive (None without one)."""
|
|
return None if positive is None else display_title((), positive)
|
|
|
|
|
|
def _verified_title(summary: dict | None, positive_title: str | None) -> str | None:
|
|
"""A linked file's Library title, withholding unconfirmed saved prompt text.
|
|
|
|
The one rule for the card and the inspector. A stored title is user data and
|
|
always shows. A title taken from the saved positive's first line shows only
|
|
when ``positive_title`` (``_positive_title`` of the file's own positive as
|
|
the shared parser reads it from the file's records) is the same title: a
|
|
saved association never replaces file evidence.
|
|
"""
|
|
if not isinstance(summary, dict) or summary.get("status") != "linked":
|
|
return None
|
|
title = summary.get("title")
|
|
if summary.get("title_source") != "text":
|
|
return title
|
|
confirmed = positive_title is not None and positive_title == title
|
|
return title if confirmed else None
|
|
|
|
|
|
def _card_link(summary: dict, positive_title: str | None) -> dict:
|
|
"""The card's link summary with an unconfirmed first-line title withheld."""
|
|
if summary.get("title_source") != "text":
|
|
return summary
|
|
return {**summary, "title": _verified_title(summary, positive_title)}
|
|
|
|
|
|
def _inspected_positive(observed: dict) -> str | None:
|
|
"""The positive the shared parser reads from the inspected file's records.
|
|
|
|
Videos give None, as on the card, which reads no video metadata.
|
|
"""
|
|
inspection = observed.get("inspection")
|
|
if inspection is None or observed["file"]["media_type"] == "video":
|
|
return None
|
|
return parsed_positive(header_params(inspection.metadata))
|
|
|
|
|
|
def _public_path(path):
|
|
"""Response-safe form of *path* (relative to the ComfyUI tree).
|
|
|
|
Imported lazily: the package defines it after importing this module.
|
|
"""
|
|
from . import _public_path as public_path
|
|
|
|
return public_path(path)
|
|
|
|
|
|
def _canonical(path):
|
|
"""Resolve symlinks and normalise case so paths compare on every OS."""
|
|
return Path(os.path.normcase(os.path.realpath(str(path))))
|
|
|
|
|
|
def _is_within(path, root):
|
|
"""True when *path* (after realpath) lives under *root* (after realpath)."""
|
|
return _canonical(path).is_relative_to(_canonical(root))
|
|
|
|
|
|
def _has_traversal(relative):
|
|
"""True for absolute paths, drive-qualified paths or any ``..`` segment."""
|
|
if not relative or os.path.isabs(relative) or os.path.splitdrive(relative)[0]:
|
|
return True
|
|
if relative[0] in ("/", "\\"):
|
|
return True
|
|
return ".." in re.split(r"[\\/]+", relative)
|
|
|
|
|
|
def _is_thumbnail_cache_path(relative):
|
|
"""True when *relative* names something in a root's ``thumbnails/``
|
|
cache (any case, either separator): never inspected as a media file."""
|
|
first = re.split(r"[\\/]+", relative.lstrip("\\/"), maxsplit=1)[0]
|
|
return first.casefold() == "thumbnails"
|
|
|
|
|
|
def _forbidden(message="Access denied"):
|
|
return web.json_response({"success": False, "error": message}, status=403)
|
|
|
|
|
|
def _media_access_error(path, allowed_dirs):
|
|
"""Return a 403 response when *path* must not be served, else None.
|
|
|
|
Fails closed: an empty *allowed_dirs* denies everything.
|
|
"""
|
|
if Path(path).suffix.lower() not in SERVABLE_EXTENSIONS:
|
|
return _forbidden("Only media files can be served")
|
|
if not allowed_dirs:
|
|
return _forbidden("No allowed image directories configured")
|
|
if not any(_is_within(path, root) for root in allowed_dirs):
|
|
return _forbidden()
|
|
return None
|
|
|
|
|
|
# Media types the gallery scans and thumbnails (a superset is never served).
|
|
GALLERY_IMAGE_EXTENSIONS = frozenset({".png", ".jpg", ".jpeg", ".webp", ".gif"})
|
|
MEDIA_EXTENSIONS = GALLERY_IMAGE_EXTENSIONS | VIDEO_EXTENSIONS
|
|
|
|
# Caps on request-triggered filesystem work.
|
|
MAX_SCAN_DEPTH = 12
|
|
MAX_THUMBNAIL_FILES = 10_000
|
|
# Already-fresh files a thumbnail job lists under Skipped (its counter stays exact).
|
|
MAX_FRESH_LISTED = MAX_OUTCOME_ROWS
|
|
|
|
|
|
def _worker_threads():
|
|
"""Configured thread count for thumbnail work.
|
|
|
|
Imported lazily because py.config imports this package.
|
|
"""
|
|
from ..config import PromptManagerConfig
|
|
|
|
return PromptManagerConfig.WORKER_THREADS
|
|
|
|
|
|
def _thumbnail_batch_size(workers):
|
|
"""Files per executor job: enough to keep every worker thread busy."""
|
|
return max(4, workers * 2)
|
|
|
|
|
|
# Preview files are written by thumbnail_encode in the configured format
|
|
# (thumbnail_settings, #317); these names stay importable from here.
|
|
_temp_sibling = temp_sibling
|
|
_replace_unless_held = replace_unless_held
|
|
|
|
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
|
|
# over a file another process has open (WinError 5 or 32), such as a preview
|
|
# the gallery is serving; the old preview stays and the file counts as an error.
|
|
PREVIEW_IN_USE = "Preview in use by another program; the old one was kept"
|
|
PREVIEW_NOT_WRITABLE = "Permission denied writing the preview"
|
|
_WINDOWS_LOCKED = (5, 32) # ERROR_ACCESS_DENIED, ERROR_SHARING_VIOLATION
|
|
|
|
|
|
def _preview_write_error(exc):
|
|
"""The readable error for a PermissionError while writing a preview."""
|
|
if getattr(exc, "winerror", None) in _WINDOWS_LOCKED:
|
|
return PREVIEW_IN_USE
|
|
return PREVIEW_NOT_WRITABLE
|
|
|
|
|
|
def _write_image_thumbnail(src, dst, settings, guard=None, track=None):
|
|
"""Write *src*'s preview into *dst* with *settings* (blocking, atomic).
|
|
|
|
The first frame, fitted inside the edge, in the configured format;
|
|
returns the path written (see thumbnail_encode.write_thumbnail).
|
|
"""
|
|
with Image.open(src) as img:
|
|
frame = thumbnail_frame(img, settings.edge)
|
|
return write_thumbnail(frame, dst, settings, guard=guard, track=track)
|
|
|
|
|
|
def _video_placeholder_image(thumbnail_size):
|
|
"""The placeholder frame for a video no decoder could read: a play mark."""
|
|
from PIL import ImageDraw, ImageFont
|
|
|
|
img = Image.new("RGB", thumbnail_size, color=(50, 50, 50))
|
|
draw = ImageDraw.Draw(img)
|
|
center_x, center_y = thumbnail_size[0] // 2, thumbnail_size[1] // 2
|
|
triangle_size = min(thumbnail_size) // 4
|
|
points = [
|
|
(center_x - triangle_size // 2, center_y - triangle_size // 2),
|
|
(center_x - triangle_size // 2, center_y + triangle_size // 2),
|
|
(center_x + triangle_size // 2, center_y),
|
|
]
|
|
draw.polygon(points, fill=(255, 255, 255))
|
|
try:
|
|
font = ImageFont.load_default()
|
|
text = "VIDEO"
|
|
bbox = draw.textbbox((0, 0), text, font=font)
|
|
text_width = bbox[2] - bbox[0]
|
|
draw.text(
|
|
(center_x - text_width // 2, center_y + triangle_size // 2 + 10),
|
|
text,
|
|
fill=(255, 255, 255),
|
|
font=font,
|
|
)
|
|
except (OSError, AttributeError):
|
|
pass
|
|
return img
|
|
|
|
|
|
def _is_decode_error(exc):
|
|
"""True when *exc* says the file itself cannot be decoded (#249 M4).
|
|
|
|
Pillow's decoder errors are OSErrors without an errno; a real I/O error
|
|
(disk full, a share gone) carries one and is worth another try.
|
|
"""
|
|
if isinstance(exc, (UnidentifiedImageError, Image.DecompressionBombError)):
|
|
return True
|
|
if isinstance(exc, SyntaxError):
|
|
return True
|
|
return isinstance(exc, OSError) and exc.errno is None
|
|
|
|
|
|
def _new_thumbnail_stats():
|
|
return {"generated": 0, "skipped": 0, "errors": []}
|
|
|
|
|
|
def _record_thumbnail_result(stats, result):
|
|
action = result["action"]
|
|
if action == "generated":
|
|
stats["generated"] += 1
|
|
elif action == "skipped":
|
|
stats["skipped"] += 1
|
|
else:
|
|
stats["errors"].append(f"{result['file']}: {result.get('error', 'failed')}")
|
|
|
|
|
|
def _thumbnail_progress_payload(index, total, stats, result, start):
|
|
elapsed = _time.monotonic() - start
|
|
rate = index / elapsed if elapsed > 0 else 0
|
|
eta = (total - index) / rate if rate > 0 else 0
|
|
return {
|
|
"processed": index,
|
|
"total_images": total,
|
|
"generated": stats["generated"],
|
|
"skipped": stats["skipped"],
|
|
"error_count": len(stats["errors"]),
|
|
"percentage": round(index / total * 100, 1) if total else 100.0,
|
|
"rate": round(rate, 1),
|
|
"eta": round(eta),
|
|
"elapsed": round(elapsed, 1),
|
|
"current_file": f"{result['dir']}/{result['file']}",
|
|
"file_type": result["type"],
|
|
"action": result["action"],
|
|
}
|
|
|
|
|
|
def _thumbnail_complete_payload(total, stats, start, remaining=0, processed=None):
|
|
"""Final payload of a thumbnail run.
|
|
|
|
``total`` counts every media file considered (pending, already fresh and
|
|
left over), ``processed`` the files this run actually worked on (defaults
|
|
to ``total``) and ``remaining`` the pending files left for the next run
|
|
because of MAX_THUMBNAIL_FILES.
|
|
"""
|
|
elapsed = _time.monotonic() - start
|
|
processed = total if processed is None else processed
|
|
errors = stats["errors"]
|
|
message = (
|
|
f"Generated {stats['generated']} new thumbnails, "
|
|
f"skipped {stats['skipped']} existing"
|
|
)
|
|
if remaining:
|
|
message += f", {remaining} more to generate on the next run"
|
|
if errors:
|
|
message += f" ({len(errors)} errors occurred)"
|
|
return {
|
|
"count": stats["generated"],
|
|
"skipped": stats["skipped"],
|
|
"total_images": total,
|
|
"remaining": remaining,
|
|
"errors": errors[:10],
|
|
"error_count": len(errors),
|
|
"elapsed_time": round(elapsed, 2),
|
|
"processing_rate": round(processed / elapsed if elapsed > 0 else 0, 2),
|
|
"message": message,
|
|
}
|
|
|
|
|
|
# The per-file states a thumbnail plan counts (#317).
|
|
_PLAN_COUNTS = ("skipped", "remaining", "fresh", "outdated", "missing")
|
|
|
|
|
|
def _preview_names(folders, folder):
|
|
"""{normcased name: mtime} of the regular files in *folder*, listed
|
|
once per plan (a missing folder is empty)."""
|
|
names = folders.get(folder)
|
|
if names is None:
|
|
names = {}
|
|
try:
|
|
with os.scandir(folder) as entries:
|
|
for entry in entries:
|
|
try:
|
|
if entry.is_file(follow_symlinks=False):
|
|
stat = entry.stat(follow_symlinks=False)
|
|
names[os.path.normcase(entry.name)] = stat.st_mtime
|
|
except OSError:
|
|
continue
|
|
except OSError:
|
|
pass
|
|
folders[folder] = names
|
|
return names
|
|
|
|
|
|
def _preview_state(src, folder, stem, settings, folders):
|
|
"""fresh, outdated or missing for *src* (see _plan_thumbnails)."""
|
|
names = _preview_names(folders, folder)
|
|
try:
|
|
src_mtime = src.stat().st_mtime
|
|
except OSError:
|
|
return "missing"
|
|
current = names.get(os.path.normcase(stem + settings.extension))
|
|
others = [
|
|
names[key]
|
|
for key in (
|
|
os.path.normcase(stem + extension)
|
|
for extension in thumbnail_settings.owned_extensions(
|
|
src.suffix, settings.extension
|
|
)
|
|
if extension != settings.extension
|
|
)
|
|
if key in names
|
|
]
|
|
current_fresh = current is not None and current >= src_mtime
|
|
if current_fresh and not others:
|
|
return "fresh"
|
|
if others and (current_fresh or max(others) >= src_mtime):
|
|
return "outdated"
|
|
return "missing"
|
|
|
|
|
|
def _thumbnail_is_fresh(src, dst):
|
|
"""True when ``dst`` exists and is at least as new as ``src`` (blocking stat)."""
|
|
try:
|
|
return dst.stat().st_mtime >= src.stat().st_mtime
|
|
except OSError:
|
|
return False
|
|
|
|
|
|
# Caps on request-driven work (list sizes, scans, request bodies).
|
|
MAX_GALLERY_FILES = 50_000
|
|
MAX_BULK_LIMIT = 5_000
|
|
MAX_JSON_BODY_BYTES = 1024 * 1024
|
|
_READ_CHUNK = 64 * 1024
|
|
|
|
|
|
_WORKSPACE_CURSOR_SECRET = os.urandom(32)
|
|
|
|
|
|
def _workspace_root(query: dict, roots: list[Path]) -> int | None:
|
|
value = query.get("root")
|
|
if value is None:
|
|
return None
|
|
try:
|
|
index = int(value)
|
|
except ValueError:
|
|
raise ValueError("Invalid gallery root") from None
|
|
if not 0 <= index < len(roots):
|
|
raise ValueError("Invalid gallery root")
|
|
return index
|
|
|
|
|
|
WORKSPACE_ORDERS = ("newest", "oldest", "name", "name-desc", "rating")
|
|
# Library-compatible bounds on the linked-positive filters (database/library.py).
|
|
MAX_FILTER_CATEGORY = 200
|
|
MAX_FILTER_TAGS = 30
|
|
MAX_FILTER_TAG = 200
|
|
|
|
|
|
class _Listed(NamedTuple):
|
|
"""One listed file plus the derived values every filter and sort reads.
|
|
|
|
Built once per gallery scan (see ``_workspace_orders``); the first four
|
|
fields are the ``(path, mtime, root, index)`` entry the page formatter takes.
|
|
"""
|
|
|
|
path: Path
|
|
mtime: float
|
|
root: Path
|
|
index: int
|
|
folded: str # relative POSIX path, case-folded (filename search)
|
|
folder: str # str(relative.parent), native separators (subfolder filter)
|
|
name: str # case-folded basename (name sorts)
|
|
tie: str # sha256 of the relative POSIX path (bounded tie-break)
|
|
video: bool
|
|
normalized: str # normalize_image_path, compared with linked rows
|
|
|
|
|
|
def _listed(path: Path, mtime: float, root: Path, index: int) -> _Listed:
|
|
text, prefix = str(path), os.path.join(str(root), "")
|
|
relative = (
|
|
text[len(prefix) :] if text.startswith(prefix) else str(path.relative_to(root))
|
|
)
|
|
posix = relative if os.sep == "/" else relative.replace(os.sep, "/")
|
|
name = relative.rpartition(os.sep)[2]
|
|
return _Listed(
|
|
path,
|
|
mtime,
|
|
root,
|
|
index,
|
|
posix.casefold(),
|
|
os.path.dirname(relative) or ".",
|
|
name.casefold(),
|
|
hashlib.sha256(posix.encode("utf-8")).hexdigest(),
|
|
os.path.splitext(name)[1].lower() in VIDEO_EXTENSIONS,
|
|
normalize_image_path(text),
|
|
)
|
|
|
|
|
|
class _ImageTagMatch(NamedTuple):
|
|
"""How one listed file's current image tags meet the filters (#249 B3)."""
|
|
|
|
tags: bool # every filter tag is in its image tags or linked positive tags
|
|
query: bool # ``q`` is a case-folded substring of one image tag name
|
|
link: tuple | None # (hit, rating, positive id) of its unambiguous link
|
|
|
|
|
|
def _current_fingerprint(path: Path) -> tuple[int, int] | None:
|
|
"""The file's present ``(st_size, st_mtime_ns)``; None when it is gone."""
|
|
try:
|
|
observed = os.stat(path)
|
|
except OSError:
|
|
return None
|
|
return observed.st_size, observed.st_mtime_ns
|
|
|
|
|
|
def _image_tags_relevant(names: list, wanted: set, query: str) -> bool:
|
|
if wanted and not wanted.isdisjoint(names):
|
|
return True
|
|
return bool(query) and any(query in name.casefold() for name in names)
|
|
|
|
|
|
def _workspace_key(entry: tuple, order: str) -> tuple:
|
|
"""The keyset sort key of *entry* (a _Listed or a 4-tuple entry)."""
|
|
if not isinstance(entry, _Listed):
|
|
entry = _listed(*entry)
|
|
if order in ("name", "name-desc"):
|
|
return (entry.name, entry.tie, entry.index, entry.mtime)
|
|
mtime = -entry.mtime if order in ("newest", "rating") else entry.mtime
|
|
return (mtime, entry.name, entry.tie, entry.index)
|
|
|
|
|
|
def _newest_key(entry: _Listed) -> tuple:
|
|
return _workspace_key(entry, "newest")
|
|
|
|
|
|
def _rated_key(entry: _Listed, rank: int) -> tuple:
|
|
"""Highest rated: rating 5..1, linked unrated (0), unlinked (-1); then newest."""
|
|
return (-rank, *_workspace_key(entry, "rating"))
|
|
|
|
|
|
def _workspace_cursor(key: tuple, scope: str) -> str:
|
|
payload = json.dumps(
|
|
{"v": 1, "scope": scope, "key": key}, separators=(",", ":"), ensure_ascii=False
|
|
).encode()
|
|
signature = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
|
|
return base64.urlsafe_b64encode(signature + payload).decode().rstrip("=")
|
|
|
|
|
|
def _workspace_cursor_key(token: str, scope: str, order: str) -> tuple:
|
|
try:
|
|
if len(token) > 4096:
|
|
raise ValueError()
|
|
raw = base64.b64decode(
|
|
token + "=" * (-len(token) % 4), altchars=b"-_", validate=True
|
|
)
|
|
signature, payload = raw[:32], raw[32:]
|
|
expected = hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).digest()
|
|
if not hmac.compare_digest(signature, expected):
|
|
raise ValueError()
|
|
data = json.loads(payload)
|
|
key = data["key"]
|
|
if data["v"] != 1 or data["scope"] != scope or not isinstance(key, list):
|
|
raise ValueError()
|
|
types = (
|
|
(str, str, int, (float, int))
|
|
if order in ("name", "name-desc")
|
|
else ((float, int), str, str, int)
|
|
)
|
|
if order == "rating":
|
|
types = (int, *types)
|
|
if len(key) != len(types) or not all(
|
|
type(value) in (kind if isinstance(kind, tuple) else (kind,))
|
|
for value, kind in zip(key, types)
|
|
):
|
|
raise ValueError()
|
|
return tuple(key)
|
|
except (ValueError, KeyError, TypeError, UnicodeError, binascii.Error):
|
|
raise ValueError("Invalid gallery cursor; refresh the gallery") from None
|
|
|
|
|
|
class _BodyTooLarge(Exception):
|
|
"""Raised when a request body exceeds MAX_JSON_BODY_BYTES."""
|
|
|
|
|
|
def _json_error(message, status):
|
|
return web.json_response({"success": False, "error": message}, status=status)
|
|
|
|
|
|
async def _read_json_body(request):
|
|
"""Read a JSON object body in bounded chunks.
|
|
|
|
Raises _BodyTooLarge past MAX_JSON_BODY_BYTES (declared or streamed) and
|
|
ValueError when the body is not a JSON object.
|
|
"""
|
|
limit = MAX_JSON_BODY_BYTES
|
|
if request.content_length is not None and request.content_length > limit:
|
|
raise _BodyTooLarge()
|
|
chunks, size = [], 0
|
|
while True:
|
|
chunk = await request.content.read(_READ_CHUNK)
|
|
if not chunk:
|
|
break
|
|
size += len(chunk)
|
|
if size > limit:
|
|
raise _BodyTooLarge()
|
|
chunks.append(chunk)
|
|
data = json.loads(b"".join(chunks).decode("utf-8"))
|
|
if not isinstance(data, dict):
|
|
raise ValueError("JSON body must be an object")
|
|
return data
|
|
|
|
|
|
async def _json_body_or_error(request):
|
|
"""Return ``(data, None)`` or ``(None, error_response)``."""
|
|
try:
|
|
return await _read_json_body(request), None
|
|
except _BodyTooLarge:
|
|
return None, _json_error("Request body too large", 413)
|
|
except (ValueError, UnicodeDecodeError):
|
|
return None, _json_error("Invalid JSON body", 400)
|
|
|
|
|
|
MAX_FAVORITE_PATH = 4096
|
|
|
|
|
|
def _favorite_target(data: Mapping, roots: list[Path]):
|
|
"""``((index, relative), None)`` for a favorite body, else ``(None, error)``.
|
|
|
|
Rejects unknown roots, traversal, absolute or drive paths and non-media
|
|
names before any filesystem or database access.
|
|
"""
|
|
index, relative = data.get("root_index"), data.get("path")
|
|
if type(index) is not int or not 0 <= index < len(roots):
|
|
return None, bad_request("Invalid gallery root")
|
|
if (
|
|
not isinstance(relative, str)
|
|
or not relative
|
|
or len(relative) > MAX_FAVORITE_PATH
|
|
):
|
|
return None, bad_request("path must be a relative media path")
|
|
if (
|
|
_has_traversal(relative)
|
|
or "\0" in relative
|
|
or re.match(r"^[a-zA-Z]:", relative)
|
|
):
|
|
return None, _forbidden()
|
|
if Path(relative).suffix.lower() not in MEDIA_EXTENSIONS:
|
|
return None, _forbidden("Only media files can be favorites")
|
|
return (index, relative), None
|
|
|
|
|
|
def _reveal_target(data: Mapping):
|
|
"""``((index, scope, relative), None)`` for a reveal body, else an error.
|
|
|
|
Only the listing identity is accepted: an int root index, the root's
|
|
64-hex scope and a bounded root-relative path, never an absolute,
|
|
drive-qualified or ``..`` path.
|
|
"""
|
|
index, scope, relative = (
|
|
data.get("root_index"),
|
|
data.get("root_scope"),
|
|
data.get("path"),
|
|
)
|
|
if type(index) is not int or index < 0:
|
|
return None, bad_request("Invalid gallery root")
|
|
if not isinstance(scope, str) or re.fullmatch(r"[0-9a-f]{64}", scope) is None:
|
|
return None, bad_request("Invalid gallery root scope")
|
|
if (
|
|
not isinstance(relative, str)
|
|
or not relative
|
|
or len(relative) > MAX_FAVORITE_PATH
|
|
or "\0" in relative
|
|
or _has_traversal(relative)
|
|
or re.match(r"^[a-zA-Z]:", relative)
|
|
):
|
|
return None, bad_request("path must be a relative media path")
|
|
return (index, scope, relative), None
|
|
|
|
|
|
def _reveal_not_found():
|
|
return web.json_response(
|
|
{"success": False, "error": "Image file not found"}, status=404
|
|
)
|
|
|
|
|
|
def _not_local():
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"code": "not-local",
|
|
"error": "Show in folder works only on the computer running ComfyUI.",
|
|
},
|
|
status=403,
|
|
)
|
|
|
|
|
|
# The path module whose case rules key favorites (tests swap in ntpath).
|
|
_path_flavor = os.path
|
|
|
|
|
|
def _favorite_relative_key(relative: str) -> str:
|
|
"""Case-normalized POSIX key of a root-relative path.
|
|
|
|
Lowercased with "/" separators where the platform folds case (Windows),
|
|
identity on POSIX, so a case-only rename keeps its favorite on Windows
|
|
and distinct Linux files stay distinct.
|
|
"""
|
|
return _path_flavor.normcase(relative).replace(_path_flavor.sep, "/")
|
|
|
|
|
|
def _listing_posix(root, prefix, path) -> str:
|
|
"""*path*'s POSIX spelling below *root* (*prefix*: ``join(root, "")``)."""
|
|
text = str(path)
|
|
if text.startswith(prefix):
|
|
relative = text[len(prefix) :]
|
|
return relative if os.sep == "/" else relative.replace(os.sep, "/")
|
|
return path.relative_to(root).as_posix()
|
|
|
|
|
|
def _favorite_listing_step(root, listing, added, removed) -> dict | None:
|
|
"""*listing* after a patched listing's change, or None to rebuild it.
|
|
|
|
Only when every removed file is added again under the same spelling (a
|
|
rewritten output) and no added file's key is already taken by another
|
|
spelling: the full build keeps the first spelling in listing order.
|
|
"""
|
|
prefix = os.path.join(str(root), "")
|
|
spellings = {_listing_posix(root, prefix, path) for path, _mtime in added}
|
|
if any(_listing_posix(root, prefix, path) not in spellings for path, _ in removed):
|
|
return None
|
|
listing = dict(listing)
|
|
for posix in spellings:
|
|
known = listing.setdefault(_favorite_relative_key(posix), posix)
|
|
if known != posix:
|
|
return None
|
|
return listing
|
|
|
|
|
|
def _favorite_root_key(root) -> str:
|
|
"""Server-side identity of a gallery root: its real, case-normalized path.
|
|
|
|
Never sent in a response; folder names alone are not unique across roots.
|
|
"""
|
|
return _favorite_relative_key(os.path.realpath(str(root)))
|
|
|
|
|
|
def _name_suffix(name):
|
|
"""``Path(name).suffix`` as Python 3.10-3.13 compute it.
|
|
|
|
The last ``.``-part unless the dot is the first or last character, so
|
|
``..png`` is ``.png`` and ``.png`` has none. Spelled out because 3.14
|
|
changed ``Path.suffix`` and ``os.path.splitext`` differs for leading dots.
|
|
"""
|
|
dot = name.rfind(".")
|
|
return name[dot:] if 0 < dot < len(name) - 1 else ""
|
|
|
|
|
|
def _walk_media(output_path, max_files):
|
|
"""Yield the ``os.DirEntry`` of each media file under *output_path*.
|
|
|
|
Symlinks are never followed, ``thumbnails/`` is skipped, descent stops
|
|
at MAX_SCAN_DEPTH and at most *max_files* entries are produced. The order
|
|
is ``os.walk``'s (top-down, subfolders in ``os.scandir`` order, files by
|
|
name), which the gallery's ties in mtime keep. Each entry's type comes
|
|
from the directory listing itself, so a walk builds no ``Path`` and makes
|
|
no extra ``lstat`` per file (#348: a 20,000-file walk spent half its time
|
|
there).
|
|
"""
|
|
stack = [(os.fspath(output_path), ())]
|
|
produced = 0
|
|
while stack:
|
|
top, rel_parts = stack.pop()
|
|
if "thumbnails" in rel_parts:
|
|
continue
|
|
dirs, files = [], []
|
|
try:
|
|
with os.scandir(top) as entries:
|
|
for entry in entries:
|
|
try:
|
|
is_dir = entry.is_dir()
|
|
except OSError:
|
|
is_dir = False
|
|
(dirs if is_dir else files).append(entry)
|
|
except OSError:
|
|
continue # as os.walk: an unreadable folder is skipped
|
|
for entry in sorted(files, key=lambda item: item.name):
|
|
if _name_suffix(entry.name).lower() not in MEDIA_EXTENSIONS:
|
|
continue
|
|
if entry.is_symlink():
|
|
continue # never read through a link out of the output tree
|
|
if produced >= max_files:
|
|
return
|
|
produced += 1
|
|
yield entry
|
|
if len(rel_parts) >= MAX_SCAN_DEPTH:
|
|
continue
|
|
for entry in reversed(dirs):
|
|
if not entry.is_symlink():
|
|
stack.append((entry.path, rel_parts + (entry.name,)))
|
|
|
|
|
|
def _settled_entry(root, relative):
|
|
"""``(Path, mtime)`` of a settled output as a walk would list it.
|
|
|
|
*relative* is its part below *root* (``gallery_listing.relative_part``).
|
|
None when a walk would not list it (``_walk_media``'s filters, and every
|
|
folder on the way must be listable); raises ``OSError`` when that cannot
|
|
be told (the file is gone or unreadable, or its on-disk spelling differs
|
|
or cannot be read). Blocking: run it in the executor.
|
|
"""
|
|
path = Path(os.path.join(os.fspath(root), relative))
|
|
info = os.lstat(path)
|
|
if stat.S_ISLNK(info.st_mode) or stat.S_ISDIR(info.st_mode):
|
|
return None
|
|
# Before the name filters: they compare the walk's (stored) spelling.
|
|
on_disk = _on_disk_path(path)
|
|
if on_disk is not None and not on_disk.endswith(os.sep + relative):
|
|
raise OSError(f"{relative} is spelled differently on disk")
|
|
*folders, name = relative.split(os.sep)
|
|
if _name_suffix(name).lower() not in MEDIA_EXTENSIONS:
|
|
return None
|
|
if "thumbnails" in folders or len(folders) > MAX_SCAN_DEPTH:
|
|
return None
|
|
folder = os.fspath(root)
|
|
for part in ("", *folders):
|
|
folder = os.path.join(folder, part) if part else folder
|
|
if not os.access(folder, os.R_OK | os.X_OK):
|
|
return None # the walk cannot list this folder
|
|
return path, info.st_mtime
|
|
|
|
|
|
def _on_disk_path(path, platform=None):
|
|
"""The file's path as its folders store the names, where that can differ.
|
|
|
|
Windows and macOS file systems ignore case. Windows' ``realpath`` gives
|
|
the stored case, but keeps the given one when it cannot open the file;
|
|
macOS' ``realpath`` always keeps the given one. A walk lists the names as
|
|
stored, so an add checks them: Windows through ``GetFinalPathNameByHandle``
|
|
(``nt._getfinalpathname``), macOS through ``F_GETPATH``. Raises OSError
|
|
when it cannot be read; None elsewhere (case-sensitive file systems).
|
|
"""
|
|
platform = platform or sys.platform
|
|
if platform == "win32":
|
|
import nt
|
|
|
|
return nt._getfinalpathname(os.fspath(path))
|
|
if platform != "darwin":
|
|
return None
|
|
import fcntl
|
|
|
|
# Non-blocking: a FIFO named like an image must not hold the executor.
|
|
descriptor = os.open(path, os.O_RDONLY | getattr(os, "O_NONBLOCK", 0))
|
|
try:
|
|
raw = fcntl.fcntl(descriptor, fcntl.F_GETPATH, bytes(1024))
|
|
finally:
|
|
os.close(descriptor)
|
|
return os.fsdecode(raw.split(b"\0", 1)[0])
|
|
|
|
|
|
def _patch_listing(root, files, folders, with_ancestors, relatives):
|
|
"""The listing with each settled output in *relatives* added (blocking).
|
|
|
|
Returns ``(files, folders, with_ancestors, unresolved)``; *files* is the
|
|
same list when nothing changed. ``unresolved`` is True when an output
|
|
could not be placed exactly, so the root needs a walk.
|
|
"""
|
|
unresolved = False
|
|
for relative in relatives:
|
|
try:
|
|
entry = _settled_entry(root, relative)
|
|
except OSError:
|
|
unresolved = True
|
|
continue
|
|
if entry is None:
|
|
continue # a walk would not list it either
|
|
patched = gallery_listing.insert_entry(files, entry, MAX_GALLERY_FILES)
|
|
if patched is None:
|
|
unresolved = True
|
|
elif patched is not files:
|
|
files = patched
|
|
folders, with_ancestors = gallery_listing.add_folder(
|
|
root, entry[0], folders, with_ancestors
|
|
)
|
|
return files, folders, with_ancestors, unresolved
|
|
|
|
|
|
def _iter_media_files(output_path, max_files):
|
|
"""Yield the ``Path`` of each media file under *output_path* (see _walk_media)."""
|
|
for entry in _walk_media(output_path, max_files):
|
|
yield Path(entry.path)
|
|
|
|
|
|
def media_thumbnail_rel(rel_path, settings=None):
|
|
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
|
|
the name the preview gets when it is written now (#317), images and
|
|
videos alike (``writer_settings``' extension)."""
|
|
settings = settings or writer_settings()
|
|
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
|
|
|
|
|
|
def existing_media_thumbnail(output_path, rel_path):
|
|
"""The existing preview *rel_path* owns that a listing or cover shows
|
|
(the format written now first, then older ones), or None. The Images
|
|
listing and History's cover use this one lookup (review M5)."""
|
|
return thumbnail_settings.existing_thumbnail(
|
|
output_path, rel_path, writer_settings().extension
|
|
)
|
|
|
|
|
|
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
|
|
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
|
|
blocking stats). The Images listing, the Needs review queue cards and the
|
|
Library use the same URL (#249 F, M10).
|
|
|
|
An existing preview the file owns (the configured format first, then an
|
|
older name such as ``_thumb.png``, #317) is listed with its mtime as
|
|
``v=`` and ``ready`` True. A missing one is still listed under the name
|
|
it will get (without ``v=``): the serve route makes it on first view and
|
|
falls back when it cannot (py/api/thumbnail_auto.py), so no card loads the
|
|
full file because its preview was not made yet. Media inside a nested
|
|
``thumbnails`` folder is never previewed: ``(None, False)``, and its card
|
|
shows the original.
|
|
"""
|
|
rel_path = Path(rel_path)
|
|
if "thumbnails" in rel_path.parts[:-1]:
|
|
return None, False
|
|
existing = existing_media_thumbnail(output_path, rel_path)
|
|
thumbnail_url = None
|
|
if existing is not None:
|
|
thumb_rel = thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
|
|
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
|
|
ready = thumbnail_url is not None
|
|
if thumbnail_url is None:
|
|
thumb_rel = media_thumbnail_rel(rel_path)
|
|
quoted = urllib.parse.quote(thumb_rel, safe="/")
|
|
thumbnail_url = f"/prompt_manager/images/serve/{quoted}?root={root_index}"
|
|
else:
|
|
thumbnail_url += f"&root={root_index}"
|
|
if root_scope is not None:
|
|
thumbnail_url += f"&root_scope={root_scope}"
|
|
return thumbnail_url, ready
|
|
|
|
|
|
def media_thumbnail_url(output_path, rel_path, root_index, *, root_scope=None):
|
|
"""The thumbnail URL of ``media_thumbnail`` (None for nested previews)."""
|
|
return media_thumbnail(output_path, rel_path, root_index, root_scope=root_scope)[0]
|
|
|
|
|
|
def _output_image_entry(
|
|
media_path, output_path, root_index, public_root=None, *, root_scope=None
|
|
):
|
|
"""Build one gallery entry for *media_path* (blocking stat).
|
|
|
|
``path`` is relative to its root (what delete/autotag resolve against) and
|
|
``root_dir`` is the root's public form; neither reveals the server layout.
|
|
"""
|
|
if public_root is None:
|
|
public_root = _public_path(output_path)
|
|
stat = media_path.stat()
|
|
rel_path = media_path.relative_to(output_path)
|
|
extension = media_path.suffix.lower()
|
|
is_video = extension in VIDEO_EXTENSIONS
|
|
|
|
thumbnail_url, thumbnail_ready = media_thumbnail(
|
|
output_path, rel_path, root_index, root_scope=root_scope
|
|
)
|
|
encoded_path = urllib.parse.quote(rel_path.as_posix(), safe="/")
|
|
url = f"/prompt_manager/images/serve/{encoded_path}?root={root_index}"
|
|
if root_scope is not None:
|
|
url += f"&root_scope={root_scope}"
|
|
|
|
entry = {
|
|
"id": hashlib.sha1(
|
|
str(media_path).encode("utf-8"), usedforsecurity=False
|
|
).hexdigest()[:16],
|
|
"filename": media_path.name,
|
|
"path": rel_path.as_posix(),
|
|
"relative_path": str(rel_path),
|
|
"root_dir": public_root,
|
|
"root_index": root_index,
|
|
"url": url,
|
|
"thumbnail_url": thumbnail_url,
|
|
# Whether the preview file exists yet (the legacy gallery's dot, #249).
|
|
"thumbnail_ready": thumbnail_ready,
|
|
"size": stat.st_size,
|
|
"modified_time": stat.st_mtime,
|
|
"extension": extension,
|
|
"media_type": "video" if is_video else "image",
|
|
"is_video": is_video,
|
|
}
|
|
if root_scope is not None:
|
|
entry["root_scope"] = root_scope
|
|
return entry
|
|
|
|
|
|
def _workspace_filters(query: Mapping) -> dict:
|
|
"""Validate the workspace listing's filter and sort parameters.
|
|
|
|
``q`` (at most 500 characters) matches the relative path, case-folded, and
|
|
the linked positive's text, title and tag names. ``category`` is exact,
|
|
``tags`` is a comma list that must all be present, ``sort=rating`` ranks by
|
|
the linked positive, and ``favorites=1`` keeps only favorites (the
|
|
Favorites page, #249 D51). Raises ValueError for any unknown or oversized
|
|
value.
|
|
"""
|
|
text = query.get("q", "").strip()
|
|
folded = text.casefold()
|
|
media = query.get("media", "all")
|
|
order = query.get("sort", "newest")
|
|
folder = query.get("subfolder", "").strip().replace("\\", "/")
|
|
# ``path``: one exact relative path (Activity opens a scan row by it, D57).
|
|
exact = query.get("path", "").strip().replace("\\", "/")
|
|
category = query.get("category", "").strip() or None
|
|
tags = sorted(
|
|
{tag.strip() for tag in query.get("tags", "").split(",") if tag.strip()}
|
|
)
|
|
favorites = query.get("favorites", "0")
|
|
if (
|
|
favorites not in ("0", "1")
|
|
or len(text) > 500
|
|
or len(folded) > 500
|
|
or media not in ("all", "image", "video")
|
|
or order not in WORKSPACE_ORDERS
|
|
or (category is not None and len(category) > MAX_FILTER_CATEGORY)
|
|
or len(tags) > MAX_FILTER_TAGS
|
|
or any(len(tag) > MAX_FILTER_TAG for tag in tags)
|
|
):
|
|
raise ValueError("Invalid gallery filters")
|
|
if folder and (_has_traversal(folder) or len(folder) > 1024):
|
|
raise ValueError("Invalid gallery folder")
|
|
if exact and (_has_traversal(exact) or len(exact) > 1024):
|
|
raise ValueError("Invalid gallery path")
|
|
return {
|
|
"text": text,
|
|
"query": folded,
|
|
"media": media,
|
|
"order": order,
|
|
"folder": folder,
|
|
"path": exact.casefold(),
|
|
"category": category,
|
|
"tags": tags,
|
|
"favorites": favorites == "1",
|
|
}
|
|
|
|
|
|
# A plain listing's cursor page leaves positive_count out (see
|
|
# _select_workspace_page); None instead means the count could not be read.
|
|
_COUNT_OMITTED = object()
|
|
|
|
|
|
def _filtered_page(
|
|
entries: list,
|
|
start: int,
|
|
size: int,
|
|
filters: dict,
|
|
links: dict | None,
|
|
favorites: frozenset | None = None,
|
|
image_tags: dict | None = None,
|
|
) -> tuple[int, list, int | None]:
|
|
"""Count the *entries* the filters admit and take *size* from *start* on.
|
|
|
|
*links* maps normalized paths to (hit, rating, positive id) for every
|
|
unambiguously linked image (see PromptDatabase.get_image_link_matches); a
|
|
category or tag filter admits only those, and ``q`` admits a filename match
|
|
or a linked-positive hit. Returns the total, the page and the number of
|
|
distinct linked positives among all admitted entries (None without links).
|
|
*favorites*, when given, admits only those listed paths (``favorites=1``).
|
|
*image_tags* maps normalized paths to their _ImageTagMatch: a tag filter
|
|
also admits a file whose current image tags (with its linked positive's
|
|
tags) carry every tag, and ``q`` also admits an image tag name hit.
|
|
"""
|
|
query, media, folder = filters["query"], filters["media"], filters["folder"]
|
|
members_only = filters["category"] is not None or bool(filters["tags"])
|
|
native_folder = folder.replace("/", os.sep)
|
|
nested = native_folder + os.sep
|
|
total, page, positives = 0, [], set()
|
|
exact = filters.get("path", "")
|
|
for position, entry in enumerate(entries):
|
|
if exact and entry.folded != exact:
|
|
continue
|
|
if folder and not (
|
|
entry.folder == native_folder or entry.folder.startswith(nested)
|
|
):
|
|
continue
|
|
if media != "all" and (media == "video") != entry.video:
|
|
continue
|
|
if favorites is not None and entry.path not in favorites:
|
|
continue
|
|
match = None if links is None else links.get(entry.normalized)
|
|
image = None if image_tags is None else image_tags.get(entry.normalized)
|
|
if members_only and match is None and not (image and image.tags):
|
|
continue
|
|
if (
|
|
query
|
|
and query not in entry.folded
|
|
and not (match and match[0])
|
|
and not (image and image.query)
|
|
):
|
|
continue
|
|
total += 1
|
|
if match is not None:
|
|
positives.add(match[2])
|
|
if position >= start and len(page) < size:
|
|
page.append(entry)
|
|
return total, page, None if links is None else len(positives)
|
|
|
|
|
|
def _in_subfolder(rel_dir, subfolder):
|
|
return rel_dir == subfolder or rel_dir.startswith(subfolder + os.sep)
|
|
|
|
|
|
class ImageRoutesMixin:
|
|
"""Mixin providing image and gallery-related API endpoints."""
|
|
|
|
def _gallery_root_scope(self, root: Path, index: int) -> str:
|
|
payload = json.dumps(
|
|
["prompt-manager-gallery-root-v1", index, str(_canonical(root))],
|
|
separators=(",", ":"),
|
|
ensure_ascii=False,
|
|
).encode("utf-8")
|
|
return hmac.new(_WORKSPACE_CURSOR_SECRET, payload, hashlib.sha256).hexdigest()
|
|
|
|
def _gallery_scope_error(
|
|
self, query: Mapping, roots: list[Path]
|
|
) -> web.Response | None:
|
|
if "root_scope" not in query:
|
|
return None
|
|
scope, root = query.get("root_scope"), query.get("root")
|
|
if (
|
|
not isinstance(scope, str)
|
|
or re.fullmatch(r"[0-9a-f]{64}", scope) is None
|
|
or not isinstance(root, str)
|
|
or re.fullmatch(r"[0-9]+", root) is None
|
|
):
|
|
return bad_request("Invalid gallery root scope")
|
|
try:
|
|
index = int(root)
|
|
except ValueError:
|
|
return bad_request("Invalid gallery root scope")
|
|
if index >= len(roots) or not hmac.compare_digest(
|
|
scope, self._gallery_root_scope(roots[index], index)
|
|
):
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"code": "source-changed",
|
|
"error": (
|
|
"The source changed. Refresh the image collection before "
|
|
"inspecting this file."
|
|
),
|
|
},
|
|
status=409,
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
return None
|
|
|
|
def _register_image_routes(self, routes):
|
|
@routes.get("/prompt_manager/prompts/{prompt_id}/images")
|
|
async def get_prompt_images_route(request):
|
|
return await self.get_prompt_images(request)
|
|
|
|
@routes.get("/prompt_manager/images/recent")
|
|
async def get_recent_images_route(request):
|
|
return await self.get_recent_images(request)
|
|
|
|
@routes.get("/prompt_manager/images/all")
|
|
async def get_all_images_route(request):
|
|
return await self.get_all_images(request)
|
|
|
|
@routes.get("/prompt_manager/images/search")
|
|
async def search_images_route(request):
|
|
return await self.search_images(request)
|
|
|
|
@routes.get("/prompt_manager/images/output")
|
|
async def get_output_images_route(request):
|
|
return await self.get_output_images(request)
|
|
|
|
@routes.get("/prompt_manager/images/{image_id}/file")
|
|
async def serve_image_route(request):
|
|
return await self.serve_image(request)
|
|
|
|
@routes.get("/prompt_manager/images/serve/{filepath:.*}")
|
|
async def serve_output_image_route(request):
|
|
return await self.serve_output_image(request)
|
|
|
|
@routes.post("/prompt_manager/images/link")
|
|
async def link_image_route(request):
|
|
return await self.link_image_to_prompt(request)
|
|
|
|
@routes.get("/prompt_manager/images/prompt/{image_path:.*}")
|
|
async def get_image_prompt_route(request):
|
|
return await self.get_image_prompt(request)
|
|
|
|
@routes.get("/prompt_manager/images/inspect/{image_path:.*}")
|
|
async def inspect_image_route(request):
|
|
return await self.inspect_image(request)
|
|
|
|
@routes.delete("/prompt_manager/images/{image_id}")
|
|
async def delete_image_route(request):
|
|
return await self.delete_image(request)
|
|
|
|
@routes.get("/prompt_manager/images/thumbnails/status")
|
|
async def thumbnail_status_route(request):
|
|
return await self.get_thumbnail_status(request)
|
|
|
|
@routes.get("/prompt_manager/images/thumbnails/plan")
|
|
async def thumbnail_plan_route(request):
|
|
return await self.get_thumbnail_plan(request)
|
|
|
|
@routes.post("/prompt_manager/images/generate-thumbnails")
|
|
async def generate_thumbnails_route(request):
|
|
return await self.generate_thumbnails(request)
|
|
|
|
# POST only: the stream writes files, so it must not be reachable
|
|
# from a prefetch, link preview or <img src>.
|
|
@routes.post("/prompt_manager/images/generate-thumbnails/progress")
|
|
async def generate_thumbnails_progress_route(request):
|
|
return await self.generate_thumbnails_with_progress(request)
|
|
|
|
@routes.post("/prompt_manager/images/clear-thumbnails")
|
|
async def clear_thumbnails_route(request):
|
|
return await self.clear_thumbnails(request)
|
|
|
|
@routes.get("/prompt_manager/gallery/subfolders")
|
|
async def get_gallery_subfolders_route(request):
|
|
return await self.get_gallery_subfolders(request)
|
|
|
|
@routes.put("/prompt_manager/images/favorite")
|
|
async def set_image_favorite_route(request):
|
|
return await self.update_image_favorite(request, favorite=True)
|
|
|
|
@routes.delete("/prompt_manager/images/favorite")
|
|
async def clear_image_favorite_route(request):
|
|
return await self.update_image_favorite(request, favorite=False)
|
|
|
|
# POST, local-only: opens this computer's file manager (D38).
|
|
@routes.post("/prompt_manager/images/reveal")
|
|
async def reveal_image_route(request):
|
|
return await self.reveal_image(request)
|
|
|
|
# POST: drops the server-side listing cache and rewalks the output roots.
|
|
@routes.post("/prompt_manager/gallery/rescan")
|
|
async def rescan_gallery_route(request):
|
|
return await self.rescan_gallery(request)
|
|
|
|
def _present_images(self, images):
|
|
"""Image dicts ready for a response: urls added, server paths hidden."""
|
|
self._enrich_images(images)
|
|
return publish_image_paths(images, _public_path)
|
|
|
|
async def get_prompt_images(self, request):
|
|
"""Get all images for a specific prompt."""
|
|
try:
|
|
prompt_id = request.match_info["prompt_id"]
|
|
images = await self._run_in_executor(self.db.get_prompt_images, prompt_id)
|
|
|
|
# Clean up any NaN values that cause JSON parsing errors (recursive)
|
|
cleaned_images = [self._clean_nan_recursive(image) for image in images]
|
|
|
|
# Add url and thumbnail_url so the frontend can serve them
|
|
cleaned_images = self._present_images(cleaned_images)
|
|
|
|
# Rows an older version stored may still hold NaN/Infinity: answer
|
|
# strict JSON, and leave prompt text that spells "NaN" untouched.
|
|
return web.json_response(
|
|
{"success": True, "images": cleaned_images},
|
|
dumps=functools.partial(dumps_strict, default=str),
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Get prompt images error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def get_recent_images(self, request):
|
|
"""Get recently generated images (bounded window, newest first)."""
|
|
try:
|
|
try:
|
|
limit, offset = parse_page_params(request.query)
|
|
except ValueError:
|
|
return bad_request("limit and offset must be integers")
|
|
|
|
images = self._present_images(
|
|
await self._run_in_executor(self.db.get_recent_images, limit, offset)
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"images": images,
|
|
"pagination": {
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"count": len(images),
|
|
},
|
|
}
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Get recent images error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def get_all_images(self, request):
|
|
"""Get generated images with linked prompts (bulk-capped pages)."""
|
|
try:
|
|
try:
|
|
limit, offset = parse_page_params(
|
|
request.query,
|
|
default_limit=MAX_BULK_LIMIT,
|
|
max_limit=MAX_BULK_LIMIT,
|
|
)
|
|
except ValueError:
|
|
return bad_request("limit and offset must be integers")
|
|
|
|
images = self._present_images(
|
|
await self._run_in_executor(
|
|
self.db.get_all_images, limit=limit, offset=offset
|
|
)
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"images": images,
|
|
"count": len(images),
|
|
"pagination": {
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"count": len(images),
|
|
"has_more": len(images) == limit,
|
|
},
|
|
}
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Get all images error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def search_images(self, request):
|
|
"""Search images by prompt text."""
|
|
try:
|
|
query = request.query.get("q", "")
|
|
if not query:
|
|
return web.json_response(
|
|
{"success": False, "error": "Search query required"}, status=400
|
|
)
|
|
|
|
try:
|
|
limit, offset = parse_page_params(request.query)
|
|
except ValueError:
|
|
return bad_request("limit and offset must be integers")
|
|
|
|
images = self._present_images(
|
|
await self._run_in_executor(
|
|
self.db.search_images_by_prompt, query, limit, offset
|
|
)
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"images": images,
|
|
"query": query,
|
|
"pagination": {
|
|
"limit": limit,
|
|
"offset": offset,
|
|
"count": len(images),
|
|
},
|
|
}
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Search images error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
def _schedule_workspace_warm(self):
|
|
"""Rebuild the workspace caches behind a changed listing (on the loop).
|
|
|
|
Only after the host's startup warm-up (warm_gallery_listings), so the
|
|
first Images open after a restart, and the first read after a refresh
|
|
found new files, pay no per-file rebuild. A walk stored while a build
|
|
runs asks for one more build instead of a second one at once.
|
|
"""
|
|
if not self._workspace_warm_enabled:
|
|
return
|
|
task = self._workspace_warm_task
|
|
if task is not None and not task.done():
|
|
self._workspace_warm_again = True
|
|
return
|
|
self._workspace_warm_task = asyncio.get_running_loop().create_task(
|
|
self._warm_workspace()
|
|
)
|
|
|
|
async def _warm_workspace(self):
|
|
while True:
|
|
self._workspace_warm_again = False
|
|
try:
|
|
listings = self._stored_listings()
|
|
if listings is None:
|
|
return # a root has no listing yet; its walk asks again
|
|
await self._run_in_executor(self._build_workspace_caches, listings)
|
|
except Exception as e:
|
|
self.logger.warning(f"Could not prepare the Images page: {e}")
|
|
return
|
|
if not self._workspace_warm_again:
|
|
return
|
|
|
|
def _stored_listings(self):
|
|
"""``[(index, root, files)]`` of every output root, or None if one has none."""
|
|
listings = []
|
|
for index, root in enumerate(self._get_all_output_dirs()):
|
|
record = self._root_listings.get(str(root))
|
|
if record is None or record.files is None:
|
|
return None
|
|
listings.append((index, root, record.files))
|
|
return listings
|
|
|
|
def _build_workspace_caches(self, listings):
|
|
"""Build what a first workspace page reads, as that page builds it (blocking).
|
|
|
|
The default page's selection builds the per-file values, the newest
|
|
order, the wanted paths and reads the prompt links (warming SQLite's
|
|
cache); the favorites filter builds each root's favorite listing.
|
|
"""
|
|
self._select_workspace_page(
|
|
listings, _workspace_filters({}), None, 1, "workspace-warm-up"
|
|
)
|
|
self._workspace_favorite_paths(listings)
|
|
|
|
def _scan_gallery_files_sync(self, output_path):
|
|
"""Scan output directory for media files (blocking I/O, run in executor).
|
|
|
|
Returns list of (path, mtime) tuples sorted by mtime descending; the
|
|
walk is bounded by MAX_SCAN_DEPTH and MAX_GALLERY_FILES.
|
|
"""
|
|
found = []
|
|
for entry in _walk_media(output_path, MAX_GALLERY_FILES):
|
|
try:
|
|
# Never a symlink (_walk_media skips them), so this is the
|
|
# file's own mtime, read from the listing where it can be.
|
|
mtime = entry.stat(follow_symlinks=False).st_mtime
|
|
except OSError:
|
|
continue
|
|
found.append((Path(entry.path), mtime))
|
|
if len(found) >= MAX_GALLERY_FILES:
|
|
self.logger.warning(f"Gallery scan capped at {MAX_GALLERY_FILES} files")
|
|
found.sort(key=lambda x: x[1], reverse=True)
|
|
return found
|
|
|
|
async def _get_gallery_files(self, output_path):
|
|
"""The shared listing of one output root: ``[(path, mtime)]``, newest first."""
|
|
return (await self._listing(output_path)).files
|
|
|
|
async def _listing(self, output_path, *, fresh=False):
|
|
"""The shared ``RootListing`` of *output_path*, with a listing (#348).
|
|
|
|
A listing that exists is returned at once; when it is older than
|
|
``REFRESH_AFTER`` one background walk replaces it. With no listing,
|
|
the caller waits for the root's single walk. ``fresh`` (Rescan) waits
|
|
for a walk that started after this call, and raises when it fails
|
|
even if a previous listing exists.
|
|
"""
|
|
record = self._root_listing(output_path)
|
|
record.retired = False # a configured root: read, so listened to
|
|
needed = record.walk_seq + 1 if fresh else 0
|
|
discarded = 0
|
|
while True:
|
|
if record.files is not None and record.stored_seq >= needed:
|
|
if not fresh:
|
|
self._refresh_if_stale(output_path, record)
|
|
return record
|
|
flight = record.flight
|
|
if flight is None:
|
|
flight = self._start_walk(output_path, record)
|
|
joined = record.walk_seq
|
|
# Shielded: a client that goes away never cancels a shared walk.
|
|
outcome = await asyncio.shield(flight)
|
|
if joined < needed:
|
|
continue # an older walk ended; Rescan needs a newer one
|
|
if outcome.kind == FAILED:
|
|
raise outcome.error
|
|
if outcome.kind == DISCARDED:
|
|
discarded += 1
|
|
if discarded >= MAX_DISCARDED_WALKS:
|
|
raise RuntimeError(
|
|
"The output listing kept changing while it was read"
|
|
)
|
|
|
|
def _refresh_if_stale(self, output_path, record):
|
|
"""Ask for a background walk when the listing is stale or dirty.
|
|
|
|
A stale listing (older than REFRESH_AFTER) is walked REFRESH_DELAY
|
|
after this read, so the walk does not share the GIL with the page's
|
|
own requests; a dirty one as soon as its interval allows.
|
|
"""
|
|
age = _time.monotonic() - (record.walked_at or 0.0)
|
|
if age >= gallery_listing.REFRESH_AFTER:
|
|
self._schedule_walk(output_path, gallery_listing.REFRESH_DELAY)
|
|
elif record.dirty:
|
|
self._schedule_walk(output_path)
|
|
|
|
def output_settled(self, path):
|
|
"""The monitor settled an output at *path*: list it (on the loop).
|
|
|
|
*path* is resolved by the monitor's thread (``notify_output_settled``).
|
|
A root with a stored listing and no walk running gets the file added
|
|
in the executor (``_run_adds``), without a walk. Every other root
|
|
holding it is marked dirty (``mark_root_dirty``): one with a walk
|
|
running gets the trailing walk, as that walk may have passed the
|
|
file's folder already.
|
|
"""
|
|
for key, record in self._settled_records(path):
|
|
relative = None
|
|
if (
|
|
record.files is not None
|
|
and record.flight is None
|
|
and record.resolved_root is not None
|
|
):
|
|
relative = gallery_listing.relative_part(record.resolved_root, path)
|
|
if relative is None:
|
|
self._mark_dirty(key, record)
|
|
continue
|
|
record.pending_adds.append(relative)
|
|
if record.add_task is None:
|
|
task = asyncio.get_running_loop().create_task(
|
|
self._run_adds(Path(key), record)
|
|
)
|
|
record.add_task = task
|
|
self._listing_tasks.add(task)
|
|
task.add_done_callback(self._listing_tasks.discard)
|
|
|
|
def mark_root_dirty(self, path):
|
|
"""A settled output at *path*: walk every root holding it again (on the loop).
|
|
|
|
*path* is resolved by the monitor's thread (``notify_output_settled``),
|
|
so this does no file I/O: it compares ``normcase`` strings with each
|
|
walk's ``resolved_root``. Only roots with a listing, or a walk running,
|
|
are marked; a root without either is walked by its next read anyway.
|
|
"""
|
|
for key, record in self._settled_records(path):
|
|
self._mark_dirty(key, record)
|
|
|
|
def _settled_records(self, path):
|
|
"""``(key, record)`` of every listed or walking root holding *path*."""
|
|
if self._listings_closed:
|
|
return []
|
|
found = []
|
|
for key, record in list(self._root_listings.items()):
|
|
if record.retired or (record.files is None and record.flight is None):
|
|
continue
|
|
resolved = record.resolved_root or os.path.normcase(key)
|
|
if gallery_listing.root_contains(resolved, path):
|
|
found.append((key, record))
|
|
return found
|
|
|
|
def _mark_dirty(self, key, record):
|
|
if self._listings_closed or record.retired:
|
|
return
|
|
if record.files is None and record.flight is None:
|
|
return # walked by its next read anyway
|
|
record.dirty = True
|
|
self._schedule_walk(Path(key))
|
|
|
|
async def _run_adds(self, root, record):
|
|
"""Add the root's pending settled outputs to its listing (on the loop).
|
|
|
|
Each batch is placed in the executor against the stored listing, and
|
|
stored only when no walk started and no drop happened meanwhile: a
|
|
walk started after the outputs settled lists them itself, and a
|
|
dropped listing is walked by its next read. An output that cannot be
|
|
placed exactly marks the root dirty instead.
|
|
"""
|
|
key = str(root)
|
|
try:
|
|
while record.pending_adds:
|
|
relatives, record.pending_adds = record.pending_adds, []
|
|
if record.files is None or record.flight is not None:
|
|
continue # dropped, or a walk started after they settled
|
|
base, generation, seq = record.files, record.generation, record.walk_seq
|
|
files, folders, with_ancestors, unresolved = (
|
|
await self._run_in_executor(
|
|
_patch_listing,
|
|
root,
|
|
base,
|
|
record.folders,
|
|
record.folders_with_ancestors,
|
|
relatives,
|
|
)
|
|
)
|
|
if self._listings_closed or record.retired:
|
|
return
|
|
if record.generation != generation or record.walk_seq != seq:
|
|
continue
|
|
if files is not base:
|
|
record.files = files
|
|
record.folders = folders
|
|
record.folders_with_ancestors = with_ancestors
|
|
record.patched += 1
|
|
self._schedule_workspace_warm()
|
|
if unresolved:
|
|
self._mark_dirty(key, record)
|
|
except asyncio.CancelledError:
|
|
raise
|
|
except Exception as exc:
|
|
self.logger.warning(f"Could not add a new output to the listing: {exc}")
|
|
record.pending_adds = []
|
|
self._mark_dirty(key, record)
|
|
finally:
|
|
record.add_task = None
|
|
|
|
def _schedule_walk(self, output_path, delay=0.0):
|
|
"""Start or defer a background walk of *output_path* (on the loop).
|
|
|
|
- A walk is running: nothing; its end re-checks ``dirty``.
|
|
- Otherwise the walk is due *delay* from now, and never sooner than
|
|
``rewalk_interval`` after the previous walk started. Due now: start
|
|
it. Later: one deferred walk per root, at least MIN_REWALK_DELAY
|
|
away; when one is already pending, the earlier deadline wins.
|
|
Every walk start cancels the pending one (``_start_walk``).
|
|
"""
|
|
if self._listings_closed:
|
|
return
|
|
record = self._root_listing(output_path)
|
|
if record.flight is not None or record.retired:
|
|
return
|
|
now = _time.monotonic()
|
|
due = now + delay
|
|
if record.last_started_at is not None:
|
|
interval = gallery_listing.rewalk_interval(record.last_duration)
|
|
due = max(due, record.last_started_at + interval)
|
|
if due <= now:
|
|
self._start_walk(output_path, record)
|
|
return
|
|
wait = max(gallery_listing.MIN_REWALK_DELAY, due - now)
|
|
loop = asyncio.get_running_loop()
|
|
handle = record.rewalk_handle
|
|
if handle is not None:
|
|
if handle.when() - loop.time() <= wait:
|
|
return # the pending walk comes first
|
|
handle.cancel()
|
|
record.rewalk_handle = loop.call_later(
|
|
wait, self._delayed_walk, output_path, record
|
|
)
|
|
|
|
def _delayed_walk(self, output_path, record):
|
|
"""The deferred walk's timer: clear the handle first, then walk.
|
|
|
|
It was armed for a stale or dirty listing at the earliest moment the
|
|
interval allowed. Only a walk start clears either reason, and every
|
|
start cancels this timer, so when it fires the walk is still due.
|
|
"""
|
|
record.rewalk_handle = None
|
|
if self._listings_closed or record.flight is not None or record.retired:
|
|
return
|
|
self._start_walk(output_path, record)
|
|
|
|
def _start_walk(self, output_path, record):
|
|
"""Start the root's one walk; returns its flight future (on the loop)."""
|
|
loop = asyncio.get_running_loop()
|
|
self._listing_loop = loop
|
|
handle, record.rewalk_handle = record.rewalk_handle, None
|
|
if handle is not None:
|
|
handle.cancel() # this walk is the one it was waiting to start
|
|
# This walk lists every output reported so far.
|
|
record.dirty = False
|
|
record.pending_adds = []
|
|
record.last_started_at = _time.monotonic()
|
|
record.walk_seq += 1
|
|
flight = loop.create_future()
|
|
record.flight = flight
|
|
task = loop.create_task(
|
|
self._run_walk(
|
|
output_path, record, flight, record.generation, record.walk_seq
|
|
)
|
|
)
|
|
self._listing_tasks.add(task)
|
|
task.add_done_callback(self._listing_tasks.discard)
|
|
return flight
|
|
|
|
async def _run_walk(self, output_path, record, flight, generation, seq):
|
|
"""Walk in the executor, then store, discard or fail (on the loop).
|
|
|
|
The flight is always resolved with a WalkOutcome, never failed, so an
|
|
error never reaches a future nobody awaits.
|
|
"""
|
|
outcome = None
|
|
cancelled = False
|
|
started = record.last_started_at
|
|
try:
|
|
files, folders, with_ancestors, resolved = await self._run_in_executor(
|
|
walk_root, self._scan_gallery_files_sync, output_path, record.files
|
|
)
|
|
except asyncio.CancelledError:
|
|
cancelled = True
|
|
outcome = WalkOutcome.failed(RuntimeError(WALK_STOPPED))
|
|
raise
|
|
except Exception as exc:
|
|
record.walked_at = _time.monotonic()
|
|
if not record.failing:
|
|
self.logger.warning(
|
|
f"Could not list output folder {output_path}: {exc}"
|
|
)
|
|
record.failing = True
|
|
# Retried by the 30 s rule, not by a trailing walk.
|
|
record.dirty = False
|
|
outcome = WalkOutcome.failed(exc)
|
|
else:
|
|
record.failing = False
|
|
record.resolved_root = resolved
|
|
if record.generation == generation:
|
|
changed = files is not record.files
|
|
record.patched = 0
|
|
record.files = files
|
|
record.folders = folders
|
|
record.folders_with_ancestors = with_ancestors
|
|
record.walked_at = _time.monotonic()
|
|
record.stored_seq = seq
|
|
outcome = WalkOutcome.stored()
|
|
if changed:
|
|
self._schedule_workspace_warm()
|
|
else:
|
|
outcome = WalkOutcome.discarded()
|
|
finally:
|
|
record.last_duration = _time.monotonic() - started
|
|
if record.flight is flight:
|
|
record.flight = None
|
|
if not flight.done():
|
|
flight.set_result(outcome)
|
|
if record.dirty and not cancelled:
|
|
# An output settled during this walk: the trailing walk.
|
|
self._schedule_walk(output_path)
|
|
|
|
async def get_output_images(self, request):
|
|
"""Get all images from ComfyUI output folder(s)."""
|
|
try:
|
|
output_dirs = self._get_all_output_dirs()
|
|
if request.query.get("mode") == "workspace":
|
|
return await self._get_workspace_images(request, list(output_dirs))
|
|
if not output_dirs:
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": "No output directories found",
|
|
"images": [],
|
|
},
|
|
)
|
|
try:
|
|
limit, offset = parse_page_params(request.query, default_limit=100)
|
|
except ValueError:
|
|
return bad_request("limit and offset must be integers")
|
|
subfolder = request.query.get("subfolder", "").strip()
|
|
|
|
all_images = await self._collect_output_images(output_dirs, subfolder)
|
|
total = len(all_images)
|
|
page = all_images[offset : offset + limit]
|
|
images = await self._run_in_executor(self._format_output_page, page)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"images": images,
|
|
"total": total,
|
|
"offset": offset,
|
|
"limit": limit,
|
|
"has_more": offset + limit < total,
|
|
}
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Output images error: {e}", exc_info=True)
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def _get_workspace_images(
|
|
self, request, output_dirs: list[Path]
|
|
) -> web.Response:
|
|
try:
|
|
limit, _offset = parse_page_params(request.query, default_limit=100)
|
|
except ValueError:
|
|
return bad_request("limit and offset must be integers")
|
|
try:
|
|
root_index = _workspace_root(request.query, output_dirs)
|
|
filters = _workspace_filters(request.query)
|
|
scope = hashlib.sha256(
|
|
json.dumps(
|
|
[
|
|
filters["query"],
|
|
filters["text"],
|
|
filters["media"],
|
|
filters["order"],
|
|
filters["folder"],
|
|
filters["path"],
|
|
root_index,
|
|
[str(root) for root in output_dirs],
|
|
filters["category"],
|
|
filters["tags"],
|
|
filters["favorites"],
|
|
]
|
|
).encode()
|
|
).hexdigest()
|
|
token = request.query.get("cursor")
|
|
after = (
|
|
_workspace_cursor_key(token, scope, filters["order"]) if token else None
|
|
)
|
|
except ValueError as exc:
|
|
return bad_request(str(exc))
|
|
|
|
listings = [
|
|
(index, root, await self._get_gallery_files(root))
|
|
for index, root in enumerate(output_dirs)
|
|
if root_index is None or index == root_index
|
|
]
|
|
try:
|
|
page, total, has_more, next_cursor, positive_count = (
|
|
await self._run_in_executor(
|
|
self._select_workspace_page, listings, filters, after, limit, scope
|
|
)
|
|
)
|
|
except Exception:
|
|
# A filter that needs the prompt links or the image tags cannot be
|
|
# served without them. Never echo the exception: it can carry SQL
|
|
# or database text. The traceback goes to the server log.
|
|
self.logger.error(
|
|
"Workspace image list could not be filtered", exc_info=True
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"code": "listing-failed",
|
|
"error": _LISTING_FAILED,
|
|
},
|
|
status=500,
|
|
)
|
|
images = await self._run_in_executor(self._format_workspace_page, page)
|
|
result = {
|
|
"success": True,
|
|
"images": images,
|
|
"total": total,
|
|
"positive_count": positive_count,
|
|
"limit": limit,
|
|
"has_more": has_more,
|
|
"next_cursor": next_cursor,
|
|
}
|
|
if positive_count is _COUNT_OMITTED:
|
|
del result["positive_count"]
|
|
return web.json_response(result)
|
|
|
|
def _workspace_listed(self, index: int, root: Path, files: list) -> list:
|
|
"""The _Listed values of one root's scan, rebuilt only where it changed.
|
|
|
|
The gallery cache hands out the same list object until it rescans.
|
|
A rescan reuses the value of every unchanged ``(path, mtime)`` and,
|
|
when nothing changed, the previous list itself, so the cached sort
|
|
orders stay valid. Only a gallery-roots change drops these values.
|
|
"""
|
|
key = (index, str(root))
|
|
cached = self._workspace_listed_cache.get(key)
|
|
if cached is not None and cached[0] is files:
|
|
return cached[1]
|
|
if cached is not None:
|
|
delta = gallery_listing.listing_delta(cached[0], files)
|
|
if delta is not None:
|
|
return self._workspace_listed_step(key, cached, files, delta)
|
|
previous = cached[2] if cached is not None else {}
|
|
listed = []
|
|
for path, mtime in files:
|
|
entry = previous.get((path, mtime))
|
|
listed.append(entry or _listed(path, mtime, root, index))
|
|
if (
|
|
cached is not None
|
|
and len(listed) == len(cached[1])
|
|
and all(new is old for new, old in zip(listed, cached[1]))
|
|
):
|
|
listed, lookup = cached[1], previous
|
|
else:
|
|
lookup = {(entry.path, entry.mtime): entry for entry in listed}
|
|
# Card facts follow the per-file values: keep unchanged files only.
|
|
cards = self._workspace_card_cache.get(key)
|
|
if cards:
|
|
self._workspace_card_cache[key] = {
|
|
file: facts for file, facts in cards.items() if file in lookup
|
|
}
|
|
self._workspace_listed_cache[key] = (files, listed, lookup, None)
|
|
return listed
|
|
|
|
def _workspace_listed_step(self, key, cached, files, delta) -> list:
|
|
"""The _Listed values after a few files were added or replaced.
|
|
|
|
A listing patched by a settled output (``output_settled``) shares
|
|
every other entry with the cached one, so only the added files are
|
|
built; the order of the values does not matter (every sort is a total
|
|
order). The step ``(previous values, added, removed)`` lets the sort
|
|
orders follow it the same way (``_workspace_orders``).
|
|
"""
|
|
index, root = key[0], Path(key[1])
|
|
_files, old_listed, old_lookup, _step = cached
|
|
added, removed = delta
|
|
if not added and not removed:
|
|
self._workspace_listed_cache[key] = (files, old_listed, old_lookup, None)
|
|
return old_listed
|
|
lookup = dict(old_lookup)
|
|
gone = [lookup.pop(item) for item in removed if item in lookup]
|
|
fresh = [_listed(path, mtime, root, index) for path, mtime in added]
|
|
for entry in fresh:
|
|
lookup[(entry.path, entry.mtime)] = entry
|
|
if gone:
|
|
gone_ids = {id(entry) for entry in gone}
|
|
listed = [entry for entry in old_listed if id(entry) not in gone_ids]
|
|
cards = self._workspace_card_cache.get(key)
|
|
if cards:
|
|
self._workspace_card_cache[key] = {
|
|
file: facts for file, facts in cards.items() if file in lookup
|
|
}
|
|
else:
|
|
listed = list(old_listed)
|
|
listed.extend(fresh)
|
|
step = (old_listed, fresh, gone)
|
|
self._workspace_listed_cache[key] = (files, listed, lookup, step)
|
|
return listed
|
|
|
|
def _workspace_orders(self, listings: list[tuple]) -> dict:
|
|
"""The selected roots' files in newest order, plus lazily added orders."""
|
|
parts = tuple(
|
|
self._workspace_listed(index, root, files)
|
|
for index, root, files in listings
|
|
)
|
|
key = tuple((index, str(root)) for index, root, _files in listings)
|
|
cached = self._workspace_order_cache.get(key)
|
|
if cached is not None and all(
|
|
old is new for old, new in zip(cached[0], parts, strict=True)
|
|
):
|
|
return cached[1]
|
|
newest = None
|
|
if cached is not None:
|
|
newest = self._newest_step(listings, parts, cached)
|
|
if newest is None:
|
|
newest = sorted(
|
|
(entry for part in parts for entry in part),
|
|
key=_newest_key,
|
|
)
|
|
# The other orders, the wanted paths and the locators follow lazily.
|
|
orders = {"newest": newest}
|
|
self._workspace_order_cache[key] = (parts, orders)
|
|
return orders
|
|
|
|
def _newest_step(self, listings, parts, cached) -> list | None:
|
|
"""The cached newest order with each part's last step applied, or None.
|
|
|
|
Only when every changed part is exactly the value list this call
|
|
built (*parts*) and one step (``_workspace_listed_step``) past the
|
|
part the cached order was built from: the added values are placed by
|
|
bisection instead of sorting the whole listing again. Page reads and
|
|
the warm build run in executor threads at once, so the per-file cache
|
|
may already hold a newer step than *parts*; then this sorts in full.
|
|
"""
|
|
old_parts, orders = cached
|
|
added, gone = [], []
|
|
for (index, root, _files), part, old in zip(
|
|
listings, parts, old_parts, strict=True
|
|
):
|
|
if part is old:
|
|
continue
|
|
listed = self._workspace_listed_cache.get((index, str(root)))
|
|
if listed is None or listed[1] is not part:
|
|
return None
|
|
step = listed[3]
|
|
if step is None or step[0] is not old:
|
|
return None
|
|
added.extend(step[1])
|
|
gone.extend(step[2])
|
|
newest = orders["newest"]
|
|
if gone:
|
|
gone_ids = {id(entry) for entry in gone}
|
|
newest = [entry for entry in newest if id(entry) not in gone_ids]
|
|
else:
|
|
newest = list(newest)
|
|
for entry in added:
|
|
insort(newest, entry, key=_newest_key)
|
|
return newest
|
|
|
|
def _workspace_wanted(self, listings: list[tuple]) -> frozenset:
|
|
"""The selected listing's normalized paths (cached with its orders)."""
|
|
orders = self._workspace_orders(listings)
|
|
if "wanted" not in orders:
|
|
orders["wanted"] = frozenset(entry.normalized for entry in orders["newest"])
|
|
return orders["wanted"]
|
|
|
|
def _workspace_order(self, listings: list[tuple], order: str) -> list:
|
|
"""The listing sorted by a filesystem order (built lazily, cached)."""
|
|
orders = self._workspace_orders(listings)
|
|
if order not in orders:
|
|
if order == "name-desc":
|
|
orders[order] = self._workspace_order(listings, "name")[::-1]
|
|
else:
|
|
orders[order] = sorted(
|
|
orders["newest"], key=lambda entry: _workspace_key(entry, order)
|
|
)
|
|
return orders[order]
|
|
|
|
def _workspace_locators(self, listings: list[tuple]) -> dict:
|
|
"""The selected listing's private locators -> _Listed (cached).
|
|
|
|
A locator is ``normcase(join(realpath(root), relative))``, the key
|
|
image tags are stored under (database/annotations.py); the listing
|
|
never follows symlinks, so it equals the listed file's own locator.
|
|
"""
|
|
orders = self._workspace_orders(listings)
|
|
reals = tuple(os.path.realpath(root) for _index, root, _files in listings)
|
|
key = ("locators", reals)
|
|
if key not in orders:
|
|
located = {}
|
|
for (index, root, files), real in zip(listings, reals):
|
|
prefix = os.path.join(str(root), "")
|
|
for entry in self._workspace_listed(index, root, files):
|
|
text = str(entry.path)
|
|
relative = (
|
|
text[len(prefix) :]
|
|
if text.startswith(prefix)
|
|
else str(entry.path.relative_to(root))
|
|
)
|
|
located[os.path.normcase(os.path.join(real, relative))] = entry
|
|
orders[key] = located
|
|
return orders[key]
|
|
|
|
def _image_tag_matches(self, listings: list[tuple], filters: dict) -> dict:
|
|
"""Normalized path -> _ImageTagMatch for files whose image tags count.
|
|
|
|
Streams the tagged byte records once and keeps only listed locators
|
|
carrying a filter tag or a ``q`` hit; only those candidates are
|
|
stat'ed (never every listed file), and a candidate's tags count only
|
|
while a byte record is verified at its present ``(size, mtime_ns)``
|
|
(spec amendment S1). Under a Tags filter the candidates' linked
|
|
positives are read too, so their tags and category combine.
|
|
"""
|
|
tags, query, category = filters["tags"], filters["query"], filters["category"]
|
|
wanted = set(tags)
|
|
located = self._workspace_locators(listings)
|
|
candidates = {}
|
|
records = self.db.iter_image_tag_records()
|
|
try:
|
|
for locator, _size, _mtime_ns, names in records:
|
|
entry = located.get(locator)
|
|
if entry is not None and locator not in candidates:
|
|
if _image_tags_relevant(names, wanted, query):
|
|
candidates[locator] = entry
|
|
finally:
|
|
records.close()
|
|
stats = {}
|
|
for locator, entry in candidates.items():
|
|
fingerprint = _current_fingerprint(entry.path)
|
|
if fingerprint is not None:
|
|
stats[locator] = fingerprint
|
|
current = self.db.current_image_tags(stats) if stats else {}
|
|
facts = {}
|
|
if wanted and current:
|
|
facts = self.db.get_image_link_facts(
|
|
[candidates[locator].normalized for locator in current],
|
|
filters["text"],
|
|
)
|
|
matches = {}
|
|
for locator, names in current.items():
|
|
entry = candidates[locator]
|
|
link = facts.get(entry.normalized)
|
|
carried = set(names).union(link[4] if link else ())
|
|
matches[entry.normalized] = _ImageTagMatch(
|
|
bool(wanted)
|
|
and wanted <= carried
|
|
and (category is None or (link is not None and link[3] == category)),
|
|
bool(query) and any(query in name.casefold() for name in names),
|
|
link[:3] if link else None,
|
|
)
|
|
return matches
|
|
|
|
def _select_workspace_page(
|
|
self,
|
|
listings: list[tuple],
|
|
filters: dict,
|
|
after: tuple | None,
|
|
limit: int,
|
|
scope: str,
|
|
) -> tuple:
|
|
"""One keyset page of the filtered listing (blocking; run in executor).
|
|
|
|
One SELECT (get_image_link_matches) reads every linked positive of the
|
|
listing: the filters, the rating sort and the positive count share it.
|
|
A plain listing (no ``q``, category, tags or rating sort) reads it only
|
|
for its first page; its cursor pages read no links and the returned
|
|
count is _COUNT_OMITTED, so the response leaves ``positive_count`` out
|
|
and the frontend keeps the first page's value. On that plain first
|
|
page a failed read leaves the listing usable and the count unknown
|
|
(None); so does a failed read under ``q`` alone, whose search then
|
|
matches filenames only (as before the linked search existed).
|
|
"""
|
|
order, category, tags = filters["order"], filters["category"], filters["tags"]
|
|
needed = (
|
|
bool(filters["query"])
|
|
or category is not None
|
|
or bool(tags)
|
|
or order == "rating"
|
|
)
|
|
links = None
|
|
if needed or after is None:
|
|
try:
|
|
links = self.db.get_image_link_matches(
|
|
filters["text"],
|
|
category,
|
|
tags,
|
|
every_link=True,
|
|
wanted=self._workspace_wanted(listings),
|
|
)
|
|
except Exception:
|
|
# Category, tags and the rating sort cannot be served without
|
|
# the links. A plain listing and a filename search can: the
|
|
# search then matches filenames only, and the count is None.
|
|
if category is not None or tags or order == "rating":
|
|
raise
|
|
self.logger.warning(
|
|
"Workspace prompt links could not be read; %s",
|
|
(
|
|
"searching filenames only"
|
|
if filters["query"]
|
|
else "positive count unknown"
|
|
),
|
|
exc_info=True,
|
|
)
|
|
image_tags = None
|
|
if filters["query"] or tags:
|
|
try:
|
|
image_tags = self._image_tag_matches(listings, filters)
|
|
except Exception:
|
|
# A Tags filter cannot be served without them (as without the
|
|
# links); a search then matches without image tags.
|
|
if tags:
|
|
raise
|
|
self.logger.warning(
|
|
"Workspace image tags could not be read; searching without "
|
|
"image tags",
|
|
exc_info=True,
|
|
)
|
|
if image_tags and links is not None:
|
|
# A linked file admitted through its image tags keeps its rating
|
|
# and counts its positive.
|
|
admitted = {
|
|
path: match.link
|
|
for path, match in image_tags.items()
|
|
if match.tags and match.link is not None and path not in links
|
|
}
|
|
if admitted:
|
|
links = {**links, **admitted}
|
|
if order == "rating":
|
|
|
|
def rank(entry):
|
|
match = links.get(entry.normalized)
|
|
return -1 if match is None else (match[1] or 0)
|
|
|
|
# Stable: within one rank the newest order (the tie-break) stays.
|
|
entries = sorted(
|
|
self._workspace_order(listings, "newest"),
|
|
key=lambda entry: -rank(entry),
|
|
)
|
|
|
|
def key(entry):
|
|
return _rated_key(entry, rank(entry))
|
|
|
|
else:
|
|
entries = self._workspace_order(listings, order)
|
|
|
|
def key(entry):
|
|
return _workspace_key(entry, order)
|
|
|
|
start = 0
|
|
if after is not None:
|
|
if order == "name-desc":
|
|
ascending = self._workspace_order(listings, "name")
|
|
start = len(entries) - bisect_left(ascending, after, key=key)
|
|
else:
|
|
start = bisect_right(entries, after, key=key)
|
|
favorites = (
|
|
self._workspace_favorite_paths(listings) if filters["favorites"] else None
|
|
)
|
|
total, page, positive_count = _filtered_page(
|
|
entries, start, limit + 1, filters, links, favorites, image_tags
|
|
)
|
|
if not needed and after is not None:
|
|
positive_count = _COUNT_OMITTED
|
|
has_more = len(page) > limit
|
|
page = page[:limit]
|
|
next_cursor = _workspace_cursor(key(page[-1]), scope) if has_more else None
|
|
return (
|
|
[(entry.path, entry.mtime, entry.root, entry.index) for entry in page],
|
|
total,
|
|
has_more,
|
|
next_cursor,
|
|
positive_count,
|
|
)
|
|
|
|
async def serve_image(self, request):
|
|
"""Serve the actual image file using streamed FileResponse."""
|
|
try:
|
|
image_id = int(request.match_info["image_id"])
|
|
image = await self._run_in_executor(self.db.get_image_by_id, image_id)
|
|
|
|
if not image:
|
|
return web.json_response(
|
|
{"success": False, "error": "Image not found"}, status=404
|
|
)
|
|
|
|
image_path = Path(image["image_path"])
|
|
allowed_dirs = list(self._get_all_output_dirs()) + self._lora_image_dirs()
|
|
|
|
denied = _media_access_error(image_path, allowed_dirs)
|
|
if denied is not None:
|
|
return denied
|
|
|
|
if not image_path.is_file():
|
|
return web.json_response(
|
|
{"success": False, "error": "Image file not found"}, status=404
|
|
)
|
|
|
|
return self._file_response(image_path)
|
|
|
|
except ValueError:
|
|
return web.json_response(
|
|
{"success": False, "error": "Invalid image ID"}, status=400
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Serve image error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
def _lora_image_dirs(self):
|
|
"""Extra directories served when the LoRA integration is enabled."""
|
|
try:
|
|
from ..config import IntegrationConfig
|
|
|
|
if not IntegrationConfig.LORA_MANAGER_ENABLED:
|
|
return []
|
|
from ..lora_utils import find_lora_directories, get_lora_image_cache_dir
|
|
|
|
lora_root = IntegrationConfig.LORA_MANAGER_PATH or ""
|
|
if lora_root.strip():
|
|
# A configured path comes from config.json, which can be hand
|
|
# edited: only a LoraManager install under custom_nodes may
|
|
# widen the serving allow-list. An empty path keeps the
|
|
# auto-detection, which itself only scans custom_nodes.
|
|
from ..lora_utils import resolve_lora_manager_path
|
|
|
|
lora_root = resolve_lora_manager_path(lora_root)
|
|
if not lora_root:
|
|
return []
|
|
|
|
dirs = [Path(d) for d in find_lora_directories(lora_root)]
|
|
dirs.append(Path(get_lora_image_cache_dir()))
|
|
return dirs
|
|
except Exception:
|
|
# LoRA integration is optional; never widen access on failure.
|
|
return []
|
|
|
|
@staticmethod
|
|
def _file_response(path):
|
|
response = web.FileResponse(path)
|
|
response.headers["Cache-Control"] = "public, max-age=3600"
|
|
# #317: name the image types explicitly; the platform's mimetypes
|
|
# table (the Windows registry) may not know .webp or .jxl.
|
|
content_type = thumbnail_settings.content_type(path)
|
|
if content_type is not None:
|
|
response.headers["Content-Type"] = content_type
|
|
return response
|
|
|
|
@staticmethod
|
|
def _select_root(output_dirs, root_param):
|
|
"""Narrow *output_dirs* to the ``?root=N`` entry when it is valid."""
|
|
if root_param is None:
|
|
return output_dirs
|
|
try:
|
|
idx = int(root_param)
|
|
except ValueError:
|
|
return []
|
|
if 0 <= idx < len(output_dirs):
|
|
return [output_dirs[idx]]
|
|
return []
|
|
|
|
async def serve_output_image(self, request):
|
|
"""Serve a media file from the ComfyUI output folder(s)."""
|
|
try:
|
|
roots = list(self._get_all_output_dirs())
|
|
scope_error = self._gallery_scope_error(request.query, roots)
|
|
if scope_error is not None:
|
|
return scope_error
|
|
filepath = request.match_info["filepath"]
|
|
if _has_traversal(filepath):
|
|
return _forbidden()
|
|
suffix = Path(filepath).suffix.lower()
|
|
if suffix not in SERVABLE_EXTENSIONS and not (
|
|
# A JPEG XL preview is served from thumbnails/ only (#317).
|
|
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
|
and _is_thumbnail_cache_path(filepath)
|
|
):
|
|
return _forbidden("Only media files can be served")
|
|
|
|
output_dirs = self._select_root(roots, request.query.get("root"))
|
|
if not output_dirs:
|
|
return _forbidden("No allowed image directories configured")
|
|
|
|
for output_path in output_dirs:
|
|
image_path = output_path / filepath
|
|
if not image_path.exists():
|
|
continue
|
|
# A symlink that points outside the root is a hard deny, not
|
|
# a fall-through to the next root.
|
|
if not _is_within(image_path, output_path):
|
|
return _forbidden()
|
|
if image_path.is_file():
|
|
return self._file_response(image_path)
|
|
|
|
# A listed preview that was not made yet is made now (#249).
|
|
preview = await self._serve_missing_thumbnail(output_dirs, filepath)
|
|
if preview is not None:
|
|
return preview
|
|
|
|
# ComfyUI's cache middleware would otherwise let browsers cache this
|
|
# miss for an hour, hiding a thumbnail generated a moment later.
|
|
return web.json_response(
|
|
{"success": False, "error": "Image file not found"},
|
|
status=404,
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Serve output image error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def rescan_gallery(self, request):
|
|
"""Rewalk every output root now and answer with the new listing.
|
|
|
|
Readers are served the shared listing and refresh it in the
|
|
background (``gallery_listing.REFRESH_AFTER``); the gallery's Rescan
|
|
button needs files added or removed on disk to show up at once, so it
|
|
waits for a walk that started after the click and fails when it does.
|
|
"""
|
|
try:
|
|
output_dirs = list(self._get_all_output_dirs())
|
|
total = 0
|
|
for output_path in output_dirs:
|
|
total += len((await self._listing(output_path, fresh=True)).files)
|
|
return web.json_response(
|
|
{"success": True, "total": total, "roots": len(output_dirs)}
|
|
)
|
|
except Exception:
|
|
self.logger.exception("Gallery rescan error")
|
|
return web.json_response(
|
|
{"success": False, "error": "Failed to rescan the gallery"}, status=500
|
|
)
|
|
|
|
async def _root_subfolders(self, output_path, include_ancestors):
|
|
"""The distinct folders holding listed files under one output root.
|
|
|
|
Built with the walk, in the executor (gallery_listing.folder_sets).
|
|
"""
|
|
listing = await self._listing(output_path)
|
|
if include_ancestors:
|
|
return listing.folders_with_ancestors
|
|
return listing.folders
|
|
|
|
async def get_gallery_subfolders(self, request):
|
|
"""Get distinct subfolders from gallery output directories.
|
|
|
|
In workspace mode every root also lists its own folders, so the
|
|
Refine dialog can offer only the chosen Source's folders.
|
|
"""
|
|
try:
|
|
output_dirs = list(self._get_all_output_dirs())
|
|
workspace = request.query.get("mode") == "workspace"
|
|
include_ancestors = (
|
|
request.query.get("include_ancestors", "").lower() == "true"
|
|
)
|
|
selected = None
|
|
if workspace:
|
|
try:
|
|
selected = _workspace_root(request.query, output_dirs)
|
|
except ValueError as exc:
|
|
return bad_request(str(exc))
|
|
walked = [
|
|
await self._root_subfolders(output_path, include_ancestors)
|
|
for output_path in output_dirs
|
|
]
|
|
chosen = walked if selected is None else [walked[selected]]
|
|
subfolders = set().union(*chosen)
|
|
payload = {"success": True, "subfolders": sorted(subfolders)}
|
|
if workspace:
|
|
payload["roots"] = [
|
|
{
|
|
"index": index,
|
|
"label": _public_path(root),
|
|
"subfolders": sorted(walked[index]),
|
|
}
|
|
for index, root in enumerate(output_dirs)
|
|
]
|
|
return web.json_response(payload)
|
|
except Exception as e:
|
|
self.logger.error(f"Subfolders error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def generate_thumbnails(self, request):
|
|
"""Generate every thumbnail in one executor job (POST, blocking).
|
|
|
|
Holds the registry as guarded work: refused while a record-changing
|
|
job runs, and such jobs are refused while it runs. A body that cannot
|
|
be read is refused before the hold, so it is never listed as a job.
|
|
The legacy ``quality`` preset is accepted and ignored: previews follow
|
|
the server's thumbnail settings (#317 R3).
|
|
"""
|
|
data, error = await _json_body_or_error(request)
|
|
if error is not None:
|
|
return error
|
|
return await self._run_held(
|
|
THUMBNAILS_JOB_KIND,
|
|
THUMBNAIL_GENERATION_TITLE,
|
|
"guarded",
|
|
lambda: self._generate_thumbnails(data),
|
|
)
|
|
|
|
async def _generate_thumbnails(self, data):
|
|
try:
|
|
output_dirs = self._get_all_output_dirs()
|
|
if not output_dirs:
|
|
return web.json_response(
|
|
{"success": False, "error": "ComfyUI output directory not found"},
|
|
status=404,
|
|
)
|
|
|
|
result = await self._run_in_executor(
|
|
self._generate_gallery_thumbnails_sync, output_dirs
|
|
)
|
|
return web.json_response(result)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Generate thumbnails error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, settings=None):
|
|
"""Blocking thumbnail generation loop (run in executor)."""
|
|
settings = writer_settings(settings)
|
|
start = _time.monotonic()
|
|
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
|
|
payload = self._execute_thumbnail_plan(plan, settings, start)
|
|
payload["thumbnails_path"] = _public_path(thumbnails_dir)
|
|
return payload
|
|
|
|
def _generate_gallery_thumbnails_sync(
|
|
self, output_dirs: list[Path], settings=None
|
|
) -> dict:
|
|
settings = writer_settings(settings)
|
|
start = _time.monotonic()
|
|
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
|
|
payload = self._execute_thumbnail_plan(plan, settings, start)
|
|
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
|
|
return payload
|
|
|
|
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
|
|
targets = plan["targets"]
|
|
stats = _new_thumbnail_stats()
|
|
stats["skipped"] = plan["skipped"]
|
|
for result in self._generate_batch(targets, settings):
|
|
_record_thumbnail_result(stats, result)
|
|
payload = _thumbnail_complete_payload(
|
|
len(targets) + plan["skipped"] + plan["remaining"],
|
|
stats,
|
|
start,
|
|
remaining=plan["remaining"],
|
|
processed=len(targets),
|
|
)
|
|
payload["success"] = True
|
|
self.logger.info(payload["message"])
|
|
return payload
|
|
|
|
def _plan_gallery_thumbnails(
|
|
self,
|
|
output_dirs: list[Path],
|
|
mode: str = "missing",
|
|
stop=None,
|
|
settings=None,
|
|
exclude=None,
|
|
) -> dict:
|
|
"""Plan every gallery root with one shared per-batch generation cap.
|
|
|
|
See ``_plan_thumbnails``; ``fresh_files`` keeps at most
|
|
MAX_FRESH_LISTED files across the roots. ``stop()`` true ends the walk
|
|
at the next file (a cancelled job); the partial plan is returned.
|
|
*exclude* (a set of source paths) leaves files a job already worked
|
|
on out of a later batch's plan.
|
|
"""
|
|
settings = writer_settings(settings)
|
|
combined = {key: 0 for key in _PLAN_COUNTS}
|
|
combined.update(targets=[], fresh_files=[])
|
|
for root in output_dirs:
|
|
if stop is not None and stop():
|
|
break
|
|
root = Path(root)
|
|
plan = self._plan_thumbnails(
|
|
root,
|
|
root / "thumbnails",
|
|
mode=mode,
|
|
stop=stop,
|
|
settings=settings,
|
|
exclude=exclude,
|
|
)
|
|
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
|
|
combined["targets"].extend(plan["targets"][:available])
|
|
for key in _PLAN_COUNTS:
|
|
combined[key] += plan[key]
|
|
room = max(0, MAX_FRESH_LISTED - len(combined["fresh_files"]))
|
|
combined["fresh_files"].extend(plan["fresh_files"][:room])
|
|
combined["remaining"] += max(0, len(plan["targets"]) - available)
|
|
return combined
|
|
|
|
async def get_thumbnail_status(self, request: web.Request) -> web.Response:
|
|
"""Count previews off-loop without generating files.
|
|
|
|
``pending`` previews are missing, ``outdated`` ones exist in another
|
|
format than the one written now (Convert outdated, #317) and ``ready``
|
|
counts every file with a preview, outdated or not.
|
|
"""
|
|
try:
|
|
output_dirs = self._get_all_output_dirs()
|
|
plan = await self._run_in_executor(
|
|
self._plan_gallery_thumbnails, output_dirs
|
|
)
|
|
pending = len(plan["targets"]) + plan["remaining"]
|
|
collection = json.dumps(sorted(str(_canonical(p)) for p in output_dirs))
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"total_images": pending + plan["skipped"],
|
|
"pending": pending,
|
|
"ready": plan["skipped"],
|
|
"outdated": plan["outdated"],
|
|
"format": writer_settings().format,
|
|
# Files whose automatic preview failed this session (#249).
|
|
"failed": self._auto_thumbnail_failures(),
|
|
"collection_id": hashlib.sha256(collection.encode()).hexdigest()[
|
|
:16
|
|
],
|
|
},
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
except Exception as exc:
|
|
self.logger.exception("Thumbnail status failed")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(exc)}, status=500
|
|
)
|
|
|
|
def _plan_thumbnails(
|
|
self,
|
|
output_path,
|
|
thumbnails_dir,
|
|
mode="missing",
|
|
stop=None,
|
|
settings=None,
|
|
exclude=None,
|
|
):
|
|
"""Decide which media files need a preview (blocking walk).
|
|
|
|
Each file is ``fresh`` (its preview in the format written now is at
|
|
least as new as the file, and no other owned preview is left),
|
|
``outdated`` (an owned preview in another format: a legacy
|
|
``_thumb.png`` or a format switched away from, #317) or ``missing``.
|
|
``mode`` picks the targets: ``missing`` the missing ones (fresh and
|
|
outdated are ``skipped`` and never queued, so a large library that is
|
|
mostly done only pays for the files that changed), ``rebuild`` every
|
|
file, ``convert`` the outdated ones (missing ones are left to the
|
|
missing run). Returns ``{"targets": [(src, dst, is_video)], "skipped",
|
|
"remaining", "fresh", "outdated", "missing", "fresh_files": [src]}``;
|
|
``fresh_files`` lists the first MAX_FRESH_LISTED fresh files. The walk
|
|
is bounded like the gallery listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES,
|
|
no symlinks), each preview folder is listed once, and at most
|
|
MAX_THUMBNAIL_FILES targets are queued per batch (a job plans again
|
|
after each batch); ``remaining`` counts the targets beyond that cap.
|
|
Sources in *exclude* (worked on by an earlier batch) are left out.
|
|
``stop()`` true ends the walk before the next file.
|
|
"""
|
|
settings = writer_settings(settings)
|
|
output_path = Path(output_path)
|
|
thumbnails_dir = Path(thumbnails_dir)
|
|
plan = {key: 0 for key in _PLAN_COUNTS}
|
|
plan.update(targets=[], fresh_files=[])
|
|
folders = {}
|
|
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
|
|
if stop is not None and stop():
|
|
break
|
|
if exclude and src in exclude:
|
|
continue
|
|
rel_no_ext = src.relative_to(output_path).with_suffix("")
|
|
stem = f"{rel_no_ext.name}{thumbnail_settings.THUMB_SUFFIX}"
|
|
dst = thumbnails_dir / rel_no_ext.parent / (stem + settings.extension)
|
|
if not _is_within(dst, thumbnails_dir):
|
|
self.logger.warning(f"Skipping thumbnail outside safe dir: {src.name}")
|
|
continue
|
|
state = _preview_state(src, dst.parent, stem, settings, folders)
|
|
plan[state] += 1
|
|
target = (
|
|
mode == "rebuild"
|
|
or (mode == "missing" and state == "missing")
|
|
or (mode == "convert" and state == "outdated")
|
|
)
|
|
if not target:
|
|
plan["skipped"] += 1
|
|
if state == "fresh" and len(plan["fresh_files"]) < MAX_FRESH_LISTED:
|
|
plan["fresh_files"].append(src)
|
|
continue
|
|
if len(plan["targets"]) >= MAX_THUMBNAIL_FILES:
|
|
plan["remaining"] += 1
|
|
continue
|
|
is_video = src.suffix.lower() in VIDEO_EXTENSIONS
|
|
plan["targets"].append((src, dst, is_video))
|
|
if plan["remaining"]:
|
|
self.logger.warning(
|
|
f"Thumbnail batch capped at {MAX_THUMBNAIL_FILES} files; "
|
|
f"{plan['remaining']} more are left for the next batch"
|
|
)
|
|
return plan
|
|
|
|
def _thumbnail_targets(self, output_path, thumbnails_dir):
|
|
"""Pending (src, dst, is_video) triples; see _plan_thumbnails."""
|
|
return self._plan_thumbnails(output_path, thumbnails_dir)["targets"]
|
|
|
|
def _generate_batch(self, targets, settings, **options):
|
|
"""Create thumbnails for ``targets`` on the configured worker threads.
|
|
|
|
Blocking; returns one result dict per target, in order. ``options``
|
|
go to ``_generate_one`` (``rebuild``, ``stop``).
|
|
"""
|
|
|
|
def one(target):
|
|
src, dst, is_video = target
|
|
return self._generate_one(src, dst, settings, is_video, **options)
|
|
|
|
return map_parallel(one, targets, _worker_threads())
|
|
|
|
def _generate_one(
|
|
self,
|
|
src,
|
|
dst,
|
|
settings,
|
|
is_video,
|
|
rebuild=False,
|
|
stop=None,
|
|
guard=None,
|
|
track=None,
|
|
placeholder=True,
|
|
):
|
|
"""Create one thumbnail (blocking). Never raises; returns a result dict.
|
|
|
|
*settings* (``thumbnail_settings.ThumbnailSettings``) give the format,
|
|
quality and edge; *dst* takes the format's extension. ``stop()`` true
|
|
before the file starts gives ``action: "cancelled"`` and touches
|
|
nothing (a job's file boundary). ``rebuild`` writes a fresh preview
|
|
again and never replaces an existing video preview with the
|
|
placeholder. Every write is atomic and verified, so a failure keeps
|
|
the old preview. ``guard()`` false just before the rename gives
|
|
``action: "error"`` with ``held: True`` and writes nothing;
|
|
``track(process)`` is handed every encoder process (both for automatic
|
|
previews, #249). An error result carries ``decode: True`` when the
|
|
file itself cannot be decoded (an I/O error does not). A generated
|
|
result names the file written as ``path``. Without ``placeholder``
|
|
(Convert outdated) a video with no decodable frame fails instead.
|
|
"""
|
|
result = {
|
|
"file": src.name,
|
|
"dir": src.parent.name,
|
|
"type": "video" if is_video else "image",
|
|
}
|
|
dst = Path(dst)
|
|
if dst.suffix != settings.extension:
|
|
dst = dst.with_suffix(settings.extension)
|
|
try:
|
|
if stop is not None and stop():
|
|
return {**result, "action": "cancelled"}
|
|
if not src.is_file():
|
|
return {**result, "action": "error", "error": "File no longer exists"}
|
|
if not rebuild and _thumbnail_is_fresh(src, dst):
|
|
return {**result, "action": "skipped", "path": str(dst)}
|
|
dst.parent.mkdir(parents=True, exist_ok=True)
|
|
if is_video:
|
|
made = self._generate_video_thumbnail(
|
|
src,
|
|
dst,
|
|
settings,
|
|
keep_existing=rebuild,
|
|
guard=guard,
|
|
track=track,
|
|
placeholder=placeholder,
|
|
)
|
|
if not made:
|
|
return {
|
|
**result,
|
|
"action": "error",
|
|
"error": "Video decode failed",
|
|
"decode": True,
|
|
}
|
|
written = made if isinstance(made, Path) else dst
|
|
else:
|
|
written = _write_image_thumbnail(src, dst, settings, guard, track)
|
|
return {**result, "action": "generated", "path": str(written)}
|
|
except PreviewWriteHeld:
|
|
return {**result, "action": "error", "error": PREVIEW_HELD, "held": True}
|
|
except UnidentifiedImageError:
|
|
return {
|
|
**result,
|
|
"action": "error",
|
|
"error": "Not a valid image file",
|
|
"decode": True,
|
|
}
|
|
except PermissionError as e:
|
|
message = _preview_write_error(e)
|
|
self.logger.warning(f"Thumbnail failed for {src.name}: {message}")
|
|
return {**result, "action": "error", "error": message}
|
|
except Exception as e:
|
|
self.logger.warning(f"Thumbnail failed for {src.name}", exc_info=True)
|
|
failed = {**result, "action": "error", "error": _safe_error(e)}
|
|
if _is_decode_error(e):
|
|
failed["decode"] = True
|
|
return failed
|
|
|
|
async def _emit_sse(self, response, event, data) -> bool:
|
|
"""Write one SSE frame. Returns False once the client has gone away."""
|
|
try:
|
|
frame = f"event: {event}\ndata: {json.dumps(data)}\n\n"
|
|
await response.write(frame.encode("utf-8"))
|
|
return True
|
|
except Exception as e:
|
|
self.logger.warning(f"Failed to send SSE message: {e}")
|
|
return False
|
|
|
|
def _generate_video_thumbnail(
|
|
self,
|
|
video_path,
|
|
thumbnail_path,
|
|
settings,
|
|
keep_existing=False,
|
|
*,
|
|
guard=None,
|
|
track=None,
|
|
placeholder=True,
|
|
):
|
|
"""Write a video's preview; returns the path written, or False.
|
|
|
|
The frame comes from OpenCV, else ffmpeg, else the placeholder frame:
|
|
a decoder that cannot open or read the clip (an AV1 file OpenCV was
|
|
built without) falls through to the next one (#249 I1). Every frame
|
|
is fitted inside the edge and encoded in *settings*' format like an
|
|
image's preview (#317). With ``keep_existing`` an existing preview is
|
|
kept, and the run fails, when no frame could be decoded, rather than
|
|
replaced by the placeholder. A PermissionError while replacing the
|
|
preview propagates (``_generate_one`` names it), as do
|
|
PreviewWriteHeld when ``guard()`` refuses the rename and
|
|
EncoderClosed at shutdown. ``track(process)`` is handed the ffmpeg
|
|
process so a shutdown can kill it. Without ``placeholder`` no
|
|
placeholder is written: the run fails when no frame decodes.
|
|
"""
|
|
try:
|
|
frame = self._opencv_frame(video_path, settings.size)
|
|
source = "OpenCV"
|
|
if frame is None:
|
|
frame = self._ffmpeg_frame(video_path, settings.size, track)
|
|
source = "ffmpeg"
|
|
if frame is not None:
|
|
written = write_thumbnail(
|
|
thumbnail_frame(frame, settings.edge),
|
|
thumbnail_path,
|
|
settings,
|
|
guard=guard,
|
|
track=track,
|
|
)
|
|
self.logger.debug(
|
|
f"Generated video thumbnail using {source}: {written}"
|
|
)
|
|
return written
|
|
if not placeholder or (keep_existing and thumbnail_path.exists()):
|
|
self.logger.warning(
|
|
f"No frame decoded for {video_path.name}; keeping its preview"
|
|
)
|
|
return False
|
|
# Last resort: the placeholder frame.
|
|
try:
|
|
written = write_thumbnail(
|
|
_video_placeholder_image(settings.size),
|
|
thumbnail_path,
|
|
settings,
|
|
guard=guard,
|
|
track=track,
|
|
)
|
|
self.logger.debug(f"Generated placeholder video thumbnail: {written}")
|
|
return written
|
|
except (PermissionError, PreviewWriteHeld, EncoderClosed):
|
|
raise
|
|
except Exception as e:
|
|
self.logger.warning(
|
|
f"Failed to create placeholder thumbnail for {video_path}: {e}"
|
|
)
|
|
return False
|
|
except (PermissionError, PreviewWriteHeld, EncoderClosed):
|
|
raise
|
|
except Exception as e:
|
|
self.logger.error(
|
|
f"Video thumbnail generation failed for {video_path}: {e}"
|
|
)
|
|
return False
|
|
|
|
def _opencv_frame(self, video_path, thumbnail_size):
|
|
"""A frame about 10% in, fitted to *thumbnail_size*, or None (no
|
|
OpenCV, or it could not open or read the clip)."""
|
|
try:
|
|
import cv2
|
|
except ImportError:
|
|
return None
|
|
try:
|
|
cap = cv2.VideoCapture(str(video_path))
|
|
try:
|
|
if not cap.isOpened():
|
|
return None
|
|
frame_count = int(cap.get(cv2.CAP_PROP_FRAME_COUNT))
|
|
cap.set(cv2.CAP_PROP_POS_FRAMES, max(1, int(frame_count * 0.1)))
|
|
ret, frame = cap.read()
|
|
finally:
|
|
cap.release()
|
|
if not ret or frame is None:
|
|
return None
|
|
img = Image.fromarray(cv2.cvtColor(frame, cv2.COLOR_BGR2RGB))
|
|
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
|
|
return img
|
|
except Exception as e:
|
|
self.logger.debug(f"OpenCV could not read {video_path}: {e}")
|
|
return None
|
|
|
|
def _ffmpeg_frame(self, video_path, thumbnail_size, track=None):
|
|
"""ffmpeg's frame at 1 s, fitted to *thumbnail_size*, as a PIL image
|
|
(a PNG on stdout, never a temp file), or None. EncoderClosed (the
|
|
shutdown) propagates."""
|
|
cmd = [
|
|
"ffmpeg",
|
|
"-v",
|
|
"error",
|
|
"-i",
|
|
# Read as a file, never as a protocol URL (review M6).
|
|
f"file:{video_path}",
|
|
"-ss",
|
|
"00:00:01",
|
|
"-vframes",
|
|
"1",
|
|
"-vf",
|
|
# Fit inside the edge, never upscale a small clip.
|
|
f"scale='min(iw,{thumbnail_size[0]})':'min(ih,{thumbnail_size[1]})'"
|
|
":force_original_aspect_ratio=decrease",
|
|
"-f",
|
|
"image2pipe",
|
|
"-vcodec",
|
|
"png",
|
|
"-",
|
|
]
|
|
try:
|
|
done = run_encoder(cmd, track=track)
|
|
except EncoderClosed:
|
|
raise
|
|
except (OSError, subprocess.SubprocessError) as e:
|
|
self.logger.debug(f"ffmpeg could not read {video_path}: {e}")
|
|
return None
|
|
if done.returncode != 0 or not done.stdout:
|
|
detail = done.stderr or b""
|
|
if isinstance(detail, bytes):
|
|
detail = detail.decode("utf-8", "replace")
|
|
detail = detail[-300:]
|
|
self.logger.warning(f"ffmpeg failed for {video_path}: {detail}")
|
|
return None
|
|
try:
|
|
frame = Image.open(io.BytesIO(done.stdout))
|
|
frame.load()
|
|
return frame
|
|
except Exception as e:
|
|
self.logger.warning(f"ffmpeg gave no readable frame for {video_path}: {e}")
|
|
return None
|
|
|
|
async def clear_thumbnails(self, request: web.Request) -> web.Response:
|
|
"""Clear generated thumbnails across gallery roots, preserving originals.
|
|
|
|
Invalid thumbnail directories return 400; unavailable gallery roots
|
|
return 404, and unexpected filesystem failures return 500. Holds the
|
|
registry as guarded work, like generation.
|
|
"""
|
|
return await self._run_held(
|
|
THUMBNAILS_JOB_KIND,
|
|
THUMBNAIL_CLEAR_TITLE,
|
|
"guarded",
|
|
self._clear_thumbnails,
|
|
)
|
|
|
|
async def _clear_thumbnails(self) -> web.Response:
|
|
try:
|
|
output_dirs = self._get_all_output_dirs()
|
|
if not output_dirs:
|
|
return web.json_response(
|
|
{"success": False, "error": "ComfyUI output directory not found"},
|
|
status=404,
|
|
)
|
|
cleared_count, cleared_size = await self._run_in_executor(
|
|
self._clear_gallery_thumbnails_sync, output_dirs
|
|
)
|
|
|
|
def format_size(bytes_size: int) -> str:
|
|
size = float(bytes_size)
|
|
for unit in ["B", "KB", "MB", "GB"]:
|
|
if size < 1024.0:
|
|
return f"{size:.1f} {unit}"
|
|
size /= 1024.0
|
|
return f"{size:.1f} TB"
|
|
|
|
size_formatted = format_size(cleared_size)
|
|
self.logger.info(
|
|
f"Thumbnail cleanup: cleared {cleared_count} files ({size_formatted})"
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"cleared_files": cleared_count,
|
|
"cleared_size": cleared_size,
|
|
"cleared_size_formatted": size_formatted,
|
|
"message": f"Cleared {cleared_count} thumbnail files "
|
|
f"({size_formatted})",
|
|
}
|
|
)
|
|
except ValueError as exc:
|
|
self.logger.error(f"Thumbnail path validation failed: {exc}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(exc)}, status=400
|
|
)
|
|
except Exception as exc:
|
|
self.logger.error(f"Clear thumbnails error: {exc}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(exc)}, status=500
|
|
)
|
|
|
|
def sweep_thumbnail_temps(self):
|
|
"""Remove the old temp files crashed preview writes left in every
|
|
root's ``thumbnails/`` folder (blocking; run at startup on its own
|
|
thread). Returns how many went."""
|
|
removed = 0
|
|
try:
|
|
output_dirs = self._get_all_output_dirs()
|
|
except Exception as exc:
|
|
self.logger.debug(f"No roots to sweep for preview temp files: {exc}")
|
|
return 0
|
|
for output_path in output_dirs:
|
|
folder = Path(output_path) / "thumbnails"
|
|
if folder.is_symlink() or not folder.is_dir():
|
|
continue
|
|
removed += sweep_stray_temps(folder)[0]
|
|
if removed:
|
|
self.logger.info(f"Removed {removed} interrupted preview temp files")
|
|
return removed
|
|
|
|
def _clear_gallery_thumbnails_sync(
|
|
self, output_dirs: list[Path]
|
|
) -> tuple[int, int]:
|
|
"""Validate all thumbnail directories before deleting generated files.
|
|
|
|
Raises ValueError for symlinked or out-of-root thumbnail directories.
|
|
Filesystem errors during path validation propagate to the route.
|
|
"""
|
|
directories = []
|
|
for output_path in output_dirs:
|
|
output_path = Path(output_path)
|
|
thumbnails_dir = output_path / "thumbnails"
|
|
if not thumbnails_dir.exists() and not thumbnails_dir.is_symlink():
|
|
continue
|
|
if (
|
|
thumbnails_dir.is_symlink()
|
|
or not _is_within(thumbnails_dir, output_path)
|
|
or _canonical(thumbnails_dir) == _canonical(output_path)
|
|
):
|
|
raise ValueError(
|
|
"Safety check failed: invalid thumbnails directory path"
|
|
)
|
|
directories.append(thumbnails_dir)
|
|
|
|
cleared_count = 0
|
|
cleared_size = 0
|
|
for thumbnails_dir in directories:
|
|
# Temp files a crashed write left (review I1): the clear holds
|
|
# every preview write, so none is in use.
|
|
temps, temp_bytes = sweep_stray_temps(thumbnails_dir, min_age=0)
|
|
cleared_count += temps
|
|
cleared_size += temp_bytes
|
|
for root, _dirs, files in os.walk(thumbnails_dir):
|
|
for name in files:
|
|
path = Path(root) / name
|
|
if (
|
|
"_thumb" not in name.lower()
|
|
or path.suffix.lower()
|
|
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
|
or path.is_symlink()
|
|
):
|
|
continue
|
|
try:
|
|
size = path.stat().st_size
|
|
path.unlink()
|
|
cleared_count += 1
|
|
cleared_size += size
|
|
except OSError as exc:
|
|
self.logger.warning(f"Failed to delete thumbnail {path}: {exc}")
|
|
for root, _dirs, _files in os.walk(thumbnails_dir, topdown=False):
|
|
if Path(root) != thumbnails_dir:
|
|
try:
|
|
Path(root).rmdir()
|
|
except OSError as exc:
|
|
self.logger.debug(
|
|
"Keeping thumbnail directory %s: %s", root, exc
|
|
)
|
|
return cleared_count, cleared_size
|
|
|
|
@staticmethod
|
|
def _resolve_client_media_path(raw_path, allowed_dirs):
|
|
"""Absolute path for a client-supplied media path, or None when it
|
|
does not name a file inside an allowed root.
|
|
|
|
Absolute paths are used as given (the caller still runs the access
|
|
check). Relative paths are the forms this API hands out: relative to
|
|
a gallery root (``sub/a.png``) or to a ComfyUI anchor
|
|
(``output/sub/a.png``), and must resolve inside an allowed root.
|
|
"""
|
|
if os.path.isabs(raw_path):
|
|
return raw_path
|
|
from ..config import GalleryConfig
|
|
|
|
for base in [*allowed_dirs, *GalleryConfig.path_anchors()]:
|
|
candidate = Path(base) / raw_path
|
|
if candidate.is_file() and any(
|
|
_is_within(candidate, root) for root in allowed_dirs
|
|
):
|
|
return str(candidate)
|
|
return None
|
|
|
|
async def link_image_to_prompt(self, request):
|
|
"""Link a generated image (inside an allowed directory) to a prompt."""
|
|
try:
|
|
data, error = await _json_body_or_error(request)
|
|
if error is not None:
|
|
return error
|
|
prompt_id = data.get("prompt_id")
|
|
image_path = data.get("image_path")
|
|
metadata = data.get("metadata", {})
|
|
|
|
if not prompt_id or not isinstance(image_path, str) or not image_path:
|
|
return bad_request("prompt_id and image_path are required")
|
|
if not os.path.isabs(image_path) and _has_traversal(image_path):
|
|
return _forbidden()
|
|
|
|
allowed_dirs = list(self._get_all_output_dirs())
|
|
resolved = self._resolve_client_media_path(image_path, allowed_dirs)
|
|
if resolved is None:
|
|
if Path(image_path).suffix.lower() not in SERVABLE_EXTENSIONS:
|
|
return _forbidden("Only media files can be served")
|
|
return web.json_response(
|
|
{"success": False, "error": "Image file not found"}, status=404
|
|
)
|
|
image_path = resolved
|
|
|
|
denied = _media_access_error(image_path, allowed_dirs)
|
|
if denied is not None:
|
|
return denied
|
|
|
|
if not os.path.isfile(image_path):
|
|
return web.json_response(
|
|
{"success": False, "error": "Image file not found"}, status=404
|
|
)
|
|
|
|
image_id = await self._run_in_executor(
|
|
self.db.link_image_to_prompt, prompt_id, image_path, metadata
|
|
)
|
|
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"image_id": image_id,
|
|
"message": "Image linked successfully",
|
|
}
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Link image error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def update_image_favorite(self, request, *, favorite: bool):
|
|
"""PUT/DELETE one favorite, named by ``{root_index, path}``.
|
|
|
|
A favorite is set only on a file in the root's listing; clearing also
|
|
accepts a well-formed path that is no longer listed, so a deleted
|
|
file's favorite can still be removed. Both are idempotent.
|
|
"""
|
|
data, error = await _json_body_or_error(request)
|
|
if error is not None:
|
|
return error
|
|
roots = list(self._get_all_output_dirs())
|
|
target, error = _favorite_target(data, roots)
|
|
if error is not None:
|
|
return error
|
|
index, relative = target
|
|
root = roots[index]
|
|
relative = Path(relative).as_posix()
|
|
try:
|
|
files = await self._get_gallery_files(root)
|
|
listing = await self._run_in_executor(self._favorite_listing, root, files)
|
|
key = _favorite_relative_key(relative)
|
|
listed = listing.get(key)
|
|
if listed is None and favorite:
|
|
return web.json_response(
|
|
{"success": False, "error": "Image file not found"}, status=404
|
|
)
|
|
root_key = _favorite_root_key(root)
|
|
if favorite and max(len(root_key), len(key)) > MAX_KEY_LENGTH:
|
|
# A stored key past the schema bound would fail the next
|
|
# startup or restore validation.
|
|
return _forbidden("Path is too long to favorite")
|
|
update = (
|
|
self.db.set_image_favorite if favorite else self.db.clear_image_favorite
|
|
)
|
|
await self._run_in_executor(update, root_key, key)
|
|
except Exception:
|
|
self.logger.exception("Favorite update failed")
|
|
return web.json_response(
|
|
{"success": False, "error": "Favorite update failed"}, status=500
|
|
)
|
|
return web.json_response(
|
|
{
|
|
"success": True,
|
|
"favorite": favorite,
|
|
"root_index": index,
|
|
"path": listed or relative,
|
|
}
|
|
)
|
|
|
|
# Swapped by tests and the disposable browser server; never a shell.
|
|
_reveal_opener = staticmethod(reveal_in_file_manager)
|
|
# One per process (a class attribute): single flight plus a cooldown.
|
|
_reveal_gate = RevealGate()
|
|
|
|
@staticmethod
|
|
def _reveal_path(relative: str, root: Path) -> Path | None:
|
|
"""The real path of a regular media file under *root*, else None."""
|
|
resolved = ImageRoutesMixin._resolve_client_media_path(relative, [root])
|
|
if resolved is None or _media_access_error(resolved, [root]) is not None:
|
|
return None
|
|
real = Path(os.path.realpath(resolved))
|
|
return real if real.is_file() and _is_within(real, root) else None
|
|
|
|
async def reveal_image(self, request):
|
|
"""POST ``{root_index, root_scope, path}``: show the file in its folder.
|
|
|
|
Local-only (a same-origin browser request from a loopback client, no
|
|
forwarding header) and JSON-only: the browser must run on the
|
|
computer whose file manager opens. One reveal at a time (429 busy).
|
|
"""
|
|
if not is_local_request(request.remote, request.headers, request.scheme):
|
|
return _not_local()
|
|
if request.content_type != "application/json":
|
|
# A non-JSON POST is a CORS "simple" request with no preflight.
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"code": "unsupported-media-type",
|
|
"error": "Send the request as application/json.",
|
|
},
|
|
status=415,
|
|
)
|
|
data, error = await _json_body_or_error(request)
|
|
if error is not None:
|
|
return error
|
|
target, error = _reveal_target(data)
|
|
if error is not None:
|
|
return error
|
|
index, scope, relative = target
|
|
roots = list(self._get_all_output_dirs())
|
|
error = self._gallery_scope_error(
|
|
{"root_scope": scope, "root": str(index)}, roots
|
|
)
|
|
if error is not None:
|
|
return error
|
|
root = roots[index]
|
|
if Path(relative).suffix.lower() not in SERVABLE_EXTENSIONS:
|
|
return _forbidden("Only media files can be shown")
|
|
gate = self._reveal_gate
|
|
if not gate.try_enter():
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"code": "busy",
|
|
"error": "The folder is already opening.",
|
|
},
|
|
status=429,
|
|
)
|
|
try:
|
|
return await self._reveal(relative, root)
|
|
finally:
|
|
gate.leave()
|
|
|
|
async def _reveal(self, relative: str, root: Path):
|
|
"""Resolve and open, inside the reveal gate."""
|
|
try:
|
|
path = await self._run_in_executor(self._reveal_path, relative, root)
|
|
except (OSError, ValueError) as exc:
|
|
# ENAMETOOLONG and the like: the client's path is not logged.
|
|
self.logger.debug(
|
|
"Show in folder could not resolve: %s", type(exc).__name__
|
|
)
|
|
return _reveal_not_found()
|
|
if path is None:
|
|
return _reveal_not_found()
|
|
try:
|
|
await asyncio.get_running_loop().run_in_executor(
|
|
reveal_executor(), self._reveal_opener, path, root
|
|
)
|
|
except RevealTargetChanged:
|
|
return _reveal_not_found()
|
|
except OpenerUnavailable:
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"code": "unavailable",
|
|
"error": "No file manager is available on this computer.",
|
|
},
|
|
status=501,
|
|
)
|
|
except Exception as exc:
|
|
# The exception text can hold the path: only its type at error
|
|
# level, the traceback at debug.
|
|
self.logger.error("Show in folder failed: %s", type(exc).__name__)
|
|
self.logger.debug("Show in folder failure", exc_info=True)
|
|
return web.json_response(
|
|
{"success": False, "error": "The folder could not be opened."},
|
|
status=500,
|
|
)
|
|
return web.json_response({"success": True})
|
|
|
|
def _favorite_listing(self, root: Path, files: list) -> dict[str, str]:
|
|
"""``{relative key: listing POSIX spelling}`` for one root's scan.
|
|
|
|
Built once per scan (the gallery cache hands out the same list until
|
|
it rescans), so a toggle is a dictionary lookup.
|
|
"""
|
|
cache_key = str(root)
|
|
cached = self._favorite_listing_cache.get(cache_key)
|
|
if cached is not None and cached[0] is files:
|
|
return cached[1]
|
|
listing = None
|
|
if cached is not None:
|
|
delta = gallery_listing.listing_delta(cached[0], files)
|
|
if delta is not None:
|
|
listing = _favorite_listing_step(root, cached[1], *delta)
|
|
if listing is None:
|
|
# From the path strings, as _listed does: relative_to per file was
|
|
# the largest cost of a cold first open (#348).
|
|
prefix = os.path.join(str(root), "")
|
|
listing = {}
|
|
for path, _mtime in files:
|
|
posix = _listing_posix(root, prefix, path)
|
|
listing.setdefault(_favorite_relative_key(posix), posix)
|
|
self._favorite_listing_cache[cache_key] = (files, listing)
|
|
return listing
|
|
|
|
def _workspace_favorite_paths(self, listings: list[tuple]) -> frozenset:
|
|
"""The listed paths of every favorite in the selected roots (blocking).
|
|
|
|
One primary-key read per root (get_root_favorites), matched against the
|
|
root's cached ``{relative key: listing spelling}``, so the work follows
|
|
the favorites rather than the files, and a key folds case exactly as
|
|
the toggle stored it (Windows). A favorite whose file is gone matches
|
|
nothing. A failed read raises: an unfiltered page must never pass for
|
|
the favorites.
|
|
"""
|
|
paths = set()
|
|
for _index, root, files in listings:
|
|
listing = self._favorite_listing(root, files)
|
|
for key in self.db.get_root_favorites(_favorite_root_key(root)):
|
|
listed = listing.get(key)
|
|
if listed is not None:
|
|
paths.add(root / listed)
|
|
return frozenset(paths)
|
|
|
|
def _workspace_favorites(self, page: list[tuple]) -> set[tuple[int, str]]:
|
|
"""``(root index, listing path)`` of the favorites among *page* entries."""
|
|
by_root: dict[tuple[int, Path], dict[str, str]] = {}
|
|
for path, _mtime, root, index in page:
|
|
posix = path.relative_to(root).as_posix()
|
|
by_root.setdefault((index, root), {})[_favorite_relative_key(posix)] = posix
|
|
try:
|
|
found = set()
|
|
for (index, root), keys in by_root.items():
|
|
for key in self.db.get_image_favorites(
|
|
_favorite_root_key(root), list(keys)
|
|
):
|
|
found.add((index, keys[key]))
|
|
return found
|
|
except Exception:
|
|
# The listing stays usable; stars show as not favorited.
|
|
self.logger.warning("Image favorites could not be read")
|
|
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())
|
|
scope_error = self._gallery_scope_error(request.query, roots)
|
|
if scope_error is not None:
|
|
return scope_error
|
|
try:
|
|
index = _workspace_root(request.query, roots)
|
|
if index is None:
|
|
raise ValueError("Gallery root is required")
|
|
except ValueError as error:
|
|
return bad_request(str(error))
|
|
root_scope = request.query.get("root_scope") or self._gallery_root_scope(
|
|
roots[index], index
|
|
)
|
|
relative = request.match_info.get("image_path", "")
|
|
if (
|
|
_has_traversal(relative)
|
|
or "\0" in relative
|
|
or re.match(r"^[a-zA-Z]:", relative)
|
|
):
|
|
return _forbidden()
|
|
if _is_thumbnail_cache_path(relative):
|
|
# A preview is a placeholder: it carries no metadata (#317).
|
|
return _forbidden("Previews are not inspected; inspect the original")
|
|
path = Path(roots[index]) / relative
|
|
denied = _media_access_error(path, [roots[index]])
|
|
if denied is not None:
|
|
return denied
|
|
if not path.is_file():
|
|
return web.json_response(
|
|
{"success": False, "error": "Image file not found"}, status=404
|
|
)
|
|
try:
|
|
|
|
def inspect() -> bytes | None:
|
|
saved, unavailable, summary = None, False, None
|
|
try:
|
|
saved = self.db.get_image_prompt_info(
|
|
str(path), False, report_ambiguity=True
|
|
)
|
|
except Exception:
|
|
unavailable = True
|
|
self.logger.warning("Saved inspection display could not be read")
|
|
if saved and saved.get("association_status") != "ambiguous":
|
|
try:
|
|
summary = self.db.get_image_link_summaries([str(path)], False)
|
|
summary = summary[str(path)]
|
|
except Exception:
|
|
# The link stays known; only its title is withheld.
|
|
self.logger.warning("Inspection link title could not be read")
|
|
try:
|
|
observed = inspect_current_file(
|
|
path,
|
|
source_label=_public_path(roots[index]),
|
|
saved=saved,
|
|
library=self._inspection_library(
|
|
saved, unavailable=unavailable
|
|
),
|
|
image_tags=self._inspection_image_tags(
|
|
roots[index], Path(relative).parts
|
|
),
|
|
)
|
|
except CurrentFileChanged:
|
|
return None
|
|
observed["library"]["title"] = _verified_title(
|
|
summary, _positive_title(_inspected_positive(observed))
|
|
)
|
|
return encode_inspection_response(
|
|
image={
|
|
"root_index": index,
|
|
"path": Path(relative).as_posix(),
|
|
"filename": path.name,
|
|
},
|
|
root_scope=root_scope,
|
|
**observed,
|
|
)
|
|
|
|
body = await self._run_in_executor(inspect)
|
|
if body is None:
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": "The file changed while being inspected. Try again.",
|
|
},
|
|
status=409,
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
return web.Response(
|
|
body=body,
|
|
content_type="application/json",
|
|
charset="utf-8",
|
|
headers={"Cache-Control": "no-store"},
|
|
)
|
|
except Exception:
|
|
self.logger.exception("Prompt inspection failed")
|
|
return web.json_response(
|
|
{"success": False, "error": "Prompt inspection failed"}, status=500
|
|
)
|
|
|
|
def _inspection_image_tags(self, root: Path, parts: tuple) -> Callable:
|
|
"""The inspector's ``image_tags`` reader for one rooted file (S1).
|
|
|
|
The returned function takes the inspection's own stable stat and
|
|
answers ``current`` only for a byte record verified at exactly that
|
|
``(size, mtime_ns)``; a failed read is ``unavailable``, never an error.
|
|
"""
|
|
locator = os.path.normcase(os.path.join(os.path.realpath(root), *parts))
|
|
|
|
def read(size: int, mtime_ns: int) -> dict:
|
|
try:
|
|
found = self.db.current_image_tags({locator: (size, mtime_ns)})
|
|
except Exception:
|
|
self.logger.warning(
|
|
"Inspection image tags could not be read", exc_info=True
|
|
)
|
|
return {"state": "unavailable", "tags": []}
|
|
tags = found.get(locator)
|
|
if tags is None:
|
|
return {"state": "none", "tags": []}
|
|
return {"state": "current", "tags": list(tags)}
|
|
|
|
return read
|
|
|
|
def _inspection_library(self, saved: dict | None, *, unavailable: bool) -> dict:
|
|
"""The inspector's Library facts; the route sets the title afterwards."""
|
|
result = {
|
|
"status": "unavailable" if unavailable else "none",
|
|
"scope": "saved-library",
|
|
"positive_id": None,
|
|
"pair_id": None,
|
|
"title": None,
|
|
"category": None,
|
|
"tags": [],
|
|
"category_scope": None,
|
|
"tags_scope": None,
|
|
}
|
|
if not isinstance(saved, dict) or not saved:
|
|
return result
|
|
if saved.get("association_status") == "ambiguous":
|
|
result["status"] = "ambiguous"
|
|
return result
|
|
pair = saved.get("pair")
|
|
recipe = isinstance(pair, dict)
|
|
result.update(
|
|
status="linked",
|
|
positive_id=(
|
|
pair.get("positive_prompt_id") if recipe else saved.get("prompt_id")
|
|
),
|
|
pair_id=pair.get("id") if recipe else None,
|
|
**library_metadata(
|
|
saved.get("category"),
|
|
saved.get("tags"),
|
|
(pair.get("category"), pair.get("tags")) if recipe else None,
|
|
),
|
|
)
|
|
return result
|
|
|
|
async def get_image_prompt(self, request):
|
|
"""Get prompt information for a specific image path."""
|
|
try:
|
|
# Get the image path from URL
|
|
raw_image_path = request.match_info.get("image_path", "")
|
|
image_path = raw_image_path
|
|
|
|
if not image_path:
|
|
return web.json_response(
|
|
{"success": False, "error": "Image path is required"}, status=400
|
|
)
|
|
|
|
# Convert relative path to absolute if needed
|
|
if "root" in request.query:
|
|
roots = self._select_root(
|
|
self._get_all_output_dirs(), request.query["root"]
|
|
)
|
|
if not roots:
|
|
return _forbidden()
|
|
if not os.path.isabs(image_path):
|
|
image_path = str(Path(roots[0]) / image_path)
|
|
if not _is_within(image_path, roots[0]):
|
|
return _forbidden()
|
|
elif not os.path.isabs(image_path):
|
|
output_dir = self._find_comfyui_output_dir()
|
|
if output_dir:
|
|
image_path = str(Path(output_dir) / image_path)
|
|
|
|
# Look up the image in generated_images table
|
|
try:
|
|
args = (image_path, False) if "root" in request.query else (image_path,)
|
|
prompt_data = await self._run_in_executor(
|
|
self.db.get_image_prompt_info, *args
|
|
)
|
|
if prompt_data:
|
|
prompt_data["image_path"] = _public_path(image_path)
|
|
return web.json_response({"success": True, "prompt": prompt_data})
|
|
return web.json_response(
|
|
{
|
|
"success": False,
|
|
"error": "No prompt found for this image",
|
|
"image_path": _public_path(image_path),
|
|
}
|
|
)
|
|
|
|
except Exception as db_error:
|
|
self.logger.error(f"Database error in get_image_prompt: {db_error}")
|
|
return web.json_response(
|
|
{"success": False, "error": "Database error occurred"}, status=500
|
|
)
|
|
|
|
except Exception as e:
|
|
self.logger.error(f"Get image prompt error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def delete_image(self, request):
|
|
"""Delete an image record."""
|
|
try:
|
|
image_id = int(request.match_info["image_id"])
|
|
success = await self._run_in_executor(self.db.delete_image, image_id)
|
|
|
|
if success:
|
|
return web.json_response(
|
|
{"success": True, "message": "Image deleted successfully"}
|
|
)
|
|
else:
|
|
return web.json_response(
|
|
{"success": False, "error": "Image not found"}, status=404
|
|
)
|
|
|
|
except ValueError:
|
|
return web.json_response(
|
|
{"success": False, "error": "Invalid image ID"}, status=400
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Delete image error: {e}")
|
|
return web.json_response(
|
|
{"success": False, "error": _safe_error(e)}, status=500
|
|
)
|
|
|
|
async def _collect_output_images(self, output_dirs, subfolder):
|
|
"""Merge cached per-root listings, newest first, optionally filtered."""
|
|
merged = []
|
|
for root_idx, output_path in enumerate(output_dirs):
|
|
for img_path, mtime in await self._get_gallery_files(output_path):
|
|
merged.append((img_path, mtime, output_path, root_idx))
|
|
merged.sort(key=lambda x: x[1], reverse=True)
|
|
if not subfolder:
|
|
return merged
|
|
return [
|
|
entry
|
|
for entry in merged
|
|
if _in_subfolder(str(entry[0].relative_to(entry[2]).parent), subfolder)
|
|
]
|
|
|
|
def _workspace_link_summaries(self, paths: list[str]) -> dict[str, dict]:
|
|
"""Card link summaries, matched exactly as the inspection route does."""
|
|
try:
|
|
return self.db.get_image_link_summaries(paths, False)
|
|
except Exception:
|
|
# The listing stays usable; cards fall back to their filenames.
|
|
self.logger.warning("Workspace link summaries could not be read")
|
|
return {}
|
|
|
|
def _card_parse(self, path: Path, records: dict | None) -> tuple:
|
|
"""One card's ``(seed, positive title)``; ``(None, None)`` on any error.
|
|
|
|
One file's unexpected parse failure must not fail the whole page: its
|
|
card shows no seed and withholds a first-line title.
|
|
"""
|
|
try:
|
|
params = header_params(records)
|
|
return card_seed(params), _positive_title(parsed_positive(params))
|
|
except Exception:
|
|
self.logger.warning(
|
|
"Workspace card facts could not be parsed for %s",
|
|
path.name,
|
|
exc_info=True,
|
|
)
|
|
return None, None
|
|
|
|
def _read_card_facts(self, path: Path, *, parse: bool) -> _CardFacts | None:
|
|
"""Read one image's header; ``parse`` adds the seed and positive title.
|
|
|
|
None when the file cannot be read at all (corrupt, locked, vanishing).
|
|
"""
|
|
try:
|
|
width, height, records = _card_header(path)
|
|
except (OSError, ValueError, Image.DecompressionBombError):
|
|
# A corrupt or disappearing preview must not hide the listing.
|
|
return None
|
|
if not parse:
|
|
return _CardFacts(width, height, None, None)
|
|
# Parsed after the handle closes, so a concurrent rename or delete
|
|
# (which Windows refuses on open files) is not held up by it.
|
|
return _CardFacts(width, height, *self._card_parse(path, records))
|
|
|
|
def _card_facts(self, entry: tuple, *, parse: bool) -> _CardFacts:
|
|
"""One listed image's card facts, read once per ``(path, mtime)``.
|
|
|
|
Kept beside the scan's per-file values (``_workspace_listed``) and
|
|
invalidated the same way: a rescan keeps the facts of unchanged files
|
|
and drops the rest, and a roots change drops them all. Only files of
|
|
the current scan are stored, so the cache is bounded by the listing.
|
|
"""
|
|
path, mtime, root, index = entry[:4]
|
|
root_key, key = (index, str(root)), (path, mtime)
|
|
cached = self._workspace_card_cache.get(root_key, {}).get(key)
|
|
if cached is not None:
|
|
return cached
|
|
facts = self._read_card_facts(path, parse=parse)
|
|
if facts is None:
|
|
# Not stored: a locked or half-written file may read next time.
|
|
return _NO_CARD_FACTS
|
|
listed = self._workspace_listed_cache.get(root_key)
|
|
if parse and listed is not None and key in listed[2]:
|
|
self._workspace_card_cache.setdefault(root_key, {})[key] = facts
|
|
return facts
|
|
|
|
def _format_workspace_page(
|
|
self, page: list[tuple], *, linked: bool = True
|
|
) -> list[dict]:
|
|
"""Workspace descriptors; ``linked`` adds card facts (link, seed, favorite)."""
|
|
images = self._format_output_page(page, scoped=True)
|
|
entries = {
|
|
(entry[3], entry[0].relative_to(entry[2]).as_posix()): entry
|
|
for entry in page
|
|
}
|
|
summaries = (
|
|
self._workspace_link_summaries(
|
|
[str(entry[0]) for entry in entries.values()]
|
|
)
|
|
if linked
|
|
else {}
|
|
)
|
|
for image in images:
|
|
entry = entries[(image["root_index"], image["path"])]
|
|
# Videos: the card reads no video metadata. Library previews show
|
|
# no card facts, so they skip the parse (``parse=linked``).
|
|
facts = (
|
|
_NO_CARD_FACTS
|
|
if image["is_video"]
|
|
else self._card_facts(entry, parse=linked)
|
|
)
|
|
image["width"], image["height"] = facts.width, facts.height
|
|
if not linked:
|
|
continue
|
|
image["seed"] = facts.seed
|
|
summary = summaries.get(str(entry[0])) or _unavailable_link()
|
|
image["linked"] = _card_link(summary, facts.positive_title)
|
|
if linked:
|
|
# Image cards carry the star; Library previews show none.
|
|
favorites = self._workspace_favorites(page)
|
|
for image in images:
|
|
image["favorite"] = (image["root_index"], image["path"]) in favorites
|
|
return images
|
|
|
|
def _format_output_page(self, page, *, scoped=False):
|
|
entries = []
|
|
public_roots = {}
|
|
for media_path, _mtime, output_path, root_index in page:
|
|
if output_path not in public_roots:
|
|
public_roots[output_path] = _public_path(output_path)
|
|
try:
|
|
entries.append(
|
|
_output_image_entry(
|
|
media_path,
|
|
output_path,
|
|
root_index,
|
|
public_roots[output_path],
|
|
root_scope=(
|
|
self._gallery_root_scope(output_path, root_index)
|
|
if scoped
|
|
else None
|
|
),
|
|
)
|
|
)
|
|
except Exception as e:
|
|
self.logger.error(f"Error processing media {media_path.name}: {e}")
|
|
return entries
|