- ffmpeg writes JPEG XL with -update 1, so a % in a file name is never an image-sequence pattern, and reads videos as file:<path>. The clear and a startup sweep remove temp files that interrupted writes left behind. - Convert outdated converts each group of same-stem files once, from the first source in the R2 order. An old preview that is already gone counts as removed, and each file's bytes count once. A current preview is only kept if it decodes. - An encoder that fails makes the write detect encoders again once. Another encoder is used if one is found; with none, the file is written as WebP and the availability Appearance shows is refreshed. - Changing the thumbnail format, quality or size while Convert outdated runs returns 409 busy. If the format still changes underneath, the job stops without removing anything. - Legacy preview spellings are matched case-insensitively, so X_thumb.JPG counts as outdated. - History's cover uses the same lookup as Images. It serves .jxl only when Accept lists image/jxl, and otherwise the original or the placeholder. - A video whose thumbnail the browser cannot show keeps its own element (Images) or its label (Library).
481 lines
17 KiB
Python
481 lines
17 KiB
Python
"""Native reported locations and already-opened current media, without ownership."""
|
|
|
|
import asyncio
|
|
from dataclasses import dataclass
|
|
import errno
|
|
import hashlib
|
|
import hmac
|
|
import io
|
|
import json
|
|
import mimetypes
|
|
import os
|
|
from pathlib import Path
|
|
import stat
|
|
from typing import BinaryIO
|
|
|
|
from aiohttp import web
|
|
|
|
from . import thumbnail_settings
|
|
from .images import _canonical, _public_path, SERVABLE_EXTENSIONS, VIDEO_EXTENSIONS
|
|
from .images import existing_media_thumbnail, media_thumbnail_rel
|
|
|
|
try:
|
|
from ...utils.execution_outputs import (
|
|
OutputReference,
|
|
OutputRoots,
|
|
snapshot_output_roots,
|
|
validate_output_reference,
|
|
)
|
|
except ImportError:
|
|
from utils.execution_outputs import (
|
|
OutputReference,
|
|
OutputRoots,
|
|
snapshot_output_roots,
|
|
validate_output_reference,
|
|
)
|
|
|
|
_SECRET = os.urandom(32)
|
|
_READ_SIZE = 64 * 1024
|
|
|
|
|
|
def _opaque(value: object) -> str:
|
|
raw = json.dumps(value, ensure_ascii=True, separators=(",", ":")).encode("ascii")
|
|
return hmac.new(_SECRET, raw, hashlib.sha256).hexdigest()
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class CurrentOutputScope:
|
|
path_style: str
|
|
lexical_output: str | None
|
|
lexical_temp: str | None
|
|
roots: OutputRoots
|
|
canonical_output: Path | None
|
|
canonical_temp: Path | None
|
|
authorized_roots: tuple[Path, ...]
|
|
current_scope: str
|
|
|
|
|
|
@dataclass(frozen=True)
|
|
class ResolvedCurrentCandidate:
|
|
public: dict
|
|
canonical_target: Path | None = None
|
|
namespace: Path | None = None
|
|
gallery_root: Path | None = None
|
|
|
|
|
|
class CurrentLocationUnavailable(Exception):
|
|
"""A bounded current-file failure without the underlying private path."""
|
|
|
|
def __init__(self, reason: str, *, stale: bool = False) -> None:
|
|
self.reason = reason
|
|
self.stale = stale
|
|
super().__init__(reason)
|
|
|
|
|
|
def _host_directory(name: str) -> str | None:
|
|
try:
|
|
import folder_paths
|
|
|
|
value = getattr(folder_paths, name)()
|
|
except (ImportError, AttributeError, OSError):
|
|
return None
|
|
return value if type(value) is str else None
|
|
|
|
|
|
def snapshot_current_output_scope(api: object) -> CurrentOutputScope:
|
|
"""Bind host lexical namespaces independently of current gallery authority."""
|
|
from ..config import GalleryConfig
|
|
|
|
style = "windows" if os.name == "nt" else "posix"
|
|
output = _host_directory("get_output_directory")
|
|
temp = _host_directory("get_temp_directory")
|
|
roots = snapshot_output_roots(style, output, temp)
|
|
canonical_output = _canonical(output) if roots.output_key is not None else None
|
|
canonical_temp = _canonical(temp) if roots.temp_key is not None else None
|
|
authorized = tuple(
|
|
sorted({_canonical(path) for path in api._get_all_output_dirs()})
|
|
)
|
|
policy = []
|
|
for entry in GalleryConfig.MONITORING_DIRECTORIES:
|
|
valid, _ = GalleryConfig.validate_gallery_root(entry)
|
|
policy.append((entry if type(entry) is str else None, valid))
|
|
current_scope = _opaque(
|
|
[
|
|
"prompt-manager-current-output-v1",
|
|
style,
|
|
output,
|
|
temp,
|
|
roots.output_key,
|
|
roots.temp_key,
|
|
str(canonical_output) if canonical_output is not None else None,
|
|
str(canonical_temp) if canonical_temp is not None else None,
|
|
[str(path) for path in authorized],
|
|
sorted(
|
|
str(_canonical(path))
|
|
for path in GalleryConfig.allowed_gallery_parents()
|
|
),
|
|
sorted(policy, key=lambda item: (item[0] or "", item[1])),
|
|
sorted(str(_canonical(path)) for path in GalleryConfig.path_anchors()),
|
|
"gallery-validation-v1",
|
|
]
|
|
)
|
|
return CurrentOutputScope(
|
|
style,
|
|
output,
|
|
temp,
|
|
roots,
|
|
canonical_output,
|
|
canonical_temp,
|
|
authorized,
|
|
current_scope,
|
|
)
|
|
|
|
|
|
def _descriptor_reason(reference: dict, scope: CurrentOutputScope) -> str | None:
|
|
try:
|
|
validate_output_reference(OutputReference("current", 0, **reference))
|
|
except (TypeError, ValueError):
|
|
return "namespace_mismatch"
|
|
if reference["path_style"] != scope.path_style:
|
|
return "foreign_path_style"
|
|
folder = reference["folder_type"]
|
|
expected = scope.roots.output_key if folder == "output" else scope.roots.temp_key
|
|
if reference["root_key"] is None or expected is None:
|
|
return "root_unavailable"
|
|
if reference["root_key"] != expected:
|
|
return "namespace_mismatch"
|
|
return None
|
|
|
|
|
|
def candidate_locator(reference: dict, scope: CurrentOutputScope) -> str | None:
|
|
"""Validate the native namespace before constructing a lexical path; no IO."""
|
|
if _descriptor_reason(reference, scope) is not None:
|
|
return None
|
|
root = (
|
|
scope.lexical_output
|
|
if reference["folder_type"] == "output"
|
|
else scope.lexical_temp
|
|
)
|
|
return os.path.normcase(
|
|
os.path.normpath(
|
|
os.path.join(root, reference["subfolder"], reference["filename"])
|
|
)
|
|
)
|
|
|
|
|
|
def _authorized(target: Path, namespace: Path, scope: CurrentOutputScope) -> bool:
|
|
return target.is_relative_to(namespace) and any(
|
|
target.is_relative_to(root) for root in scope.authorized_roots
|
|
)
|
|
|
|
|
|
def _unavailable(reason: str, *, error: bool = False) -> ResolvedCurrentCandidate:
|
|
return ResolvedCurrentCandidate(
|
|
{
|
|
"status": "error" if error else "unavailable",
|
|
"reason": reason,
|
|
"evidence_class": "locator_candidate",
|
|
}
|
|
)
|
|
|
|
|
|
def resolve_current_candidate(
|
|
reference: dict, scope: CurrentOutputScope, gallery: dict
|
|
) -> ResolvedCurrentCandidate:
|
|
"""Observe only this exact current locator and its snapshot-owned association."""
|
|
reason = _descriptor_reason(reference, scope)
|
|
if reason is not None:
|
|
return _unavailable(reason)
|
|
lexical = candidate_locator(reference, scope)
|
|
namespace = (
|
|
scope.canonical_output
|
|
if reference["folder_type"] == "output"
|
|
else scope.canonical_temp
|
|
)
|
|
try:
|
|
if Path(reference["filename"]).suffix.lower() not in SERVABLE_EXTENSIONS:
|
|
return _unavailable("unsupported_media")
|
|
if namespace is None or not namespace.is_dir():
|
|
return _unavailable("root_unavailable")
|
|
target = _canonical(lexical)
|
|
if not _authorized(target, namespace, scope):
|
|
return _unavailable("not_authorized")
|
|
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
|
return _unavailable("unsupported_media")
|
|
observed = target.stat()
|
|
if not stat.S_ISREG(observed.st_mode):
|
|
return _unavailable("not_regular_file")
|
|
if not os.access(target, os.R_OK) or not observed.st_mode & 0o444:
|
|
return _unavailable("unreadable")
|
|
matching = gallery.get(lexical, {})
|
|
root = max(
|
|
(root for root in scope.authorized_roots if target.is_relative_to(root)),
|
|
key=lambda root: len(root.parts),
|
|
)
|
|
return ResolvedCurrentCandidate(
|
|
{
|
|
"status": "available",
|
|
"reason": None,
|
|
"evidence_class": "locator_candidate",
|
|
"gallery_match": matching.get("gallery_match", "none"),
|
|
"gallery_image_id": matching.get("gallery_image_id"),
|
|
"source_label": _public_path(namespace),
|
|
"relative_path": Path(
|
|
reference["subfolder"], reference["filename"]
|
|
).as_posix(),
|
|
"current_scope": scope.current_scope,
|
|
"location_key": _opaque(
|
|
["current-location-v1", scope.current_scope, str(target)]
|
|
),
|
|
},
|
|
target,
|
|
namespace,
|
|
root,
|
|
)
|
|
except FileNotFoundError:
|
|
return _unavailable("missing")
|
|
except PermissionError:
|
|
return _unavailable("unreadable")
|
|
except (OSError, ValueError):
|
|
return _unavailable("temporary_unavailable", error=True)
|
|
|
|
|
|
def _open_current_target(
|
|
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
|
|
) -> BinaryIO:
|
|
"""Open only the canonical target and verify authority against the actual handle.
|
|
|
|
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
|
|
if _canonical(target) != target:
|
|
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
|
if not _authorized(target, namespace, scope):
|
|
raise CurrentLocationUnavailable("not_authorized")
|
|
suffix = target.suffix.lower()
|
|
if suffix not in SERVABLE_EXTENSIONS and not (
|
|
preview and suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
|
):
|
|
raise CurrentLocationUnavailable("unsupported_media")
|
|
flags = (
|
|
os.O_RDONLY
|
|
| getattr(os, "O_BINARY", 0)
|
|
| getattr(os, "O_NOFOLLOW", 0)
|
|
| getattr(os, "O_NONBLOCK", 0)
|
|
)
|
|
try:
|
|
fd = os.open(target, flags)
|
|
except FileNotFoundError:
|
|
raise CurrentLocationUnavailable("missing") from None
|
|
except PermissionError:
|
|
raise CurrentLocationUnavailable("unreadable") from None
|
|
except OSError as error:
|
|
if error.errno in (errno.ELOOP, errno.ENOTDIR):
|
|
raise CurrentLocationUnavailable("not_authorized", stale=True) from None
|
|
raise CurrentLocationUnavailable("temporary_unavailable") from None
|
|
try:
|
|
observed = os.fstat(fd)
|
|
if not stat.S_ISREG(observed.st_mode):
|
|
raise CurrentLocationUnavailable("not_regular_file")
|
|
if not observed.st_mode & 0o444 or not os.access(target, os.R_OK):
|
|
raise CurrentLocationUnavailable("unreadable")
|
|
current = os.stat(target, follow_symlinks=False)
|
|
if (
|
|
_canonical(target) != target
|
|
or (observed.st_dev, observed.st_ino) != (current.st_dev, current.st_ino)
|
|
or not _authorized(target, namespace, scope)
|
|
):
|
|
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
|
return os.fdopen(fd, "rb")
|
|
except FileNotFoundError:
|
|
os.close(fd)
|
|
raise CurrentLocationUnavailable("missing") from None
|
|
except PermissionError:
|
|
os.close(fd)
|
|
raise CurrentLocationUnavailable("unreadable") from None
|
|
except BaseException:
|
|
os.close(fd)
|
|
raise
|
|
|
|
|
|
def _reauthorize_current_target(
|
|
resolved: ResolvedCurrentCandidate, scope: CurrentOutputScope
|
|
) -> None:
|
|
if _canonical(
|
|
resolved.canonical_target
|
|
) != resolved.canonical_target or not _authorized(
|
|
resolved.canonical_target, resolved.namespace, scope
|
|
):
|
|
raise CurrentLocationUnavailable("not_authorized", stale=True)
|
|
|
|
|
|
@dataclass
|
|
class _OpenedCurrentMedia:
|
|
handle: BinaryIO
|
|
size: int
|
|
content_type: str
|
|
|
|
def close(self) -> None:
|
|
self.handle.close()
|
|
|
|
|
|
def _preview_writable(root: Path, preview: Path) -> bool:
|
|
"""A preview may be written: ``thumbnails`` and every link segment of the
|
|
preview's folder resolve inside *root* (the serve route's rule, #249)."""
|
|
folder = root / "thumbnails"
|
|
return _canonical(folder).is_relative_to(_canonical(root)) and _canonical(
|
|
preview
|
|
).is_relative_to(_canonical(folder))
|
|
|
|
|
|
def _is_jxl(path) -> bool:
|
|
return Path(path).suffix.lower() == ".jxl"
|
|
|
|
|
|
def _preview_without_jxl(root: Path, relative: Path) -> Path | None:
|
|
"""The first existing preview *relative* owns that is not JPEG XL."""
|
|
return next(
|
|
(
|
|
path
|
|
for path in thumbnail_settings.owned_thumbnails(root, relative)
|
|
if not _is_jxl(path)
|
|
),
|
|
None,
|
|
)
|
|
|
|
|
|
def accepts_jxl(request) -> bool:
|
|
"""Whether the request's ``Accept`` lists ``image/jxl``."""
|
|
accept = request.headers.get("Accept", "")
|
|
return any(
|
|
part.split(";", 1)[0].strip().lower() == "image/jxl"
|
|
for part in accept.split(",")
|
|
)
|
|
|
|
|
|
def _placeholder_media(placeholder: bytes) -> "_OpenedCurrentMedia":
|
|
return _OpenedCurrentMedia(io.BytesIO(placeholder), len(placeholder), "image/jpeg")
|
|
|
|
|
|
def _cover_target(resolved, ensure_preview, placeholder, accepts_jxl):
|
|
"""What the cover opens: a preview path, the placeholder media, or None
|
|
for the original (see ``_prepare_current_media``)."""
|
|
target = resolved.canonical_target
|
|
relative = target.relative_to(resolved.gallery_root)
|
|
is_video = target.suffix.lower() in VIDEO_EXTENSIONS
|
|
# #317: any preview the file owns (the current format first); a missing
|
|
# one is made under the current format's name.
|
|
preview = existing_media_thumbnail(resolved.gallery_root, relative)
|
|
if preview is not None and not accepts_jxl and _is_jxl(preview):
|
|
preview = _preview_without_jxl(resolved.gallery_root, relative)
|
|
if preview is None:
|
|
if is_video and placeholder is not None:
|
|
return _placeholder_media(placeholder)
|
|
return None
|
|
return _canonical(preview)
|
|
if preview is not None:
|
|
return _canonical(preview)
|
|
preview = resolved.gallery_root / media_thumbnail_rel(relative)
|
|
if ensure_preview is not None and _preview_writable(resolved.gallery_root, preview):
|
|
ensure_preview(target, preview, is_video)
|
|
if is_video and placeholder is not None:
|
|
return _placeholder_media(placeholder)
|
|
return None
|
|
|
|
|
|
def _prepare_current_media(
|
|
resolved: ResolvedCurrentCandidate,
|
|
scope: CurrentOutputScope,
|
|
*,
|
|
thumbnail: bool,
|
|
ensure_preview=None,
|
|
placeholder: bytes | None = None,
|
|
accepts_jxl: bool = True,
|
|
) -> _OpenedCurrentMedia:
|
|
"""Open the current file, or its preview for ``thumbnail``.
|
|
|
|
A missing preview that may be written is handed to
|
|
``ensure_preview(src, dst, is_video)`` (the route makes it and reads
|
|
again, #249). Without one an image's original is opened and a video is
|
|
the ``placeholder`` JPEG, never the video inside an ``<img>``.
|
|
|
|
The cover's URL names no format, so a JPEG XL preview goes only to a
|
|
browser whose ``Accept`` lists ``image/jxl`` (*accepts_jxl*, review M3).
|
|
Another one gets the file's preview in another format when there is one,
|
|
else the original image or a video's placeholder: never a broken image.
|
|
"""
|
|
target = resolved.canonical_target
|
|
is_preview = False
|
|
if thumbnail:
|
|
chosen = _cover_target(resolved, ensure_preview, placeholder, accepts_jxl)
|
|
if isinstance(chosen, _OpenedCurrentMedia):
|
|
return chosen
|
|
if chosen is not None:
|
|
target, is_preview = chosen, True
|
|
handle = _open_current_target(target, resolved.namespace, scope, is_preview)
|
|
try:
|
|
return _OpenedCurrentMedia(
|
|
handle,
|
|
os.fstat(handle.fileno()).st_size,
|
|
thumbnail_settings.content_type(target)
|
|
or mimetypes.guess_type(target.name)[0]
|
|
or "application/octet-stream",
|
|
)
|
|
except BaseException:
|
|
handle.close()
|
|
raise
|
|
|
|
|
|
async def _current_media_response(
|
|
request: web.Request, opened: _OpenedCurrentMedia
|
|
) -> web.StreamResponse:
|
|
"""Stream a bounded single range from a previously authorized handle."""
|
|
try:
|
|
try:
|
|
selected = request.http_range
|
|
start, stop = selected.start, selected.stop
|
|
partial = request.headers.get("Range") is not None
|
|
if (
|
|
partial
|
|
and request.headers["Range"].startswith("bytes=-")
|
|
and start == 0
|
|
):
|
|
raise ValueError("Empty suffix range")
|
|
if start is not None and start < 0:
|
|
start = max(0, opened.size + start)
|
|
start = start or 0
|
|
stop = min(opened.size, stop) if stop is not None else opened.size
|
|
if partial and (start >= stop or start >= opened.size):
|
|
raise ValueError("Unsatisfiable current media range")
|
|
except ValueError:
|
|
return web.Response(
|
|
status=416,
|
|
headers={
|
|
"Cache-Control": "no-store",
|
|
"Content-Range": f"bytes */{opened.size}",
|
|
},
|
|
)
|
|
headers = {
|
|
"Cache-Control": "no-store",
|
|
"Accept-Ranges": "bytes",
|
|
"Content-Type": opened.content_type,
|
|
"Content-Length": str(stop - start),
|
|
}
|
|
if partial:
|
|
headers["Content-Range"] = f"bytes {start}-{stop - 1}/{opened.size}"
|
|
response = web.StreamResponse(status=206 if partial else 200, headers=headers)
|
|
await response.prepare(request)
|
|
if request.method != "HEAD":
|
|
opened.handle.seek(start)
|
|
remaining = stop - start
|
|
while remaining:
|
|
chunk = await asyncio.get_running_loop().run_in_executor(
|
|
None, opened.handle.read, min(_READ_SIZE, remaining)
|
|
)
|
|
if not chunk:
|
|
break
|
|
remaining -= len(chunk)
|
|
await response.write(chunk)
|
|
await response.write_eof()
|
|
return response
|
|
finally:
|
|
opened.close()
|