Compare commits
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
e1e3bbc691 | ||
|
|
cd6fad6c60 | ||
|
|
1dcebd640c |
@@ -775,23 +775,6 @@ serve `__` system folders. Thumbnails stay in `output/thumbnails`.
|
||||
- **An explicit `database.default_path`** in `config.json` and `PROMPT_MANAGER_CONFIG_PATH`
|
||||
keep working as before (described below); such a library is not offered a move.
|
||||
|
||||
### Thumbnails
|
||||
|
||||
Previews in `output/thumbnails` are written as **WebP, quality 80, 512 px** by default
|
||||
(System › Appearance › Thumbnails sets the format, quality and size limit for every
|
||||
preview). Previews from older versions keep their old format (usually PNG) until you
|
||||
run **Convert outdated** (System › Overview or Appearance): it rewrites each one from
|
||||
its original and then removes the old file. Nothing converts on its own.
|
||||
|
||||
**JPEG XL** (smaller still) can be chosen once the server has an encoder. Install one
|
||||
of these, then restart ComfyUI:
|
||||
|
||||
- `pip install pillow-jxl-plugin` in ComfyUI's Python environment (simplest), or
|
||||
- an `ffmpeg` on PATH built with `libjxl`, or
|
||||
- `cjxl` from [libjxl](https://github.com/libjxl/libjxl) on PATH.
|
||||
|
||||
Browsers that cannot show JPEG XL see the original images in the grid instead.
|
||||
|
||||
### Database Settings
|
||||
|
||||
You can customize the database path by creating a `config.json` file in the library's
|
||||
|
||||
+32
-146
@@ -2163,27 +2163,20 @@ A list that cannot be read shows "Sources could not be loaded. Your saved data h
|
||||
changed." and Retry, never an empty list.
|
||||
|
||||
Thumbnail generation is an Activity job (S5, `py/api/thumbnail_jobs.py`). `GET
|
||||
/prompt_manager/images/thumbnails/plan?mode=missing|rebuild|convert`
|
||||
counts the work (`targets`, `fresh`, `outdated`, `remaining` past the 10,000-file batch cap,
|
||||
the source labels, and the server's `format`, `quality` and `size` it writes with) and
|
||||
writes nothing; an unknown mode is a 400 `invalid-request` that starts nothing. `size`
|
||||
and `quality` an older client sends are ignored (#317). `POST /prompt_manager/jobs/thumbnails {mode}` starts the
|
||||
/prompt_manager/images/thumbnails/plan?mode=missing|rebuild&size=128..2048&quality=low|medium|high`
|
||||
counts the work (`targets`, `fresh`, `remaining` past the 10,000-file cap, the source
|
||||
labels) and writes nothing; a value out of range is a 400 `invalid-request` that starts
|
||||
nothing. `POST /prompt_manager/jobs/thumbnails {mode, size, quality}` starts the
|
||||
`thumbnails` job ("Thumbnail generation"): admission `guarded` (never beside a scan or
|
||||
another record-changing job, one at a time, a legacy clear refused while it runs),
|
||||
cancellable at a file boundary (a file not yet started is left alone; written previews
|
||||
stay), outcome groups Generated / Skipped (already fresh, listed without a log line
|
||||
each) / Errors. Format, quality and the longest edge are the server's thumbnail
|
||||
settings (#317, below), read once when the job starts and shown in its scope. Video
|
||||
previews take the same settings: the OpenCV frame, ffmpeg's frame (no OpenCV; a PNG on
|
||||
stdout fitted inside the edge keeping its aspect ratio and never upscaled, `-vf
|
||||
scale='min(iw,E)':'min(ih,E)':force_original_aspect_ratio=decrease`) and the
|
||||
placeholder are encoded like an image's preview.
|
||||
MAX_THUMBNAIL_FILES (10,000) bounds one batch, not the job: after each batch the
|
||||
missing, rebuild and convert jobs plan again, leaving out every file a batch already
|
||||
worked on, and go on until nothing is left, the job is cancelled, or a batch made no
|
||||
progress (every file failed: the rest waits for the next run). Progress counts against
|
||||
the overall total and the final line reports every batch (`TestBatchesContinue`,
|
||||
`test_convert_goes_on_past_the_per_batch_cap`). The plan dialog counts all of it.
|
||||
each) / Errors. `size` is the longest edge and `quality` the encoder (low: JPEG 70, no
|
||||
optimize; medium: 85, optimized, what the legacy routes always wrote; high: 95,
|
||||
optimized). Video previews take the same choice: the OpenCV frame and the placeholder
|
||||
are saved with those JPEG settings, and the ffmpeg fallback (no OpenCV) maps them to
|
||||
`-q:v` 8 / 4 / 2 and fits the frame inside the edge keeping its aspect ratio (`-vf
|
||||
scale=E:E:force_original_aspect_ratio=decrease`, no longer a stretched square).
|
||||
`rebuild` writes fresh previews again, each through the same temp-file
|
||||
rename, so nothing is cleared first and a failed file keeps its old preview (a video
|
||||
whose frame cannot be decoded keeps its preview instead of getting the placeholder);
|
||||
@@ -2191,7 +2184,7 @@ originals are only read (`tests/test_thumbnail_jobs.py`). Cancel is also checked
|
||||
file during the planning walk: a job cancelled there stops the walk at the next file,
|
||||
publishes no `start` frame and ends "Cancelled before any preview was written". Every
|
||||
exit sets a final status line: "N previews written · M errors" (plus "· R more on the
|
||||
next run" when a batch made no progress), "No previews to generate · N already up to date" (or "No
|
||||
next run" past the cap), "No previews to generate · N already up to date" (or "No
|
||||
images or videos to preview"), "Cancelled · N previews written", or the failure; a job
|
||||
with nothing to do never reads "Processing file 0 of 0" (`test_every_exit_sets_a_truthful_final_status_line`,
|
||||
and "a finished thumbnail job with nothing to do says so" in `workspace-activity.spec.mjs`).
|
||||
@@ -2208,9 +2201,8 @@ recording runs beside it instead of being deferred for a whole rebuild
|
||||
all `records` jobs still defer it.
|
||||
|
||||
Deliberate change: the legacy gallery stream `POST /images/generate-thumbnails/progress`
|
||||
now starts this job (missing previews with the server's thumbnail settings; its
|
||||
`quality` preset no longer sizes anything, #317 R3) or attaches to the running one (never
|
||||
to a running Convert outdated, which it refuses with its error frame), and streams the job's unchanged `status`/`start`/
|
||||
now starts this job (missing previews, its quality preset as the size, medium encoding)
|
||||
or attaches to the running one, and streams the job's unchanged `status`/`start`/
|
||||
`progress`/`file_error`/`complete` frames after a first `job {id, started}` frame that
|
||||
names the job and whether this stream started it. The stream reads the registry after
|
||||
its headers are sent, so a job that starts in that gap is attached to, not refused. A
|
||||
@@ -2226,17 +2218,18 @@ generation was cancelled". The blocking
|
||||
`POST /images/generate-thumbnails` and the clear stay legacy holds.
|
||||
|
||||
The workspace starts it from "Thumbnail maintenance" (`thumbnail-review.mjs`, the
|
||||
concept's `thumbnail-review`), opened by Sources' Thumbnail generation → Review options,
|
||||
Overview's Review thumbnails and Convert outdated, and Appearance's Convert outdated.
|
||||
Scope is Missing thumbnails only (`missing`, the default), Rebuild all (explicit opt-in)
|
||||
(`rebuild`) or Convert outdated (removes older formats) (`convert`, #317). D131: the
|
||||
concept's Quality and Preview size limit fields are replaced by a line naming the
|
||||
server's settings ("Previews are written as WebP · quality 80 · 512 px. Change this in
|
||||
System › Appearance."). Start closes the dialog and starts the job
|
||||
through the Activity store, which toasts "<job title> started."; the job opens
|
||||
concept's `thumbnail-review`), opened by Sources' Thumbnail generation → Review options
|
||||
and Overview's Review thumbnails. Scope is Missing thumbnails only (`missing`, the
|
||||
default) or Rebuild all (explicit opt-in) (`rebuild`); Quality is Medium · optimized /
|
||||
High · original / Low · fastest (`medium` / `high` / `low`), preselected from
|
||||
Appearance's Image loading quality (`gallerySettings.imageQuality`, the same values,
|
||||
Q3); Preview size limit is the longest edge, 512 by default, a whole number from 128 to
|
||||
2048. A size outside that range marks the field `aria-invalid`, the plan line says
|
||||
"Enter a size from 128 to 2048 pixels." and nothing is requested; Start reports the
|
||||
field's validity and keeps the dialog open. Start closes the dialog and starts the job
|
||||
through the Activity store, which toasts "Thumbnail generation started."; the job opens
|
||||
in Activity. A start refused because another job holds the slot toasts the D57 busy
|
||||
link toast and stays. The legacy gallery's thumbnail tools keep working on the same
|
||||
server settings.
|
||||
link toast and stays. The legacy gallery's thumbnail tools are unchanged.
|
||||
|
||||
Automatic previews (#249, `py/api/thumbnail_auto.py`). The approved mock has no
|
||||
repeated global thumbnail prompt (`docs/concepts/workspace/README.md`), so the
|
||||
@@ -2255,13 +2248,13 @@ workspace never asks; previews are a cache the server keeps:
|
||||
more gets its fallback at once without listing a folder. Inside the admission the
|
||||
preview maps back to its media file under the same root (exact names, then one folder
|
||||
listing for a mixed-case extension; a regular file inside the root, never through a
|
||||
link) and is made with the server's thumbnail settings (#317). When it cannot be made an
|
||||
link) and is made with the job's defaults (512 px, medium). When it cannot be made an
|
||||
image's original is served and a video's placeholder frame (a video is never sent to
|
||||
an `<img>`), both `Cache-Control: no-store`; cards and the inspector cover also swap a
|
||||
video back to its own element when its preview image fails. Only a file that cannot
|
||||
be decoded is remembered for the session (path, size, mtime; retried once it changes)
|
||||
and counted as `failed` in `/images/thumbnails/status`, which Overview's row adds
|
||||
("N missing · M outdated · K failed"); I/O errors and a preview another program holds
|
||||
("N missing thumbnails · M failed"); I/O errors and a preview another program holds
|
||||
open (Windows) are not. A preview folder (or any link segment of it) resolving out of
|
||||
the root is never written. Media inside a nested `thumbnails` folder is listed with no
|
||||
thumbnail and keeps its original. The listing's `thumbnail_ready` says whether the
|
||||
@@ -2291,87 +2284,6 @@ workspace never asks; previews are a cache the server keeps:
|
||||
The media viewer's main media and downloads load the original
|
||||
(`workspace-thumbnails.spec.mjs` records every media request; `tests/test_thumbnail_auto.py`).
|
||||
|
||||
Thumbnail format and settings (#317, `py/api/thumbnail_settings.py`,
|
||||
`py/api/thumbnail_encode.py`, `py/api/thumbnail_convert.py`):
|
||||
|
||||
- Server settings `thumbnail_format` (`webp` default, `jxl`, `jpeg`),
|
||||
`thumbnail_quality` (1–100, default 80) and `thumbnail_edge` (128–2048 px, default
|
||||
512) live in config.json's `thumbnails` section and are saved through `POST
|
||||
/prompt_manager/settings` with its revision; a bad value is a 400 and changes
|
||||
nothing. The legacy `gallery.web_interface.thumbnail_size` (never read; older saves
|
||||
wrote its default 256) is ignored. `GET /prompt_manager/settings` also reports
|
||||
`thumbnail_encoders` (`jxl: {available, encoder, reason}`). One module reads the
|
||||
settings; every writer uses it: automatic previews (capture and first view), the
|
||||
thumbnail job, the history route, and the legacy gallery's stream and blocking
|
||||
generate (`tests/test_thumbnail_format.py`).
|
||||
- Names: `thumbnails/<stem>_thumb.webp|.jxl|.jpg` for images and videos alike. Every
|
||||
lookup (listings, duplicates, enrichment, the autotag scan, the history route) takes
|
||||
the first existing preview the file owns, the configured format first: an older
|
||||
release's `_thumb.png` (or `.jpeg`, `.gif`, the source's own spelling or the
|
||||
upper-case one) keeps showing, and counts as outdated, until it is converted. History's
|
||||
cover uses the same lookup as the Images listing. Two files with one stem in a folder
|
||||
share one preview name, made from the first source in `.png`, `.jpg`, `.jpeg`,
|
||||
`.webp`, `.gif`, then videos. A request for an older name answers the current preview. Previews
|
||||
are served with an explicit `image/webp` / `image/jxl` type (the platform's mimetypes
|
||||
table may not know them) and the same cache headers; `.jxl` is served from
|
||||
`thumbnails/` only.
|
||||
- Every preview is written by `write_thumbnail`: the first frame (animated sources give
|
||||
a still), RGB, fitted inside the edge, encoded into a hidden temp sibling that is
|
||||
closed, verified to decode (a JPEG XL file Pillow cannot read is checked by its
|
||||
signature) and renamed over the preview; a failure keeps the old file. ffmpeg writes
|
||||
with `-update 1`, so a `%` in a name is never a sequence pattern, and reads a video as
|
||||
`file:<path>`. Temp files a crash left (`.<stem>.part-<pid>-<thread><ext>`) are
|
||||
removed by the clear and, when older than two minutes, by a sweep at startup (which
|
||||
also removes one carrying its own PID that predates the process: containers reuse
|
||||
PIDs).
|
||||
- JPEG XL needs no new dependency: Pillow (natively or with `pillow_jxl`, imported when
|
||||
installed), else ffmpeg with `libjxl`, else `cjxl`, detected once. Quality maps to
|
||||
libjxl's distance (`0.1 + (100 − q) × 0.09` from 30 up: q80 → 1.9, q85 → 1.45; never
|
||||
lossless). Without an encoder, saving `jxl` is a 400 `jxl-unavailable` with the
|
||||
reason. An encoder that fails because its binary or module is gone makes that write
|
||||
detect again once: another encoder found is used, none at all writes the file as WebP
|
||||
(logged once) and refreshes the availability Appearance reads; any other failure
|
||||
raises without a probe. "No encoder" is probed again on the settings read, on a
|
||||
`jxl` save and after 60 s, so one slow probe never disables JPEG XL until a restart; if JPEG XL has no encoder, previews are written as WebP
|
||||
and count as the format written. Encoder processes (and ffmpeg's frames) are tracked,
|
||||
bounded by a 30 s timeout and killed at the app's cleanup, after which none starts.
|
||||
- A preview in another format than the one written now is *outdated*.
|
||||
`/images/thumbnails/status` reports `pending` (missing), `outdated` and `ready`; the
|
||||
missing job never writes a second preview beside an outdated one. Convert outdated
|
||||
(`POST /jobs/thumbnails {mode: "convert"}`, "Convert outdated thumbnails") runs under
|
||||
the thumbnail job's kind, guarded admission, worker threads and cancel: for each
|
||||
outdated file it writes the new preview from the source (a video with no decodable
|
||||
frame fails instead of getting the placeholder; a current preview it keeps must
|
||||
decode), then removes that file's old-format previews (regular files in its
|
||||
`thumbnails/` folder that the source owns, the source still present; one already gone
|
||||
counts as removed, and a path that is the new preview under another spelling is never
|
||||
removed). Files sharing a preview name are converted once, as a group, from
|
||||
the first source above, and each file's bytes count once. A failure keeps the old
|
||||
preview; orphans (source gone) and links are never touched. While it runs, a change of
|
||||
the thumbnail format, quality or size limit is a 409 `busy` naming it, and should the
|
||||
format still change underneath, the job stops at the next file without removing
|
||||
anything ("Stopped: the thumbnail format changed …"). Its final line is "N converted · F
|
||||
failed · <before> → <after>"
|
||||
(`tests/test_thumbnail_convert.py`, "Convert outdated replaces older-format previews"
|
||||
in `workspace-system-sources.spec.mjs`). Nothing converts by itself: Appearance's
|
||||
format change only makes previews outdated.
|
||||
- A thumbnail is only a grid or cover placeholder. Inspection refuses any path under a
|
||||
root's `thumbnails/` (403) and always parses the original; the media viewer, its
|
||||
download, History's viewer and sending use the original
|
||||
(`TestThumbnailsAreOnlyPlaceholders`, `the viewer and its download always use the
|
||||
original` in `workspace-media-viewer.test.js`). The inspector cover accepts the
|
||||
listing's preview in any format the file owns (`inspection-state.mjs`).
|
||||
- The workspace decodes a 1×1 embedded JPEG XL once (`thumbnail-support.mjs`); where it
|
||||
fails, cards and the inspector cover load the original for a `.jxl` preview, and any
|
||||
preview `<img>` that fails to load falls back to the original (the legacy gallery
|
||||
already does). A video whose preview it cannot show keeps its own element (Images) or
|
||||
its "Video preview" label (Library), never a broken image. History's cover URL names
|
||||
no format, so the server serves a `.jxl` preview only when the URL carries `jxl=1`
|
||||
(added by the workspace where its probe decoded JPEG XL: Firefox may not list it in
|
||||
Accept; any value but `0` or `1` is a 400) or the request's `Accept` lists
|
||||
`image/jxl`; otherwise the file's preview in another format, else the original
|
||||
image or a video's placeholder. Appearance says when this browser cannot show JPEG XL thumbnails.
|
||||
|
||||
Overview (`system-overview.mjs`) reads `GET /prompt_manager/system/overview` once per
|
||||
visit through `latestOnly()` on the section scope; a late answer after the section left
|
||||
paints nothing. Until it answers, the section shows "Loading the overview…"; a failure
|
||||
@@ -2399,7 +2311,7 @@ the connection's `total_changes` is 0 at close and the data reads back unchanged
|
||||
`-shm` bytes are not compared: every WAL reader rewrites its read marks there, so no
|
||||
reader can keep it byte-identical (`test_health_check_never_writes`).
|
||||
|
||||
Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D113; D121–D131 assigned):
|
||||
Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D113; D121–D129 assigned):
|
||||
|
||||
- **D92** (shell): no "Sample configuration" chip. The Save status is truthful: "No
|
||||
unsaved changes.", "Unsaved changes.", "Saved in this browser." / "Saved to the server
|
||||
@@ -2419,11 +2331,9 @@ Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D
|
||||
"Backup status" reads "No retained backup record." until verified backups exist (S8).
|
||||
The thumbnail row is "Checking thumbnails…" until its own read answers, then "No
|
||||
missing thumbnails", "N missing thumbnails" (the pending count of
|
||||
`/images/thumbnails/status`, followed by " · M outdated" when previews are in an
|
||||
older format and " · K failed" when automatic previews failed this session) or
|
||||
"Thumbnail status unavailable"; Review thumbnails opens the thumbnail dialog (D99),
|
||||
and while previews are outdated a second button, Convert outdated, opens it on that
|
||||
scope (D131). "One display configuration" says "Grid
|
||||
`/images/thumbnails/status`, followed by " · M failed" when automatic previews
|
||||
failed this session) or "Thumbnail status unavailable"; Review thumbnails
|
||||
opens the thumbnail dialog (D99). "One display configuration" says "Grid
|
||||
and density settings are shared across views." (no paging). The sources row is
|
||||
"N output sources" with "Monitoring and enrichment share one source list.", prefixed
|
||||
by "M of N configured sources available." or "None configured is available; ComfyUI's
|
||||
@@ -2491,8 +2401,8 @@ Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D
|
||||
maintenance") with production copy. A `p.small[role=status]` line before the actions
|
||||
says what the server's read-only plan (`GET /images/thumbnails/plan`) counts for the
|
||||
chosen scope: "N previews to generate · M already up to date." or, for Rebuild all,
|
||||
"Every preview is regenerated: N files.", N counting the files past the per-batch
|
||||
cap too (#317); "Counting previews…" while it is read and "The previews could not be
|
||||
"Every preview is regenerated: N files.", with "· R more on the next run" past the
|
||||
per-run cap; "Counting previews…" while it is read and "The previews could not be
|
||||
counted." when the read fails (Start still starts). Each scope is read once per
|
||||
dialog, since size and quality change no count. The primary is "Start" (the
|
||||
concept's "Simulate thumbnail job"). Overview's Review thumbnails opens the same
|
||||
@@ -2566,30 +2476,6 @@ Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D
|
||||
`system shell and overview match the concept` (the mapping inserts the row) and
|
||||
`library notice matches the concept notice` with its mutation check
|
||||
(system-concept.spec), and the D129 tests of workspace-system.spec.
|
||||
- **D130** (Appearance, #317): the concept keeps "Thumbnail quality, size limit" in
|
||||
Appearance (README) but draws no rows for them. Appearance gains a second
|
||||
`section.surface`, "Thumbnails", after "Browsing & display", built from the concept's
|
||||
`.setting-row` markup: "Thumbnail format" (WebP / JPEG XL / JPEG), "Thumbnail
|
||||
quality" (1–100), "Thumbnail size limit" (128–2048 px), all host settings saved with
|
||||
the revision, then the operation "Convert outdated thumbnails". JPEG XL is not in the
|
||||
concept: it is offered only when the server found an encoder, else the option is
|
||||
disabled and the row shows the server's reason ("No JPEG XL encoder found. Install
|
||||
pillow-jxl-plugin …"); a row note also says when this browser cannot show JPEG XL
|
||||
thumbnails. After a save that changed the format, the operation's line reads "Format
|
||||
saved. Existing previews keep their old format until you convert them." Pinned by the
|
||||
appearance comparison (the mapping inserts the surface), `Appearance › Thumbnails
|
||||
saves the server thumbnail settings` (workspace-system.spec) and
|
||||
`workspace-system-thumbnails.test.js`.
|
||||
- **D131** (Thumbnail maintenance and Overview, #317): the dialog's Quality and Preview
|
||||
size limit fields are replaced by a `p.small` after the plan line naming the server's
|
||||
settings ("Previews are written as WebP · quality 80 · 512 px. Change this in System ›
|
||||
Appearance."), since previews follow those settings everywhere; Scope gains "Convert
|
||||
outdated (removes older formats)", whose plan line reads "N previews in an older format
|
||||
to convert." Overview's thumbnail row reads "N missing · M outdated · K failed" and,
|
||||
while previews are outdated, adds a second button, Convert outdated, that opens the
|
||||
dialog on that scope. Pinned by the thumbnail dialog comparison (mapped) and its
|
||||
mutation check (system-concept.spec) and `the thumbnail row counts outdated previews`
|
||||
(workspace-system.spec).
|
||||
- **D124** (inspector): with "Load metadata on selection" off, the Images panel and
|
||||
the viewer's details show "Metadata loads when you ask for it." and a "Load metadata"
|
||||
button instead of reading the file. The concept has no such state, so no concept
|
||||
|
||||
@@ -87,15 +87,6 @@ try:
|
||||
routes = config.routes
|
||||
api = PromptManagerAPI()
|
||||
api.add_routes(routes)
|
||||
# Temp files of preview writes a crash interrupted (#317), off the
|
||||
# import path: the walk may cover a large thumbnails folder.
|
||||
import threading
|
||||
|
||||
threading.Thread(
|
||||
target=api.sweep_thumbnail_temps,
|
||||
name="PromptManagerPreviewTempSweep",
|
||||
daemon=True,
|
||||
).start()
|
||||
host_app = getattr(config.server_instance, "app", None)
|
||||
if host_app is not None and hasattr(host_app, "on_cleanup"):
|
||||
host_app.on_cleanup.append(api.cleanup_history_reads)
|
||||
|
||||
+7
-14
@@ -9,7 +9,6 @@ Split into domain-specific mixins for maintainability:
|
||||
- JobRoutesMixin: the session job registry behind Activity (py/api/jobs.py)
|
||||
- ImageTagJobsMixin: bulk Add tags / Auto-tag as image-tags jobs
|
||||
- ThumbnailJobsMixin: thumbnail plan, thumbnail job and the gallery stream on it
|
||||
- ThumbnailConvertMixin: Convert outdated thumbnails (#317), a thumbnail job mode
|
||||
- AutoThumbnailMixin: previews made on first view, on capture and after a scan
|
||||
- ReviewRoutesMixin: the Needs review queue, item detail and decisions
|
||||
- ReviewRecordingMixin: Needs review recording for the scan and the monitor
|
||||
@@ -51,7 +50,6 @@ from .settings_routes import SettingsRoutesMixin
|
||||
from .job_routes import JobRoutesMixin
|
||||
from .image_tag_jobs import ImageTagJobsMixin
|
||||
from .thumbnail_jobs import ThumbnailJobsMixin
|
||||
from .thumbnail_convert import ThumbnailConvertMixin
|
||||
from .thumbnail_auto import AutoThumbnailMixin
|
||||
from .system import SystemRoutesMixin
|
||||
from .library_location import LibraryLocationRoutesMixin
|
||||
@@ -297,7 +295,6 @@ class PromptManagerAPI(
|
||||
JobRoutesMixin,
|
||||
ImageTagJobsMixin,
|
||||
ThumbnailJobsMixin,
|
||||
ThumbnailConvertMixin,
|
||||
AutoThumbnailMixin,
|
||||
SystemRoutesMixin,
|
||||
LibraryLocationRoutesMixin,
|
||||
@@ -641,20 +638,16 @@ class PromptManagerAPI(
|
||||
f"{url_quote(rel_path.as_posix(), safe='/')}"
|
||||
)
|
||||
|
||||
# Any preview the file owns, the current format first (#317)
|
||||
from . import thumbnail_settings
|
||||
# Check for thumbnail
|
||||
rel_no_ext = rel_path.with_suffix("")
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
|
||||
)
|
||||
from .prompts import thumbnail_url_for
|
||||
|
||||
existing = thumbnail_settings.existing_thumbnail(
|
||||
output_path, rel_path
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
)
|
||||
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)
|
||||
if thumbnail_url:
|
||||
image["thumbnail_url"] = thumbnail_url
|
||||
break # Found matching root, stop searching
|
||||
|
||||
+28
-43
@@ -18,7 +18,6 @@ from pathlib import Path
|
||||
from aiohttp import web
|
||||
|
||||
from .prompts import thumbnail_url_for
|
||||
from . import thumbnail_settings
|
||||
from .jobs import (
|
||||
DEFAULT_SCAN_SCOPE,
|
||||
SCAN_GROUP_NAMES,
|
||||
@@ -147,48 +146,39 @@ def _physical_file_key(path, stat):
|
||||
return ("path", os.path.normcase(os.path.realpath(str(path))))
|
||||
|
||||
|
||||
def _other_sources(output_path, rel_path):
|
||||
"""Whether another media file shares *rel_path*'s stem in its folder."""
|
||||
folder = Path(output_path) / Path(rel_path).parent
|
||||
stem = Path(rel_path).stem
|
||||
own = Path(rel_path).suffix.lower()
|
||||
for extension in IMAGE_EXTENSIONS + VIDEO_EXTENSIONS:
|
||||
if extension == own:
|
||||
continue
|
||||
for name in (stem + extension, stem + extension.upper()):
|
||||
if (folder / name).is_file():
|
||||
return True
|
||||
return False
|
||||
def _thumbnail_rel_path(rel_path, thumbnail_ext):
|
||||
"""Relative path of the thumbnail the gallery would generate for ``rel_path``."""
|
||||
return f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb{thumbnail_ext}"
|
||||
|
||||
|
||||
def _media_thumbnail_rel_path(rel_path):
|
||||
"""The thumbnail of a media file: a video's preview is a ``.jpg``."""
|
||||
extension = rel_path.suffix.lower()
|
||||
return _thumbnail_rel_path(
|
||||
rel_path, ".jpg" if extension in VIDEO_EXTENSIONS else rel_path.suffix
|
||||
)
|
||||
|
||||
|
||||
def _remove_thumbnail(output_path, rel_path, logger=None):
|
||||
"""Remove the previews of ``rel_path`` under ``output_path``, if any.
|
||||
"""Remove the thumbnail of ``rel_path`` under ``output_path``, if any.
|
||||
|
||||
Shared by the legacy duplicate delete and the reviewed removal job.
|
||||
Every preview the file owns goes, in any format (#317); a preview name
|
||||
another same-stem file still shares (``x_thumb.webp`` of ``x.png`` and
|
||||
``x.mp4``, ruling R2) stays for it. A preview that is a link, or one that
|
||||
cannot be removed, is left alone (a stale preview never fails a removal).
|
||||
Returns whether one was removed.
|
||||
Shared by the legacy duplicate delete and the reviewed removal job. A
|
||||
thumbnail that is a link, or one that cannot be removed, is left alone
|
||||
(a stale preview never fails a removal). Returns whether one was removed.
|
||||
"""
|
||||
output_path = Path(output_path)
|
||||
rel_path = Path(rel_path)
|
||||
shared = _other_sources(output_path, rel_path)
|
||||
removed = False
|
||||
for thumbnail_path in thumbnail_settings.owned_thumbnails(output_path, rel_path):
|
||||
extension = thumbnail_path.suffix.lower()
|
||||
if shared and extension in thumbnail_settings.WRITTEN_EXTENSIONS:
|
||||
continue
|
||||
try:
|
||||
os.remove(thumbnail_path)
|
||||
except OSError as e:
|
||||
if logger is not None:
|
||||
logger.warning(f"Could not delete thumbnail for {rel_path}: {e}")
|
||||
continue
|
||||
removed = True
|
||||
thumbnail_path = output_path / _media_thumbnail_rel_path(Path(rel_path))
|
||||
try:
|
||||
if thumbnail_path.is_symlink() or not thumbnail_path.is_file():
|
||||
return False
|
||||
os.remove(thumbnail_path)
|
||||
except OSError as e:
|
||||
if logger is not None:
|
||||
logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
|
||||
return removed
|
||||
logger.warning(f"Could not delete thumbnail for {rel_path}: {e}")
|
||||
return False
|
||||
if logger is not None:
|
||||
logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
|
||||
return True
|
||||
|
||||
|
||||
async def _read_json(request):
|
||||
@@ -855,13 +845,8 @@ class AdminRoutesMixin:
|
||||
extension = media_path.suffix.lower()
|
||||
is_video = extension in VIDEO_EXTENSIONS
|
||||
|
||||
existing = thumbnail_settings.existing_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)
|
||||
thumb_rel = _thumbnail_rel_path(rel_path, ".jpg" if is_video else extension)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, output_path / thumb_rel)
|
||||
|
||||
return {
|
||||
"id": str(hash(str(media_path))),
|
||||
|
||||
@@ -10,7 +10,6 @@ from urllib.parse import quote
|
||||
|
||||
from .jobs import AUTOTAG_BATCH_JOB_KIND
|
||||
from .prompts import thumbnail_url_for
|
||||
from . import thumbnail_settings
|
||||
from .. import model_gate
|
||||
|
||||
from aiohttp import web
|
||||
@@ -793,16 +792,13 @@ class AutotagRoutesMixin:
|
||||
rel_path = image_path.relative_to(output_path)
|
||||
thumbnail_url = None
|
||||
if has_thumbnails:
|
||||
# Any preview the file owns, the current format first (#317)
|
||||
existing = thumbnail_settings.existing_thumbnail(
|
||||
output_path, rel_path
|
||||
thumb_rel = (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}"
|
||||
f"_thumb{image_path.suffix}"
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(
|
||||
thumb_rel, output_path / thumb_rel
|
||||
)
|
||||
if existing is not None:
|
||||
thumb_rel = (
|
||||
thumbnail_settings.thumbnail_stem_rel(rel_path)
|
||||
+ existing.suffix
|
||||
)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
|
||||
images.append(
|
||||
{
|
||||
"filename": image_path.name,
|
||||
|
||||
@@ -20,7 +20,6 @@ from .history_current import (
|
||||
_OpenedCurrentMedia,
|
||||
_current_media_response,
|
||||
_prepare_current_media,
|
||||
accepts_jxl,
|
||||
_reauthorize_current_target,
|
||||
candidate_locator,
|
||||
resolve_current_candidate,
|
||||
@@ -143,18 +142,6 @@ def _query(request: web.Request) -> None:
|
||||
raise ValueError("Duplicate history query parameter")
|
||||
|
||||
|
||||
def _jxl_flag(request: web.Request) -> bool:
|
||||
"""The cover's ``jxl`` flag (#317): ``1`` when the workspace decoded a
|
||||
JPEG XL image (a browser may not list ``image/jxl`` in Accept), ``0`` or
|
||||
absent otherwise; anything else is a bad request."""
|
||||
value = request.query.get("jxl")
|
||||
if value is None:
|
||||
return False
|
||||
if value not in ("0", "1"):
|
||||
raise ValueError("Invalid jxl flag")
|
||||
return value == "1"
|
||||
|
||||
|
||||
def _page_limit(request: web.Request) -> int:
|
||||
limit = _decimal(request.query.get("limit", "40"), minimum=1)
|
||||
if limit > 100:
|
||||
@@ -807,7 +794,6 @@ class HistoryRoutesMixin:
|
||||
media_options = {
|
||||
"ensure_preview": lambda *preview: needed.append(preview),
|
||||
"placeholder": self._video_placeholder_bytes(),
|
||||
"accepts_jxl": _jxl_flag(request) or accepts_jxl(request),
|
||||
}
|
||||
prepared = await execute()
|
||||
if needed:
|
||||
|
||||
+26
-81
@@ -15,9 +15,7 @@ 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 (
|
||||
@@ -241,19 +239,14 @@ def resolve_current_candidate(
|
||||
|
||||
|
||||
def _open_current_target(
|
||||
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
|
||||
target: Path, namespace: Path, scope: CurrentOutputScope
|
||||
) -> 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)."""
|
||||
"""Open only the canonical target and verify authority against the actual handle."""
|
||||
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
|
||||
):
|
||||
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
raise CurrentLocationUnavailable("unsupported_media")
|
||||
flags = (
|
||||
os.O_RDONLY
|
||||
@@ -326,61 +319,6 @@ def _preview_writable(root: Path, preview: Path) -> bool:
|
||||
).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,
|
||||
@@ -388,7 +326,6 @@ def _prepare_current_media(
|
||||
thumbnail: bool,
|
||||
ensure_preview=None,
|
||||
placeholder: bytes | None = None,
|
||||
accepts_jxl: bool = True,
|
||||
) -> _OpenedCurrentMedia:
|
||||
"""Open the current file, or its preview for ``thumbnail``.
|
||||
|
||||
@@ -396,28 +333,36 @@ def _prepare_current_media(
|
||||
``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)
|
||||
relative = target.relative_to(resolved.gallery_root)
|
||||
is_video = target.suffix.lower() in VIDEO_EXTENSIONS
|
||||
extension = ".jpg" if is_video else target.suffix.lower()
|
||||
preview = (
|
||||
resolved.gallery_root
|
||||
/ "thumbnails"
|
||||
/ f"{relative.with_suffix('').as_posix()}_thumb{extension}"
|
||||
)
|
||||
try:
|
||||
preview.stat()
|
||||
except FileNotFoundError:
|
||||
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 _OpenedCurrentMedia(
|
||||
io.BytesIO(placeholder), len(placeholder), "image/jpeg"
|
||||
)
|
||||
else:
|
||||
target = _canonical(preview)
|
||||
handle = _open_current_target(target, resolved.namespace, scope)
|
||||
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",
|
||||
mimetypes.guess_type(target.name)[0] or "application/octet-stream",
|
||||
)
|
||||
except BaseException:
|
||||
handle.close()
|
||||
|
||||
+265
-353
@@ -6,11 +6,10 @@ import binascii
|
||||
import functools
|
||||
import hashlib
|
||||
import hmac
|
||||
import io
|
||||
import json
|
||||
import os
|
||||
import re
|
||||
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
|
||||
import threading
|
||||
import time as _time
|
||||
import urllib.parse
|
||||
from bisect import bisect_left, bisect_right
|
||||
@@ -22,19 +21,6 @@ from aiohttp import web
|
||||
from PIL import Image, UnidentifiedImageError
|
||||
|
||||
from .jobs import MAX_OUTCOME_ROWS, THUMBNAILS_JOB_KIND
|
||||
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
|
||||
@@ -226,13 +212,6 @@ def _has_traversal(relative):
|
||||
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)
|
||||
|
||||
@@ -277,10 +256,55 @@ def _thumbnail_batch_size(workers):
|
||||
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
|
||||
def _temp_sibling(dst):
|
||||
"""Hidden temp path next to *dst* that keeps its extension (for format sniffing)."""
|
||||
dst = Path(dst)
|
||||
token = f"{os.getpid()}-{threading.get_ident()}"
|
||||
return dst.with_name(f".{dst.stem}.part-{token}{dst.suffix}")
|
||||
|
||||
|
||||
class PreviewWriteHeld(Exception):
|
||||
"""A preview write stopped before its rename: ``guard()`` said no (#249)."""
|
||||
|
||||
|
||||
# The result error of a write held before its rename (never remembered).
|
||||
PREVIEW_HELD = "Preview writes are held while another job runs"
|
||||
|
||||
|
||||
def _replace_unless_held(tmp, dst, guard=None):
|
||||
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
|
||||
if guard is not None and not guard():
|
||||
raise PreviewWriteHeld(PREVIEW_HELD)
|
||||
os.replace(tmp, dst)
|
||||
|
||||
|
||||
def _atomic_save(img, dst, fmt=None, *, guard=None, **save_kwargs):
|
||||
"""Save a PIL image to *dst* without a partially written file ever being
|
||||
visible at that path: write a temp sibling, then rename over it.
|
||||
|
||||
``guard()`` (automatic previews) is asked again just before the rename;
|
||||
false leaves *dst* untouched and raises PreviewWriteHeld.
|
||||
"""
|
||||
tmp = _temp_sibling(dst)
|
||||
try:
|
||||
img.save(tmp, fmt, **save_kwargs)
|
||||
_replace_unless_held(tmp, dst, guard)
|
||||
finally:
|
||||
if tmp.exists():
|
||||
tmp.unlink()
|
||||
|
||||
|
||||
# Encoder settings per thumbnail quality (the System thumbnail dialog's
|
||||
# "Medium · optimized", "High · original", "Low · fastest"). The legacy
|
||||
# routes always encode as "medium", which is what they always wrote.
|
||||
THUMBNAIL_ENCODINGS = {
|
||||
"low": {"quality": 70, "optimize": False},
|
||||
"medium": {"quality": 85, "optimize": True},
|
||||
"high": {"quality": 95, "optimize": True},
|
||||
}
|
||||
DEFAULT_THUMBNAIL_ENCODING = THUMBNAIL_ENCODINGS["medium"]
|
||||
# ffmpeg's JPEG scale (-q:v, 2 best to 31 worst) for each encoding's quality.
|
||||
_FFMPEG_QSCALE = {95: 2, 85: 4, 70: 8}
|
||||
|
||||
# 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
|
||||
@@ -297,15 +321,26 @@ def _preview_write_error(exc):
|
||||
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).
|
||||
def _ffmpeg_qscale(encoding):
|
||||
"""ffmpeg's ``-q:v`` for a THUMBNAIL_ENCODINGS entry."""
|
||||
return str(_FFMPEG_QSCALE.get(encoding["quality"], 4))
|
||||
|
||||
The first frame, fitted inside the edge, in the configured format;
|
||||
returns the path written (see thumbnail_encode.write_thumbnail).
|
||||
|
||||
def _write_image_thumbnail(src, dst, thumbnail_size, encoding=None, guard=None):
|
||||
"""Resize *src* into *dst* (blocking PIL work), atomically.
|
||||
|
||||
``encoding`` is one of THUMBNAIL_ENCODINGS (medium when None); a PNG
|
||||
preview keeps only its ``optimize`` flag. ``guard`` see _atomic_save.
|
||||
"""
|
||||
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
|
||||
with Image.open(src) as img:
|
||||
frame = thumbnail_frame(img, settings.edge)
|
||||
return write_thumbnail(frame, dst, settings, guard=guard, track=track)
|
||||
if img.mode in ("RGBA", "LA", "P"):
|
||||
img = img.convert("RGB")
|
||||
img.thumbnail(thumbnail_size, Image.Resampling.LANCZOS)
|
||||
save_kwargs = dict(encoding)
|
||||
if dst.suffix.lower() == ".png":
|
||||
save_kwargs = {"optimize": encoding["optimize"]}
|
||||
_atomic_save(img, dst, guard=guard, **save_kwargs)
|
||||
|
||||
|
||||
def _video_placeholder_image(thumbnail_size):
|
||||
@@ -417,58 +452,6 @@ def _thumbnail_complete_payload(total, stats, start, remaining=0, processed=None
|
||||
}
|
||||
|
||||
|
||||
# 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:
|
||||
@@ -791,48 +774,39 @@ def _iter_media_files(output_path, max_files):
|
||||
yield root_path / name
|
||||
|
||||
|
||||
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 media_thumbnail_rel(rel_path):
|
||||
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*.
|
||||
|
||||
|
||||
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
|
||||
The extension is lower case, and a video's preview is a ``.jpg``: the
|
||||
name every thumbnail writer (the plan, the job, automatic previews) uses.
|
||||
"""
|
||||
rel_path = Path(rel_path)
|
||||
extension = rel_path.suffix.lower()
|
||||
return (
|
||||
f"thumbnails/{rel_path.with_suffix('').as_posix()}_thumb"
|
||||
f"{'.jpg' if extension in VIDEO_EXTENSIONS else 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
|
||||
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (one
|
||||
blocking stat). 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.
|
||||
An existing preview's URL carries its mtime as ``v=`` and ``ready`` is
|
||||
True. A missing one is still listed (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)
|
||||
thumb_rel = media_thumbnail_rel(rel_path)
|
||||
thumbnail_url = thumbnail_url_for(thumb_rel, Path(output_path) / thumb_rel)
|
||||
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:
|
||||
@@ -1759,11 +1733,6 @@ class ImageRoutesMixin:
|
||||
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
|
||||
@@ -1789,12 +1758,7 @@ class ImageRoutesMixin:
|
||||
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)
|
||||
):
|
||||
if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS:
|
||||
return _forbidden("Only media files can be served")
|
||||
|
||||
output_dirs = self._select_root(roots, request.query.get("root"))
|
||||
@@ -1909,14 +1873,22 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
|
||||
# Fixed quality presets: clients choose a name, never a raw pixel size.
|
||||
THUMBNAIL_SIZES = {"low": (150, 150), "medium": (300, 300), "high": (600, 600)}
|
||||
|
||||
@classmethod
|
||||
def _thumbnail_size(cls, quality):
|
||||
"""Map a quality preset name to a pixel size; anything else is medium."""
|
||||
if not isinstance(quality, str):
|
||||
return cls.THUMBNAIL_SIZES["medium"]
|
||||
return cls.THUMBNAIL_SIZES.get(quality, cls.THUMBNAIL_SIZES["medium"])
|
||||
|
||||
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:
|
||||
@@ -1930,6 +1902,8 @@ class ImageRoutesMixin:
|
||||
|
||||
async def _generate_thumbnails(self, data):
|
||||
try:
|
||||
thumbnail_size = self._thumbnail_size(data.get("quality", "medium"))
|
||||
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
if not output_dirs:
|
||||
return web.json_response(
|
||||
@@ -1938,7 +1912,7 @@ class ImageRoutesMixin:
|
||||
)
|
||||
|
||||
result = await self._run_in_executor(
|
||||
self._generate_gallery_thumbnails_sync, output_dirs
|
||||
self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size
|
||||
)
|
||||
self.invalidate_gallery_cache()
|
||||
return web.json_response(result)
|
||||
@@ -1949,30 +1923,30 @@ class ImageRoutesMixin:
|
||||
{"success": False, "error": _safe_error(e)}, status=500
|
||||
)
|
||||
|
||||
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, settings=None):
|
||||
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, thumbnail_size):
|
||||
"""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)
|
||||
plan = self._plan_thumbnails(output_path, thumbnails_dir)
|
||||
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
|
||||
payload["thumbnails_path"] = _public_path(thumbnails_dir)
|
||||
return payload
|
||||
|
||||
def _generate_gallery_thumbnails_sync(
|
||||
self, output_dirs: list[Path], settings=None
|
||||
self, output_dirs: list[Path], thumbnail_size: tuple[int, int]
|
||||
) -> 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)
|
||||
plan = self._plan_gallery_thumbnails(output_dirs)
|
||||
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
|
||||
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
|
||||
return payload
|
||||
|
||||
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
|
||||
def _execute_thumbnail_plan(
|
||||
self, plan: dict, thumbnail_size: tuple[int, int], start: float
|
||||
) -> dict:
|
||||
targets = plan["targets"]
|
||||
stats = _new_thumbnail_stats()
|
||||
stats["skipped"] = plan["skipped"]
|
||||
for result in self._generate_batch(targets, settings):
|
||||
for result in self._generate_batch(targets, thumbnail_size):
|
||||
_record_thumbnail_result(stats, result)
|
||||
payload = _thumbnail_complete_payload(
|
||||
len(targets) + plan["skipped"] + plan["remaining"],
|
||||
@@ -1986,52 +1960,41 @@ class ImageRoutesMixin:
|
||||
return payload
|
||||
|
||||
def _plan_gallery_thumbnails(
|
||||
self,
|
||||
output_dirs: list[Path],
|
||||
mode: str = "missing",
|
||||
stop=None,
|
||||
settings=None,
|
||||
exclude=None,
|
||||
self, output_dirs: list[Path], rebuild: bool = False, stop=None
|
||||
) -> dict:
|
||||
"""Plan every gallery root with one shared per-batch generation cap.
|
||||
"""Plan every gallery root with one shared per-run 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=[])
|
||||
combined = {
|
||||
"targets": [],
|
||||
"skipped": 0,
|
||||
"remaining": 0,
|
||||
"fresh": 0,
|
||||
"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,
|
||||
root, root / "thumbnails", rebuild=rebuild, stop=stop
|
||||
)
|
||||
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
|
||||
combined["targets"].extend(plan["targets"][:available])
|
||||
for key in _PLAN_COUNTS:
|
||||
combined[key] += plan[key]
|
||||
combined["skipped"] += plan["skipped"]
|
||||
combined["fresh"] += plan["fresh"]
|
||||
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)
|
||||
combined["remaining"] += plan["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.
|
||||
"""
|
||||
"""Count pending thumbnails off-loop without generating files."""
|
||||
try:
|
||||
output_dirs = self._get_all_output_dirs()
|
||||
plan = await self._run_in_executor(
|
||||
@@ -2045,8 +2008,6 @@ class ImageRoutesMixin:
|
||||
"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()[
|
||||
@@ -2061,90 +2022,76 @@ class ImageRoutesMixin:
|
||||
{"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).
|
||||
def _plan_thumbnails(self, output_path, thumbnails_dir, rebuild=False, stop=None):
|
||||
"""Decide which media files need a thumbnail (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.
|
||||
Returns ``{"targets": [(src, dst, is_video)], "skipped": n,
|
||||
"remaining": n, "fresh": n, "fresh_files": [src]}``. Files whose
|
||||
thumbnail is already newer than the source are ``fresh``. Without
|
||||
``rebuild`` they are counted as skipped and never queued, so a large
|
||||
library that is mostly done only pays for the files that changed;
|
||||
``fresh_files`` lists the first MAX_FRESH_LISTED of them. With
|
||||
``rebuild`` every file is a target (nothing is skipped). The walk is
|
||||
bounded like the gallery listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES,
|
||||
no symlinks) and at most MAX_THUMBNAIL_FILES targets are queued per
|
||||
run; ``remaining`` counts the targets beyond that cap. ``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 = {}
|
||||
targets = []
|
||||
skipped = 0
|
||||
remaining = 0
|
||||
fresh = 0
|
||||
fresh_files = []
|
||||
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
|
||||
suffix = src.suffix.lower()
|
||||
is_video = suffix in VIDEO_EXTENSIONS
|
||||
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)
|
||||
thumb_suffix = ".jpg" if is_video else suffix
|
||||
dst = thumbnails_dir / f"{rel_no_ext.as_posix()}_thumb{thumb_suffix}"
|
||||
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)
|
||||
if _thumbnail_is_fresh(src, dst):
|
||||
fresh += 1
|
||||
if not rebuild:
|
||||
skipped += 1
|
||||
if len(fresh_files) < MAX_FRESH_LISTED:
|
||||
fresh_files.append(src)
|
||||
continue
|
||||
if len(targets) >= MAX_THUMBNAIL_FILES:
|
||||
remaining += 1
|
||||
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"]:
|
||||
targets.append((src, dst, is_video))
|
||||
if remaining:
|
||||
self.logger.warning(
|
||||
f"Thumbnail batch capped at {MAX_THUMBNAIL_FILES} files; "
|
||||
f"{plan['remaining']} more are left for the next batch"
|
||||
f"Thumbnail run capped at {MAX_THUMBNAIL_FILES} new files; "
|
||||
f"{remaining} more will be generated on the next run"
|
||||
)
|
||||
return plan
|
||||
return {
|
||||
"targets": targets,
|
||||
"skipped": skipped,
|
||||
"remaining": remaining,
|
||||
"fresh": fresh,
|
||||
"fresh_files": fresh_files,
|
||||
}
|
||||
|
||||
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):
|
||||
def _generate_batch(self, targets, thumbnail_size, **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``).
|
||||
go to ``_generate_one`` (``encoding``, ``rebuild``, ``stop``).
|
||||
"""
|
||||
|
||||
def one(target):
|
||||
src, dst, is_video = target
|
||||
return self._generate_one(src, dst, settings, is_video, **options)
|
||||
return self._generate_one(src, dst, thumbnail_size, is_video, **options)
|
||||
|
||||
return map_parallel(one, targets, _worker_threads())
|
||||
|
||||
@@ -2152,55 +2099,48 @@ class ImageRoutesMixin:
|
||||
self,
|
||||
src,
|
||||
dst,
|
||||
settings,
|
||||
thumbnail_size,
|
||||
is_video,
|
||||
encoding=None,
|
||||
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.
|
||||
``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, 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 the ffmpeg 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).
|
||||
"""
|
||||
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)}
|
||||
return {**result, "action": "skipped"}
|
||||
dst.parent.mkdir(parents=True, exist_ok=True)
|
||||
if is_video:
|
||||
made = self._generate_video_thumbnail(
|
||||
src,
|
||||
dst,
|
||||
settings,
|
||||
thumbnail_size,
|
||||
encoding,
|
||||
keep_existing=rebuild,
|
||||
guard=guard,
|
||||
track=track,
|
||||
placeholder=placeholder,
|
||||
)
|
||||
if not made:
|
||||
return {
|
||||
@@ -2209,10 +2149,9 @@ class ImageRoutesMixin:
|
||||
"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)}
|
||||
_write_image_thumbnail(src, dst, thumbnail_size, encoding, guard)
|
||||
return {**result, "action": "generated"}
|
||||
except PreviewWriteHeld:
|
||||
return {**result, "action": "error", "error": PREVIEW_HELD, "held": True}
|
||||
except UnidentifiedImageError:
|
||||
@@ -2247,70 +2186,62 @@ class ImageRoutesMixin:
|
||||
self,
|
||||
video_path,
|
||||
thumbnail_path,
|
||||
settings,
|
||||
thumbnail_size,
|
||||
encoding=None,
|
||||
keep_existing=False,
|
||||
*,
|
||||
guard=None,
|
||||
track=None,
|
||||
placeholder=True,
|
||||
):
|
||||
"""Write a video's preview; returns the path written, or False.
|
||||
"""Generate thumbnail from video file. Returns True if successful.
|
||||
|
||||
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.
|
||||
Tries OpenCV, then ffmpeg, then writes 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). ``encoding``
|
||||
is one of THUMBNAIL_ENCODINGS (medium when None); it encodes the
|
||||
OpenCV frame, ffmpeg's frame (as ``-q:v``) and the placeholder alike.
|
||||
Both decoders fit the frame inside ``thumbnail_size`` keeping its
|
||||
aspect ratio. 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 does PreviewWriteHeld
|
||||
when ``guard()`` refuses the rename. ``track(process)`` is handed the
|
||||
ffmpeg process so a shutdown can kill it.
|
||||
"""
|
||||
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
|
||||
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"
|
||||
frame = self._opencv_frame(video_path, thumbnail_size)
|
||||
if frame is not None:
|
||||
written = write_thumbnail(
|
||||
thumbnail_frame(frame, settings.edge),
|
||||
thumbnail_path,
|
||||
settings,
|
||||
guard=guard,
|
||||
track=track,
|
||||
)
|
||||
_atomic_save(frame, thumbnail_path, "JPEG", guard=guard, **encoding)
|
||||
self.logger.debug(
|
||||
f"Generated video thumbnail using {source}: {written}"
|
||||
f"Generated video thumbnail using OpenCV: {thumbnail_path}"
|
||||
)
|
||||
return written
|
||||
if not placeholder or (keep_existing and thumbnail_path.exists()):
|
||||
return True
|
||||
if self._ffmpeg_frame(
|
||||
video_path, thumbnail_path, thumbnail_size, encoding, guard, track
|
||||
):
|
||||
return True
|
||||
if 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,
|
||||
img = _video_placeholder_image(thumbnail_size)
|
||||
_atomic_save(img, thumbnail_path, "JPEG", guard=guard, **encoding)
|
||||
self.logger.debug(
|
||||
f"Generated placeholder video thumbnail: {thumbnail_path}"
|
||||
)
|
||||
self.logger.debug(f"Generated placeholder video thumbnail: {written}")
|
||||
return written
|
||||
except (PermissionError, PreviewWriteHeld, EncoderClosed):
|
||||
return True
|
||||
except (PermissionError, PreviewWriteHeld):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.warning(
|
||||
f"Failed to create placeholder thumbnail for {video_path}: {e}"
|
||||
)
|
||||
return False
|
||||
except (PermissionError, PreviewWriteHeld, EncoderClosed):
|
||||
except (PermissionError, PreviewWriteHeld):
|
||||
raise
|
||||
except Exception as e:
|
||||
self.logger.error(
|
||||
@@ -2344,52 +2275,61 @@ class ImageRoutesMixin:
|
||||
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."""
|
||||
def _ffmpeg_frame(
|
||||
self, video_path, thumbnail_path, thumbnail_size, encoding, guard, track
|
||||
):
|
||||
"""Write ffmpeg's frame at 1 s into *thumbnail_path*; True on success."""
|
||||
import subprocess # nosec B404 - fixed argv, no shell
|
||||
|
||||
tmp_path = _temp_sibling(thumbnail_path)
|
||||
cmd = [
|
||||
"ffmpeg",
|
||||
"-v",
|
||||
"error",
|
||||
"-i",
|
||||
# Read as a file, never as a protocol URL (review M6).
|
||||
f"file:{video_path}",
|
||||
str(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]})'"
|
||||
f"scale={thumbnail_size[0]}:{thumbnail_size[1]}"
|
||||
":force_original_aspect_ratio=decrease",
|
||||
"-f",
|
||||
"image2pipe",
|
||||
"-vcodec",
|
||||
"png",
|
||||
"-",
|
||||
"-q:v",
|
||||
_ffmpeg_qscale(encoding),
|
||||
"-y",
|
||||
str(tmp_path),
|
||||
]
|
||||
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
|
||||
process = subprocess.Popen( # nosec B603 B607 - fixed argv
|
||||
cmd,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
text=True,
|
||||
)
|
||||
if track is not None:
|
||||
track(process)
|
||||
try:
|
||||
_out, stderr = process.communicate(timeout=30)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
return False
|
||||
if process.returncode == 0 and tmp_path.exists():
|
||||
_replace_unless_held(tmp_path, thumbnail_path, guard)
|
||||
self.logger.debug(
|
||||
f"Generated video thumbnail using ffmpeg: {thumbnail_path}"
|
||||
)
|
||||
return True
|
||||
self.logger.warning(f"ffmpeg failed for {video_path}: {stderr}")
|
||||
return False
|
||||
except (FileNotFoundError, OSError) as e:
|
||||
if isinstance(e, PermissionError) and tmp_path.exists():
|
||||
raise
|
||||
return False
|
||||
finally:
|
||||
try:
|
||||
tmp_path.unlink()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
async def clear_thumbnails(self, request: web.Request) -> web.Response:
|
||||
"""Clear generated thumbnails across gallery roots, preserving originals.
|
||||
@@ -2451,25 +2391,6 @@ class ImageRoutesMixin:
|
||||
{"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]:
|
||||
@@ -2497,18 +2418,12 @@ class ImageRoutesMixin:
|
||||
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.suffix.lower() not in GALLERY_IMAGE_EXTENSIONS
|
||||
or path.is_symlink()
|
||||
):
|
||||
continue
|
||||
@@ -2831,9 +2746,6 @@ class ImageRoutesMixin:
|
||||
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:
|
||||
|
||||
@@ -13,7 +13,6 @@ checks one path and writes nothing.
|
||||
"""
|
||||
|
||||
import contextlib
|
||||
import functools
|
||||
import os
|
||||
|
||||
from aiohttp import web
|
||||
@@ -24,11 +23,9 @@ from ..config_store import (
|
||||
get_config_store,
|
||||
)
|
||||
from . import sources
|
||||
from . import thumbnail_encode
|
||||
from . import thumbnail_settings
|
||||
from .admin import _current_image_monitor
|
||||
from .job_routes import busy_response
|
||||
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
|
||||
from .jobs import JobBusy
|
||||
|
||||
try:
|
||||
from ...utils.validators import validate_result_timeout, validate_worker_threads
|
||||
@@ -44,12 +41,7 @@ _SETTINGS_FILE_KEYS = {
|
||||
"WORKER_THREADS": ("performance", "worker_threads"),
|
||||
"MONITORING_ENABLED": ("gallery", "monitoring", "enabled"),
|
||||
"SAFETY_BACKUP": ("maintenance", "safety_backup"),
|
||||
"THUMBNAIL_FORMAT": ("thumbnails", "format"),
|
||||
"THUMBNAIL_QUALITY": ("thumbnails", "quality"),
|
||||
"THUMBNAIL_EDGE": ("thumbnails", "edge"),
|
||||
}
|
||||
# The thumbnail settings a running Convert outdated job fixes (#317).
|
||||
_THUMBNAIL_SETTINGS = ("THUMBNAIL_FORMAT", "THUMBNAIL_QUALITY", "THUMBNAIL_EDGE")
|
||||
# Attributes that live on GalleryConfig; the others on PromptManagerConfig.
|
||||
_GALLERY_SETTINGS = frozenset({"MONITORING_ENABLED"})
|
||||
SOURCE_CHANGE_JOB_KIND = "source_change"
|
||||
@@ -184,15 +176,12 @@ class SettingsRoutesMixin:
|
||||
]
|
||||
revision = await self._run_in_executor(self._config_revision)
|
||||
source_status, monitor = await self._run_in_executor(self._source_status)
|
||||
# #317: which thumbnail formats can be written (a cached probe).
|
||||
encoders = await self._run_in_executor(thumbnail_encode.encoder_status)
|
||||
return web.json_response(
|
||||
{
|
||||
"success": True,
|
||||
**revision,
|
||||
"sources": source_status,
|
||||
"monitor": monitor,
|
||||
"thumbnail_encoders": encoders,
|
||||
"settings": {
|
||||
"monitoring_enabled": GalleryConfig.MONITORING_ENABLED,
|
||||
"result_timeout": PromptManagerConfig.RESULT_TIMEOUT,
|
||||
@@ -200,9 +189,6 @@ class SettingsRoutesMixin:
|
||||
"infinite_scroll": PromptManagerConfig.INFINITE_SCROLL,
|
||||
"worker_threads": PromptManagerConfig.WORKER_THREADS,
|
||||
"safety_backup": PromptManagerConfig.SAFETY_BACKUP,
|
||||
"thumbnail_format": PromptManagerConfig.THUMBNAIL_FORMAT,
|
||||
"thumbnail_quality": PromptManagerConfig.THUMBNAIL_QUALITY,
|
||||
"thumbnail_edge": PromptManagerConfig.THUMBNAIL_EDGE,
|
||||
"cpu_count": PromptManagerConfig.max_worker_threads(),
|
||||
"gallery_root_paths": root_paths,
|
||||
"gallery_root_path": root_paths[0] if root_paths else "",
|
||||
@@ -309,26 +295,6 @@ class SettingsRoutesMixin:
|
||||
return web.json_response(
|
||||
{"success": False, "error": str(ve)}, status=400
|
||||
)
|
||||
if updates.get("THUMBNAIL_FORMAT") == "jxl":
|
||||
# #317: JPEG XL only with an encoder (a cached probe).
|
||||
encoder = await self._run_in_executor(
|
||||
functools.partial(
|
||||
thumbnail_encode.detect_jxl_encoder, recheck_missing=True
|
||||
)
|
||||
)
|
||||
if encoder is None:
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "jxl-unavailable",
|
||||
"error": thumbnail_encode.JXL_UNAVAILABLE,
|
||||
},
|
||||
status=400,
|
||||
)
|
||||
|
||||
refused = self._thumbnail_change_refused(updates)
|
||||
if refused is not None:
|
||||
return refused
|
||||
|
||||
# Off the loop: an accepted root is resolved (filesystem access).
|
||||
new_roots, error = await self._run_in_executor(
|
||||
@@ -389,33 +355,6 @@ class SettingsRoutesMixin:
|
||||
status=500,
|
||||
)
|
||||
|
||||
def _thumbnail_change_refused(self, updates):
|
||||
"""409 ``busy`` for a thumbnail format, quality or size change while
|
||||
Convert outdated runs (review I4): the job converts to the settings
|
||||
it started with, and a change under it would make it remove previews
|
||||
in the newly chosen format. Unchanged values pass."""
|
||||
from ..config import PromptManagerConfig
|
||||
|
||||
changed = any(
|
||||
key in updates and updates[key] != getattr(PromptManagerConfig, key)
|
||||
for key in _THUMBNAIL_SETTINGS
|
||||
)
|
||||
job = self._jobs.active(THUMBNAILS_JOB_KIND) if changed else None
|
||||
if job is None or job.scope.get("mode") != "convert":
|
||||
return None
|
||||
return web.json_response(
|
||||
{
|
||||
"success": False,
|
||||
"code": "busy",
|
||||
"error": (
|
||||
f"{job.title} is running. Change the thumbnail settings "
|
||||
"when it finishes, or cancel it first."
|
||||
),
|
||||
"running": {"id": job.id, "title": job.title},
|
||||
},
|
||||
status=409,
|
||||
)
|
||||
|
||||
def _publish_settings(self, updates, new_roots):
|
||||
"""Install committed settings at runtime (inside the config commit).
|
||||
|
||||
@@ -552,18 +491,6 @@ class SettingsRoutesMixin:
|
||||
updates["WORKER_THREADS"] = validate_worker_threads(
|
||||
data["worker_threads"], PromptManagerConfig.max_worker_threads()
|
||||
)
|
||||
if "thumbnail_format" in data:
|
||||
updates["THUMBNAIL_FORMAT"] = thumbnail_settings.validate_format(
|
||||
data["thumbnail_format"]
|
||||
)
|
||||
if "thumbnail_quality" in data:
|
||||
updates["THUMBNAIL_QUALITY"] = thumbnail_settings.validate_quality(
|
||||
data["thumbnail_quality"]
|
||||
)
|
||||
if "thumbnail_edge" in data:
|
||||
updates["THUMBNAIL_EDGE"] = thumbnail_settings.validate_edge(
|
||||
data["thumbnail_edge"]
|
||||
)
|
||||
return updates
|
||||
|
||||
def _parse_gallery_roots(self, data):
|
||||
|
||||
+24
-46
@@ -33,11 +33,9 @@ previews behind those URLs are made here, in three ways:
|
||||
(missing only, visible and cancellable) instead of writing them silently,
|
||||
and fewer go through the worker.
|
||||
|
||||
Every preview is written by ``_generate_one`` with the server's thumbnail
|
||||
settings (format, quality, size limit; ``thumbnail_settings``, #317), into a
|
||||
temp sibling verified and renamed over the old one, so a reader never sees a
|
||||
partial file. A request for an older preview name (``_thumb.png``) is answered
|
||||
with the preview in the current format.
|
||||
Every preview is written by ``_generate_one`` with the thumbnail job's default
|
||||
size and quality (``DEFAULT_THUMBNAIL_EDGE``, medium), into a temp sibling
|
||||
renamed over the old one, so a reader never sees a partial file.
|
||||
|
||||
Holds: automatic previews only write files under ``thumbnails/`` and never
|
||||
touch the database, like the thumbnail job, so they run beside a scan, image
|
||||
@@ -65,10 +63,11 @@ from pathlib import Path
|
||||
|
||||
from aiohttp import web
|
||||
|
||||
from . import thumbnail_settings
|
||||
from .images import (
|
||||
GALLERY_IMAGE_EXTENSIONS,
|
||||
MEDIA_EXTENSIONS,
|
||||
THUMBNAIL_CLEAR_TITLE,
|
||||
THUMBNAIL_ENCODINGS,
|
||||
VIDEO_EXTENSIONS,
|
||||
_has_traversal,
|
||||
_is_within,
|
||||
@@ -84,20 +83,16 @@ from .jobs import (
|
||||
JobBusy,
|
||||
)
|
||||
from .settings_routes import SOURCE_CHANGE_JOB_KIND
|
||||
from .thumbnail_encode import shutdown_encoders, writer_settings
|
||||
from .thumbnail_jobs import ThumbnailOptions
|
||||
from .thumbnail_jobs import DEFAULT_THUMBNAIL_EDGE, ThumbnailOptions
|
||||
|
||||
try:
|
||||
from ...utils.parallel import map_parallel
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils.parallel import map_parallel
|
||||
|
||||
# The placeholder frame a video without a preview is answered with (JPEG
|
||||
# bytes, never written to disk); written previews follow the settings.
|
||||
PLACEHOLDER_SIZE = (thumbnail_settings.DEFAULT_EDGE, thumbnail_settings.DEFAULT_EDGE)
|
||||
PLACEHOLDER_QUALITY = 85
|
||||
# Source extensions in the order a shared preview name looks for them (R2).
|
||||
_SOURCE_ORDER = thumbnail_settings.IMAGE_SOURCE_ORDER + tuple(sorted(VIDEO_EXTENSIONS))
|
||||
AUTO_THUMBNAIL_EDGE = DEFAULT_THUMBNAIL_EDGE
|
||||
AUTO_THUMBNAIL_SIZE = (AUTO_THUMBNAIL_EDGE, AUTO_THUMBNAIL_EDGE)
|
||||
AUTO_THUMBNAIL_ENCODING = THUMBNAIL_ENCODINGS["medium"]
|
||||
# Previews views may write at once (the size of the view pool).
|
||||
ON_DEMAND_LIMIT = 3
|
||||
# View requests that may wait on the gate; one more gets its fallback at once.
|
||||
@@ -151,8 +146,8 @@ def _dst_key(dst):
|
||||
def video_placeholder_jpeg():
|
||||
"""The placeholder frame as JPEG bytes (a video whose preview is missing)."""
|
||||
buffer = io.BytesIO()
|
||||
_video_placeholder_image(PLACEHOLDER_SIZE).save(
|
||||
buffer, "JPEG", quality=PLACEHOLDER_QUALITY, optimize=True
|
||||
_video_placeholder_image(AUTO_THUMBNAIL_SIZE).save(
|
||||
buffer, "JPEG", **AUTO_THUMBNAIL_ENCODING
|
||||
)
|
||||
return buffer.getvalue()
|
||||
|
||||
@@ -176,7 +171,7 @@ def _is_preview_request(filepath):
|
||||
stem.endswith("_thumb")
|
||||
and len(stem) > len("_thumb")
|
||||
and extension == extension.lower()
|
||||
and extension in thumbnail_settings.THUMBNAIL_EXTENSIONS
|
||||
and extension in GALLERY_IMAGE_EXTENSIONS
|
||||
)
|
||||
|
||||
|
||||
@@ -189,27 +184,12 @@ def _regular_file(path):
|
||||
|
||||
|
||||
def _candidates(parts):
|
||||
"""``(folder parts, base, source extensions)`` of a checked preview
|
||||
request: every media extension that owns the requested preview name
|
||||
(``thumbnail_settings.owns``), images first (ruling R2)."""
|
||||
"""``(folder parts, base, extensions)`` of a checked preview request."""
|
||||
stem, extension = os.path.splitext(parts[-1])
|
||||
wanted = [
|
||||
source
|
||||
for source in _SOURCE_ORDER
|
||||
if source in MEDIA_EXTENSIONS and thumbnail_settings.owns(extension, source)
|
||||
]
|
||||
wanted = [extension] + (sorted(VIDEO_EXTENSIONS) if extension == ".jpg" else [])
|
||||
return parts[1:-1], stem[: -len("_thumb")], wanted
|
||||
|
||||
|
||||
def _current_preview(root, parts):
|
||||
"""The preview *parts* names, in the format written now (#317): a request
|
||||
for an older name (``_thumb.png`` before a conversion) is answered with
|
||||
the current file."""
|
||||
_folder, base, _wanted = _candidates(parts)
|
||||
name = base + thumbnail_settings.THUMB_SUFFIX + writer_settings().extension
|
||||
return Path(root).joinpath(*parts[:-1], name)
|
||||
|
||||
|
||||
def _quick_source(root, parts):
|
||||
"""``(src, is_video)`` by exact name only (no folder listing), or None."""
|
||||
root = Path(root)
|
||||
@@ -227,12 +207,11 @@ def _thumbnail_source(root, parts):
|
||||
"""``(src, dst, is_video)`` for the preview *parts* under *root*, or None.
|
||||
|
||||
*parts* passed ``_is_preview_request``. The source has the preview's name
|
||||
without ``_thumb`` and an extension that owns the requested one (any case:
|
||||
exact names first, then one listing of the folder; ruling R4). It must be
|
||||
a regular file inside *root*. ``dst`` is the preview in the format written
|
||||
now (#317), whatever extension was requested; it is None when the
|
||||
preview's folder resolves outside ``root/thumbnails`` (a link): nothing is
|
||||
written there.
|
||||
without ``_thumb`` and an extension that previews as the requested one
|
||||
(any case: exact names first, then one listing of the folder); a ``.jpg``
|
||||
may also be a video's. It must be a regular file inside *root*. ``dst``
|
||||
is None when the preview's folder resolves outside ``root/thumbnails`` (a
|
||||
link): nothing is written there.
|
||||
"""
|
||||
root = Path(root)
|
||||
folder_parts, base, wanted = _candidates(parts)
|
||||
@@ -245,7 +224,7 @@ def _thumbnail_source(root, parts):
|
||||
return None
|
||||
rel = Path(*folder_parts, found.name)
|
||||
dst = root / media_thumbnail_rel(rel)
|
||||
if dst != _current_preview(root, parts):
|
||||
if dst != root.joinpath(*parts):
|
||||
return None
|
||||
if not _is_within(root / "thumbnails", root) or not _is_within(
|
||||
dst, root / "thumbnails"
|
||||
@@ -626,8 +605,6 @@ class AutoThumbnailMixin:
|
||||
thumbnailer.close()
|
||||
if pool is not None:
|
||||
pool.shutdown(wait=False, cancel_futures=True)
|
||||
if final:
|
||||
shutdown_encoders() # kill JPEG XL / ffmpeg encoders still running
|
||||
|
||||
def _auto_thumbnail_failures(self):
|
||||
thumbnailer = self._auto_thumbnailer
|
||||
@@ -637,8 +614,9 @@ class AutoThumbnailMixin:
|
||||
return self._generate_one(
|
||||
src,
|
||||
dst,
|
||||
writer_settings(),
|
||||
AUTO_THUMBNAIL_SIZE,
|
||||
is_video,
|
||||
encoding=AUTO_THUMBNAIL_ENCODING,
|
||||
stop=stop,
|
||||
guard=guard,
|
||||
track=track,
|
||||
@@ -752,7 +730,7 @@ class AutoThumbnailMixin:
|
||||
if quick is None:
|
||||
continue
|
||||
return self._preview_response(BUSY, quick[0], None, quick[1])
|
||||
dst = _current_preview(root, parts)
|
||||
dst = root.joinpath(*parts)
|
||||
if outcome == READY:
|
||||
return self._file_response(dst)
|
||||
located = await gate.loop.run_in_executor(
|
||||
@@ -829,7 +807,7 @@ class AutoThumbnailMixin:
|
||||
plan = await self._run_in_executor(self._plan_gallery_thumbnails, output_dirs)
|
||||
missing = len(plan["targets"]) + plan["remaining"]
|
||||
if missing >= SCAN_THUMBNAIL_JOB_THRESHOLD:
|
||||
options = ThumbnailOptions("missing", writer_settings())
|
||||
options = ThumbnailOptions("missing", AUTO_THUMBNAIL_EDGE, "medium")
|
||||
try:
|
||||
self._start_thumbnail_job(options)
|
||||
except JobBusy as busy:
|
||||
|
||||
@@ -1,405 +0,0 @@
|
||||
"""Convert outdated thumbnails (#317): an explicit, cancellable Activity job.
|
||||
|
||||
``POST /prompt_manager/jobs/thumbnails {mode: "convert"}`` (and
|
||||
``GET /prompt_manager/images/thumbnails/plan?mode=convert`` for its count)
|
||||
starts "Convert outdated thumbnails": the thumbnail job's kind, ``guarded``
|
||||
admission (never beside a record-changing job such as a scan, a restore or
|
||||
a duplicate removal; one thumbnail job at a time), its worker threads and
|
||||
its outcome groups. Nothing converts on its own: changing the format in
|
||||
Appearance only makes previews "outdated".
|
||||
|
||||
A preview is outdated when the file owns one in another format than the one
|
||||
written now (``writer_settings``: a legacy ``_thumb.png``, or a format
|
||||
switched away from). For each such file, at a file boundary (Cancel keeps
|
||||
what was converted):
|
||||
|
||||
1. the new preview is written from the SOURCE media in the configured
|
||||
format (``_generate_one``: atomic, verified to decode; a video whose frame
|
||||
cannot be decoded fails rather than getting the placeholder), unless a
|
||||
current preview at least as new as the source is already there and
|
||||
decodes;
|
||||
2. only then are that file's old-format previews removed: regular files (no
|
||||
links) in its ``thumbnails/`` folder, owned by that source
|
||||
(``thumbnail_settings.owns``), with the source still present. One already
|
||||
gone counts as removed.
|
||||
|
||||
Files sharing a stem in one folder (``x.png`` and ``x.mp4``) share one
|
||||
preview (ruling R2), so they are converted once, as a group: the preview is
|
||||
made from the first source in ``thumbnail_settings.source_rank`` order
|
||||
(``.png``, ``.jpg``, ``.jpeg``, ``.webp``, ``.gif``, then videos), the old
|
||||
previews of every source in the group are removed, and each removed or
|
||||
written file's bytes count once.
|
||||
|
||||
A failure keeps the old preview. Previews whose source is gone (orphans) are
|
||||
never planned, so never touched. The format the job converts to is fixed
|
||||
when it starts; Appearance refuses a thumbnail settings change while it runs
|
||||
(409), and should the format still change underneath, the job stops at the
|
||||
next file without removing anything in another format. The final status line
|
||||
reads "N converted · F failed · <bytes before> → <bytes after>".
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import functools
|
||||
import os
|
||||
import time
|
||||
from pathlib import Path
|
||||
|
||||
from . import thumbnail_settings
|
||||
from .thumbnail_encode import ThumbnailVerifyError, verify_thumbnail, writer_settings
|
||||
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
|
||||
from .images import _thumbnail_is_fresh, _worker_threads, _thumbnail_batch_size
|
||||
from .prompts import safe_error_message
|
||||
|
||||
try:
|
||||
from ...utils.parallel import map_parallel
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils.parallel import map_parallel
|
||||
|
||||
CONVERT_JOB_TITLE = "Convert outdated thumbnails"
|
||||
NOTHING_OUTDATED = "No outdated previews to convert"
|
||||
CANCELLED_BEFORE_CONVERTING = "Cancelled before any preview was converted"
|
||||
SOURCE_GONE = "The file is gone; its old preview was kept"
|
||||
OLD_NOT_REMOVED = "Converted; the old preview could not be removed"
|
||||
FORMAT_CHANGED = (
|
||||
"Stopped: the thumbnail format changed while converting · "
|
||||
"{converted} converted. Run Convert outdated again."
|
||||
)
|
||||
_UNITS = ("B", "KB", "MB", "GB", "TB")
|
||||
|
||||
|
||||
def readable_bytes(count):
|
||||
"""``1.5 KB``-style size (whole bytes below 1 KB)."""
|
||||
size = float(count)
|
||||
for unit in _UNITS:
|
||||
if size < 1024 or unit == _UNITS[-1]:
|
||||
return f"{int(size)} B" if unit == "B" else f"{size:.1f} {unit}"
|
||||
size /= 1024
|
||||
return f"{size:.1f} {_UNITS[-1]}" # pragma: no cover - loop always returns
|
||||
|
||||
|
||||
def _old_previews(src, dst, settings):
|
||||
"""``[(path, size)]``: *src*'s previews beside *dst* in another format."""
|
||||
stem = dst.name[: -len(dst.suffix)] if dst.suffix else dst.name
|
||||
old = []
|
||||
for path in thumbnail_settings.owned_in_folder(
|
||||
dst.parent, stem, src.suffix, settings.extension
|
||||
):
|
||||
if path.suffix == settings.extension:
|
||||
continue
|
||||
try:
|
||||
old.append((path, path.stat().st_size))
|
||||
except OSError:
|
||||
continue
|
||||
return old
|
||||
|
||||
|
||||
def _group_targets(targets):
|
||||
"""``[[(src, dst, is_video)]]``: targets sharing a preview name (one
|
||||
folder, one stem) in one group, its first source first (R2)."""
|
||||
groups = {}
|
||||
for target in targets:
|
||||
dst = Path(target[1])
|
||||
key = os.path.normcase(str(dst.with_suffix("")))
|
||||
groups.setdefault(key, []).append(target)
|
||||
ordered = []
|
||||
for group in groups.values():
|
||||
group.sort(
|
||||
key=lambda t: (thumbnail_settings.source_rank(Path(t[0]).suffix), str(t[0]))
|
||||
)
|
||||
ordered.append(group)
|
||||
return ordered
|
||||
|
||||
|
||||
def _same_file(a, b):
|
||||
"""Whether *a* and *b* name one file (re-review N1: on a case-insensitive
|
||||
disk ``x_thumb.JPG`` is the ``x_thumb.jpg`` just written). Without inode
|
||||
numbers (st_ino 0 on some SMB/FAT mounts) the resolved, normcased paths
|
||||
are compared."""
|
||||
try:
|
||||
first, second = os.stat(a), os.stat(b)
|
||||
except OSError:
|
||||
return False
|
||||
if first.st_ino and second.st_ino:
|
||||
return (first.st_dev, first.st_ino) == (second.st_dev, second.st_ino)
|
||||
return os.path.normcase(os.path.realpath(a)) == os.path.normcase(
|
||||
os.path.realpath(b)
|
||||
)
|
||||
|
||||
|
||||
def _format_changed(settings):
|
||||
"""Whether previews are written in another format than *settings* now."""
|
||||
return writer_settings().extension != settings.extension
|
||||
|
||||
|
||||
def _finished_line(totals, remaining):
|
||||
line = (
|
||||
f"{totals['converted']:,} converted · {totals['failed']:,} failed · "
|
||||
f"{readable_bytes(totals['before'])} → {readable_bytes(totals['after'])}"
|
||||
)
|
||||
if remaining:
|
||||
line += f" · {remaining:,} more on the next run"
|
||||
return line
|
||||
|
||||
|
||||
class ThumbnailConvertMixin:
|
||||
"""The Convert outdated job (started through ``_start_thumbnail_job``)."""
|
||||
|
||||
async def _run_convert_job(self, job, options):
|
||||
try:
|
||||
state = await self._convert_job_body(job, options)
|
||||
except Exception:
|
||||
self.logger.exception("Thumbnail conversion failed")
|
||||
job.update(status_line="Thumbnail conversion stopped on an internal error.")
|
||||
job.log_line("Conversion failed · internal error")
|
||||
state = "failed"
|
||||
job.finish(state)
|
||||
|
||||
async def _convert_job_body(self, job, options):
|
||||
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
|
||||
if not output_dirs:
|
||||
message = "ComfyUI output directory not found"
|
||||
job.update(status_line=message)
|
||||
job.log_line(f"Conversion failed · {message}")
|
||||
return "failed"
|
||||
folders = _plural(len(output_dirs), "output folder")
|
||||
job.scope["output_folders"] = len(output_dirs)
|
||||
job.update(status_line=f"Looking for outdated previews in {folders}")
|
||||
job.log_line(
|
||||
f"Conversion started · {folders} · to {options.settings.describe()}"
|
||||
)
|
||||
plan = await self._plan_conversion(job, options, output_dirs)
|
||||
if job.cancelled:
|
||||
return self._end_conversion(job, 0, CANCELLED_BEFORE_CONVERTING)
|
||||
if not plan["targets"]:
|
||||
job.update(progress=100, status_line=NOTHING_OUTDATED)
|
||||
job.log_line(f"Conversion finished · {NOTHING_OUTDATED.lower()}")
|
||||
return "finished"
|
||||
totals, left = await self._convert_in_batches(job, options, plan, output_dirs)
|
||||
self.invalidate_gallery_cache()
|
||||
if totals["stopped"]:
|
||||
job.update(status_line=FORMAT_CHANGED.format(converted=totals["converted"]))
|
||||
job.log_line(
|
||||
"Conversion stopped · the thumbnail format changed · "
|
||||
f"{_plural(totals['converted'], 'preview')} converted"
|
||||
)
|
||||
return "cancelled"
|
||||
if job.cancelled:
|
||||
return self._end_conversion(
|
||||
job,
|
||||
totals["converted"],
|
||||
f"Cancelled · {_plural(totals['converted'], 'preview')} converted",
|
||||
)
|
||||
line = _finished_line(totals, left)
|
||||
job.update(progress=100, status_line=line)
|
||||
job.log_line(f"Conversion finished · {line}")
|
||||
self.logger.info(f"Thumbnail conversion: {line}")
|
||||
return "finished"
|
||||
|
||||
@staticmethod
|
||||
def _end_conversion(job, converted, status_line):
|
||||
job.update(status_line=status_line)
|
||||
job.log_line(
|
||||
f"Conversion cancelled · {_plural(converted, 'preview')} converted"
|
||||
)
|
||||
return "cancelled"
|
||||
|
||||
async def _plan_conversion(self, job, options, output_dirs, exclude=None):
|
||||
"""One batch's plan (at most MAX_THUMBNAIL_FILES targets), off-loop."""
|
||||
return await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails,
|
||||
output_dirs,
|
||||
"convert",
|
||||
stop=lambda: job.cancelled,
|
||||
settings=options.settings,
|
||||
exclude=exclude,
|
||||
)
|
||||
|
||||
async def _convert_in_batches(self, job, options, plan, output_dirs):
|
||||
"""Convert ``plan``, then plan again after each batch until nothing
|
||||
is left, the job is cancelled or stopped, or a batch converted
|
||||
nothing (files that fail are never planned again). Returns
|
||||
``(totals across batches, files left)``."""
|
||||
totals = {"converted": 0, "failed": 0, "before": 0, "after": 0}
|
||||
totals["stopped"] = False
|
||||
run = {
|
||||
"index": 0,
|
||||
"batches": 0,
|
||||
"total": len(plan["targets"]) + plan["remaining"],
|
||||
}
|
||||
run["start"] = time.monotonic()
|
||||
job.update(
|
||||
total=run["total"], status_line=_scan_status_line(0, run["total"], 0, 0)
|
||||
)
|
||||
attempted = set()
|
||||
while True:
|
||||
before = totals["converted"]
|
||||
await self._convert_targets(
|
||||
job, options, plan["targets"], output_dirs, totals, run
|
||||
)
|
||||
attempted.update(src for src, _dst, _video in plan["targets"])
|
||||
stop = job.cancelled or totals["stopped"]
|
||||
if stop or not plan["remaining"] or totals["converted"] == before:
|
||||
return totals, plan["remaining"]
|
||||
plan = await self._plan_conversion(
|
||||
job, options, output_dirs, exclude=attempted
|
||||
)
|
||||
if job.cancelled:
|
||||
return totals, 0
|
||||
run["total"] = run["index"] + len(plan["targets"]) + plan["remaining"]
|
||||
job.update(total=run["total"])
|
||||
if not plan["targets"]:
|
||||
return totals, plan["remaining"]
|
||||
|
||||
async def _convert_targets(self, job, options, targets, output_dirs, totals, run):
|
||||
"""Convert one batch's *targets* in worker batches of preview groups,
|
||||
recording each file against the job's overall ``run`` counters."""
|
||||
roots = _scan_roots(output_dirs)
|
||||
groups = _group_targets(targets)
|
||||
batch_size = _thumbnail_batch_size(_worker_threads())
|
||||
for batch_start in range(0, len(groups), batch_size):
|
||||
if job.cancelled or totals["stopped"]:
|
||||
break
|
||||
batch = groups[batch_start : batch_start + batch_size]
|
||||
results = await self._run_in_executor(
|
||||
self._convert_batch, batch, options.settings, lambda: job.cancelled
|
||||
)
|
||||
for group, group_results in zip(batch, results):
|
||||
for (src, _dst, _video), result in zip(group, group_results):
|
||||
if result["action"] == "stopped":
|
||||
totals["stopped"] = True
|
||||
if result["action"] in ("cancelled", "stopped"):
|
||||
continue
|
||||
run["index"] += 1
|
||||
index, total = run["index"], max(run["total"], run["index"])
|
||||
self._record_conversion(job, src, result, roots, totals)
|
||||
job.update(processed=index, progress=index * 100 // total)
|
||||
run["batches"] += 1
|
||||
job.update(
|
||||
status_line=_scan_status_line(
|
||||
run["index"],
|
||||
run["total"],
|
||||
run["batches"],
|
||||
time.monotonic() - run["start"],
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0) # let other requests run between batches
|
||||
|
||||
@staticmethod
|
||||
def _record_conversion(job, src, result, roots, totals):
|
||||
path, label, index = _scan_location(src, roots)
|
||||
where = f"{label}/{path}" if label else path
|
||||
row = {"path": path, "root": label, "root_index": index}
|
||||
if result["action"] == "converted":
|
||||
totals["converted"] += 1
|
||||
totals["before"] += result["before"]
|
||||
totals["after"] += result["after"]
|
||||
detail = result["detail"]
|
||||
job.record("generated", {**row, "detail": detail}, f"+ {where} · {detail}")
|
||||
return
|
||||
totals["failed"] += 1
|
||||
detail = result.get("error") or "failed"
|
||||
job.record("errors", {**row, "detail": detail}, f"! {where} · {detail}")
|
||||
|
||||
def _convert_batch(self, groups, settings, stop):
|
||||
"""Convert preview ``groups`` on the configured worker threads
|
||||
(blocking); one result list per group."""
|
||||
convert = functools.partial(self._convert_group, settings=settings, stop=stop)
|
||||
return map_parallel(convert, groups, _worker_threads())
|
||||
|
||||
def _convert_group(self, group, settings, stop):
|
||||
"""Convert the preview a group of same-stem files shares, once.
|
||||
|
||||
The first source writes it; the others are recorded with the same
|
||||
outcome and no bytes of their own."""
|
||||
src, dst, is_video = group[0]
|
||||
siblings = [Path(target[0]) for target in group[1:]]
|
||||
result = self._convert_one(
|
||||
src, dst, is_video, settings, stop=stop, siblings=siblings
|
||||
)
|
||||
if result["action"] != "converted":
|
||||
return [result] * len(group)
|
||||
shared = {**result, "before": 0, "after": 0}
|
||||
shared["detail"] = f"{result['detail']} (shares {Path(src).name}'s preview)"
|
||||
return [result] + [shared] * len(siblings)
|
||||
|
||||
def _convert_one(self, src, dst, is_video, settings, stop=None, siblings=()):
|
||||
"""Convert one file's preview (blocking; never raises).
|
||||
|
||||
``converted`` with the bytes removed and written, ``error`` (the old
|
||||
preview kept), ``cancelled`` (a file boundary) or ``stopped`` (the
|
||||
format changed underneath, review I4). *siblings* share the preview
|
||||
(R2): their old previews go too.
|
||||
"""
|
||||
if stop is not None and stop():
|
||||
return {"action": "cancelled"}
|
||||
if _format_changed(settings):
|
||||
return {"action": "stopped"}
|
||||
dst = Path(dst)
|
||||
try:
|
||||
old = self._old_group_previews([Path(src), *siblings], dst, settings)
|
||||
if not self._current_preview_usable(src, dst, settings):
|
||||
made = self._generate_one(
|
||||
src, dst, settings, is_video, rebuild=True, placeholder=False
|
||||
)
|
||||
if made["action"] != "generated":
|
||||
return {"action": "error", "error": made.get("error")}
|
||||
dst = Path(made["path"])
|
||||
if dst.suffix != settings.extension or not dst.is_file():
|
||||
return {"action": "error", "error": "The new preview was not written"}
|
||||
if not os.path.isfile(src):
|
||||
return {"action": "error", "error": SOURCE_GONE}
|
||||
if _format_changed(settings):
|
||||
return {"action": "stopped"}
|
||||
old = [(path, size) for path, size in old if not _same_file(path, dst)]
|
||||
removed, kept = self._remove_old_previews(old)
|
||||
if kept:
|
||||
return {"action": "error", "error": OLD_NOT_REMOVED}
|
||||
label = thumbnail_settings.FORMAT_LABELS[settings.format]
|
||||
return {
|
||||
"action": "converted",
|
||||
"before": removed,
|
||||
"after": dst.stat().st_size,
|
||||
"detail": f"Converted to {label}",
|
||||
}
|
||||
except Exception as exc:
|
||||
self.logger.warning(f"Conversion failed for {src.name}", exc_info=True)
|
||||
return {"action": "error", "error": safe_error_message(exc)}
|
||||
|
||||
@staticmethod
|
||||
def _old_group_previews(sources, dst, settings):
|
||||
"""``[(path, size)]`` of every source's old previews, each path once."""
|
||||
seen, old = set(), []
|
||||
for source in sources:
|
||||
for path, size in _old_previews(source, dst, settings):
|
||||
key = os.path.normcase(str(path))
|
||||
if key not in seen:
|
||||
seen.add(key)
|
||||
old.append((path, size))
|
||||
return old
|
||||
|
||||
@staticmethod
|
||||
def _current_preview_usable(src, dst, settings):
|
||||
"""A current preview at least as new as *src* that decodes (M4)."""
|
||||
if not _thumbnail_is_fresh(src, dst):
|
||||
return False
|
||||
try:
|
||||
verify_thumbnail(dst, settings.format)
|
||||
except ThumbnailVerifyError:
|
||||
return False
|
||||
return True
|
||||
|
||||
def _remove_old_previews(self, old):
|
||||
"""``(bytes removed, [kept paths])``; a file already gone counts as
|
||||
removed, with no bytes (another run or a sibling removed it)."""
|
||||
removed, kept = 0, []
|
||||
for path, size in old:
|
||||
try:
|
||||
os.remove(path)
|
||||
except FileNotFoundError:
|
||||
continue
|
||||
except OSError as exc:
|
||||
self.logger.warning(f"Could not remove old preview {path}: {exc}")
|
||||
kept.append(path)
|
||||
continue
|
||||
removed += size
|
||||
return removed, kept
|
||||
@@ -1,524 +0,0 @@
|
||||
"""Thumbnail encoders (#317): every preview file is written here.
|
||||
|
||||
``write_thumbnail(frame, dst, settings)`` encodes a prepared frame
|
||||
(``thumbnail_frame``: the first frame, RGB, fitted inside the edge) in the
|
||||
configured format into a hidden temp sibling, closes it, verifies that it
|
||||
decodes, and renames it over ``dst`` (``os.replace``), so a reader never sees
|
||||
a partial file and a failure keeps the old preview. ``guard()`` is asked just
|
||||
before the rename (automatic previews' holds, #249).
|
||||
|
||||
Formats: WebP (Pillow, ``method=4``) and JPEG (Pillow, ``optimize``) always
|
||||
work. JPEG XL needs no new dependency; the first encoder found is used:
|
||||
|
||||
1. Pillow, natively or through the ``pillow_jxl`` plugin (imported when
|
||||
installed: ``pip install pillow-jxl-plugin``), given the quality itself;
|
||||
2. ffmpeg built with ``libjxl`` (``-distance``), fed a PNG on stdin;
|
||||
3. ``cjxl`` from libjxl (``-d``), fed a temp PNG that is closed first.
|
||||
|
||||
Quality maps to a JPEG XL distance with libjxl's own
|
||||
``JxlEncoderDistanceFromQuality``: ``0.1 + (100 - q) * 0.09`` from 30 up
|
||||
(q80 → d1.9, q85 → d1.45, q90 → d1.0), the quadratic below 30, and q100 →
|
||||
d0.1 (a preview is never lossless). Pillow's plugin applies the same mapping
|
||||
inside libjxl. Detection is cached (``detect_jxl_encoder(refresh=True)``
|
||||
probes again); "none found" is probed again on the settings read
|
||||
(``encoder_status``) and after MISSING_ENCODER_TTL, so one slow probe never
|
||||
turns JPEG XL off until a restart. An encoder that fails because its binary
|
||||
or module is gone makes that write detect again: another encoder found is
|
||||
used for that file, none at all writes it as WebP (logged once) and
|
||||
refreshes what Appearance reads. Any other failure (one bad file) raises
|
||||
without a probe.
|
||||
|
||||
Encoder processes (and ffmpeg's video frames, images.py) go through
|
||||
``run_encoder``: tracked, bounded by a timeout, and killed by
|
||||
``shutdown_encoders()`` at the app's cleanup, after which none starts.
|
||||
"""
|
||||
|
||||
import importlib
|
||||
import io
|
||||
import os
|
||||
import re
|
||||
import shutil
|
||||
import subprocess # nosec B404 - fixed argv lists, never a shell
|
||||
import threading
|
||||
import time
|
||||
from dataclasses import replace
|
||||
from pathlib import Path
|
||||
|
||||
from PIL import Image
|
||||
|
||||
from . import thumbnail_settings
|
||||
|
||||
try:
|
||||
from ...utils.logging_config import get_logger
|
||||
except ImportError: # tests import the package top-level
|
||||
from utils.logging_config import get_logger
|
||||
|
||||
logger = get_logger("prompt_manager.thumbnails")
|
||||
|
||||
PILLOW_JXL_FORMAT = "JXL"
|
||||
JXL_UNAVAILABLE = (
|
||||
"No JPEG XL encoder found. Install pillow-jxl-plugin "
|
||||
"(pip install pillow-jxl-plugin), ffmpeg built with libjxl, or cjxl from "
|
||||
"libjxl, then restart ComfyUI."
|
||||
)
|
||||
# Seconds an encoder process may run (ffmpeg's video frame shares it).
|
||||
ENCODE_TIMEOUT = 30
|
||||
PROBE_TIMEOUT = 10
|
||||
JXL_EFFORT = 7
|
||||
WEBP_METHOD = 4
|
||||
JXL_SIGNATURES = (b"\xff\x0a", b"\x00\x00\x00\x0cJXL \r\n\x87\n")
|
||||
_NO_WINDOW = getattr(subprocess, "CREATE_NO_WINDOW", 0)
|
||||
|
||||
|
||||
class PreviewWriteHeld(Exception):
|
||||
"""A preview write stopped before its rename: ``guard()`` said no (#249)."""
|
||||
|
||||
|
||||
# The result error of a write held before its rename (never remembered).
|
||||
PREVIEW_HELD = "Preview writes are held while another job runs"
|
||||
|
||||
|
||||
class ThumbnailVerifyError(RuntimeError):
|
||||
"""The encoded preview did not decode; it was never renamed in."""
|
||||
|
||||
|
||||
class EncoderFailed(RuntimeError):
|
||||
"""An encoder process failed or wrote nothing."""
|
||||
|
||||
|
||||
class EncoderClosed(RuntimeError):
|
||||
"""The app is shutting down: no encoder process runs any more."""
|
||||
|
||||
|
||||
# ── temp files ───────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def temp_sibling(dst, tag="part"):
|
||||
"""Hidden temp path next to *dst* that keeps its extension."""
|
||||
dst = Path(dst)
|
||||
token = f"{os.getpid()}-{threading.get_ident()}"
|
||||
return dst.with_name(f".{dst.stem}.{tag}-{token}{dst.suffix}")
|
||||
|
||||
|
||||
def replace_unless_held(tmp, dst, guard=None):
|
||||
"""Rename *tmp* over *dst*, unless ``guard()`` is false (PreviewWriteHeld)."""
|
||||
if guard is not None and not guard():
|
||||
raise PreviewWriteHeld(PREVIEW_HELD)
|
||||
os.replace(tmp, dst)
|
||||
|
||||
|
||||
# ``temp_sibling``'s names: ``.<stem>.<tag>-<pid>-<thread id><ext>``.
|
||||
_TEMP_NAME = re.compile(r"^\..+\.(?:part|src)-(\d+)-\d+\.[A-Za-z0-9]+$")
|
||||
# A temp at least this old (seconds) outlived its write: no encode runs longer.
|
||||
STRAY_TEMP_AGE = 4 * ENCODE_TIMEOUT
|
||||
# When this process started (this module's import): a temp carrying its PID
|
||||
# that is older was left by an earlier run with the same PID (containers).
|
||||
PROCESS_STARTED = time.time()
|
||||
|
||||
|
||||
def sweep_stray_temps(folder, min_age=None):
|
||||
"""Remove the temp files a crashed write left under *folder* (a preview
|
||||
folder, walked without following links); returns ``(count, bytes)``.
|
||||
|
||||
Only ``temp_sibling`` names are touched, never a link. With *min_age*
|
||||
(seconds, the startup sweep) a temp younger than that, or one this
|
||||
process wrote since it started (it may still be writing it), is left;
|
||||
``min_age=0`` (the clear, which holds every preview write) removes them
|
||||
all.
|
||||
"""
|
||||
min_age = STRAY_TEMP_AGE if min_age is None else min_age
|
||||
now = time.time()
|
||||
own = str(os.getpid())
|
||||
count = size = 0
|
||||
for root, _dirs, files in os.walk(folder):
|
||||
for name in files:
|
||||
match = _TEMP_NAME.match(name)
|
||||
if match is None:
|
||||
continue
|
||||
path = Path(root) / name
|
||||
try:
|
||||
stat = path.lstat()
|
||||
if path.is_symlink() or not path.is_file():
|
||||
continue
|
||||
if min_age and (
|
||||
now - stat.st_mtime < min_age
|
||||
or (match.group(1) == own and stat.st_mtime >= PROCESS_STARTED)
|
||||
):
|
||||
continue
|
||||
path.unlink()
|
||||
except OSError as exc:
|
||||
logger.debug(f"Could not remove stray temp {name}: {exc}")
|
||||
continue
|
||||
count += 1
|
||||
size += stat.st_size
|
||||
return count, size
|
||||
|
||||
|
||||
def _unlink_quietly(path):
|
||||
try:
|
||||
Path(path).unlink()
|
||||
except FileNotFoundError:
|
||||
pass
|
||||
except OSError as exc:
|
||||
logger.debug(f"Could not remove temp file {Path(path).name}: {exc}")
|
||||
|
||||
|
||||
# ── encoder processes ────────────────────────────────────────────────
|
||||
|
||||
_process_lock = threading.Lock()
|
||||
_processes = set()
|
||||
_closed = False
|
||||
|
||||
|
||||
def run_encoder(cmd, *, input=None, timeout=ENCODE_TIMEOUT, track=None):
|
||||
"""Run *cmd* (a fixed argv) with *input* on stdin; returns a
|
||||
CompletedProcess with bytes output.
|
||||
|
||||
The process is tracked so ``shutdown_encoders`` can kill it; past
|
||||
*timeout* it is killed and TimeoutExpired raised. After the shutdown
|
||||
nothing starts (EncoderClosed). ``track(process)`` sees it too.
|
||||
"""
|
||||
with _process_lock:
|
||||
if _closed:
|
||||
raise EncoderClosed("Encoders are shut down")
|
||||
process = subprocess.Popen( # nosec B603 - fixed argv, no shell
|
||||
cmd,
|
||||
stdin=subprocess.PIPE if input is not None else subprocess.DEVNULL,
|
||||
stdout=subprocess.PIPE,
|
||||
stderr=subprocess.PIPE,
|
||||
creationflags=_NO_WINDOW,
|
||||
)
|
||||
with _process_lock:
|
||||
_processes.add(process)
|
||||
closed = _closed
|
||||
try:
|
||||
if closed:
|
||||
process.kill()
|
||||
if track is not None:
|
||||
track(process)
|
||||
try:
|
||||
out, err = process.communicate(input=input, timeout=timeout)
|
||||
except subprocess.TimeoutExpired:
|
||||
process.kill()
|
||||
process.communicate()
|
||||
raise
|
||||
finally:
|
||||
with _process_lock:
|
||||
_processes.discard(process)
|
||||
closed = _closed
|
||||
if closed:
|
||||
raise EncoderClosed("Encoder stopped by the shutdown")
|
||||
return subprocess.CompletedProcess(cmd, process.returncode, out, err)
|
||||
|
||||
|
||||
def shutdown_encoders():
|
||||
"""Kill every running encoder process; none starts afterwards."""
|
||||
global _closed
|
||||
with _process_lock:
|
||||
_closed = True
|
||||
running = list(_processes)
|
||||
for process in running:
|
||||
try:
|
||||
process.kill()
|
||||
except OSError:
|
||||
pass
|
||||
|
||||
|
||||
def reset_encoders():
|
||||
"""Allow encoders again and forget the detection (tests, a reload)."""
|
||||
global _closed, _detected, _fallback_logged
|
||||
with _process_lock:
|
||||
_closed = False
|
||||
with _detect_lock:
|
||||
_detected = _UNSET
|
||||
_fallback_logged = False
|
||||
|
||||
|
||||
# ── JPEG XL detection ────────────────────────────────────────────────
|
||||
|
||||
_UNSET = object()
|
||||
_detect_lock = threading.Lock()
|
||||
_detected = _UNSET
|
||||
_detected_at = 0.0
|
||||
# Seconds after which "no JPEG XL encoder" is probed again.
|
||||
MISSING_ENCODER_TTL = 60
|
||||
_fallback_logged = False
|
||||
|
||||
|
||||
def _pillow_jxl_format():
|
||||
"""Pillow's JPEG XL format id when it can save one, else None."""
|
||||
try:
|
||||
native = Image.registered_extensions().get(".jxl")
|
||||
except Exception:
|
||||
native = None
|
||||
if native and native in Image.SAVE:
|
||||
return native
|
||||
return PILLOW_JXL_FORMAT if PILLOW_JXL_FORMAT in Image.SAVE else None
|
||||
|
||||
|
||||
def _pillow_can_save_jxl():
|
||||
if _pillow_jxl_format():
|
||||
return True
|
||||
try:
|
||||
importlib.import_module("pillow_jxl")
|
||||
except Exception:
|
||||
return False
|
||||
return _pillow_jxl_format() is not None
|
||||
|
||||
|
||||
def _pillow_decodes_jxl():
|
||||
try:
|
||||
native = Image.registered_extensions().get(".jxl")
|
||||
except Exception:
|
||||
native = None
|
||||
return bool(native and native in Image.OPEN) or PILLOW_JXL_FORMAT in Image.OPEN
|
||||
|
||||
|
||||
def _ffmpeg_has_libjxl():
|
||||
"""Whether the ffmpeg on PATH lists the ``libjxl`` encoder."""
|
||||
if not shutil.which("ffmpeg"):
|
||||
return False
|
||||
try:
|
||||
done = run_encoder(
|
||||
["ffmpeg", "-hide_banner", "-encoders"], timeout=PROBE_TIMEOUT
|
||||
)
|
||||
except (OSError, subprocess.SubprocessError, EncoderClosed):
|
||||
return False
|
||||
listing = done.stdout
|
||||
if isinstance(listing, bytes):
|
||||
listing = listing.decode("utf-8", "replace")
|
||||
return any(
|
||||
len(line.split()) > 1 and line.split()[1] == "libjxl"
|
||||
for line in listing.splitlines()
|
||||
)
|
||||
|
||||
|
||||
def _detect():
|
||||
if _pillow_can_save_jxl():
|
||||
return "pillow"
|
||||
if _ffmpeg_has_libjxl():
|
||||
return "ffmpeg"
|
||||
if shutil.which("cjxl"):
|
||||
return "cjxl"
|
||||
return None
|
||||
|
||||
|
||||
def detect_jxl_encoder(refresh=False, recheck_missing=False):
|
||||
"""``pillow``, ``ffmpeg``, ``cjxl`` or None (cached; blocking on a probe).
|
||||
|
||||
A cached None is probed again with *recheck_missing* or once it is older
|
||||
than MISSING_ENCODER_TTL."""
|
||||
global _detected, _detected_at
|
||||
with _detect_lock:
|
||||
now = time.monotonic()
|
||||
stale_missing = _detected is None and (
|
||||
recheck_missing or now - _detected_at >= MISSING_ENCODER_TTL
|
||||
)
|
||||
if refresh or _detected is _UNSET or stale_missing:
|
||||
_detected = _detect()
|
||||
_detected_at = now
|
||||
return _detected
|
||||
|
||||
|
||||
def _encoder_gone(encoder):
|
||||
"""Whether *encoder*'s binary or module is no longer there."""
|
||||
if encoder == "pillow":
|
||||
return not _pillow_can_save_jxl()
|
||||
return shutil.which(encoder) is None
|
||||
|
||||
|
||||
def encoder_status():
|
||||
"""Which formats can be written now (``GET /prompt_manager/settings``;
|
||||
a "none found" is probed again here)."""
|
||||
encoder = detect_jxl_encoder(recheck_missing=True)
|
||||
return {
|
||||
"webp": {"available": True},
|
||||
"jpeg": {"available": True},
|
||||
"jxl": {
|
||||
"available": encoder is not None,
|
||||
"encoder": encoder,
|
||||
"reason": None if encoder is not None else JXL_UNAVAILABLE,
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def jxl_distance(quality):
|
||||
"""libjxl's distance for a 1–100 quality (q100 gives 0.1, never lossless)."""
|
||||
if quality >= 100:
|
||||
return 0.1
|
||||
if quality >= 30:
|
||||
return 0.1 + (100 - quality) * 0.09
|
||||
return 53.0 / 3000.0 * quality * quality - 23.0 / 20.0 * quality + 25.0
|
||||
|
||||
|
||||
# ── frames and writers ───────────────────────────────────────────────
|
||||
|
||||
|
||||
def thumbnail_frame(img, edge):
|
||||
"""The first frame of *img* as RGB, fitted inside ``edge`` x ``edge``."""
|
||||
try:
|
||||
img.seek(0)
|
||||
except (EOFError, AttributeError, ValueError):
|
||||
pass
|
||||
try:
|
||||
img.draft("RGB", (edge, edge)) # JPEG: decode at a reduced scale
|
||||
except (AttributeError, ValueError, OSError):
|
||||
pass
|
||||
frame = img.convert("RGB") if img.mode != "RGB" else img.copy()
|
||||
frame.thumbnail((edge, edge), Image.Resampling.LANCZOS)
|
||||
return frame
|
||||
|
||||
|
||||
def _png_bytes(img):
|
||||
buffer = io.BytesIO()
|
||||
img.save(buffer, "PNG", compress_level=1)
|
||||
return buffer.getvalue()
|
||||
|
||||
|
||||
def _write_webp(img, tmp, settings, track):
|
||||
img.save(tmp, "WEBP", quality=settings.quality, method=WEBP_METHOD)
|
||||
|
||||
|
||||
def _write_jpeg(img, tmp, settings, track):
|
||||
img.save(tmp, "JPEG", quality=settings.quality, optimize=True)
|
||||
|
||||
|
||||
def _check(done, tmp, name):
|
||||
if done.returncode != 0 or not Path(tmp).is_file():
|
||||
detail = (done.stderr or b"")[-300:]
|
||||
if isinstance(detail, bytes):
|
||||
detail = detail.decode("utf-8", "replace")
|
||||
raise EncoderFailed(f"{name} could not encode the preview: {detail.strip()}")
|
||||
|
||||
|
||||
class _EncoderGone(RuntimeError):
|
||||
"""No JPEG XL encoder is left: this file is written as WebP."""
|
||||
|
||||
|
||||
def _write_jxl(img, tmp, settings, track):
|
||||
"""Encode with the detected encoder. One that failed because it is gone
|
||||
makes the write detect again once (another one is used, none raises
|
||||
_EncoderGone); any other failure raises without a probe."""
|
||||
encoder = detect_jxl_encoder()
|
||||
try:
|
||||
_encode_jxl(encoder, img, tmp, settings, track)
|
||||
return
|
||||
except (OSError, EncoderFailed) as exc:
|
||||
if encoder is not None and not _encoder_gone(encoder):
|
||||
raise
|
||||
again = detect_jxl_encoder(refresh=True)
|
||||
if again is None:
|
||||
raise _EncoderGone(str(exc)) from exc
|
||||
if again == encoder:
|
||||
raise
|
||||
logger.info(f"JPEG XL encoder {encoder} failed; using {again}")
|
||||
_unlink_quietly(tmp)
|
||||
_encode_jxl(again, img, tmp, settings, track)
|
||||
|
||||
|
||||
def _encode_jxl(encoder, img, tmp, settings, track):
|
||||
distance = f"{jxl_distance(settings.quality):.2f}"
|
||||
if encoder == "pillow":
|
||||
img.save(
|
||||
tmp,
|
||||
_pillow_jxl_format() or PILLOW_JXL_FORMAT,
|
||||
quality=min(settings.quality, 99),
|
||||
effort=JXL_EFFORT,
|
||||
num_threads=1,
|
||||
)
|
||||
return
|
||||
if encoder == "ffmpeg":
|
||||
cmd = [
|
||||
"ffmpeg", "-v", "error", "-f", "png_pipe", "-i", "-",
|
||||
"-frames:v", "1", "-c:v", "libjxl", "-distance", distance,
|
||||
"-effort", str(JXL_EFFORT), "-f", "image2", "-update", "1",
|
||||
"-y", str(tmp),
|
||||
] # fmt: skip
|
||||
_check(run_encoder(cmd, input=_png_bytes(img), track=track), tmp, "ffmpeg")
|
||||
return
|
||||
if encoder == "cjxl":
|
||||
source = temp_sibling(Path(tmp).with_suffix(".png"), "src")
|
||||
try:
|
||||
with open(source, "wb") as handle:
|
||||
handle.write(_png_bytes(img))
|
||||
cmd = ["cjxl", str(source), str(tmp), "-d", distance, "-e", str(JXL_EFFORT)]
|
||||
_check(run_encoder(cmd, track=track), tmp, "cjxl")
|
||||
finally:
|
||||
_unlink_quietly(source)
|
||||
return
|
||||
raise EncoderFailed(JXL_UNAVAILABLE)
|
||||
|
||||
|
||||
_WRITERS = {"webp": _write_webp, "jpeg": _write_jpeg, "jxl": _write_jxl}
|
||||
|
||||
|
||||
def verify_thumbnail(path, fmt):
|
||||
"""Raise ThumbnailVerifyError unless *path* decodes as a preview.
|
||||
|
||||
A JPEG XL file Pillow cannot read (no plugin) is checked by signature.
|
||||
"""
|
||||
try:
|
||||
if fmt == "jxl" and not _pillow_decodes_jxl():
|
||||
with open(path, "rb") as handle:
|
||||
head = handle.read(12)
|
||||
if not head.startswith(JXL_SIGNATURES):
|
||||
raise ThumbnailVerifyError("The JPEG XL preview has no signature")
|
||||
return
|
||||
with Image.open(path) as written:
|
||||
written.load()
|
||||
except ThumbnailVerifyError:
|
||||
raise
|
||||
except Exception as exc:
|
||||
raise ThumbnailVerifyError(f"The new preview does not decode: {exc}") from exc
|
||||
|
||||
|
||||
def _jxl_fallback():
|
||||
"""Log once that JPEG XL previews are being written as WebP."""
|
||||
global _fallback_logged
|
||||
with _detect_lock:
|
||||
first, _fallback_logged = not _fallback_logged, True
|
||||
if first:
|
||||
logger.warning(
|
||||
"JPEG XL is the thumbnail format but no JPEG XL encoder is available "
|
||||
"any more; previews are written as WebP until one is installed"
|
||||
)
|
||||
|
||||
|
||||
def writer_settings(settings=None):
|
||||
"""The settings a preview is written with now: the configured ones, but
|
||||
WebP while JPEG XL has no encoder (logged once). Its extension names the
|
||||
file, so "outdated" means a preview this server would not write now."""
|
||||
settings = settings or thumbnail_settings.current()
|
||||
if settings.format == "jxl" and detect_jxl_encoder() is None:
|
||||
_jxl_fallback()
|
||||
return replace(settings, format="webp")
|
||||
return settings
|
||||
|
||||
|
||||
def write_thumbnail(frame, dst, settings, *, guard=None, track=None):
|
||||
"""Encode *frame* into *dst* in ``settings``' format; returns the path
|
||||
written (``.webp`` when JPEG XL fell back). Atomic and verified; raises
|
||||
PreviewWriteHeld, ThumbnailVerifyError, EncoderFailed, EncoderClosed or
|
||||
the OSError of the write."""
|
||||
dst = Path(dst)
|
||||
fmt = settings.format
|
||||
if fmt == "jxl" and detect_jxl_encoder() is None:
|
||||
_jxl_fallback()
|
||||
fmt, dst = "webp", dst.with_suffix(".webp")
|
||||
try:
|
||||
return _write_verified(frame, dst, fmt, settings, guard, track)
|
||||
except _EncoderGone:
|
||||
_jxl_fallback()
|
||||
dst = dst.with_suffix(".webp")
|
||||
return _write_verified(frame, dst, "webp", settings, guard, track)
|
||||
|
||||
|
||||
def _write_verified(frame, dst, fmt, settings, guard, track):
|
||||
tmp = temp_sibling(dst)
|
||||
try:
|
||||
_WRITERS[fmt](frame, tmp, settings, track)
|
||||
verify_thumbnail(tmp, fmt)
|
||||
replace_unless_held(tmp, dst, guard)
|
||||
finally:
|
||||
_unlink_quietly(tmp)
|
||||
return dst
|
||||
+118
-143
@@ -1,32 +1,29 @@
|
||||
"""Thumbnail generation as an Activity job (#249 System S5, Task 12; #317).
|
||||
"""Thumbnail generation as an Activity job (#249 System S5, Task 12).
|
||||
|
||||
- ``GET /prompt_manager/images/thumbnails/plan?mode=`` counts what a run
|
||||
would do over every gallery root and writes nothing: ``{success, targets,
|
||||
fresh, outdated, remaining, roots, mode, format, quality, size}``.
|
||||
- ``POST /prompt_manager/jobs/thumbnails {mode}`` starts the ``thumbnails``
|
||||
job (202 ``{job}``). Its admission class is ``guarded``: it never runs
|
||||
beside a record-changing job, and one thumbnail job runs at a time. It
|
||||
stops at a file boundary when cancelled, keeping the previews it wrote.
|
||||
Its outcome groups are ``generated``, ``skipped`` (already fresh) and
|
||||
``errors``.
|
||||
- ``GET /prompt_manager/images/thumbnails/plan?mode=&size=&quality=`` counts
|
||||
what a run would do over every gallery root and writes nothing:
|
||||
``{success, targets, fresh, remaining, roots, mode, size, quality}``.
|
||||
- ``POST /prompt_manager/jobs/thumbnails {mode, size, quality}`` starts the
|
||||
``thumbnails`` job (202 ``{job}``). Its admission class is ``guarded``: it
|
||||
never runs beside a record-changing job, and one thumbnail job runs at a
|
||||
time. It stops at a file boundary when cancelled, keeping the previews it
|
||||
wrote. Its outcome groups are ``generated``, ``skipped`` (already fresh)
|
||||
and ``errors``.
|
||||
|
||||
``mode`` is ``missing`` (previews that are absent or older than their file),
|
||||
``rebuild`` (every preview again, fresh ones too) or ``convert`` (Convert
|
||||
outdated: previews in another format, see ``thumbnail_convert.py``). Format, quality and
|
||||
size limit are the server's thumbnail settings (``thumbnail_settings``,
|
||||
#317), read once when the job starts and shown in its scope; a ``size`` or
|
||||
``quality`` an older client sends is ignored. Every preview is written next
|
||||
to the old one, verified and renamed over it, so nothing is cleared first and
|
||||
a failure keeps the old preview; originals are only read.
|
||||
``mode`` is ``missing`` (previews that are absent or older than their file)
|
||||
or ``rebuild`` (every preview again, fresh ones too). ``size`` is the
|
||||
preview's longest edge, 128 to 2048 pixels. ``quality`` picks the encoder
|
||||
settings (``THUMBNAIL_ENCODINGS``). Every preview is written next to the old
|
||||
one and renamed over it, so nothing is cleared first and a failure keeps the
|
||||
old preview; originals are only read.
|
||||
|
||||
The legacy gallery stream ``POST /images/generate-thumbnails/progress``
|
||||
starts this job (missing previews, the server's settings; its ``quality``
|
||||
preset is ignored, #317 R3) or attaches to the running one. Its first frame,
|
||||
``job {id, started}``, names that job and whether this stream started it (the
|
||||
gallery cancels by that id, and only a job it started); then come the job's
|
||||
legacy ``status``/``start``/``progress``/``file_error``/``complete`` frames. A
|
||||
client that goes away no longer stops the work: the job outlives the request,
|
||||
as a scan does.
|
||||
starts this job (its quality preset still sizes the previews) or attaches to
|
||||
the running one. Its first frame, ``job {id, started}``, names that job and
|
||||
whether this stream started it (the gallery cancels by that id, and only a
|
||||
job it started); then come the job's legacy ``status``/``start``/
|
||||
``progress``/``file_error``/``complete`` frames. A client that goes away no
|
||||
longer stops the work: the job outlives the request, as a scan does.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
@@ -39,6 +36,7 @@ from aiohttp import web
|
||||
|
||||
from .admin import _plural, _scan_location, _scan_roots, _scan_status_line
|
||||
from .images import (
|
||||
THUMBNAIL_ENCODINGS,
|
||||
_BodyTooLarge,
|
||||
_new_thumbnail_stats,
|
||||
_read_json_body,
|
||||
@@ -50,13 +48,24 @@ from .images import (
|
||||
)
|
||||
from .job_routes import busy_response
|
||||
from .jobs import THUMBNAIL_GROUP_NAMES, THUMBNAILS_JOB_KIND, JobBusy
|
||||
from .thumbnail_convert import CONVERT_JOB_TITLE
|
||||
from .thumbnail_encode import writer_settings
|
||||
from .thumbnail_settings import ThumbnailSettings
|
||||
|
||||
THUMBNAIL_JOB_TITLE = "Thumbnail generation"
|
||||
THUMBNAIL_MODES = ("missing", "rebuild", "convert")
|
||||
_OPTIONS_ERROR = 'mode must be "missing", "rebuild" or "convert"'
|
||||
THUMBNAIL_MODES = ("missing", "rebuild")
|
||||
THUMBNAIL_QUALITIES = tuple(THUMBNAIL_ENCODINGS)
|
||||
MIN_THUMBNAIL_EDGE = 128
|
||||
MAX_THUMBNAIL_EDGE = 2048
|
||||
# The concept's "Preview size limit" default.
|
||||
DEFAULT_THUMBNAIL_EDGE = 512
|
||||
DEFAULT_THUMBNAIL_OPTIONS = {
|
||||
"mode": "missing",
|
||||
"size": DEFAULT_THUMBNAIL_EDGE,
|
||||
"quality": "medium",
|
||||
}
|
||||
_OPTIONS_ERROR = (
|
||||
'mode must be "missing" or "rebuild", quality "low", "medium" or "high", '
|
||||
f"and size a whole number of pixels from {MIN_THUMBNAIL_EDGE} "
|
||||
f"to {MAX_THUMBNAIL_EDGE}"
|
||||
)
|
||||
CANCELLED_ERROR = "Thumbnail generation was cancelled"
|
||||
CANCELLED_BEFORE_WRITING = "Cancelled before any preview was written"
|
||||
NOTHING_TO_PREVIEW = "No images or videos to preview"
|
||||
@@ -71,35 +80,56 @@ _LOG_PREFIXES = {"generated": "+", "errors": "!"}
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThumbnailOptions:
|
||||
"""One run's choices: the mode and the thumbnail settings it writes with."""
|
||||
"""One run's choices: the mode, the preview's longest edge and quality."""
|
||||
|
||||
mode: str
|
||||
settings: ThumbnailSettings
|
||||
edge: int
|
||||
quality: str
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return (self.edge, self.edge)
|
||||
|
||||
@property
|
||||
def encoding(self):
|
||||
return THUMBNAIL_ENCODINGS[self.quality]
|
||||
|
||||
@property
|
||||
def rebuild(self):
|
||||
return self.mode == "rebuild"
|
||||
|
||||
def scope(self):
|
||||
return {
|
||||
"mode": self.mode,
|
||||
"format": self.settings.format,
|
||||
"quality": self.settings.quality,
|
||||
"size": self.settings.edge,
|
||||
}
|
||||
return {"mode": self.mode, "size": self.edge, "quality": self.quality}
|
||||
|
||||
|
||||
def _edge(value, *, from_query):
|
||||
"""The preview edge as an int, or None when it is not one in range."""
|
||||
if from_query and isinstance(value, str):
|
||||
if not value.isascii() or not value.isdigit() or len(value) > 5:
|
||||
return None
|
||||
value = int(value)
|
||||
if isinstance(value, bool) or not isinstance(value, int):
|
||||
return None
|
||||
return value if MIN_THUMBNAIL_EDGE <= value <= MAX_THUMBNAIL_EDGE else None
|
||||
|
||||
|
||||
def thumbnail_options(raw, *, from_query=False):
|
||||
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
|
||||
|
||||
An absent mode is ``missing``; the settings are the server's now.
|
||||
Absent fields take ``DEFAULT_THUMBNAIL_OPTIONS``. Query values are
|
||||
strings, so a query ``size`` may be decimal digits; a JSON ``size`` must
|
||||
be an int.
|
||||
"""
|
||||
if not isinstance(raw, dict) and not hasattr(raw, "get"):
|
||||
return None, _OPTIONS_ERROR
|
||||
mode = raw.get("mode", "missing")
|
||||
if mode not in THUMBNAIL_MODES:
|
||||
mode = raw.get("mode", DEFAULT_THUMBNAIL_OPTIONS["mode"])
|
||||
quality = raw.get("quality", DEFAULT_THUMBNAIL_OPTIONS["quality"])
|
||||
edge = _edge(raw.get("size", DEFAULT_THUMBNAIL_EDGE), from_query=from_query)
|
||||
if mode not in THUMBNAIL_MODES or quality not in THUMBNAIL_QUALITIES:
|
||||
return None, _OPTIONS_ERROR
|
||||
return ThumbnailOptions(mode, writer_settings()), None
|
||||
if edge is None:
|
||||
return None, _OPTIONS_ERROR
|
||||
return ThumbnailOptions(mode, edge, quality), None
|
||||
|
||||
|
||||
def _invalid(message):
|
||||
@@ -138,10 +168,7 @@ class ThumbnailJobsMixin:
|
||||
try:
|
||||
output_dirs = await self._run_in_executor(self._get_all_output_dirs)
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails,
|
||||
output_dirs,
|
||||
options.mode,
|
||||
settings=options.settings,
|
||||
self._plan_gallery_thumbnails, output_dirs, rebuild=options.rebuild
|
||||
)
|
||||
except Exception:
|
||||
self.logger.exception("Thumbnail plan failed")
|
||||
@@ -158,7 +185,6 @@ class ThumbnailJobsMixin:
|
||||
"success": True,
|
||||
"targets": len(plan["targets"]),
|
||||
"fresh": plan["fresh"],
|
||||
"outdated": plan["outdated"],
|
||||
"remaining": plan["remaining"],
|
||||
"roots": [self._public_path(root) for root in output_dirs],
|
||||
**options.scope(),
|
||||
@@ -189,20 +215,14 @@ class ThumbnailJobsMixin:
|
||||
return web.json_response({"success": True, "job": job.summary()}, status=202)
|
||||
|
||||
def _start_thumbnail_job(self, options):
|
||||
"""Register the thumbnail job synchronously; raises JobBusy.
|
||||
|
||||
``convert`` runs Convert outdated (``thumbnail_convert.py``) under the
|
||||
same kind, admission, cancel and groups.
|
||||
"""
|
||||
convert = options.mode == "convert"
|
||||
runner = self._run_convert_job if convert else self._run_thumbnail_job
|
||||
"""Register the thumbnail job synchronously; raises JobBusy."""
|
||||
return self._jobs.start(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
CONVERT_JOB_TITLE if convert else THUMBNAIL_JOB_TITLE,
|
||||
THUMBNAIL_JOB_TITLE,
|
||||
admission="guarded",
|
||||
cancellable=True,
|
||||
scope=options.scope(),
|
||||
runner=functools.partial(runner, options=options),
|
||||
runner=functools.partial(self._run_thumbnail_job, options=options),
|
||||
groups=THUMBNAIL_GROUP_NAMES,
|
||||
)
|
||||
|
||||
@@ -245,7 +265,7 @@ class ThumbnailJobsMixin:
|
||||
job.update(status_line=f"Looking for media files in {folders}")
|
||||
job.log_line(
|
||||
f"Thumbnails started · {folders} · {_MODE_LOG[options.mode]} · "
|
||||
f"{options.settings.describe()}"
|
||||
f"{options.edge} px · {options.quality}"
|
||||
)
|
||||
job.publish(
|
||||
_frame(
|
||||
@@ -253,84 +273,41 @@ class ThumbnailJobsMixin:
|
||||
{"phase": "scanning", "message": "Scanning for images and videos..."},
|
||||
)
|
||||
)
|
||||
plan = await self._plan_thumbnail_batch(job, options, output_dirs)
|
||||
plan = await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails,
|
||||
output_dirs,
|
||||
rebuild=options.rebuild,
|
||||
stop=lambda: job.cancelled,
|
||||
)
|
||||
if job.cancelled: # M2: stopped during the walk, before any start frame
|
||||
return self._end_cancelled(job, 0, CANCELLED_BEFORE_WRITING)
|
||||
roots = _scan_roots(output_dirs)
|
||||
self._record_fresh(job, plan, roots)
|
||||
stats, start, left, attempted = await self._generate_in_batches(
|
||||
job, options, plan, roots, output_dirs
|
||||
)
|
||||
stats, start = await self._generate_thumbnail_targets(job, options, plan, roots)
|
||||
self.invalidate_gallery_cache()
|
||||
generated = _plural(stats["generated"], "preview")
|
||||
if job.cancelled:
|
||||
return self._end_cancelled(
|
||||
job, stats["generated"], f"Cancelled · {generated} written"
|
||||
)
|
||||
overall = {"targets": attempted, "skipped": plan["skipped"], "remaining": left}
|
||||
payload = _thumbnail_complete_payload(
|
||||
len(attempted) + plan["skipped"] + left,
|
||||
len(plan["targets"]) + plan["skipped"] + plan["remaining"],
|
||||
stats,
|
||||
start,
|
||||
remaining=left,
|
||||
processed=len(attempted),
|
||||
remaining=plan["remaining"],
|
||||
processed=len(plan["targets"]),
|
||||
)
|
||||
job.update(progress=100, status_line=_finished_line(overall, stats))
|
||||
if left:
|
||||
job.log_line(f"{left:,} more previews are left for the next run")
|
||||
job.update(progress=100, status_line=_finished_line(plan, stats))
|
||||
if plan["remaining"]:
|
||||
job.log_line(
|
||||
f"{plan['remaining']:,} more previews are left for the next run"
|
||||
)
|
||||
errors = _plural(len(stats["errors"]), "error")
|
||||
job.log_line(f"Thumbnails finished · {generated} written · {errors}")
|
||||
self.logger.info(payload["message"])
|
||||
job.publish(_frame("complete", payload))
|
||||
return "finished"
|
||||
|
||||
async def _plan_thumbnail_batch(self, job, options, output_dirs, exclude=None):
|
||||
"""One batch's plan (at most MAX_THUMBNAIL_FILES targets), off-loop."""
|
||||
return await self._run_in_executor(
|
||||
self._plan_gallery_thumbnails,
|
||||
output_dirs,
|
||||
options.mode,
|
||||
stop=lambda: job.cancelled,
|
||||
settings=options.settings,
|
||||
exclude=exclude,
|
||||
)
|
||||
|
||||
async def _generate_in_batches(self, job, options, plan, roots, output_dirs):
|
||||
"""Work through ``plan``, then plan again after each batch until
|
||||
nothing is left, the job is cancelled, or a batch made no progress
|
||||
(every file failed: they are not planned again, and the rest waits
|
||||
for the next run). Files worked on are never planned twice. Returns
|
||||
``(stats, start, files left, files worked on)``; progress counts
|
||||
against the overall total."""
|
||||
run = {
|
||||
"index": 0,
|
||||
"batches": 0,
|
||||
"total": len(plan["targets"]) + plan["remaining"],
|
||||
}
|
||||
stats = _new_thumbnail_stats()
|
||||
stats["skipped"] = plan["skipped"]
|
||||
start = time.monotonic()
|
||||
self._publish_thumbnail_start(job, plan, run["total"])
|
||||
attempted = set()
|
||||
while True:
|
||||
before = stats["generated"] + stats["skipped"]
|
||||
await self._generate_thumbnail_targets(
|
||||
job, options, plan, roots, stats, run, start
|
||||
)
|
||||
attempted.update(src for src, _dst, _video in plan["targets"])
|
||||
progressed = stats["generated"] + stats["skipped"] > before
|
||||
if job.cancelled or not plan["remaining"] or not progressed:
|
||||
return stats, start, plan["remaining"], attempted
|
||||
plan = await self._plan_thumbnail_batch(
|
||||
job, options, output_dirs, exclude=attempted
|
||||
)
|
||||
if job.cancelled:
|
||||
return stats, start, 0, attempted
|
||||
run["total"] = run["index"] + len(plan["targets"]) + plan["remaining"]
|
||||
job.update(total=run["total"])
|
||||
if not plan["targets"]:
|
||||
return stats, start, plan["remaining"], attempted
|
||||
|
||||
@staticmethod
|
||||
def _end_cancelled(job, generated, status_line):
|
||||
"""Close a cancelled job: its status line, log line and error frame."""
|
||||
@@ -358,10 +335,10 @@ class ThumbnailJobsMixin:
|
||||
if plan["skipped"]:
|
||||
job.log_line(f"= {plan['skipped']:,} previews already up to date")
|
||||
|
||||
@staticmethod
|
||||
def _publish_thumbnail_start(job, plan, total):
|
||||
"""The legacy stream's start frame, with the job's overall total."""
|
||||
async def _generate_thumbnail_targets(self, job, options, plan, roots):
|
||||
"""Generate ``plan``'s targets in worker batches, publishing each file."""
|
||||
targets = plan["targets"]
|
||||
total = len(targets)
|
||||
video_count = sum(1 for _, _, is_video in targets if is_video)
|
||||
message = (
|
||||
f"Found {total - video_count} images and {video_count} videos to process"
|
||||
@@ -377,7 +354,7 @@ class ThumbnailJobsMixin:
|
||||
{
|
||||
"total_images": total,
|
||||
"skipped": plan["skipped"],
|
||||
"remaining": 0,
|
||||
"remaining": plan["remaining"],
|
||||
"phase": "processing",
|
||||
"image_count": total - video_count,
|
||||
"video_count": video_count,
|
||||
@@ -385,30 +362,28 @@ class ThumbnailJobsMixin:
|
||||
},
|
||||
)
|
||||
)
|
||||
|
||||
async def _generate_thumbnail_targets(
|
||||
self, job, options, plan, roots, stats, run, start
|
||||
):
|
||||
"""Generate one batch (``plan``'s targets) in worker batches,
|
||||
publishing each file against the job's overall ``run`` counters."""
|
||||
targets = plan["targets"]
|
||||
stats = _new_thumbnail_stats()
|
||||
stats["skipped"] = plan["skipped"]
|
||||
start = time.monotonic()
|
||||
batch_size = _thumbnail_batch_size(_worker_threads())
|
||||
generate = {
|
||||
"encoding": options.encoding,
|
||||
"rebuild": options.rebuild,
|
||||
"stop": lambda: job.cancelled,
|
||||
}
|
||||
for batch_start in range(0, len(targets), batch_size):
|
||||
index = 0
|
||||
batches = 0
|
||||
for batch_start in range(0, total, batch_size):
|
||||
if job.cancelled:
|
||||
break
|
||||
batch = targets[batch_start : batch_start + batch_size]
|
||||
results = await self._run_in_executor(
|
||||
self._generate_batch, batch, options.settings, **generate
|
||||
self._generate_batch, batch, options.size, **generate
|
||||
)
|
||||
for (src, _dst, _video), result in zip(batch, results):
|
||||
if result["action"] == "cancelled":
|
||||
continue
|
||||
run["index"] += 1
|
||||
index, total = run["index"], max(run["total"], run["index"])
|
||||
index += 1
|
||||
_record_thumbnail_result(stats, result)
|
||||
self._record_thumbnail_outcome(job, src, result, roots)
|
||||
job.update(processed=index, progress=index * 100 // total)
|
||||
@@ -425,13 +400,14 @@ class ThumbnailJobsMixin:
|
||||
_thumbnail_progress_payload(index, total, stats, result, start),
|
||||
)
|
||||
)
|
||||
run["batches"] += 1
|
||||
batches += 1
|
||||
job.update(
|
||||
status_line=_scan_status_line(
|
||||
run["index"], run["total"], run["batches"], time.monotonic() - start
|
||||
index, total, batches, time.monotonic() - start
|
||||
)
|
||||
)
|
||||
await asyncio.sleep(0) # let other requests run between batches
|
||||
return stats, start
|
||||
|
||||
@staticmethod
|
||||
def _record_thumbnail_outcome(job, src, result, roots):
|
||||
@@ -454,13 +430,14 @@ class ThumbnailJobsMixin:
|
||||
async def generate_thumbnails_with_progress(self, request):
|
||||
"""The gallery's thumbnail stream (POST, server-sent events).
|
||||
|
||||
Starts the thumbnail job (missing previews with the server's
|
||||
thumbnail settings; the gallery's ``quality`` preset is ignored, R3),
|
||||
or attaches to the running thumbnail job, and streams its frames. A
|
||||
Starts the thumbnail job with the gallery's quality preset as the
|
||||
preview size (missing previews, medium encoding, as before), or
|
||||
attaches to the running thumbnail job, and streams its frames. A
|
||||
legacy hold (the blocking generate or clear) or a record-changing
|
||||
job refuses it with the stream's error frame. The job goes on when
|
||||
the client goes away.
|
||||
"""
|
||||
thumbnail_size = self._thumbnail_size(request.query.get("quality", "medium"))
|
||||
response = web.StreamResponse(
|
||||
status=200,
|
||||
headers={
|
||||
@@ -472,7 +449,7 @@ class ThumbnailJobsMixin:
|
||||
await response.prepare(request)
|
||||
# M3: read the registry after the last await, so a job that started
|
||||
# while the headers went out is attached to, not refused.
|
||||
job, started, busy = self._legacy_stream_job()
|
||||
job, started, busy = self._legacy_stream_job(thumbnail_size)
|
||||
if busy is not None:
|
||||
message = f"{busy.title} already running"
|
||||
await self._emit_sse(
|
||||
@@ -499,21 +476,19 @@ class ThumbnailJobsMixin:
|
||||
await events.aclose()
|
||||
return response
|
||||
|
||||
def _legacy_stream_job(self):
|
||||
def _legacy_stream_job(self, thumbnail_size):
|
||||
"""``(job, started, busy)`` for the legacy stream, synchronously.
|
||||
|
||||
Attaches to the running cancellable thumbnail job, or starts one for
|
||||
the missing previews with the server's settings, or names the job in
|
||||
the way.
|
||||
Attaches to the running cancellable thumbnail job, or starts one with
|
||||
the gallery's preset as the size (missing previews, medium encoding,
|
||||
as the gallery always wrote), or names the job in the way.
|
||||
"""
|
||||
running = self._jobs.active(THUMBNAILS_JOB_KIND)
|
||||
if running is not None and running.scope.get("mode") == "convert":
|
||||
return None, False, running # no legacy frames to follow (#317)
|
||||
if running is not None and running.cancellable:
|
||||
return running, False, None
|
||||
busy = self._jobs.blocker(THUMBNAILS_JOB_KIND, admission="guarded")
|
||||
if busy is not None:
|
||||
return None, False, busy
|
||||
# blocker() said free and nothing awaited since, so the start is admitted.
|
||||
options = ThumbnailOptions("missing", writer_settings())
|
||||
options = ThumbnailOptions("missing", thumbnail_size[0], "medium")
|
||||
return self._start_thumbnail_job(options), True, None
|
||||
|
||||
@@ -1,228 +0,0 @@
|
||||
"""Thumbnail settings and names (#317): the one place every preview writer reads.
|
||||
|
||||
Settings (System › Appearance, ``POST /prompt_manager/settings``, config.json
|
||||
``thumbnails``): ``thumbnail_format`` (``webp`` default, ``jxl``, ``jpeg``),
|
||||
``thumbnail_quality`` (1–100, default 80) and ``thumbnail_edge`` (the longest
|
||||
edge, 128–2048 px, default 512). ``current()`` is the snapshot a writer takes
|
||||
once per preview; automatic previews, the Activity job, the history route and
|
||||
the legacy gallery routes all go through it.
|
||||
|
||||
Names: a media file ``<rel>/<stem>.<ext>`` previews as
|
||||
``thumbnails/<rel>/<stem>_thumb<extension of the format>`` (``.webp``,
|
||||
``.jxl``, ``.jpg``), images and videos alike. Two files with one stem in one
|
||||
folder share that name (ruling R2). Older releases kept the source's own
|
||||
extension (``.png``, ``.jpeg``, ``.gif``, ``.webp``, ``.jpg``; ``.jpg`` for a
|
||||
video): those are read, served and converted, never written. A preview with a
|
||||
format extension (``.webp``, ``.jxl``, ``.jpg``) belongs to every same-stem
|
||||
source; a legacy-only one (``.png``, ``.jpeg``, ``.gif``) only to a source
|
||||
with that extension (``owns``, ruling R4). Lookups take the first existing
|
||||
preview the source owns, preferring the configured format.
|
||||
"""
|
||||
|
||||
import os
|
||||
from dataclasses import dataclass
|
||||
from pathlib import Path
|
||||
|
||||
FORMATS = ("webp", "jxl", "jpeg")
|
||||
FORMAT_EXTENSIONS = {"webp": ".webp", "jxl": ".jxl", "jpeg": ".jpg"}
|
||||
FORMAT_LABELS = {"webp": "WebP", "jxl": "JPEG XL", "jpeg": "JPEG"}
|
||||
DEFAULT_FORMAT = "webp"
|
||||
DEFAULT_QUALITY = 80
|
||||
DEFAULT_EDGE = 512
|
||||
MIN_QUALITY, MAX_QUALITY = 1, 100
|
||||
MIN_EDGE, MAX_EDGE = 128, 2048
|
||||
|
||||
# Extensions a format writes, then the ones only older releases wrote.
|
||||
WRITTEN_EXTENSIONS = (".webp", ".jxl", ".jpg")
|
||||
LEGACY_EXTENSIONS = (".png", ".jpeg", ".gif")
|
||||
THUMBNAIL_EXTENSIONS = WRITTEN_EXTENSIONS + LEGACY_EXTENSIONS
|
||||
CONTENT_TYPES = {
|
||||
".webp": "image/webp",
|
||||
".jxl": "image/jxl",
|
||||
".jpg": "image/jpeg",
|
||||
".jpeg": "image/jpeg",
|
||||
".png": "image/png",
|
||||
".gif": "image/gif",
|
||||
}
|
||||
THUMB_SUFFIX = "_thumb"
|
||||
|
||||
|
||||
@dataclass(frozen=True)
|
||||
class ThumbnailSettings:
|
||||
"""One preview's settings: the format, the quality and the longest edge."""
|
||||
|
||||
format: str
|
||||
quality: int
|
||||
edge: int
|
||||
|
||||
@property
|
||||
def extension(self):
|
||||
return FORMAT_EXTENSIONS[self.format]
|
||||
|
||||
@property
|
||||
def size(self):
|
||||
return (self.edge, self.edge)
|
||||
|
||||
def describe(self):
|
||||
"""``WebP · quality 80 · 512 px`` (log lines and the job's scope)."""
|
||||
return f"{FORMAT_LABELS[self.format]} · quality {self.quality} · {self.edge} px"
|
||||
|
||||
|
||||
def current():
|
||||
"""The configured settings now (read once per preview or per run)."""
|
||||
from ..config import PromptManagerConfig
|
||||
|
||||
fmt = PromptManagerConfig.THUMBNAIL_FORMAT
|
||||
return ThumbnailSettings(
|
||||
fmt if fmt in FORMATS else DEFAULT_FORMAT,
|
||||
int(PromptManagerConfig.THUMBNAIL_QUALITY),
|
||||
int(PromptManagerConfig.THUMBNAIL_EDGE),
|
||||
)
|
||||
|
||||
|
||||
def _whole(value, low, high, message):
|
||||
if type(value) is not int or not low <= value <= high:
|
||||
raise ValueError(message)
|
||||
return value
|
||||
|
||||
|
||||
def validate_format(value):
|
||||
if not isinstance(value, str) or value not in FORMATS:
|
||||
raise ValueError('thumbnail_format must be "webp", "jxl" or "jpeg"')
|
||||
return value
|
||||
|
||||
|
||||
def validate_quality(value):
|
||||
return _whole(
|
||||
value,
|
||||
MIN_QUALITY,
|
||||
MAX_QUALITY,
|
||||
f"thumbnail_quality must be a whole number from {MIN_QUALITY} to {MAX_QUALITY}",
|
||||
)
|
||||
|
||||
|
||||
def validate_edge(value):
|
||||
return _whole(
|
||||
value,
|
||||
MIN_EDGE,
|
||||
MAX_EDGE,
|
||||
"thumbnail_edge must be a whole number of pixels "
|
||||
f"from {MIN_EDGE} to {MAX_EDGE}",
|
||||
)
|
||||
|
||||
|
||||
# ── names ────────────────────────────────────────────────────────────
|
||||
|
||||
|
||||
def thumbnail_stem_rel(rel_path):
|
||||
"""``thumbnails/<rel without extension>_thumb`` (POSIX)."""
|
||||
return f"thumbnails/{Path(rel_path).with_suffix('').as_posix()}{THUMB_SUFFIX}"
|
||||
|
||||
|
||||
def thumbnail_rel(rel_path, extension=None):
|
||||
"""The preview of *rel_path* in *extension* (the configured one when None)."""
|
||||
return thumbnail_stem_rel(rel_path) + (extension or current().extension)
|
||||
|
||||
|
||||
# Ruling R2: files sharing a stem share one preview, made from the first
|
||||
# source in this order: these images, then videos (by extension name).
|
||||
IMAGE_SOURCE_ORDER = (".png", ".jpg", ".jpeg", ".webp", ".gif")
|
||||
|
||||
|
||||
def source_rank(source_extension):
|
||||
"""Sort key of a source extension in R2's order (images first)."""
|
||||
extension = source_extension.lower()
|
||||
if extension in IMAGE_SOURCE_ORDER:
|
||||
return (0, IMAGE_SOURCE_ORDER.index(extension), extension)
|
||||
return (1, 0, extension)
|
||||
|
||||
|
||||
def owns(thumb_extension, source_extension):
|
||||
"""Whether a ``_thumb<thumb_extension>`` preview can belong to a source
|
||||
with *source_extension* (ruling R4)."""
|
||||
thumb_extension = thumb_extension.lower()
|
||||
if thumb_extension in WRITTEN_EXTENSIONS:
|
||||
return True
|
||||
if thumb_extension in LEGACY_EXTENSIONS:
|
||||
return thumb_extension == source_extension.lower()
|
||||
return False
|
||||
|
||||
|
||||
def _legacy_extension(source_extension):
|
||||
from .inspection_service import VIDEO_EXTENSIONS
|
||||
|
||||
source_extension = source_extension.lower()
|
||||
return ".jpg" if source_extension in VIDEO_EXTENSIONS else source_extension
|
||||
|
||||
|
||||
def candidate_extensions(source_extension, preferred=None):
|
||||
"""The preview extensions a source owns, in lookup order: the configured
|
||||
one, the name an older release gave it, then the other written ones."""
|
||||
preferred = preferred or current().extension
|
||||
ordered = [preferred, _legacy_extension(source_extension), *WRITTEN_EXTENSIONS]
|
||||
seen = []
|
||||
for extension in ordered:
|
||||
if extension not in seen and owns(extension, source_extension):
|
||||
seen.append(extension)
|
||||
return tuple(seen)
|
||||
|
||||
|
||||
def owned_extensions(source_extension, preferred=None):
|
||||
"""``candidate_extensions`` plus the other spellings an older release
|
||||
may have given a legacy preview (review M1): the source's own
|
||||
(``X_thumb.JPG`` for ``X.JPG``) and the upper-case legacy one."""
|
||||
extensions = list(candidate_extensions(source_extension, preferred))
|
||||
legacy = _legacy_extension(source_extension)
|
||||
for spelling in (source_extension, legacy.upper()):
|
||||
if spelling not in extensions and owns(spelling, source_extension):
|
||||
extensions.append(spelling)
|
||||
return tuple(extensions)
|
||||
|
||||
|
||||
def _is_preview_file(path):
|
||||
try:
|
||||
return not path.is_symlink() and path.is_file()
|
||||
except OSError:
|
||||
return False
|
||||
|
||||
|
||||
def owned_in_folder(folder, stem, source_extension, preferred=None):
|
||||
"""Yield the existing previews ``<folder>/<stem><ext>`` a source with
|
||||
*source_extension* owns, in lookup order (regular files, no links; one
|
||||
file found under two spellings on a case-insensitive disk once)."""
|
||||
seen = set()
|
||||
for extension in owned_extensions(source_extension, preferred):
|
||||
path = Path(folder) / (stem + extension)
|
||||
if not _is_preview_file(path):
|
||||
continue
|
||||
try:
|
||||
stat = path.stat()
|
||||
identity = (stat.st_dev, stat.st_ino) if stat.st_ino else str(path)
|
||||
except OSError:
|
||||
continue
|
||||
if identity in seen:
|
||||
continue # the same file under another case (Windows, macOS)
|
||||
seen.add(identity)
|
||||
yield path
|
||||
|
||||
|
||||
def _owned(root, rel_path, preferred):
|
||||
rel_path = Path(rel_path)
|
||||
stem = Path(root) / thumbnail_stem_rel(rel_path)
|
||||
return owned_in_folder(stem.parent, stem.name, rel_path.suffix, preferred)
|
||||
|
||||
|
||||
def owned_thumbnails(root, rel_path, preferred=None):
|
||||
"""Every existing preview of *rel_path* under *root*, in lookup order."""
|
||||
return list(_owned(root, rel_path, preferred))
|
||||
|
||||
|
||||
def existing_thumbnail(root, rel_path, preferred=None):
|
||||
"""The preview a listing shows for *rel_path*: the first owned one that
|
||||
exists (the configured format first), or None."""
|
||||
return next(_owned(root, rel_path, preferred), None)
|
||||
|
||||
|
||||
def content_type(path):
|
||||
"""The Content-Type of a preview file by its extension, or None."""
|
||||
return CONTENT_TYPES.get(os.path.splitext(str(path))[1].lower())
|
||||
+5
-32
@@ -202,6 +202,7 @@ class GalleryConfig:
|
||||
AUTO_CLEANUP_MISSING_FILES (bool): Automatically remove missing file records
|
||||
MAX_IMAGE_AGE_DAYS (int): Maximum age in days before cleaning up images
|
||||
IMAGES_PER_PAGE (int): Number of images to display per page in web UI
|
||||
THUMBNAIL_SIZE (int): Size in pixels for generated thumbnails
|
||||
ENABLE_SEARCH (bool): Enable search functionality in web interface
|
||||
ENABLE_METADATA_VIEW (bool): Enable metadata viewing for images
|
||||
MAX_CONCURRENT_PROCESSING (int): Maximum concurrent image processing tasks
|
||||
@@ -226,6 +227,7 @@ class GalleryConfig:
|
||||
|
||||
# Web interface settings
|
||||
IMAGES_PER_PAGE = 20
|
||||
THUMBNAIL_SIZE = 256
|
||||
ENABLE_SEARCH = True
|
||||
ENABLE_METADATA_VIEW = True
|
||||
|
||||
@@ -265,6 +267,7 @@ class GalleryConfig:
|
||||
},
|
||||
"web_interface": {
|
||||
"images_per_page": cls.IMAGES_PER_PAGE,
|
||||
"thumbnail_size": cls.THUMBNAIL_SIZE,
|
||||
"enable_search": cls.ENABLE_SEARCH,
|
||||
"enable_metadata_view": cls.ENABLE_METADATA_VIEW,
|
||||
},
|
||||
@@ -327,6 +330,8 @@ class GalleryConfig:
|
||||
web_interface = new_config.get("web_interface", {})
|
||||
if "images_per_page" in web_interface:
|
||||
cls.IMAGES_PER_PAGE = web_interface["images_per_page"]
|
||||
if "thumbnail_size" in web_interface:
|
||||
cls.THUMBNAIL_SIZE = web_interface["thumbnail_size"]
|
||||
if "enable_search" in web_interface:
|
||||
cls.ENABLE_SEARCH = web_interface["enable_search"]
|
||||
if "enable_metadata_view" in web_interface:
|
||||
@@ -664,15 +669,6 @@ class PromptManagerConfig:
|
||||
# where it is, so the workspace stops offering to move it.
|
||||
KEEP_LIBRARY_IN_NODE_FOLDER = False
|
||||
|
||||
# Thumbnails (#317): every preview writer reads these through
|
||||
# py/api/thumbnail_settings.py (System › Appearance). The legacy
|
||||
# ``gallery.web_interface.thumbnail_size`` was never read and is ignored.
|
||||
THUMBNAIL_FORMATS = ("webp", "jxl", "jpeg")
|
||||
THUMBNAIL_FORMAT = "webp"
|
||||
THUMBNAIL_QUALITY = 80
|
||||
THUMBNAIL_EDGE = 512
|
||||
THUMBNAIL_EDGE_RANGE = (128, 2048)
|
||||
|
||||
@classmethod
|
||||
def max_worker_threads(cls) -> int:
|
||||
"""Largest accepted worker-thread count: the detected core count."""
|
||||
@@ -729,11 +725,6 @@ class PromptManagerConfig:
|
||||
},
|
||||
"maintenance": {"safety_backup": cls.SAFETY_BACKUP},
|
||||
"library": {"keep_in_node_folder": cls.KEEP_LIBRARY_IN_NODE_FOLDER},
|
||||
"thumbnails": {
|
||||
"format": cls.THUMBNAIL_FORMAT,
|
||||
"quality": cls.THUMBNAIL_QUALITY,
|
||||
"edge": cls.THUMBNAIL_EDGE,
|
||||
},
|
||||
"gallery": GalleryConfig.get_config(),
|
||||
"integrations": IntegrationConfig.get_config(),
|
||||
"autotag": AutotagConfig.get_config(),
|
||||
@@ -879,8 +870,6 @@ class PromptManagerConfig:
|
||||
):
|
||||
cls.KEEP_LIBRARY_IN_NODE_FOLDER = library["keep_in_node_folder"]
|
||||
|
||||
cls._update_thumbnails(new_config.get("thumbnails"))
|
||||
|
||||
# Update gallery config
|
||||
if "gallery" in new_config:
|
||||
GalleryConfig.update_config(new_config["gallery"])
|
||||
@@ -892,22 +881,6 @@ class PromptManagerConfig:
|
||||
if "autotag" in new_config:
|
||||
AutotagConfig.update_config(new_config["autotag"])
|
||||
|
||||
@classmethod
|
||||
def _update_thumbnails(cls, section: Any) -> None:
|
||||
"""Apply a ``thumbnails`` section; a hand-edited bad value is ignored."""
|
||||
if not isinstance(section, dict):
|
||||
return
|
||||
fmt = section.get("format")
|
||||
if fmt in cls.THUMBNAIL_FORMATS:
|
||||
cls.THUMBNAIL_FORMAT = fmt
|
||||
quality = section.get("quality")
|
||||
if type(quality) is int and 1 <= quality <= 100:
|
||||
cls.THUMBNAIL_QUALITY = quality
|
||||
edge = section.get("edge")
|
||||
low, high = cls.THUMBNAIL_EDGE_RANGE
|
||||
if type(edge) is int and low <= edge <= high:
|
||||
cls.THUMBNAIL_EDGE = edge
|
||||
|
||||
|
||||
# Load configuration on import
|
||||
try:
|
||||
|
||||
@@ -356,12 +356,7 @@ def create_app(root: Path) -> web.Application:
|
||||
with Image.new("RGB", (64, 64), (100, 80, 120)) as image:
|
||||
image.save(root / "output" / name, pnginfo=info)
|
||||
os.utime(root / "output" / name, (time.time() - 100, time.time() - 100))
|
||||
from py.api.thumbnail_settings import ThumbnailSettings
|
||||
|
||||
# 150 px previews in the default format (#317 thumbnail settings).
|
||||
api._generate_gallery_thumbnails_sync(
|
||||
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
|
||||
)
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
if include_video:
|
||||
# Seed after thumbnail generation to exercise a real unthumbnailed video.
|
||||
video = root / "output" / "clip.webm"
|
||||
|
||||
@@ -482,8 +482,6 @@ function mapAppearanceConcept(reference, settings) {
|
||||
control: { ...toggleRow.control, id, checked } }));
|
||||
return {
|
||||
...reference,
|
||||
// #317: the server's thumbnail settings follow in their own surface ("Thumbnails", README: retained in Appearance).
|
||||
layout: [reference.layout[0], 'section.surface', ...reference.layout.slice(1)],
|
||||
// D97: two added toggles and the "Reset display defaults" operation close the surface.
|
||||
surface: { ...reference.surface, tree: [...reference.surface.tree, 'div.setting-row', 'div.setting-row', 'div.operation'] },
|
||||
rows: [...reference.rows.map(row => row.control?.id === 'timeout'
|
||||
@@ -532,8 +530,7 @@ test('appearance comparison detects mutations', async ({ page, request }) => {
|
||||
// Unmapped, the concept differs only where D97 and the real timeout are recorded.
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||||
.toEqual(['layout.1', 'layout.2', 'layout.length', 'operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13',
|
||||
'rows.length', 'surface.tree'].sort());
|
||||
.toEqual(['operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', 'rows.length', 'surface.tree'].sort());
|
||||
const measure = () => page.evaluate(measureAppearance, '#workspace-main');
|
||||
const paths = async () => diffInventory(expected, await measure()).map(item => item.path);
|
||||
// A removed row.
|
||||
@@ -845,16 +842,13 @@ function measureThumbnailReview() {
|
||||
|
||||
/**
|
||||
* D99: the planned counts line (`p.small[role=status]`) before the actions, from the server's plan for the chosen
|
||||
* options, and the primary "Start" (the concept's "Simulate thumbnail job"). D131 (#317): the Quality and Preview size
|
||||
* limit fields give way to a second `p.small` naming the server's thumbnail settings, and Scope gains "Convert
|
||||
* outdated (removes older formats)".
|
||||
* options, and the primary "Start" (the concept's "Simulate thumbnail job").
|
||||
*/
|
||||
function mapThumbnailConcept(reference, plan) {
|
||||
const [cancel, start] = reference.actions.buttons;
|
||||
const [scope] = reference.fields;
|
||||
const tree = ['div.field', 'p.small', 'p.small', 'div.dialog-actions'];
|
||||
const fields = [{ ...scope, control: { ...scope.control, options: [...scope.control.options, 'Convert outdated (removes older formats)'] } }];
|
||||
return { ...reference, tree, fields,
|
||||
const tree = [...reference.tree];
|
||||
tree.splice(tree.indexOf('div.dialog-actions'), 0, 'p.small');
|
||||
return { ...reference, tree,
|
||||
plan: { ...reference.plan, role: 'status',
|
||||
text: `${number(plan.targets)} ${plan.targets === 1 ? 'preview' : 'previews'} to generate · ${number(plan.fresh)} already up to date.` },
|
||||
actions: { ...reference.actions, buttons: [cancel, { ...start, text: 'Start' }] } };
|
||||
@@ -880,12 +874,12 @@ async function productionThumbnailReview(page, theme, entry) {
|
||||
await page.locator('#workspace-main').getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||||
}
|
||||
await expect(page.locator('dialog#modal[open] #modal-title')).toHaveText('Thumbnail maintenance');
|
||||
await expect(page.locator('dialog#modal[open] #modal-body > p.small').first()).toHaveText(/ already up to date\.$/);
|
||||
await expect(page.locator('dialog#modal[open] #modal-body > p.small')).toHaveText(/ already up to date\.$/);
|
||||
await page.mouse.move(0, 0);
|
||||
return page.evaluate(measureThumbnailReview);
|
||||
}
|
||||
|
||||
const thumbnailPlan = request => serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing');
|
||||
const thumbnailPlan = request => serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing&size=512&quality=medium');
|
||||
|
||||
for (const entry of ['sources', 'overview']) for (const theme of THEMES) for (const [width, height] of SIZES) {
|
||||
test(`thumbnail dialog matches the concept from ${entry} ${theme} ${width}x${height}`, async ({ page, request }, testInfo) => {
|
||||
@@ -908,24 +902,22 @@ test('thumbnail dialog comparison detects mutations', async ({ page, request })
|
||||
const expected = mapThumbnailConcept(concept, plan);
|
||||
const production = await productionThumbnailReview(page, 'dark', 'sources');
|
||||
expect(diffInventory(expected, production)).toEqual([]);
|
||||
// Unmapped, the concept differs only where D99 and D131 are recorded.
|
||||
expect(diffInventory(concept, production).map(item => item.path.split('.').slice(0, 2).join('.'))
|
||||
.filter((path, index, all) => all.indexOf(path) === index).sort())
|
||||
.toEqual(['actions.buttons', 'fields.0', 'fields.1', 'fields.2', 'fields.length', 'plan.role', 'plan.text', 'tree.1', 'tree.2']
|
||||
.sort());
|
||||
// Unmapped, the concept differs only where D99 is recorded.
|
||||
expect(diffInventory(concept, production).map(item => item.path).sort())
|
||||
.toEqual(['actions.buttons.1.text', 'plan.role', 'plan.text', 'tree.3', 'tree.4', 'tree.length']);
|
||||
const paths = async () => diffInventory(expected, await page.evaluate(measureThumbnailReview)).map(item => item.path);
|
||||
// A changed option label, a swapped option order, a swapped action order and a changed style.
|
||||
await page.evaluate(() => { document.querySelector('#thumb-mode option').textContent = 'Missing only'; });
|
||||
expect(await paths()).toEqual(['fields.0.control.options.0']);
|
||||
await page.evaluate(() => { document.querySelector('#thumb-mode option').textContent = 'Missing thumbnails only'; });
|
||||
await page.evaluate(() => { document.querySelector('#thumb-quality option').textContent = 'Medium'; });
|
||||
expect(await paths()).toEqual(['fields.1.control.options.0']);
|
||||
await page.evaluate(() => { document.querySelector('#thumb-quality option').textContent = 'Medium · optimized'; });
|
||||
await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.prepend(select.lastElementChild); });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['fields.0.control.options.0', 'fields.0.control.options.1']));
|
||||
await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.append(select.firstElementChild); select.selectedIndex = 0; });
|
||||
await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.prepend(select.lastElementChild); select.selectedIndex = 0; });
|
||||
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['actions.buttons.0.text', 'actions.buttons.1.text']));
|
||||
await page.evaluate(() => { const actions = document.querySelector('dialog#modal[open] .dialog-actions'); actions.prepend(actions.lastElementChild); });
|
||||
const undoStyle = await mutate(page, { css: 'dialog#modal #thumb-mode { border-color: rgb(255, 0, 0); } dialog#modal #modal-body > p.small { color: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['fields.0.control.borderTopColor', 'plan.color']));
|
||||
const undoStyle = await mutate(page, { css: 'dialog#modal #thumb-limit { border-color: rgb(255, 0, 0); } dialog#modal #modal-body > p.small { color: rgb(255, 0, 0); }' });
|
||||
expect(await paths()).toEqual(expect.arrayContaining(['fields.2.control.borderTopColor', 'plan.color']));
|
||||
await undoStyle();
|
||||
expect(diffInventory(expected, await page.evaluate(measureThumbnailReview))).toEqual([]);
|
||||
});
|
||||
|
||||
@@ -399,7 +399,7 @@ test('real video media has a usable card and native playback cleans up on close
|
||||
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
|
||||
const media = await (await request.get('/prompt_manager/images/output?limit=40')).json();
|
||||
// #249: a video with no preview yet still lists its preview URL; the server makes the frame on first view.
|
||||
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toMatch(/\/thumbnails\/clip_thumb\.webp\?root=0/);
|
||||
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toMatch(/\/thumbnails\/clip_thumb\.jpg\?root=0/);
|
||||
await page.goto(workspace);
|
||||
await refine(page, { Media: 'video' });
|
||||
await expect(cards(page)).toHaveCount(1);
|
||||
@@ -722,7 +722,7 @@ test('refresh releases the previous inspector cover source', async ({ page, requ
|
||||
await card.getByRole('button', { name: 'Inspect clip.webm', exact: true }).click();
|
||||
// #249: a video's cover is its preview frame, not the video.
|
||||
const cover = details(page).locator('img.detail-cover');
|
||||
await expect(cover).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.webp\?/);
|
||||
await expect(cover).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.jpg\?/);
|
||||
await expect(details(page).locator('video')).toHaveCount(0);
|
||||
await cover.evaluate(element => { window.__oldInspectorCover = element; });
|
||||
await rescan(page);
|
||||
|
||||
@@ -331,25 +331,27 @@ function thumbnailRequests(page) {
|
||||
const missingLine = plan => `${planCount(plan.targets)} ${plan.targets === 1 ? 'preview' : 'previews'} to generate · ` +
|
||||
`${planCount(plan.fresh)} already up to date.`;
|
||||
|
||||
const SETTINGS_LINE = 'Previews are written as WebP · quality 80 · 512 px. Change this in System › Appearance.';
|
||||
|
||||
test('Review options shows the plan counts for the chosen scope and the server settings before Start', async ({ page, request }) => {
|
||||
test('Review options shows the plan counts for the chosen scope/quality/size before Start', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 3 } })).toBeOK();
|
||||
// Image loading quality (Appearance, Q3) preselects the dialog's quality.
|
||||
await page.addInitScript(() => { if (location.protocol.startsWith('http')) localStorage.setItem('gallerySettings', JSON.stringify({ imageQuality: 'high' })); });
|
||||
const seen = thumbnailRequests(page);
|
||||
const plan = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=missing`);
|
||||
const plan = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=missing&size=512&quality=high`);
|
||||
expect(plan.targets).toBe(3);
|
||||
const review = await openThumbnailReview(page);
|
||||
// D131: Scope, the plan line, the server's settings line, the actions (no quality or size field of its own).
|
||||
await expect(review.locator('#modal-body > *')).toHaveCount(4);
|
||||
await expect(review.locator('.field > label')).toHaveText(['Scope']);
|
||||
await expect(review.locator('#thumb-mode option')).toHaveText(['Missing thumbnails only', 'Rebuild all (explicit opt-in)',
|
||||
'Convert outdated (removes older formats)']);
|
||||
await expect(review.locator('#modal-body > *')).toHaveCount(5);
|
||||
await expect(review.locator('.field > label')).toHaveText(['Scope', 'Quality', 'Preview size limit']);
|
||||
await expect(review.locator('#thumb-mode option')).toHaveText(['Missing thumbnails only', 'Rebuild all (explicit opt-in)']);
|
||||
await expect(review.locator('#thumb-quality option')).toHaveText(['Medium · optimized', 'High · original', 'Low · fastest']);
|
||||
await expect(review.locator('#thumb-mode')).toHaveValue('missing');
|
||||
const [line, settings] = [review.locator('#modal-body > p.small').first(), review.locator('#modal-body > p.small').last()];
|
||||
await expect(review.locator('#thumb-quality')).toHaveValue('high');
|
||||
await expect(review.locator('#thumb-limit')).toHaveValue('512');
|
||||
await expect(review.locator('#thumb-limit')).toHaveAttribute('min', '128');
|
||||
await expect(review.locator('#thumb-limit')).toHaveAttribute('max', '2048');
|
||||
const line = review.locator('#modal-body > p.small');
|
||||
await expect(line).toHaveAttribute('role', 'status');
|
||||
await expect(line).toHaveText(missingLine(plan));
|
||||
await expect(settings).toHaveText(SETTINGS_LINE);
|
||||
expect(seen.plans).toEqual([{ mode: 'missing' }]);
|
||||
expect(seen.plans).toEqual([{ mode: 'missing', size: '512', quality: 'high' }]);
|
||||
await expect(review.locator('.dialog-actions > button')).toHaveText(['Cancel', 'Start']);
|
||||
// Reviewing starts nothing; Cancel closes.
|
||||
await review.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
@@ -358,67 +360,63 @@ test('Review options shows the plan counts for the chosen scope and the server s
|
||||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
|
||||
});
|
||||
|
||||
test('Convert outdated replaces older-format previews from their originals and removes the old files (#317)', async ({ page, request }) => {
|
||||
const { files } = await (await request.post('/__test__/system/legacy-previews', { data: { count: 3 } })).json();
|
||||
const before = await serverJson(request, '/prompt_manager/images/thumbnails/status');
|
||||
expect(before.outdated).toBe(3);
|
||||
test('size outside 128–2048 reports validity and requests nothing', async ({ page, request }) => {
|
||||
const seen = thumbnailRequests(page);
|
||||
const review = await openThumbnailReview(page);
|
||||
const line = review.locator('#modal-body > p.small').first();
|
||||
const line = review.locator('#modal-body > p.small');
|
||||
await expect(line).toHaveText(/ already up to date\.$/);
|
||||
await review.locator('#thumb-mode').selectOption('convert');
|
||||
await expect(line).toHaveText('3 previews in an older format to convert.');
|
||||
// Choosing the scope converts nothing: only Start does.
|
||||
expect(seen.starts).toEqual([]);
|
||||
await review.getByRole('button', { name: 'Start', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
expect(seen.starts).toEqual([{ mode: 'convert' }]);
|
||||
const id = page.url().split('/').pop();
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
|
||||
const { job } = await serverJson(request, `/prompt_manager/jobs/${id}`);
|
||||
expect(job).toMatchObject({ kind: 'thumbnails', title: 'Convert outdated thumbnails', scope: { mode: 'convert', format: 'webp' } });
|
||||
expect(job.status_line).toMatch(/^3 converted · 0 failed · .+ → .+$/);
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Convert outdated thumbnails');
|
||||
const after = await serverJson(request, '/prompt_manager/images/thumbnails/status');
|
||||
expect(after.outdated).toBe(0);
|
||||
// The listing now names the WebP previews; the old PNG previews are gone.
|
||||
for (const file of files) {
|
||||
const name = file.replace(/\.png$/, '');
|
||||
expect((await request.get(`/prompt_manager/images/serve/thumbnails/${name}_thumb.webp?root=0`)).headers()['content-type']).toBe('image/webp');
|
||||
expect((await request.get(`/prompt_manager/images/serve/${file}?root=0`)).ok()).toBe(true);
|
||||
const size = review.locator('#thumb-limit');
|
||||
for (const value of ['4096', '64', '']) {
|
||||
await size.fill(value);
|
||||
await expect(size).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(line).toHaveText('Enter a size from 128 to 2048 pixels.');
|
||||
expect(await size.evaluate(input => input.validity.valid)).toBe(false);
|
||||
// Start keeps the dialog open and sends nothing; neither does a scope change.
|
||||
await review.getByRole('button', { name: 'Start', exact: true }).click();
|
||||
await expect(dialog(page).locator('#modal-title')).toHaveText('Thumbnail maintenance');
|
||||
await review.locator('#thumb-mode').selectOption('rebuild');
|
||||
await expect(line).toHaveText('Enter a size from 128 to 2048 pixels.');
|
||||
await review.locator('#thumb-mode').selectOption('missing');
|
||||
}
|
||||
expect(seen.starts).toEqual([]);
|
||||
expect(seen.plans).toEqual([{ mode: 'missing', size: '512', quality: 'medium' }]);
|
||||
await size.fill('1024');
|
||||
await expect(size).not.toHaveAttribute('aria-invalid');
|
||||
await expect(line).toHaveText(/ already up to date\.$/);
|
||||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
|
||||
});
|
||||
|
||||
test('Start closes the dialog, toasts, and opens the job in Activity', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 2 } })).toBeOK();
|
||||
const seen = thumbnailRequests(page);
|
||||
const review = await openThumbnailReview(page);
|
||||
await review.locator('#thumb-quality').selectOption('low');
|
||||
await review.locator('#thumb-limit').fill('256');
|
||||
await review.getByRole('button', { name: 'Start', exact: true }).click();
|
||||
await expect(dialog(page)).toHaveCount(0);
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
// The toast says it started (or, for a run this quick, that it already finished); Activity shows its progress.
|
||||
await expect(toast(page)).toHaveText(/^(Thumbnail generation started\.|Thumbnails finished · 2 generated · 0 errors)$/);
|
||||
expect(seen.starts).toEqual([{ mode: 'missing' }]);
|
||||
expect(seen.starts).toEqual([{ mode: 'missing', size: 256, quality: 'low' }]);
|
||||
const id = page.url().split('/').pop();
|
||||
const { job } = await serverJson(request, `/prompt_manager/jobs/${id}`);
|
||||
expect(job).toMatchObject({ kind: 'thumbnails', title: 'Thumbnail generation',
|
||||
scope: { mode: 'missing', format: 'webp', size: 512, quality: 80 } });
|
||||
expect(job).toMatchObject({ kind: 'thumbnails', title: 'Thumbnail generation', scope: { mode: 'missing', size: 256, quality: 'low' } });
|
||||
await expect(page.locator('#workspace-main h1')).toHaveText('Thumbnail generation');
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
|
||||
});
|
||||
|
||||
test('Rebuild all is explicit and the plan line says every preview is regenerated', async ({ page, request }) => {
|
||||
const seen = thumbnailRequests(page);
|
||||
const rebuild = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=rebuild`);
|
||||
const rebuild = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=rebuild&size=512&quality=medium`);
|
||||
expect(rebuild.targets).toBeGreaterThan(0);
|
||||
const review = await openThumbnailReview(page);
|
||||
// Missing thumbnails only is the default; rebuilding is the explicit second option.
|
||||
await expect(review.locator('#thumb-mode')).toHaveValue('missing');
|
||||
const line = review.locator('#modal-body > p.small').first();
|
||||
const line = review.locator('#modal-body > p.small');
|
||||
await expect(line).toHaveText(/ already up to date\.$/);
|
||||
await review.locator('#thumb-mode').selectOption('rebuild');
|
||||
await expect(line).toHaveText(`Every preview is regenerated: ${planCount(rebuild.targets)} files.`);
|
||||
expect(seen.plans).toEqual([{ mode: 'missing' }, { mode: 'rebuild' }]);
|
||||
expect(seen.plans).toEqual([{ mode: 'missing', size: '512', quality: 'medium' }, { mode: 'rebuild', size: '512', quality: 'medium' }]);
|
||||
// Back and forth reads each scope once.
|
||||
await review.locator('#thumb-mode').selectOption('missing');
|
||||
await expect(line).toHaveText(/ already up to date\.$/);
|
||||
@@ -427,7 +425,7 @@ test('Rebuild all is explicit and the plan line says every preview is regenerate
|
||||
expect(seen.plans).toHaveLength(2);
|
||||
await review.getByRole('button', { name: 'Start', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
expect(seen.starts).toEqual([{ mode: 'rebuild' }]);
|
||||
expect(seen.starts).toEqual([{ mode: 'rebuild', size: 512, quality: 'medium' }]);
|
||||
const id = page.url().split('/').pop();
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
|
||||
});
|
||||
@@ -435,7 +433,7 @@ test('Rebuild all is explicit and the plan line says every preview is regenerate
|
||||
test('a plan that cannot be read says so; Start still starts', async ({ page }) => {
|
||||
await page.route(`**${THUMBNAIL_PLAN_URL}?*`, route => route.fulfill({ status: 500, json: { success: false, error: 'disk' } }));
|
||||
const review = await openThumbnailReview(page);
|
||||
await expect(review.locator('#modal-body > p.small').first()).toHaveText('The previews could not be counted.');
|
||||
await expect(review.locator('#modal-body > p.small')).toHaveText('The previews could not be counted.');
|
||||
await expect(review.getByRole('button', { name: 'Start', exact: true })).not.toHaveAttribute('aria-disabled');
|
||||
await review.getByRole('button', { name: 'Cancel', exact: true }).click();
|
||||
});
|
||||
|
||||
@@ -235,7 +235,7 @@ test('thumbnail row reflects the status endpoint', async ({ page, request }) =>
|
||||
|
||||
test('Overview Review thumbnails opens the same dialog', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 2 } })).toBeOK();
|
||||
const plan = await serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing');
|
||||
const plan = await serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing&size=512&quality=medium');
|
||||
const starts = [];
|
||||
page.on('request', item => { if (item.method() === 'POST' && item.url().endsWith('/prompt_manager/jobs/thumbnails')) starts.push(item.postDataJSON()); });
|
||||
const layout = await openOverview(page);
|
||||
@@ -245,8 +245,8 @@ test('Overview Review thumbnails opens the same dialog', async ({ page, request
|
||||
const review = page.locator('dialog#modal[open]');
|
||||
await expect(review.locator('#modal-title')).toHaveText('Thumbnail maintenance');
|
||||
await expect(page).toHaveURL(`${workspace}#/system/overview`);
|
||||
await expect(review.locator('.field > label')).toHaveText(['Scope']);
|
||||
await expect(review.locator('#modal-body > p.small').first()).toHaveText(`2 previews to generate · ${number(plan.fresh)} already up to date.`);
|
||||
await expect(review.locator('.field > label')).toHaveText(['Scope', 'Quality', 'Preview size limit']);
|
||||
await expect(review.locator('#modal-body > p.small')).toHaveText(`2 previews to generate · ${number(plan.fresh)} already up to date.`);
|
||||
// Escape closes it and focus returns to the row's button.
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(review).toHaveCount(0);
|
||||
@@ -254,61 +254,11 @@ test('Overview Review thumbnails opens the same dialog', async ({ page, request
|
||||
await row.getByRole('button', { name: 'Review thumbnails', exact: true }).click();
|
||||
await review.getByRole('button', { name: 'Start', exact: true }).click();
|
||||
await expect(page).toHaveURL(/#\/activity\/[0-9a-f]{32}$/);
|
||||
expect(starts).toEqual([{ mode: 'missing' }]);
|
||||
expect(starts).toEqual([{ mode: 'missing', size: 512, quality: 'medium' }]);
|
||||
const id = page.url().split('/').pop();
|
||||
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
|
||||
});
|
||||
|
||||
test('the thumbnail row counts outdated previews and offers Convert outdated (#317)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 2 } })).toBeOK();
|
||||
const layout = await openOverview(page);
|
||||
const row = layout.locator('.operation').filter({ has: page.getByRole('button', { name: 'Review thumbnails', exact: true }) });
|
||||
await expect(row.locator('h3')).toHaveText('No missing thumbnails · 2 outdated');
|
||||
await expect(row.locator('.row-actions > button')).toHaveText(['Review thumbnails', 'Convert outdated']);
|
||||
await row.getByRole('button', { name: 'Convert outdated', exact: true }).click();
|
||||
const review = page.locator('dialog#modal[open]');
|
||||
await expect(review.locator('#modal-title')).toHaveText('Thumbnail maintenance');
|
||||
// It opens on the Convert outdated scope; nothing starts until Start.
|
||||
await expect(review.locator('#thumb-mode')).toHaveValue('convert');
|
||||
await expect(review.locator('#modal-body > p.small').first()).toHaveText('2 previews in an older format to convert.');
|
||||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
|
||||
await page.keyboard.press('Escape');
|
||||
await expect(row.getByRole('button', { name: 'Convert outdated', exact: true })).toBeFocused();
|
||||
});
|
||||
|
||||
test('Appearance › Thumbnails saves the server thumbnail settings and never converts by itself (#317)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: false } })).toBeOK();
|
||||
await expect(await request.post('/__test__/system/legacy-previews', { data: { count: 1 } })).toBeOK();
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
const surface = page.locator('#workspace-main .settings-layout > section.surface').nth(1);
|
||||
await expect(surface.locator(':scope > h2')).toHaveText('Thumbnails');
|
||||
await expect(surface.locator('.setting-row label')).toHaveText(['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit']);
|
||||
const format = surface.locator('#thumbnail-format');
|
||||
await expect(format).toBeEnabled();
|
||||
await expect(format).toHaveValue('webp');
|
||||
await expect(surface.locator('#thumbnail-quality')).toHaveValue('80');
|
||||
await expect(surface.locator('#thumbnail-edge')).toHaveValue('512');
|
||||
// D130: JPEG XL is offered only with an encoder; without one the row says why, in the server's words.
|
||||
await expect(format.locator('option[value="jxl"]')).toBeDisabled();
|
||||
await expect(surface.locator('.setting-row').first().locator('p.small').first()).toContainText('No JPEG XL encoder found.');
|
||||
await format.selectOption('jpeg');
|
||||
await surface.locator('#thumbnail-quality').fill('65');
|
||||
await surface.locator('#thumbnail-quality').dispatchEvent('change');
|
||||
await page.getByRole('button', { name: 'Save preferences', exact: true }).click();
|
||||
await expect(page.locator('#settings-status')).toHaveText('Saved to the server configuration.');
|
||||
const { settings } = await serverJson(request, '/prompt_manager/settings');
|
||||
expect([settings.thumbnail_format, settings.thumbnail_quality, settings.thumbnail_edge]).toEqual(['jpeg', 65, 512]);
|
||||
// Changing the format converts nothing: the row says so and the old preview is still listed.
|
||||
await expect(surface.locator('.operation p')).toHaveText('Format saved. Existing previews keep their old format until you convert them.');
|
||||
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
|
||||
expect((await serverJson(request, '/prompt_manager/images/thumbnails/status')).outdated).toBeGreaterThan(0);
|
||||
// With an encoder JPEG XL can be chosen.
|
||||
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: true } })).toBeOK();
|
||||
await page.reload();
|
||||
await expect(page.locator('#workspace-main #thumbnail-format option[value="jxl"]')).toBeEnabled();
|
||||
await expect(page.locator('#workspace-main #thumbnail-format')).toHaveValue('jpeg');
|
||||
});
|
||||
|
||||
test('the thumbnail row paints when its own status read answers', async ({ page, request }) => {
|
||||
const held = async () => (await serverJson(request, '/__test__/hold')).held[THUMBNAILS_URL] ?? 0;
|
||||
await expect(await request.post('/__test__/hold', { data: { path: THUMBNAILS_URL } })).toBeOK();
|
||||
@@ -433,7 +383,7 @@ const panel = page => page.getByRole('complementary', { name: 'Selected image de
|
||||
|
||||
async function openAppearance(page) {
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2').first()).toHaveText('Browsing & display');
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||||
// The host rows are enabled once the server settings answered.
|
||||
await expect(page.locator('#timeout')).toBeEnabled();
|
||||
}
|
||||
@@ -650,16 +600,14 @@ test('server settings that cannot be read say so in their rows', async ({ page }
|
||||
? route.fulfill({ status: 500, contentType: 'application/json', body: JSON.stringify({ success: false, error: 'Injected failure' }) })
|
||||
: route.continue()));
|
||||
await page.goto(`${workspace}#/system/appearance`);
|
||||
// #317: the Thumbnails rows are server settings too.
|
||||
for (const id of ['open-mode', 'timeout', 'thumbnail-format', 'thumbnail-quality', 'thumbnail-edge']) {
|
||||
for (const id of ['open-mode', 'timeout']) {
|
||||
const row = page.locator('#workspace-main .setting-row').filter({ has: page.locator(`#${id}`) });
|
||||
await expect(row.locator('p.small').first()).toHaveText('Server settings could not be read.');
|
||||
await expect(row.locator('p.small')).toHaveText('Server settings could not be read.');
|
||||
await expect(page.locator(`#${id}`)).toBeDisabled();
|
||||
}
|
||||
// The browser rows are this browser's, and work as before.
|
||||
await expect(page.locator('#default-view')).toBeEnabled();
|
||||
await expect(page.locator('#workspace-main .setting-row p.small', { hasText: 'Server settings could not be read.' })).toHaveCount(5);
|
||||
await expect(page.locator('#workspace-main section.surface').first().locator('.setting-row p.small')).toHaveCount(2);
|
||||
await expect(page.locator('#workspace-main .setting-row p.small')).toHaveCount(2);
|
||||
});
|
||||
|
||||
test('stale revision shows the conflict status', async ({ page, request }) => {
|
||||
@@ -721,7 +669,7 @@ test('a change another tab saved while System was closed is a conflict on return
|
||||
await other.close();
|
||||
// Back in System: the draft is kept and flagged, and nothing was written over the other tab's value.
|
||||
await page.evaluate(() => { location.hash = '#/system/appearance'; });
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2').first()).toHaveText('Browsing & display');
|
||||
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2')).toHaveText('Browsing & display');
|
||||
await expect(statusOf(page)).toHaveText('Settings changed elsewhere. Reload this section to see them.');
|
||||
await expect(page.locator('#density')).toHaveAttribute('aria-invalid', 'true');
|
||||
await expect(page.locator('#density')).toHaveValue('compact');
|
||||
|
||||
@@ -44,7 +44,7 @@ test('a page of the Images grid requests previews, never originals, in every lay
|
||||
const listing = await (await request.get('/prompt_manager/images/output?limit=40')).json();
|
||||
const fresh = listing.images.filter(image => image.path.startsWith('unthumbnailed/'));
|
||||
expect(fresh).toHaveLength(6);
|
||||
for (const image of fresh) expect(image.thumbnail_url).toMatch(/\/thumbnails\/unthumbnailed\/plain_\d{3}_thumb\.webp\?root=0/);
|
||||
for (const image of fresh) expect(image.thumbnail_url).toMatch(/\/thumbnails\/unthumbnailed\/plain_\d{3}_thumb\.png\?root=0/);
|
||||
const seen = recordMedia(page);
|
||||
await page.goto(images);
|
||||
await expect(cards(page)).toHaveCount(8);
|
||||
@@ -73,7 +73,7 @@ test('the inspector cover is the preview and only the media viewer loads the ori
|
||||
const card = cards(page).filter({ hasText: 'plain_001.png' });
|
||||
await card.getByRole('button', { name: 'Inspect plain_001.png', exact: true }).click();
|
||||
const cover = details(page).locator('img.detail-cover');
|
||||
await expect(cover).toHaveAttribute('src', /\/thumbnails\/unthumbnailed\/plain_001_thumb\.webp\?/);
|
||||
await expect(cover).toHaveAttribute('src', /\/thumbnails\/unthumbnailed\/plain_001_thumb\.png\?/);
|
||||
await settled(page);
|
||||
await cover.evaluate(image => image.decode().catch(() => {}));
|
||||
expect(originals(seen)).toEqual([]);
|
||||
@@ -88,7 +88,7 @@ test('a video card shows its preview frame instead of loading the video', async
|
||||
const seen = recordMedia(page);
|
||||
await page.goto(images);
|
||||
const card = cards(page).filter({ hasText: 'clip.webm' });
|
||||
await expect(card.locator('.media-art > .image-open > img')).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.webp\?/);
|
||||
await expect(card.locator('.media-art > .image-open > img')).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.jpg\?/);
|
||||
await expect(card.locator('video')).toHaveCount(0);
|
||||
await settled(page);
|
||||
expect(originals(seen)).toEqual([]);
|
||||
@@ -107,7 +107,7 @@ test('Library cards and the shared gallery request previews only', async ({ page
|
||||
|
||||
test('a video whose preview cannot be loaded swaps back to its own element', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset', { data: { include_video: true } })).toBeOK();
|
||||
await page.route(url => url.pathname.endsWith('/thumbnails/clip_thumb.webp'), route => route.fulfill({ status: 404, body: '' }));
|
||||
await page.route(url => url.pathname.endsWith('/thumbnails/clip_thumb.jpg'), route => route.fulfill({ status: 404, body: '' }));
|
||||
await page.goto(images);
|
||||
const card = cards(page).filter({ hasText: 'clip.webm' });
|
||||
await expect(card.locator('.media-art > .image-open > video')).toHaveCount(1);
|
||||
@@ -133,38 +133,3 @@ test('an image listed without a preview (a nested thumbnails folder) shows its o
|
||||
expect(seen.length).toBeGreaterThan(0);
|
||||
expect(seen.every(item => !item.preview)).toBe(true);
|
||||
});
|
||||
|
||||
test('a JPEG XL thumbnail this browser cannot show loads the original; one it can show is used (#317)', async ({ page, request }) => {
|
||||
await expect(await request.post('/__test__/reset')).toBeOK();
|
||||
await expect(await request.post('/__test__/system/reset')).toBeOK();
|
||||
try {
|
||||
await expect(await request.post('/__test__/system/jxl-encoder', { data: { available: true } })).toBeOK();
|
||||
const saved = await request.post('/prompt_manager/settings', { data: { thumbnail_format: 'jxl' } });
|
||||
await expect(saved).toBeOK();
|
||||
await expect(await request.post('/__test__/system/unthumbnailed', { data: { count: 1 } })).toBeOK();
|
||||
const listing = await (await request.get('/prompt_manager/images/output?limit=40')).json();
|
||||
const fresh = listing.images.find(image => image.path === 'unthumbnailed/plain_001.png');
|
||||
expect(fresh.thumbnail_url).toMatch(/\/thumbnails\/unthumbnailed\/plain_001_thumb\.jxl\?root=0/);
|
||||
await page.goto(images);
|
||||
// What this browser does with JPEG XL, decided the way the workspace decides it (an embedded probe).
|
||||
const decodes = await page.evaluate(() => new Promise(resolve => {
|
||||
const probe = new Image();
|
||||
probe.onload = () => resolve(true);
|
||||
probe.onerror = () => resolve(false);
|
||||
probe.src = 'data:image/jxl;base64,/woAkAEAE4gCALQAtZ8gAAAVKqOMG7yc6/nyQ4fFtI3rDG21bWEJY7O9MEhIOGwY4s3xwRCTShIA';
|
||||
}));
|
||||
const card = cards(page).filter({ hasText: 'plain_001.png' });
|
||||
const media = card.locator('.media-art > .image-open > img');
|
||||
// A browser without JPEG XL shows the original; one with it shows the preview, or the original when the
|
||||
// preview cannot be made here (no real encoder in the test server): never a broken image.
|
||||
if (!decodes) await expect(media).toHaveAttribute('src', /\/unthumbnailed\/plain_001\.png\?root=0/);
|
||||
await expect.poll(() => media.evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
// The inspector cover follows the same rule.
|
||||
await card.getByRole('button', { name: 'Inspect plain_001.png', exact: true }).click();
|
||||
const cover = details(page).locator('img.detail-cover');
|
||||
if (!decodes) await expect(cover).toHaveAttribute('src', /\/unthumbnailed\/plain_001\.png\?/);
|
||||
await expect.poll(() => cover.evaluate(image => image.complete && image.naturalWidth > 0)).toBe(true);
|
||||
} finally {
|
||||
await request.post('/__test__/system/reset');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -173,8 +173,7 @@ test('visual fixture exposes real stable media and library records', async ({ re
|
||||
await expect(await request.get(image.url)).toBeOK();
|
||||
const thumbnail = await request.get(image.thumbnail_url);
|
||||
await expect(thumbnail).toBeOK();
|
||||
// #317: previews are written as WebP by default.
|
||||
expect(thumbnail.headers()['content-type']).toBe('image/webp');
|
||||
expect(thumbnail.headers()['content-type']).toBe('image/png');
|
||||
const inspection = await (await request.get(`/prompt_manager/images/inspect/${encodeURIComponent(image.path)}?root=${image.root_index}`)).json();
|
||||
expect(inspection.success).toBe(true);
|
||||
expect(inspection.image.path).toBe(image.path);
|
||||
|
||||
@@ -469,7 +469,7 @@ test('System is native: the concept shell, section navigation, sections, hand-of
|
||||
if (hash === 'overview') await expect(layout.locator(':scope > .summary-metrics')).toBeVisible();
|
||||
if (hash === 'sources') await expect(layout.locator('#source-list .operation')).toHaveCount(2);
|
||||
expect(await layout.evaluate(node => [...node.children].map(child => `${child.localName}.${child.className}`)))
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'section.surface', 'div.save-row'],
|
||||
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', 'div.save-row'],
|
||||
sources: ['div.notice', 'section.surface', 'div.save-row'],
|
||||
maintenance: ['div.notice', 'section.surface handoff', 'div.save-row'] }[hash]
|
||||
?? ['section.surface handoff', 'div.save-row']);
|
||||
|
||||
@@ -513,12 +513,7 @@ def seed_workspace_concept(
|
||||
tags = [row[0] for row in conn.execute("SELECT name FROM tags ORDER BY id")]
|
||||
if tuple(counts) != (len(CONCEPT_PROMPTS), CONCEPT_RUN_COUNT, CONCEPT_RUN_COUNT):
|
||||
raise RuntimeError(f"Concept fixture run counts are not real: {tuple(counts)}")
|
||||
from py.api.thumbnail_settings import ThumbnailSettings
|
||||
|
||||
# 150 px previews in the default format (#317 thumbnail settings).
|
||||
api._generate_gallery_thumbnails_sync(
|
||||
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
|
||||
)
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"epoch": epoch,
|
||||
|
||||
@@ -29,13 +29,6 @@ read. These per-server hooks only change what a System test needs on top:
|
||||
touches no file and ends with the library in the default folder.
|
||||
``failing`` is the same library whose move fails verification. Reset
|
||||
restores the real resolver, the real move and "Keep it here".
|
||||
- ``POST /__test__/system/legacy-previews`` ``{count}`` (#317) writes ``count``
|
||||
PNGs into ``output/legacy`` with an older release's preview beside each
|
||||
(``thumbnails/legacy/<name>_thumb.png``, newer than its file): outdated
|
||||
previews for Convert outdated;
|
||||
- ``POST /__test__/system/jxl-encoder`` ``{available}`` (#317) makes the
|
||||
server read as having (``cjxl``) or lacking a JPEG XL encoder without
|
||||
probing; reset restores the thumbnail settings and the real detection.
|
||||
|
||||
Delays use the shared request holds (``request_hold.py``).
|
||||
"""
|
||||
@@ -79,12 +72,7 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
PromptManagerConfig.WORKER_THREADS,
|
||||
GalleryConfig.MONITORING_ENABLED,
|
||||
PromptManagerConfig.THUMBNAIL_FORMAT,
|
||||
PromptManagerConfig.THUMBNAIL_QUALITY,
|
||||
PromptManagerConfig.THUMBNAIL_EDGE,
|
||||
)
|
||||
from py.api import thumbnail_encode
|
||||
|
||||
monitor = getattr(image_monitor, "_monitor_instance", None)
|
||||
failures: dict[str, int] = {}
|
||||
|
||||
@@ -113,11 +101,7 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
PromptManagerConfig.WEBUI_DISPLAY_MODE,
|
||||
PromptManagerConfig.WORKER_THREADS,
|
||||
GalleryConfig.MONITORING_ENABLED,
|
||||
PromptManagerConfig.THUMBNAIL_FORMAT,
|
||||
PromptManagerConfig.THUMBNAIL_QUALITY,
|
||||
PromptManagerConfig.THUMBNAIL_EDGE,
|
||||
) = host_values
|
||||
thumbnail_encode.reset_encoders()
|
||||
image_monitor._monitor_instance = monitor
|
||||
api.__dict__.pop("_library_resolver", None)
|
||||
library_move.move_library = real_move
|
||||
@@ -202,43 +186,6 @@ def register_system_commands(app, routes, api, root: Path):
|
||||
api.invalidate_gallery_cache()
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/system/legacy-previews")
|
||||
async def legacy_previews(request):
|
||||
data = await request.json()
|
||||
count = data.get("count")
|
||||
if (
|
||||
isinstance(count, bool)
|
||||
or not isinstance(count, int)
|
||||
or not 1 <= count <= MAX_UNTHUMBNAILED
|
||||
):
|
||||
raise web.HTTPBadRequest()
|
||||
folder = root / "output" / "legacy"
|
||||
previews = root / "output" / "thumbnails" / "legacy"
|
||||
folder.mkdir(exist_ok=True)
|
||||
previews.mkdir(parents=True, exist_ok=True)
|
||||
names = []
|
||||
for number in range(count):
|
||||
name = f"old_{number + 1:03}"
|
||||
with Image.effect_noise((256, 256), 50) as noise:
|
||||
noise.convert("RGB").save(folder / f"{name}.png")
|
||||
noise.convert("RGB").save(previews / f"{name}_thumb.png")
|
||||
later = (folder / f"{name}.png").stat().st_mtime + 5
|
||||
os.utime(previews / f"{name}_thumb.png", (later, later))
|
||||
names.append(f"legacy/{name}.png")
|
||||
api.invalidate_gallery_cache()
|
||||
return web.json_response({"success": True, "files": names})
|
||||
|
||||
@routes.post("/__test__/system/jxl-encoder")
|
||||
async def jxl_encoder(request):
|
||||
data = await request.json()
|
||||
available = data.get("available")
|
||||
if not isinstance(available, bool):
|
||||
raise web.HTTPBadRequest()
|
||||
# Test-only: stand in the cached detection (no probe).
|
||||
with thumbnail_encode._detect_lock:
|
||||
thumbnail_encode._detected = "cjxl" if available else None
|
||||
return web.json_response({"success": True})
|
||||
|
||||
@routes.post("/__test__/system/monitor")
|
||||
async def fake_monitor(request):
|
||||
data = await request.json()
|
||||
|
||||
@@ -148,12 +148,7 @@ def seed_tagging(api: PromptManagerAPI, root: Path, extra_images: int = 0) -> di
|
||||
image_id = db.link_image_to_prompt(positive_id, str(output / relative))
|
||||
if kind == "linked":
|
||||
db.link_image_to_pair(pair_id, image_id, {"source": "fixture"})
|
||||
from py.api.thumbnail_settings import ThumbnailSettings
|
||||
|
||||
# 150 px previews in the default format (#317 thumbnail settings).
|
||||
api._generate_gallery_thumbnails_sync(
|
||||
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
|
||||
)
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
return {
|
||||
"positive_id": positive_id,
|
||||
|
||||
@@ -164,10 +164,5 @@ def seed_workspace_visual(api: PromptManagerAPI, root: Path) -> None:
|
||||
path = root / directory / category.lower() / filename
|
||||
_write_art(path, index, positive, negatives[offset % len(negatives)])
|
||||
api.db.link_image_to_prompt(prompt_id, str(path))
|
||||
from py.api.thumbnail_settings import ThumbnailSettings
|
||||
|
||||
# 150 px previews in the default format (#317 thumbnail settings).
|
||||
api._generate_gallery_thumbnails_sync(
|
||||
api._get_all_output_dirs(), ThumbnailSettings("webp", 80, 150)
|
||||
)
|
||||
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
|
||||
api.invalidate_gallery_cache()
|
||||
|
||||
@@ -40,22 +40,3 @@ test('stale inspection echoes use the canonical recovery vocabulary and identity
|
||||
}
|
||||
});
|
||||
test('the recorded output inspector cover is its preview, not the original (#249)',()=>{const a=adapter(),target=media('2',1);const record=a.toRecord(envelope(target),target,extractGenerationFacts);assert.deepEqual(record.preview,{url:target.thumbnail_url});assert.notEqual(record.preview.url,record.download.url);});
|
||||
|
||||
// #317 M3 follow-up: Firefox may decode JPEG XL without listing image/jxl in Accept, so the cover URL says so itself.
|
||||
test('the recorded output cover asks for JPEG XL only where this browser decodes it',async()=>{
|
||||
const support=await import('../../web/js/workspace/thumbnail-support.mjs');
|
||||
const a=adapter(),target=media('2',1);
|
||||
const probe=ok=>({createImage:()=>({set src(_){queueMicrotask(()=>ok?this.onload():this.onerror());}})});
|
||||
try {
|
||||
support.resetJxlSupport();
|
||||
assert.equal(a.toRecord(envelope(target),target,extractGenerationFacts).preview.url,target.thumbnail_url,'unknown: no flag');
|
||||
await support.detectJxlSupport(probe(true));
|
||||
const flagged=new URL(a.toRecord(envelope(target),target,extractGenerationFacts).preview.url,'http://localhost/');
|
||||
assert.equal(flagged.searchParams.get('jxl'),'1');
|
||||
assert.equal(flagged.searchParams.getAll('jxl').length,1);
|
||||
assert.equal(`${flagged.pathname}?${new URLSearchParams([...flagged.searchParams].filter(([k])=>k!=='jxl'))}`,target.thumbnail_url);
|
||||
support.resetJxlSupport();
|
||||
await support.detectJxlSupport(probe(false));
|
||||
assert.equal(a.toRecord(envelope(target),target,extractGenerationFacts).preview.url,target.thumbnail_url,'no support: no flag');
|
||||
} finally {support.resetJxlSupport();}
|
||||
});
|
||||
|
||||
@@ -1,71 +0,0 @@
|
||||
// #317: a card's media is the listing's thumbnail (WebP, JPEG XL or JPEG), never something the browser cannot show:
|
||||
// a JPEG XL thumbnail in a browser without JPEG XL support loads the original, and a thumbnail that fails to load falls
|
||||
// back to the original. A video whose preview fails still swaps back to its own element (#249).
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
import { mountedDom } from './helpers/mounted-dom.mjs';
|
||||
|
||||
const support = await import('../../web/js/workspace/thumbnail-support.mjs');
|
||||
const { cardMedia } = await import('../../web/js/workspace/image-card.mjs');
|
||||
|
||||
function withDom(run) {
|
||||
const dom = mountedDom();
|
||||
dom.win.ApiClient = { resolveUrl: url => `http://host${url}` };
|
||||
try { return run(dom); } finally { support.resetJxlSupport(); dom.restore(); }
|
||||
}
|
||||
|
||||
const image = (thumbnail, extra = {}) => ({ root_index: 0, path: 'a.png', filename: 'a.png', url: '/serve/a.png?root=0',
|
||||
thumbnail_url: thumbnail, ...extra });
|
||||
|
||||
test('a WebP thumbnail is the card media and falls back to the original when it fails', () => withDom(() => {
|
||||
const node = cardMedia(image('/serve/thumbnails/a_thumb.webp?root=0'));
|
||||
assert.equal(node.tagName, 'img');
|
||||
assert.equal(node.src, 'http://host/serve/thumbnails/a_thumb.webp?root=0');
|
||||
node.fire('error');
|
||||
assert.equal(node.src, 'http://host/serve/a.png?root=0');
|
||||
}));
|
||||
|
||||
test('a JPEG XL thumbnail loads the original where this browser cannot show JPEG XL', async () => {
|
||||
support.resetJxlSupport();
|
||||
await support.detectJxlSupport({ createImage: () => ({ set src(_) { queueMicrotask(() => this.onerror()); } }) });
|
||||
const dom = mountedDom();
|
||||
dom.win.ApiClient = { resolveUrl: url => `http://host${url}` };
|
||||
try {
|
||||
assert.equal(support.jxlSupport(), false);
|
||||
const node = cardMedia(image('/serve/thumbnails/a_thumb.jxl?root=0'));
|
||||
assert.equal(node.src, 'http://host/serve/a.png?root=0');
|
||||
} finally { support.resetJxlSupport(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('a JPEG XL thumbnail is used where this browser shows JPEG XL', async () => {
|
||||
await support.detectJxlSupport({ createImage: () => ({ set src(_) { queueMicrotask(() => this.onload()); } }) });
|
||||
const dom = mountedDom();
|
||||
dom.win.ApiClient = { resolveUrl: url => `http://host${url}` };
|
||||
try {
|
||||
const node = cardMedia(image('/serve/thumbnails/a_thumb.jxl?root=0'));
|
||||
assert.equal(node.src, 'http://host/serve/thumbnails/a_thumb.jxl?root=0');
|
||||
} finally { support.resetJxlSupport(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('a video whose preview fails still swaps back to its own element', () => withDom(dom => {
|
||||
const holder = new dom.Node('div');
|
||||
const node = cardMedia(image('/serve/thumbnails/c_thumb.webp?root=0', { path: 'c.mp4', filename: 'c.mp4', url: '/serve/c.mp4?root=0', is_video: true }));
|
||||
holder.append(node);
|
||||
node.replaceWith = replacement => { holder.children = [replacement]; };
|
||||
node.fire('error');
|
||||
assert.equal(holder.children[0].tagName, 'video');
|
||||
assert.equal(holder.children[0].src, 'http://host/serve/c.mp4?root=0');
|
||||
}));
|
||||
|
||||
test('a video with a JPEG XL thumbnail keeps its own element where JPEG XL cannot be shown (review M2)', async () => {
|
||||
support.resetJxlSupport();
|
||||
await support.detectJxlSupport({ createImage: () => ({ set src(_) { queueMicrotask(() => this.onerror()); } }) });
|
||||
const dom = mountedDom();
|
||||
dom.win.ApiClient = { resolveUrl: url => `http://host${url}` };
|
||||
try {
|
||||
const node = cardMedia(image('/serve/thumbnails/c_thumb.jxl?root=0',
|
||||
{ path: 'c.mp4', filename: 'c.mp4', url: '/serve/c.mp4?root=0', is_video: true }));
|
||||
assert.equal(node.tagName, 'video');
|
||||
assert.equal(node.src, 'http://host/serve/c.mp4?root=0');
|
||||
} finally { support.resetJxlSupport(); dom.restore(); }
|
||||
});
|
||||
@@ -913,26 +913,6 @@ test('record carries the listing thumbnail as its preview, never another file (#
|
||||
assert.deepEqual(toRecord(data, video, extractGenerationFacts).preview, { url: video.thumbnail_url });
|
||||
});
|
||||
|
||||
test('the preview may be any thumbnail format the file owns (#317)', () => {
|
||||
const toRecord = contract('toInspectionRecord'), entry = image();
|
||||
const base = '/prompt_manager/images/serve/thumbnails/folder/a%20%23%25_thumb';
|
||||
for (const extension of ['.webp', '.jxl', '.jpg', '.png']) {
|
||||
const thumbnail_url = `${base}${extension}?v=3&root=0&root_scope=${scopeA}`;
|
||||
assert.deepEqual(toRecord(envelope(entry), { ...entry, thumbnail_url }, extractGenerationFacts).preview, { url: thumbnail_url }, extension);
|
||||
}
|
||||
// Not a thumbnail format, upper case, no extension, or a legacy name another file owns: no preview.
|
||||
for (const extension of ['.txt', '.WEBP', '', '.gif', '.jpeg', '.webp.png']) {
|
||||
const thumbnail_url = `${base}${extension}?root=0&root_scope=${scopeA}`;
|
||||
assert.equal(toRecord(envelope(entry), { ...entry, thumbnail_url }, extractGenerationFacts).preview, null, extension);
|
||||
}
|
||||
const video = { root_index: 0, path: 'clip.MP4', filename: 'clip.MP4', url: `/prompt_manager/images/serve/clip.MP4?root=0&root_scope=${scopeA}`, root_scope: scopeA };
|
||||
const data = envelope(video); data.file = { ...data.file, media_type: 'video', extension: '.mp4', format: 'MP4' };
|
||||
for (const [extension, owned] of [['.webp', true], ['.jxl', true], ['.jpg', true], ['.png', false]]) {
|
||||
const thumbnail_url = `/prompt_manager/images/serve/thumbnails/clip_thumb${extension}?root=0&root_scope=${scopeA}`;
|
||||
assert.deepEqual(toRecord(data, { ...video, thumbnail_url }, extractGenerationFacts).preview, owned ? { url: thumbnail_url } : null, extension);
|
||||
}
|
||||
});
|
||||
|
||||
test("names the server quotes (parentheses, '!*') still match their original and preview (#249 M11)", () => {
|
||||
const toRecord = contract('toInspectionRecord');
|
||||
const entry = { root_index: 0, path: "folder/a (1)!'*.png", filename: "a (1)!'*.png", root_scope: scopeA,
|
||||
|
||||
@@ -324,29 +324,3 @@ test('server adapters still cache-bust the original on open and retry', async ()
|
||||
} finally { view.dispose(); controller.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
test('the viewer and its download always use the original, never the thumbnail in any format (#317)', async () => {
|
||||
const {rootedFileAdapter, createInspectionController} = await import('../../web/js/workspace/inspection-state.mjs');
|
||||
const scope = 'a'.repeat(64);
|
||||
for (const extension of ['.webp', '.jxl', '.jpg', '.png']) {
|
||||
const item = {root_index:0,path:'folder/a.png',filename:'a.png',root_scope:scope,is_video:false,
|
||||
url:`/prompt_manager/images/serve/folder/a.png?root=0&root_scope=${scope}`,
|
||||
thumbnail_url:`/prompt_manager/images/serve/thumbnails/folder/a_thumb${extension}?v=3&root=0&root_scope=${scope}`};
|
||||
const f = mountedDom(), restoreGlobals = withViewerGlobals(f), parentScope = createScope(), paths = [];
|
||||
const controller = createInspectionController({adapter:rootedFileAdapter,api:{get(url) {paths.push(url);return new Promise(() => {});}}});
|
||||
const view = module.createMediaViewer({container:f.container,parentScope,inspectionController:controller,adapter:rootedFileAdapter,
|
||||
onSelect:entry => controller.select(entry),sender:{open(){}}});
|
||||
try {
|
||||
view.open({items:[item],selectedKey:'0:folder/a.png'});
|
||||
// The metadata read is the original's inspection, never a path under thumbnails/.
|
||||
assert.deepEqual(paths, [`/prompt_manager/images/inspect/folder/a.png?root=0&root_scope=${scope}`], extension);
|
||||
const media = f.walk(f.container,n => n.tagName === 'img');
|
||||
assert.equal(media.length, 1, extension);
|
||||
const url = new URL(media[0].src);url.searchParams.delete('_pm_viewer');
|
||||
assert.equal(`${url.pathname}${url.search}`, item.url, extension);
|
||||
assert.doesNotMatch(media[0].src, /thumbnails/, extension);
|
||||
const download = f.walk(f.container,n => n.attributes['aria-label'] === 'Download original')[0];
|
||||
if (download) assert.equal(download.href, item.url, extension);
|
||||
} finally { view.dispose(); controller.dispose(); parentScope.dispose(); restoreGlobals(); f.restore(); }
|
||||
}
|
||||
});
|
||||
|
||||
@@ -59,18 +59,6 @@ test('the thumbnail row is its own read of the thumbnail status', async () => {
|
||||
assert.equal(thumbnailTitle({ pending: 2 }, { effective: 1 }), '2 missing thumbnails');
|
||||
});
|
||||
|
||||
test('the thumbnail row says N missing · M outdated · K failed, and offers Convert outdated only for outdated ones (#317)', async () => {
|
||||
const { thumbnailTitle, convertOffered } = await overviewModule;
|
||||
assert.equal(thumbnailTitle({ pending: 3, outdated: 17500, failed: 2 }), '3 missing thumbnails · 17,500 outdated · 2 failed');
|
||||
assert.equal(thumbnailTitle({ pending: 0, outdated: 1, failed: 0 }), 'No missing thumbnails · 1 outdated');
|
||||
assert.equal(thumbnailTitle({ pending: 0, outdated: 0, failed: 4 }), 'No missing thumbnails · 4 failed');
|
||||
assert.equal(thumbnailTitle({ pending: 1, outdated: 'x' }), '1 missing thumbnail');
|
||||
assert.equal(convertOffered({ pending: 0, outdated: 5 }), true);
|
||||
for (const status of [undefined, null, { pending: 0 }, { outdated: 0 }, { outdated: -1 }, { outdated: '5' }]) {
|
||||
assert.equal(convertOffered(status), false, JSON.stringify(status));
|
||||
}
|
||||
});
|
||||
|
||||
test('an integrity count SQLite stopped at reads as a lower bound', async () => {
|
||||
const { checkChip } = await overviewModule;
|
||||
assert.equal(checkChip({ id: 'integrity', status: 'issues', count: 100, capped: true }), '100+ problems found');
|
||||
|
||||
@@ -1,195 +0,0 @@
|
||||
// System › Appearance › Thumbnails (#317): the server's thumbnail format, quality and size limit as host rows of the
|
||||
// one Save row, JPEG XL offered only with an encoder (D130), a note where this browser cannot show JPEG XL, and the
|
||||
// explicit Convert outdated operation (never automatic).
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const thumbnailsModule = import('../../web/js/workspace/system-thumbnails.mjs');
|
||||
const stateModule = import('../../web/js/workspace/system-state.mjs');
|
||||
const coreModule = import('../../web/js/shared/toolkit/core.js');
|
||||
|
||||
/** A minimal DOM whose elements count their live listeners (a listener's signal aborts it, as in a browser). */
|
||||
function fakeDOM() {
|
||||
const previous = global.document;
|
||||
const elements = [];
|
||||
class Element {
|
||||
constructor(tag) {
|
||||
this.tagName = tag.toUpperCase();
|
||||
this.children = [];
|
||||
this.attributes = {};
|
||||
this.listeners = [];
|
||||
this.dataset = {};
|
||||
this.className = '';
|
||||
this.id = '';
|
||||
this.value = '';
|
||||
this._text = '';
|
||||
elements.push(this);
|
||||
}
|
||||
get textContent() { return this._text + this.children.map(child => child.textContent).join(''); }
|
||||
set textContent(text) { this.children = []; this._text = String(text); }
|
||||
append(...nodes) {
|
||||
for (const node of nodes) {
|
||||
const child = typeof node === 'string' ? Object.assign(new Element('#text'), { _text: node }) : node;
|
||||
child.parentNode = this;
|
||||
this.children.push(child);
|
||||
}
|
||||
}
|
||||
remove() { if (this.parentNode) this.parentNode.children = this.parentNode.children.filter(child => child !== this); this.parentNode = null; }
|
||||
replaceChildren(...nodes) { this.children = []; this._text = ''; this.append(...nodes); }
|
||||
setAttribute(name, value) { this.attributes[name] = String(value); }
|
||||
getAttribute(name) { return Object.hasOwn(this.attributes, name) ? this.attributes[name] : null; }
|
||||
removeAttribute(name) { delete this.attributes[name]; }
|
||||
addEventListener(type, callback, options = {}) {
|
||||
if (options.signal?.aborted) return;
|
||||
const entry = { type, callback };
|
||||
this.listeners.push(entry);
|
||||
options.signal?.addEventListener('abort', () => { this.listeners = this.listeners.filter(item => item !== entry); });
|
||||
}
|
||||
removeEventListener(type, callback) { this.listeners = this.listeners.filter(item => item.type !== type || item.callback !== callback); }
|
||||
dispatch(type) { for (const { callback } of this.listeners.filter(item => item.type === type)) callback({ type, target: this }); }
|
||||
click() { this.dispatch('click'); }
|
||||
}
|
||||
global.document = {
|
||||
createElement: tag => new Element(tag),
|
||||
createTextNode: text => Object.assign(new Element('#text'), { _text: String(text) }),
|
||||
};
|
||||
const walk = (node, found = []) => { found.push(node); node.children.forEach(child => walk(child, found)); return found; };
|
||||
return {
|
||||
listenerCount: () => elements.reduce((sum, element) => sum + element.listeners.length, 0),
|
||||
find: (root, predicate) => walk(root).find(predicate),
|
||||
tags: node => node.children.map(child => child.tagName.toLowerCase()),
|
||||
restore() { global.document = previous; },
|
||||
};
|
||||
}
|
||||
|
||||
const HOST = { thumbnail_format: 'webp', thumbnail_quality: 80, thumbnail_edge: 512,
|
||||
thumbnail_encoders: { webp: { available: true }, jpeg: { available: true }, jxl: { available: true, encoder: 'cjxl', reason: null } } };
|
||||
const NO_JXL = { webp: { available: true }, jpeg: { available: true },
|
||||
jxl: { available: false, encoder: null, reason: 'No JPEG XL encoder found. Install pillow-jxl-plugin.' } };
|
||||
|
||||
async function mounted({ host = HOST, browserJxl = true, hostFails = false, writeHost = async () => ({ success: true, persisted: true, revision: 'sha256:two' }) } = {}) {
|
||||
const { mountThumbnailSettings } = await thumbnailsModule;
|
||||
const { createSystemDrafts } = await stateModule;
|
||||
const { createScope } = await coreModule;
|
||||
const dom = fakeDOM();
|
||||
let hostValues = null;
|
||||
const drafts = createSystemDrafts({
|
||||
readBrowser: () => ({}), readRuntime: () => ({}),
|
||||
readHost: async () => { if (hostFails) throw new Error('offline'); return { values: hostValues, revision: 'sha256:one' }; },
|
||||
writeBrowser: async () => ({ persisted: true }), writeHost, writeRuntime: async () => ({}),
|
||||
});
|
||||
const scope = createScope();
|
||||
const reviews = [];
|
||||
let answer;
|
||||
const support = () => new Promise(resolve => { answer = resolve; });
|
||||
const surface = mountThumbnailSettings(null, { scope, drafts, api: {}, activity: {}, support,
|
||||
openReview: options => reviews.push(options) });
|
||||
const byId = id => dom.find(surface, node => node.dataset?.setting === id);
|
||||
const notes = () => dom.find(surface, node => node.dataset?.setting === 'thumbnail-format').parentNode.children[0].children.slice(2)
|
||||
.map(node => node.textContent);
|
||||
return { dom, drafts, scope, surface, byId, reviews, notes, browser: () => answer(browserJxl),
|
||||
loadHost: async () => { hostValues = host; await drafts.load('host'); } };
|
||||
}
|
||||
|
||||
test('a Thumbnails surface of three host rows and the Convert outdated operation', async () => {
|
||||
const { dom, surface, scope } = await mounted();
|
||||
try {
|
||||
assert.equal(surface.className, 'surface');
|
||||
assert.equal(surface.children[0].textContent, 'Thumbnails');
|
||||
const rows = surface.children.slice(2);
|
||||
assert.deepEqual(rows.map(row => row.children[0].children[0].textContent),
|
||||
['Thumbnail format', 'Thumbnail quality', 'Thumbnail size limit', 'Convert outdated thumbnails']);
|
||||
assert.deepEqual(rows.map(row => row.className), ['setting-row', 'setting-row', 'setting-row', 'operation']);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
test('the rows edit the server settings once they were read', async () => {
|
||||
const { FORMAT_OPTIONS } = await thumbnailsModule;
|
||||
assert.deepEqual(FORMAT_OPTIONS, [['webp', 'WebP'], ['jxl', 'JPEG XL'], ['jpeg', 'JPEG']]);
|
||||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||||
try {
|
||||
for (const id of ['thumbnail-format', 'thumbnail-quality', 'thumbnail-edge']) assert.equal(byId(id).disabled, true, id);
|
||||
await loadHost();
|
||||
assert.equal(byId('thumbnail-format').value, 'webp');
|
||||
assert.equal(byId('thumbnail-quality').value, '80');
|
||||
assert.equal(byId('thumbnail-edge').value, '512');
|
||||
assert.equal(byId('thumbnail-format').disabled, false);
|
||||
byId('thumbnail-format').value = 'jpeg'; byId('thumbnail-format').dispatch('change');
|
||||
byId('thumbnail-quality').value = '60'; byId('thumbnail-quality').dispatch('change');
|
||||
byId('thumbnail-edge').value = '300'; byId('thumbnail-edge').dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), { thumbnail_format: 'jpeg', thumbnail_quality: 60, thumbnail_edge: 300 });
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('quality takes 1–100 and the size limit 128–2048 whole numbers only', async () => {
|
||||
const { wholeNumber, QUALITY_LIMIT, EDGE_LIMIT } = await thumbnailsModule;
|
||||
assert.deepEqual(['1', ' 100 ', '0', '101', '80.5', '', 'x'].map(text => wholeNumber(text, QUALITY_LIMIT)), [1, 100, null, null, null, null, null]);
|
||||
assert.deepEqual(['128', '2048', '127', '2049', '1e3'].map(text => wholeNumber(text, EDGE_LIMIT)), [128, 2048, null, null, null]);
|
||||
const { dom, drafts, scope, byId, loadHost } = await mounted();
|
||||
try {
|
||||
await loadHost();
|
||||
byId('thumbnail-quality').value = '0'; byId('thumbnail-quality').dispatch('change');
|
||||
byId('thumbnail-edge').value = '4096'; byId('thumbnail-edge').dispatch('change');
|
||||
assert.deepEqual(drafts.changes('host'), {});
|
||||
assert.deepEqual([...drafts.failed('host')].sort(), ['thumbnail_edge', 'thumbnail_quality']);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('JPEG XL is disabled with the server reason when no encoder is installed (D130)', async () => {
|
||||
const { dom, scope, byId, loadHost, notes } = await mounted({ host: { ...HOST, thumbnail_encoders: NO_JXL } });
|
||||
try {
|
||||
await loadHost();
|
||||
const jxl = byId('thumbnail-format').children.find(option => option.value === 'jxl');
|
||||
assert.equal(jxl.disabled, true);
|
||||
assert.deepEqual(notes(), [NO_JXL.jxl.reason]);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
const ready = await mounted();
|
||||
try {
|
||||
await ready.loadHost();
|
||||
assert.equal(ready.byId('thumbnail-format').children.find(option => option.value === 'jxl').disabled, false);
|
||||
assert.deepEqual(ready.notes(), []);
|
||||
} finally { ready.scope.dispose(); ready.dom.restore(); }
|
||||
});
|
||||
|
||||
test('a browser that cannot show JPEG XL says the originals are shown instead', async () => {
|
||||
const { BROWSER_NO_JXL } = await thumbnailsModule;
|
||||
const { dom, scope, loadHost, notes, browser } = await mounted({ browserJxl: false });
|
||||
try {
|
||||
await loadHost();
|
||||
browser();
|
||||
await new Promise(resolve => setImmediate(resolve));
|
||||
assert.deepEqual(notes(), [BROWSER_NO_JXL]);
|
||||
} finally { scope.dispose(); dom.restore(); }
|
||||
});
|
||||
|
||||
test('Convert outdated opens Thumbnail maintenance on its convert scope; a format save says previews keep theirs', async () => {
|
||||
const { FORMAT_SAVED, CONVERT_DESCRIPTION } = await thumbnailsModule;
|
||||
const { dom, drafts, scope, surface, byId, reviews, loadHost } = await mounted();
|
||||
try {
|
||||
const operation = surface.children.at(-1);
|
||||
assert.equal(operation.children[0].children[1].textContent, CONVERT_DESCRIPTION);
|
||||
dom.find(operation, node => node.tagName === 'BUTTON').click();
|
||||
assert.equal(reviews.length, 1);
|
||||
assert.equal(reviews[0].mode, 'convert');
|
||||
await loadHost();
|
||||
byId('thumbnail-quality').value = '70'; byId('thumbnail-quality').dispatch('change');
|
||||
await drafts.save();
|
||||
assert.equal(operation.children[0].children[1].textContent, CONVERT_DESCRIPTION, 'quality alone converts nothing');
|
||||
byId('thumbnail-format').value = 'jpeg'; byId('thumbnail-format').dispatch('change');
|
||||
await drafts.save();
|
||||
assert.equal(operation.children[0].children[1].textContent, FORMAT_SAVED);
|
||||
assert.equal(reviews.length, 1, 'nothing starts by itself');
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
|
||||
test('rows whose server settings could not be read say so, like Appearance’s host rows', async () => {
|
||||
const { HOST_UNAVAILABLE } = await import('../../web/js/workspace/system-appearance.mjs');
|
||||
const { dom, drafts, scope, surface, byId } = await mounted({ hostFails: true });
|
||||
const notes = () => surface.children.slice(2, 5).map(row => row.children[0].children.slice(2).map(note => note.textContent));
|
||||
try {
|
||||
assert.deepEqual(notes(), [[], [], []]);
|
||||
assert.equal(await drafts.reloadHost(), false);
|
||||
assert.deepEqual(notes(), [[HOST_UNAVAILABLE], [HOST_UNAVAILABLE], [HOST_UNAVAILABLE]]);
|
||||
for (const id of ['thumbnail-format', 'thumbnail-quality', 'thumbnail-edge']) assert.equal(byId(id).disabled, true, id);
|
||||
} finally { scope.dispose(); assert.equal(dom.listenerCount(), 0); dom.restore(); }
|
||||
});
|
||||
@@ -1,36 +1,35 @@
|
||||
// System › thumbnail review (#249 System S5 Task 14): the concept's "Thumbnail maintenance" dialog (app.js 1433–1437,
|
||||
// `thumbnail-review`) on the thumbnail job (S5 Task 12). Pure parts: the scopes the job takes (Convert outdated, #317),
|
||||
// the D131 settings line that replaced its quality and size fields, the D99 plan line and the start outcome.
|
||||
// `thumbnail-review`) on the thumbnail job (S5 Task 12). Pure parts: the option values the job takes, the preview size
|
||||
// check, the Image loading quality preselection (Q3), the D99 plan line and the start outcome.
|
||||
const test = require('node:test');
|
||||
const assert = require('node:assert/strict');
|
||||
|
||||
const reviewModule = import('../../web/js/workspace/thumbnail-review.mjs');
|
||||
|
||||
test('the scopes map to the job modes; Convert outdated is the explicit third (#317)', async () => {
|
||||
const { SCOPE_OPTIONS } = await reviewModule;
|
||||
assert.deepEqual(SCOPE_OPTIONS, [['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)'],
|
||||
['convert', 'Convert outdated (removes older formats)']]);
|
||||
test('the concept options map to the job values in the concept order', async () => {
|
||||
const { SCOPE_OPTIONS, QUALITY_OPTIONS, SIZE_LIMIT } = await reviewModule;
|
||||
assert.deepEqual(SCOPE_OPTIONS, [['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)']]);
|
||||
assert.deepEqual(QUALITY_OPTIONS, [['medium', 'Medium · optimized'], ['high', 'High · original'], ['low', 'Low · fastest']]);
|
||||
assert.deepEqual(SIZE_LIMIT, { min: 128, max: 2048, value: 512 });
|
||||
});
|
||||
|
||||
test('D131: the dialog names the server thumbnail settings instead of its own quality and size fields', async () => {
|
||||
const { settingsText, SETTINGS_UNKNOWN } = await reviewModule;
|
||||
assert.equal(settingsText({ format: 'webp', quality: 80, size: 512 }),
|
||||
'Previews are written as WebP · quality 80 · 512 px. Change this in System › Appearance.');
|
||||
assert.equal(settingsText({ format: 'jxl', quality: 90, size: 1024 }),
|
||||
'Previews are written as JPEG XL · quality 90 · 1,024 px. Change this in System › Appearance.');
|
||||
assert.equal(settingsText({ format: 'jpeg', quality: 5, size: 128 }),
|
||||
'Previews are written as JPEG · quality 5 · 128 px. Change this in System › Appearance.');
|
||||
for (const plan of [null, {}, { format: 'png', quality: 80, size: 512 }, { format: 'webp', quality: '80', size: 512 }]) {
|
||||
assert.equal(settingsText(plan), SETTINGS_UNKNOWN);
|
||||
test('size outside 128–2048 reports validity: only whole pixels in range are a size', async () => {
|
||||
const { previewSize } = await reviewModule;
|
||||
assert.equal(previewSize('512'), 512);
|
||||
assert.equal(previewSize('128'), 128);
|
||||
assert.equal(previewSize('2048'), 2048);
|
||||
for (const text of ['', ' ', '127', '2049', '4096', '512.5', '-512', 'abc', '1e3', '0x200']) {
|
||||
assert.equal(previewSize(text), null, text);
|
||||
}
|
||||
});
|
||||
|
||||
test('Convert outdated says how many previews in an older format it converts, past the per-batch cap too (#317)', async () => {
|
||||
const { planText } = await reviewModule;
|
||||
assert.equal(planText({ mode: 'convert', targets: 17500, fresh: 2000, outdated: 17500, remaining: 7500 }),
|
||||
'25,000 previews in an older format to convert.');
|
||||
assert.equal(planText({ mode: 'convert', targets: 1, fresh: 0, outdated: 1, remaining: 0 }), '1 preview in an older format to convert.');
|
||||
assert.equal(planText({ mode: 'convert', targets: 0, fresh: 9, outdated: 0, remaining: 0 }), 'No previews in an older format.');
|
||||
test('Image loading quality preselects the dialog quality (Q3); anything else is Medium', async () => {
|
||||
const { preselectedQuality } = await reviewModule;
|
||||
assert.equal(preselectedQuality('low'), 'low');
|
||||
assert.equal(preselectedQuality('high'), 'high');
|
||||
assert.equal(preselectedQuality('medium'), 'medium');
|
||||
assert.equal(preselectedQuality(undefined), 'medium');
|
||||
assert.equal(preselectedQuality('original'), 'medium');
|
||||
});
|
||||
|
||||
test('the plan line counts what a missing-only run generates and what is already up to date (D99)', async () => {
|
||||
@@ -39,7 +38,7 @@ test('the plan line counts what a missing-only run generates and what is already
|
||||
assert.equal(planText({ mode: 'missing', targets: 1, fresh: 1, remaining: 0 }), '1 preview to generate · 1 already up to date.');
|
||||
assert.equal(planText({ mode: 'missing', targets: 0, fresh: 0, remaining: 0 }), '0 previews to generate · 0 already up to date.');
|
||||
assert.equal(planText({ mode: 'missing', targets: 10000, fresh: 5, remaining: 2500 }),
|
||||
'12,500 previews to generate · 5 already up to date.');
|
||||
'10,000 previews to generate · 5 already up to date · 2,500 more on the next run.');
|
||||
});
|
||||
|
||||
test('Rebuild all says every preview is regenerated (D99)', async () => {
|
||||
@@ -47,7 +46,7 @@ test('Rebuild all says every preview is regenerated (D99)', async () => {
|
||||
assert.equal(planText({ mode: 'rebuild', targets: 42, fresh: 30, remaining: 0 }), 'Every preview is regenerated: 42 files.');
|
||||
assert.equal(planText({ mode: 'rebuild', targets: 1, fresh: 1, remaining: 0 }), 'Every preview is regenerated: 1 file.');
|
||||
assert.equal(planText({ mode: 'rebuild', targets: 10000, fresh: 9000, remaining: 3 }),
|
||||
'Every preview is regenerated: 10,003 files.');
|
||||
'Every preview is regenerated: 10,000 files · 3 more on the next run.');
|
||||
});
|
||||
|
||||
test('an answer without counts is a failed plan, never zeros', async () => {
|
||||
@@ -65,9 +64,9 @@ test('Start starts the thumbnail job with the chosen options and opens it in Act
|
||||
const calls = [];
|
||||
const navigated = [];
|
||||
const store = { start: async (kind, body) => { calls.push([kind, body]); return { job: { id: 'a'.repeat(32) } }; } };
|
||||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'convert' },
|
||||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'rebuild', size: 1024, quality: 'high' },
|
||||
navigate: hash => navigated.push(hash), notify: () => assert.fail('no toast of its own') });
|
||||
assert.deepEqual(calls, [['thumbnails', { mode: 'convert' }]]);
|
||||
assert.deepEqual(calls, [['thumbnails', { mode: 'rebuild', size: 1024, quality: 'high' }]]);
|
||||
assert.deepEqual(navigated, [`#/activity/${'a'.repeat(32)}`]);
|
||||
});
|
||||
|
||||
@@ -75,7 +74,7 @@ test('a refused start shows the busy link toast and stays', async () => {
|
||||
const { startThumbnails } = await reviewModule;
|
||||
const toasts = [];
|
||||
const store = { start: async () => ({ busy: { id: 'b'.repeat(32), title: 'Scan for metadata' } }) };
|
||||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing' },
|
||||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing', size: 512, quality: 'medium' },
|
||||
navigate: () => assert.fail('stays'), notify: message => toasts.push(message) });
|
||||
assert.deepEqual(toasts, [['Another job is running: ', { text: 'Scan for metadata', href: `#/activity/${'b'.repeat(32)}` }, '.']]);
|
||||
});
|
||||
@@ -84,11 +83,11 @@ test('a failed start says so; a start after the section left says nothing', asyn
|
||||
const { startThumbnails, START_FAILED } = await reviewModule;
|
||||
const toasts = [];
|
||||
const store = { start: async () => { throw new Error('offline'); } };
|
||||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing' },
|
||||
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing', size: 512, quality: 'medium' },
|
||||
navigate: () => assert.fail('stays'), notify: message => toasts.push(message) });
|
||||
assert.deepEqual(toasts, [START_FAILED]);
|
||||
const gone = new AbortController();
|
||||
gone.abort();
|
||||
await startThumbnails({ store, scope: { signal: gone.signal }, options: { mode: 'missing' },
|
||||
await startThumbnails({ store, scope: { signal: gone.signal }, options: { mode: 'missing', size: 512, quality: 'medium' },
|
||||
navigate: () => assert.fail('stays'), notify: () => assert.fail('silent') });
|
||||
});
|
||||
|
||||
@@ -1,101 +0,0 @@
|
||||
// #317: JPEG XL thumbnails in browsers that cannot decode them. The workspace decodes a tiny embedded JPEG XL once;
|
||||
// where it fails, cards and the inspector cover use the original instead, and any thumbnail <img> that fails to load
|
||||
// falls back to the original. A thumbnail is only a placeholder: the original is what every other view uses.
|
||||
import test from 'node:test';
|
||||
import assert from 'node:assert/strict';
|
||||
|
||||
const support = await import('../../web/js/workspace/thumbnail-support.mjs');
|
||||
|
||||
/** A fake Image whose decode answers `ok` once its src is set. */
|
||||
function fakeImage(ok, log) {
|
||||
return () => {
|
||||
const image = {};
|
||||
Object.defineProperty(image, 'src', {
|
||||
set(value) { log.push(value); queueMicrotask(() => (ok ? image.onload?.() : image.onerror?.())); },
|
||||
});
|
||||
return image;
|
||||
};
|
||||
}
|
||||
|
||||
test('a .jxl thumbnail URL is recognised by its path, never its query', () => {
|
||||
assert.equal(support.isJxlUrl('/prompt_manager/images/serve/thumbnails/a_thumb.jxl?v=1&root=0'), true);
|
||||
assert.equal(support.isJxlUrl('/prompt_manager/images/serve/thumbnails/a_thumb.JXL'), true);
|
||||
assert.equal(support.isJxlUrl('/prompt_manager/images/serve/thumbnails/a_thumb.webp?x=.jxl'), false);
|
||||
assert.equal(support.isJxlUrl('/x/a.jxl#frag'), true);
|
||||
for (const value of [null, undefined, '', 42]) assert.equal(support.isJxlUrl(value), false);
|
||||
});
|
||||
|
||||
test('decode support is probed once with the embedded image and remembered', async () => {
|
||||
support.resetJxlSupport();
|
||||
const log = [];
|
||||
assert.equal(support.jxlSupport(), undefined, 'unknown before the probe');
|
||||
const first = support.detectJxlSupport({ createImage: fakeImage(true, log) });
|
||||
const again = support.detectJxlSupport({ createImage: fakeImage(false, log) });
|
||||
assert.equal(await first, true);
|
||||
assert.equal(await again, true);
|
||||
assert.equal(log.length, 1, 'one probe');
|
||||
assert.match(log[0], /^data:image\/jxl;base64,\/woA/);
|
||||
assert.equal(support.jxlSupport(), true);
|
||||
support.resetJxlSupport();
|
||||
assert.equal(await support.detectJxlSupport({ createImage: fakeImage(false, log) }), false);
|
||||
assert.equal(support.jxlSupport(), false);
|
||||
support.resetJxlSupport();
|
||||
assert.equal(await support.detectJxlSupport({ createImage: () => { throw new Error('no Image'); } }), false);
|
||||
support.resetJxlSupport();
|
||||
});
|
||||
|
||||
test('cards use the original for a JPEG XL thumbnail this browser cannot show', () => {
|
||||
const image = { url: '/serve/a.png?root=0', thumbnail_url: '/serve/thumbnails/a_thumb.jxl?root=0' };
|
||||
const webp = { ...image, thumbnail_url: '/serve/thumbnails/a_thumb.webp?root=0' };
|
||||
assert.equal(support.thumbnailSource(image, false), image.url);
|
||||
assert.equal(support.thumbnailSource(image, true), image.thumbnail_url);
|
||||
assert.equal(support.thumbnailSource(image, undefined), image.thumbnail_url, 'unknown: try it, the error falls back');
|
||||
assert.equal(support.thumbnailSource(webp, false), webp.thumbnail_url);
|
||||
assert.equal(support.thumbnailSource({ url: image.url }, false), image.url);
|
||||
assert.equal(support.thumbnailSource({ url: image.url, thumbnail_url: null }, true), image.url);
|
||||
});
|
||||
|
||||
test('a thumbnail that fails to load falls back to the original once', () => {
|
||||
const listeners = {};
|
||||
const node = { src: '/serve/thumbnails/a_thumb.jxl',
|
||||
addEventListener(type, handler, options) { listeners[type] = { handler, options }; } };
|
||||
support.fallBackToOriginal(node, '/serve/a.png');
|
||||
assert.equal(listeners.error.options.once, true);
|
||||
listeners.error.handler();
|
||||
assert.equal(node.src, '/serve/a.png');
|
||||
const original = { src: '/serve/a.png', addEventListener: () => assert.fail('no listener for the original itself') };
|
||||
support.fallBackToOriginal(original, '/serve/a.png');
|
||||
const listen = [];
|
||||
const scoped = { src: '/serve/thumbnails/b_thumb.webp', isConnected: true };
|
||||
support.fallBackToOriginal(scoped, '/serve/b.png', (target, type, handler, options) => listen.push([target, type, handler, options]));
|
||||
assert.equal(listen[0][1], 'error');
|
||||
listen[0][2]();
|
||||
assert.equal(scoped.src, '/serve/b.png');
|
||||
});
|
||||
|
||||
test('a video card shows its thumbnail only when this browser can show it (review M2)', () => {
|
||||
const video = { is_video: true, url: '/serve/c.mp4?root=0', thumbnail_url: '/serve/thumbnails/c_thumb.jxl?root=0' };
|
||||
assert.equal(support.showsThumbnail(video, false), false, 'JPEG XL unsupported: the video keeps its own element');
|
||||
assert.equal(support.showsThumbnail(video, true), true);
|
||||
assert.equal(support.showsThumbnail(video, undefined), true, 'unknown: try it, the error swaps');
|
||||
assert.equal(support.showsThumbnail({ ...video, thumbnail_url: '/serve/thumbnails/c_thumb.webp' }, false), true);
|
||||
assert.equal(support.showsThumbnail({ ...video, thumbnail_url: null }, true), false);
|
||||
assert.equal(support.showsThumbnail({ url: '/serve/a.png', thumbnail_url: '/serve/thumbnails/a_thumb.jxl' }, false), true,
|
||||
'an image always shows something: its original');
|
||||
});
|
||||
|
||||
test('a video thumbnail that fails to load is replaced once, never left broken', () => {
|
||||
const listeners = {};
|
||||
let replaced = null;
|
||||
const node = { addEventListener(type, handler, options) { listeners[type] = { handler, options }; },
|
||||
replaceWith(next) { replaced = next; } };
|
||||
support.replaceOnError(node, () => 'label');
|
||||
assert.equal(listeners.error.options.once, true);
|
||||
listeners.error.handler();
|
||||
assert.equal(replaced, 'label');
|
||||
const listen = [];
|
||||
support.replaceOnError(node, () => 'other', (...args) => listen.push(args));
|
||||
assert.equal(listen[0][1], 'error');
|
||||
listen[0][2]();
|
||||
assert.equal(replaced, 'other');
|
||||
});
|
||||
@@ -1328,168 +1328,6 @@ class TestSettingsMisc(AdminAPITestCase):
|
||||
self.assertEqual(resp.status, 500)
|
||||
|
||||
|
||||
class TestThumbnailSettings(AdminAPITestCase):
|
||||
"""#317: thumbnail format, quality and size limit are server settings."""
|
||||
|
||||
URL = "/prompt_manager/settings"
|
||||
|
||||
def _encoder(self, name):
|
||||
from py.api import thumbnail_encode
|
||||
|
||||
return patch.object(thumbnail_encode, "detect_jxl_encoder", return_value=name)
|
||||
|
||||
async def test_get_reports_the_settings_and_the_encoders(self):
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
with self._encoder(None):
|
||||
body = await (await self.client.request("GET", self.URL)).json()
|
||||
settings = body["settings"]
|
||||
self.assertEqual(
|
||||
(
|
||||
settings["thumbnail_format"],
|
||||
settings["thumbnail_quality"],
|
||||
settings["thumbnail_edge"],
|
||||
),
|
||||
("webp", 80, 512),
|
||||
)
|
||||
jxl = body["thumbnail_encoders"]["jxl"]
|
||||
self.assertEqual((jxl["available"], jxl["encoder"]), (False, None))
|
||||
self.assertIn("pillow-jxl-plugin", jxl["reason"])
|
||||
with self._encoder("cjxl"):
|
||||
body = await (await self.client.request("GET", self.URL)).json()
|
||||
self.assertEqual(
|
||||
body["thumbnail_encoders"]["jxl"],
|
||||
{"available": True, "encoder": "cjxl", "reason": None},
|
||||
)
|
||||
|
||||
async def test_save_commits_the_thumbnails_section(self):
|
||||
self._write_config({"custom": {"keep": 1}})
|
||||
resp = await self.client.request(
|
||||
"POST",
|
||||
self.URL,
|
||||
json={
|
||||
"thumbnail_format": "jpeg",
|
||||
"thumbnail_quality": 60,
|
||||
"thumbnail_edge": 300,
|
||||
},
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
saved = self._read_config()
|
||||
self.assertEqual(
|
||||
saved["thumbnails"], {"format": "jpeg", "quality": 60, "edge": 300}
|
||||
)
|
||||
self.assertEqual(saved["custom"], {"keep": 1})
|
||||
self.assertEqual(
|
||||
(
|
||||
PromptManagerConfig.THUMBNAIL_FORMAT,
|
||||
PromptManagerConfig.THUMBNAIL_QUALITY,
|
||||
PromptManagerConfig.THUMBNAIL_EDGE,
|
||||
),
|
||||
("jpeg", 60, 300),
|
||||
)
|
||||
|
||||
async def test_bad_values_are_refused_and_change_nothing(self):
|
||||
before = PromptManagerConfig.get_config()["thumbnails"]
|
||||
for payload in (
|
||||
{"thumbnail_format": "png"},
|
||||
{"thumbnail_quality": 0},
|
||||
{"thumbnail_quality": "80"},
|
||||
{"thumbnail_edge": 4096},
|
||||
{"thumbnail_edge": True},
|
||||
):
|
||||
resp = await self.client.request("POST", self.URL, json=payload)
|
||||
self.assertEqual(resp.status, 400, payload)
|
||||
self.assertEqual(PromptManagerConfig.get_config()["thumbnails"], before)
|
||||
self.assertFalse(os.path.exists(self.config_path))
|
||||
|
||||
async def test_jpeg_xl_without_an_encoder_is_refused_with_a_code(self):
|
||||
with self._encoder(None):
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "jxl", "result_timeout": 7}
|
||||
)
|
||||
body = await resp.json()
|
||||
self.assertEqual((resp.status, body["code"]), (400, "jxl-unavailable"))
|
||||
self.assertIn("pillow-jxl-plugin", body["error"])
|
||||
self.assertNotEqual(PromptManagerConfig.THUMBNAIL_FORMAT, "jxl")
|
||||
self.assertNotEqual(PromptManagerConfig.RESULT_TIMEOUT, 7)
|
||||
self.assertFalse(os.path.exists(self.config_path))
|
||||
|
||||
async def test_jpeg_xl_with_an_encoder_is_saved(self):
|
||||
with self._encoder("ffmpeg"):
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "jxl"}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(self._read_config()["thumbnails"]["format"], "jxl")
|
||||
self.assertEqual(PromptManagerConfig.THUMBNAIL_FORMAT, "jxl")
|
||||
|
||||
async def _running_job(self, mode):
|
||||
from py.api.jobs import THUMBNAILS_JOB_KIND
|
||||
from py.api.thumbnail_convert import CONVERT_JOB_TITLE
|
||||
|
||||
gate = asyncio.Event()
|
||||
|
||||
async def runner(job):
|
||||
await gate.wait()
|
||||
job.finish("finished")
|
||||
|
||||
job = self.api._jobs.start(
|
||||
THUMBNAILS_JOB_KIND,
|
||||
CONVERT_JOB_TITLE if mode == "convert" else "Thumbnail generation",
|
||||
admission="guarded",
|
||||
scope={"mode": mode},
|
||||
runner=runner,
|
||||
)
|
||||
|
||||
async def release():
|
||||
gate.set()
|
||||
await asyncio.wait_for(job.task, 5)
|
||||
|
||||
self.addAsyncCleanup(release)
|
||||
return job
|
||||
|
||||
async def test_thumbnail_changes_wait_for_a_running_conversion(self):
|
||||
# Review I4: a convert job's format is fixed; changing the format,
|
||||
# quality or size limit under it is refused with the job named.
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
job = await self._running_job("convert")
|
||||
for change in (
|
||||
{"thumbnail_format": "jpeg"},
|
||||
{"thumbnail_quality": 70},
|
||||
{"thumbnail_edge": 300},
|
||||
):
|
||||
resp = await self.client.request("POST", self.URL, json=change)
|
||||
body = await resp.json()
|
||||
self.assertEqual(resp.status, 409, change)
|
||||
self.assertEqual(body["code"], "busy")
|
||||
self.assertEqual(body["running"]["id"], job.id)
|
||||
self.assertIn("Convert outdated thumbnails", body["error"])
|
||||
self.assertIn("thumbnail", body["error"].lower())
|
||||
self.assertEqual(
|
||||
(
|
||||
PromptManagerConfig.THUMBNAIL_FORMAT,
|
||||
PromptManagerConfig.THUMBNAIL_QUALITY,
|
||||
PromptManagerConfig.THUMBNAIL_EDGE,
|
||||
),
|
||||
("webp", 80, 512),
|
||||
)
|
||||
# The same values, or other settings, still save.
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "webp", "thumbnail_quality": 80}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
async def test_a_missing_previews_job_does_not_block_thumbnail_changes(self):
|
||||
await self._running_job("missing")
|
||||
resp = await self.client.request(
|
||||
"POST", self.URL, json={"thumbnail_format": "jpeg"}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
|
||||
|
||||
class TestDiagnostics(AdminAPITestCase):
|
||||
|
||||
def _install_monitor(self, monitor):
|
||||
|
||||
@@ -1133,17 +1133,10 @@ class TestScanOutputDir(AutotagAPITestCase):
|
||||
self.assertEqual(data["count"], 2)
|
||||
self.assertIs(data["truncated"], False)
|
||||
self.assertIsNone(data["images"][0]["thumbnail_url"])
|
||||
# An older release's preview keeps its spelling; a case-insensitive
|
||||
# disk (Windows, macOS) finds it under the ".jpg" lookup first (#317).
|
||||
# Either way the URL names that file and is served.
|
||||
thumb_url = data["images"][1]["thumbnail_url"]
|
||||
self.assertRegex(
|
||||
thumb_url,
|
||||
r"^/prompt_manager/images/serve/thumbnails/sub/b_thumb\.(JPG|jpg)\?v=\d+$",
|
||||
data["images"][1]["thumbnail_url"],
|
||||
r"^/prompt_manager/images/serve/thumbnails/sub/b_thumb\.JPG\?v=\d+$",
|
||||
)
|
||||
served = await self.client.request("GET", thumb_url)
|
||||
self.assertEqual(served.status, 200)
|
||||
self.assertEqual(await served.read(), b"t")
|
||||
self.assertEqual(
|
||||
data["images"][1]["url"], "/prompt_manager/images/serve/sub/b.JPG"
|
||||
)
|
||||
|
||||
@@ -14,7 +14,6 @@ from unittest import skipUnless
|
||||
from unittest.mock import patch
|
||||
|
||||
from aiohttp import web
|
||||
from PIL import Image
|
||||
from aiohttp.test_utils import make_mocked_request
|
||||
|
||||
from tests.test_api_images import ImageAPITestCase, make_png
|
||||
@@ -432,11 +431,10 @@ class HistoryOutputAPITests(ImageAPITestCase):
|
||||
self.assertEqual(int(response.headers["Content-Length"]), len(payload))
|
||||
self.assertEqual(await response.read(), b"")
|
||||
# A missing preview is made on first view (#249): an undecodable video
|
||||
# gets the placeholder frame, never the whole file, written in the
|
||||
# thumbnail format (#317, WebP by default).
|
||||
preview = self.output_dir / "thumbnails" / "clip_thumb.webp"
|
||||
# gets the placeholder frame, never the whole file.
|
||||
preview = self.output_dir / "thumbnails" / "clip_thumb.jpg"
|
||||
response = await self.current(descriptor["thumbnail_url"])
|
||||
self.assertEqual(response.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
|
||||
self.assertEqual(await response.read(), preview.read_bytes())
|
||||
self.assertLess(preview.stat().st_size, len(payload))
|
||||
make_png(preview)
|
||||
@@ -1175,105 +1173,6 @@ class HistoryOutputAPITests(ImageAPITestCase):
|
||||
await asyncio.wait_for(job.task, 5)
|
||||
self.assertFalse((self.output_dir / "thumbnails").exists())
|
||||
|
||||
async def test_the_cover_is_the_preview_the_images_listing_shows(self):
|
||||
# Review M5: JPEG XL configured with no encoder left writes WebP; the
|
||||
# history cover prefers that WebP, like the Images listing.
|
||||
from py.api import thumbnail_encode
|
||||
from py.config import PromptManagerConfig
|
||||
|
||||
make_png(self.output_dir / "image.png")
|
||||
folder = self.output_dir / "thumbnails"
|
||||
folder.mkdir()
|
||||
(folder / "image_thumb.jxl").write_bytes(b"\xff\x0a stale jxl")
|
||||
webp = folder / "image_thumb.webp"
|
||||
Image.new("RGB", (16, 16)).save(webp, "WEBP")
|
||||
start = self.capture([self.ref()])
|
||||
descriptor = await self.media(start)
|
||||
with (
|
||||
patch.object(PromptManagerConfig, "THUMBNAIL_FORMAT", "jxl"),
|
||||
patch.object(thumbnail_encode, "detect_jxl_encoder", return_value=None),
|
||||
):
|
||||
response = await self.current(descriptor["thumbnail_url"])
|
||||
body = await response.read()
|
||||
self.assertEqual(response.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(body, webp.read_bytes())
|
||||
|
||||
async def _jxl_cover(self, filename, accept):
|
||||
from py.api import thumbnail_encode
|
||||
from py.config import PromptManagerConfig
|
||||
|
||||
folder = self.output_dir / "thumbnails"
|
||||
folder.mkdir(exist_ok=True)
|
||||
stem = filename.rsplit(".", 1)[0]
|
||||
jxl = folder / f"{stem}_thumb.jxl"
|
||||
jxl.write_bytes(b"\xff\x0a jxl preview")
|
||||
start = self.capture([self.ref(filename=filename)])
|
||||
descriptor = await self.media(start)
|
||||
headers = {"Accept": accept} if accept else {}
|
||||
with (
|
||||
patch.object(PromptManagerConfig, "THUMBNAIL_FORMAT", "jxl"),
|
||||
patch.object(thumbnail_encode, "detect_jxl_encoder", return_value="cjxl"),
|
||||
):
|
||||
response = await self.current(descriptor["thumbnail_url"], headers=headers)
|
||||
body = await response.read()
|
||||
return response, body, jxl
|
||||
|
||||
async def test_a_jxl_cover_is_served_only_to_a_browser_that_accepts_it(self):
|
||||
# Review M3: History's cover URL names no format, so the server picks:
|
||||
# a browser that does not list image/jxl gets the original image.
|
||||
original = make_png(self.output_dir / "image.png")
|
||||
response, body, jxl = await self._jxl_cover(
|
||||
"image.png", "image/avif,image/webp,image/*,*/*;q=0.8"
|
||||
)
|
||||
self.assertEqual(response.status, 200)
|
||||
self.assertEqual(body, original.read_bytes())
|
||||
self.assertEqual(response.headers["Content-Type"], "image/png")
|
||||
self.assertTrue(jxl.exists())
|
||||
|
||||
async def test_a_browser_that_lists_image_jxl_gets_the_jxl_cover(self):
|
||||
make_png(self.output_dir / "image.png")
|
||||
response, body, jxl = await self._jxl_cover(
|
||||
"image.png", "image/webp,image/avif,image/jxl,image/*;q=0.8"
|
||||
)
|
||||
self.assertEqual(response.headers["Content-Type"], "image/jxl")
|
||||
self.assertEqual(body, jxl.read_bytes())
|
||||
|
||||
async def test_the_jxl_flag_selects_the_jxl_cover_without_accept(self):
|
||||
# M3 follow-up: a browser that decodes JPEG XL but does not list it in
|
||||
# Accept (Firefox) says so with jxl=1 on the cover URL.
|
||||
make_png(self.output_dir / "image.png")
|
||||
folder = self.output_dir / "thumbnails"
|
||||
folder.mkdir(exist_ok=True)
|
||||
jxl = folder / "image_thumb.jxl"
|
||||
jxl.write_bytes(b"\xff\x0a jxl preview")
|
||||
start = self.capture([self.ref()])
|
||||
descriptor = await self.media(start)
|
||||
from py.api import thumbnail_encode
|
||||
from py.config import PromptManagerConfig
|
||||
|
||||
with (
|
||||
patch.object(PromptManagerConfig, "THUMBNAIL_FORMAT", "jxl"),
|
||||
patch.object(thumbnail_encode, "detect_jxl_encoder", return_value="cjxl"),
|
||||
):
|
||||
url = descriptor["thumbnail_url"]
|
||||
response = await self.current(url + "&jxl=1", headers={"Accept": "*/*"})
|
||||
self.assertEqual(response.headers["Content-Type"], "image/jxl")
|
||||
self.assertEqual(response.headers["Cache-Control"], "no-store")
|
||||
self.assertEqual(await response.read(), jxl.read_bytes())
|
||||
response = await self.current(url + "&jxl=0", headers={"Accept": "*/*"})
|
||||
self.assertEqual(response.headers["Content-Type"], "image/png")
|
||||
await response.read()
|
||||
for bad in ("&jxl=true", "&jxl=", "&jxl=1&jxl=1", "&jxl=2"):
|
||||
response = await self.current(url + bad)
|
||||
self.assertEqual(response.status, 400, bad)
|
||||
await response.read()
|
||||
|
||||
async def test_a_video_jxl_cover_is_the_placeholder_without_jxl_support(self):
|
||||
(self.output_dir / "clip.webm").write_bytes(b"AV1 stream no decoder here")
|
||||
response, body, _jxl = await self._jxl_cover("clip.webm", "image/*,*/*")
|
||||
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
|
||||
self.assertTrue(body.startswith(b"\xff\xd8"))
|
||||
|
||||
async def test_a_preview_that_cannot_be_made_serves_the_original(self):
|
||||
broken = self.output_dir / "image.png"
|
||||
broken.write_bytes(b"\x89PNG\r\n\x1a\n not an image")
|
||||
|
||||
+49
-70
@@ -33,8 +33,7 @@ from aiohttp.test_utils import AioHTTPTestCase # noqa: E402
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from database.operations import PromptDatabase # noqa: E402
|
||||
import py.api.images as images_module
|
||||
from py.api.thumbnail_settings import ThumbnailSettings # noqa: E402
|
||||
import py.api.images as images_module # noqa: E402
|
||||
from py.api import PromptManagerAPI # noqa: E402
|
||||
from py.api.prompts import MAX_PAGE_LIMIT # noqa: E402
|
||||
from py.config import GalleryConfig # noqa: E402
|
||||
@@ -398,7 +397,7 @@ class ThumbnailTestCase(ImageAPITestCase):
|
||||
self.api._run_in_executor = spy
|
||||
return calls
|
||||
|
||||
def _thumb_path(self, name, suffix=".webp"):
|
||||
def _thumb_path(self, name, suffix=".png"):
|
||||
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
|
||||
|
||||
|
||||
@@ -497,8 +496,7 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
|
||||
self.assertNotIn("_generate_one", calls)
|
||||
self.assertIn("_plan_gallery_thumbnails", calls)
|
||||
|
||||
async def test_a_run_past_the_batch_cap_goes_on_to_the_end(self):
|
||||
# The cap bounds one batch; the run plans again and finishes (#317).
|
||||
async def test_capped_run_reports_remaining_and_the_next_run_finishes(self):
|
||||
for name in ("a", "b", "c"):
|
||||
make_png(self.output_dir / f"{name}.png", size=(64, 64))
|
||||
|
||||
@@ -511,19 +509,18 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
|
||||
)
|
||||
|
||||
names = [e for e, _ in first]
|
||||
self.assertEqual(names.count("progress"), 3) # only pending files are work
|
||||
self.assertEqual(dict(first)["start"]["total_images"], 3)
|
||||
self.assertEqual(
|
||||
[d["total_images"] for e, d in first if e == "progress"], [3] * 3
|
||||
)
|
||||
self.assertEqual(names.count("progress"), 2) # only pending files are work
|
||||
done = dict(first)["complete"]
|
||||
self.assertEqual(done["count"], 3)
|
||||
self.assertEqual(done["count"], 2)
|
||||
self.assertEqual(done["remaining"], 1)
|
||||
self.assertEqual(done["total_images"], 3)
|
||||
self.assertIn("1 more", done["message"])
|
||||
done = dict(second)["complete"]
|
||||
self.assertEqual(done["count"], 1)
|
||||
self.assertEqual(done["skipped"], 2)
|
||||
self.assertEqual(done["remaining"], 0)
|
||||
self.assertEqual(done["total_images"], 3)
|
||||
self.assertNotIn("more", done["message"])
|
||||
done = dict(second)["complete"]
|
||||
self.assertEqual(done["count"], 0)
|
||||
self.assertEqual(done["skipped"], 3)
|
||||
|
||||
async def test_blocking_endpoint_reports_remaining(self):
|
||||
for name in ("a", "b", "c"):
|
||||
@@ -591,15 +588,14 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
|
||||
events = parse_sse(await resp.text())
|
||||
self.assertEqual(events[-1][0], "error")
|
||||
|
||||
async def test_any_quality_preset_uses_the_server_size_limit(self):
|
||||
# #317 R3: the server's thumbnail settings size every preview.
|
||||
make_png(self.output_dir / "one.png", size=(1000, 1000))
|
||||
async def test_unknown_quality_falls_back_to_medium(self):
|
||||
make_png(self.output_dir / "one.png", size=(400, 400))
|
||||
|
||||
resp = await self.client.request("POST", f"{self.PROGRESS_URL}?quality=huge")
|
||||
await resp.text()
|
||||
|
||||
with Image.open(self._thumb_path("one")) as thumb:
|
||||
self.assertEqual(max(thumb.size), 512)
|
||||
self.assertEqual(max(thumb.size), 300)
|
||||
|
||||
|
||||
class TestGenerateThumbnailsPost(ThumbnailTestCase):
|
||||
@@ -914,8 +910,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
||||
make_png(self.output_dir / "a.png", size=(64, 64))
|
||||
thumbs = self.output_dir / "thumbnails"
|
||||
thumbs.mkdir()
|
||||
dst = thumbs / "a_thumb.webp"
|
||||
settings = ThumbnailSettings("webp", 80, 32)
|
||||
dst = thumbs / "a_thumb.png"
|
||||
|
||||
def crash_mid_write(self_img, fp, *args, **kwargs):
|
||||
# Simulates an interrupted encoder: bytes on disk, then failure.
|
||||
@@ -925,14 +920,14 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
||||
with patch.object(Image.Image, "save", crash_mid_write):
|
||||
with self.assertRaises(OSError):
|
||||
images_module._write_image_thumbnail(
|
||||
self.output_dir / "a.png", dst, settings
|
||||
self.output_dir / "a.png", dst, (32, 32)
|
||||
)
|
||||
self.assertFalse(dst.exists(), "a half-written thumbnail was left in place")
|
||||
self.assertEqual(os.listdir(thumbs), [])
|
||||
|
||||
images_module._write_image_thumbnail(self.output_dir / "a.png", dst, settings)
|
||||
images_module._write_image_thumbnail(self.output_dir / "a.png", dst, (32, 32))
|
||||
self.assertTrue(dst.is_file())
|
||||
self.assertEqual(os.listdir(thumbs), ["a_thumb.webp"])
|
||||
self.assertEqual(os.listdir(thumbs), ["a_thumb.png"])
|
||||
|
||||
def test_thumbnail_url_is_versioned_by_its_mtime(self):
|
||||
import os
|
||||
@@ -940,9 +935,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
||||
|
||||
make_png(self.output_dir / "a.png", size=(64, 64))
|
||||
self.api._generate_thumbnails_sync(
|
||||
self.output_dir,
|
||||
self.output_dir / "thumbnails",
|
||||
ThumbnailSettings("webp", 80, 32),
|
||||
self.output_dir, self.output_dir / "thumbnails", (32, 32)
|
||||
)
|
||||
thumb = self._thumb_path("a")
|
||||
|
||||
@@ -951,7 +944,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
||||
)
|
||||
self.assertRegex(
|
||||
entry["thumbnail_url"],
|
||||
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?v=\d+&root=0$",
|
||||
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
|
||||
)
|
||||
first = int(re.search(r"v=(\d+)", entry["thumbnail_url"]).group(1))
|
||||
self.assertEqual(first, int(thumb.stat().st_mtime))
|
||||
@@ -967,9 +960,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
||||
async def test_versioned_thumbnail_url_is_served(self):
|
||||
make_png(self.output_dir / "a.png", size=(64, 64))
|
||||
self.api._generate_thumbnails_sync(
|
||||
self.output_dir,
|
||||
self.output_dir / "thumbnails",
|
||||
ThumbnailSettings("webp", 80, 32),
|
||||
self.output_dir, self.output_dir / "thumbnails", (32, 32)
|
||||
)
|
||||
entry = images_module._output_image_entry(
|
||||
self.output_dir / "a.png", self.output_dir, 0
|
||||
@@ -978,7 +969,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
|
||||
resp = await self.client.request("GET", entry["thumbnail_url"])
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/png")
|
||||
|
||||
async def test_missing_image_404_is_not_cacheable(self):
|
||||
resp = await self.client.request(
|
||||
@@ -1033,9 +1024,7 @@ class TestMediaScanCaps(ImageAPITestCase):
|
||||
for name in ("a", "b", "c"):
|
||||
make_png(self.output_dir / f"{name}.png")
|
||||
self.api._generate_thumbnails_sync(
|
||||
self.output_dir,
|
||||
self.output_dir / "thumbnails",
|
||||
ThumbnailSettings("webp", 80, 64),
|
||||
self.output_dir, self.output_dir / "thumbnails", (64, 64)
|
||||
)
|
||||
for name in ("d", "e", "f"):
|
||||
make_png(self.output_dir / f"{name}.png")
|
||||
@@ -1094,6 +1083,11 @@ class TestMediaScanCaps(ImageAPITestCase):
|
||||
|
||||
self.assertEqual(targets, [])
|
||||
|
||||
def test_thumbnail_size_ignores_non_string_quality(self):
|
||||
self.assertEqual(self.api._thumbnail_size({"w": 99999}), (300, 300))
|
||||
self.assertEqual(self.api._thumbnail_size(None), (300, 300))
|
||||
self.assertEqual(self.api._thumbnail_size("low"), (150, 150))
|
||||
|
||||
|
||||
class TestJsonBodyCaps(ImageAPITestCase):
|
||||
"""JSON bodies are read in chunks and rejected past MAX_JSON_BODY_BYTES."""
|
||||
@@ -1464,7 +1458,7 @@ class TestGalleryListing(ImageRouteCoverageCase):
|
||||
|
||||
class TestThumbnailUnits(ImageRouteCoverageCase):
|
||||
|
||||
SIZE = ThumbnailSettings("webp", 80, 64)
|
||||
SIZE = (64, 64)
|
||||
|
||||
def test_generate_one_reports_vanished_source(self):
|
||||
result = self.api._generate_one(
|
||||
@@ -1477,9 +1471,11 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
|
||||
src = self.output_dir / "clip.mp4"
|
||||
src.write_bytes(b"\x00")
|
||||
with patch.object(self.api, "_generate_video_thumbnail", return_value=True):
|
||||
ok = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
|
||||
ok = self.api._generate_one(src, self._dst("clip", ".jpg"), self.SIZE, True)
|
||||
with patch.object(self.api, "_generate_video_thumbnail", return_value=False):
|
||||
bad = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
|
||||
bad = self.api._generate_one(
|
||||
src, self._dst("clip", ".jpg"), self.SIZE, True
|
||||
)
|
||||
self.assertEqual((ok["action"], ok["type"]), ("generated", "video"))
|
||||
self.assertEqual(bad["action"], "error")
|
||||
|
||||
@@ -1502,7 +1498,7 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
|
||||
)
|
||||
self.assertEqual(result["count"], 2)
|
||||
self.assertTrue(self._dst("alpha").is_file())
|
||||
self.assertTrue(self._dst("photo").is_file())
|
||||
self.assertTrue(self._dst("photo", ".jpg").is_file())
|
||||
|
||||
async def test_emit_sse_swallows_write_errors(self):
|
||||
response = MagicMock()
|
||||
@@ -1525,15 +1521,14 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
|
||||
)
|
||||
self.assertEqual(status, 500)
|
||||
|
||||
def _dst(self, name, suffix=".webp"):
|
||||
def _dst(self, name, suffix=".png"):
|
||||
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
|
||||
|
||||
|
||||
class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
|
||||
"""_generate_video_thumbnail degrades: cv2 -> ffmpeg -> placeholder."""
|
||||
|
||||
# JPEG keeps these checks format-specific; #317 settings carry the edge.
|
||||
SIZE = ThumbnailSettings("jpeg", 85, 48)
|
||||
SIZE = (48, 48)
|
||||
|
||||
@property
|
||||
def video(self):
|
||||
@@ -1586,7 +1581,7 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
|
||||
)
|
||||
self.assertTrue(ok)
|
||||
with Image.open(self.thumb) as img:
|
||||
self.assertEqual((img.format, img.size), ("JPEG", self.SIZE.size))
|
||||
self.assertEqual((img.format, img.size), ("JPEG", self.SIZE))
|
||||
|
||||
def test_cv2_crash_is_contained(self):
|
||||
cv2 = MagicMock()
|
||||
@@ -1619,26 +1614,18 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
|
||||
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
||||
self.assertTrue(ok)
|
||||
with Image.open(self.thumb) as img:
|
||||
self.assertEqual(img.size, self.SIZE.size)
|
||||
self.assertEqual(img.size, self.SIZE)
|
||||
|
||||
@staticmethod
|
||||
def _process(returncode, stderr=b"", stdout=b""):
|
||||
def _process(returncode, stderr=""):
|
||||
process = MagicMock(returncode=returncode)
|
||||
process.communicate.return_value = (stdout, stderr)
|
||||
process.communicate.return_value = ("", stderr)
|
||||
return process
|
||||
|
||||
@staticmethod
|
||||
def _png_frame(size=(96, 64)):
|
||||
import io
|
||||
|
||||
buffer = io.BytesIO()
|
||||
Image.new("RGB", size, (200, 0, 0)).save(buffer, "PNG")
|
||||
return buffer.getvalue()
|
||||
|
||||
def test_ffmpeg_failure_yields_placeholder(self):
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
patch("subprocess.Popen", return_value=self._process(1, b"no stream")),
|
||||
patch("subprocess.Popen", return_value=self._process(1, "no stream")),
|
||||
):
|
||||
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
||||
self.assertTrue(ok)
|
||||
@@ -1647,23 +1634,18 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
|
||||
def test_ffmpeg_success(self):
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
patch(
|
||||
"subprocess.Popen",
|
||||
return_value=self._process(0, stdout=self._png_frame()),
|
||||
) as run,
|
||||
patch("subprocess.Popen", return_value=self._process(0)) as run,
|
||||
):
|
||||
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
|
||||
self.assertTrue(ok)
|
||||
self.assertEqual(run.call_args.args[0][0], "ffmpeg")
|
||||
with Image.open(self.thumb) as img:
|
||||
# ffmpeg's frame (a PNG on stdout) fitted inside the edge (#317).
|
||||
self.assertEqual((img.format, img.size), ("JPEG", (48, 32)))
|
||||
|
||||
def test_ffmpeg_is_tracked_and_a_hold_before_the_rename_writes_nothing(self):
|
||||
tracked = []
|
||||
|
||||
def writing(cmd, **kwargs):
|
||||
return self._process(0, stdout=self._png_frame())
|
||||
Path(cmd[-1]).write_bytes(b"\xff\xd8 frame")
|
||||
return self._process(0)
|
||||
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
@@ -2079,7 +2061,7 @@ class TestThumbnailStartupStatus(ThumbnailTestCase):
|
||||
):
|
||||
with self.subTest(route=route):
|
||||
for root in (self.output_dir, second):
|
||||
for thumb in (root / "thumbnails").glob("*_thumb.webp"):
|
||||
for thumb in (root / "thumbnails").glob("*_thumb.png"):
|
||||
thumb.unlink()
|
||||
response = await self.client.post(route, json={})
|
||||
if route == self.PROGRESS_URL:
|
||||
@@ -2087,7 +2069,7 @@ class TestThumbnailStartupStatus(ThumbnailTestCase):
|
||||
else:
|
||||
data = await response.json()
|
||||
self.assertEqual(data["count"], 2)
|
||||
self.assertTrue((second / "thumbnails" / "b_thumb.webp").exists())
|
||||
self.assertTrue((second / "thumbnails" / "b_thumb.png").exists())
|
||||
status = await (await self.client.get(self.STATUS_URL)).json()
|
||||
self.assertEqual(status["pending"], 0)
|
||||
self.assertEqual(status["total_images"], 2)
|
||||
@@ -2107,13 +2089,10 @@ class TestThumbnailStartupStatus(ThumbnailTestCase):
|
||||
make_png(second / "b.png")
|
||||
self.api._get_all_output_dirs = lambda: [self.output_dir, second]
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
|
||||
plan = self.api._plan_gallery_thumbnails([self.output_dir, second])
|
||||
response = await self.client.post(self.PROGRESS_URL)
|
||||
data = dict(parse_sse(await response.text()))["complete"]
|
||||
# One batch holds one file across both roots; the run goes on (#317).
|
||||
self.assertEqual((len(plan["targets"]), plan["remaining"]), (1, 1))
|
||||
self.assertEqual(data["count"], 2)
|
||||
self.assertEqual(data["remaining"], 0)
|
||||
self.assertEqual(data["count"], 1)
|
||||
self.assertEqual(data["remaining"], 1)
|
||||
self.assertEqual(data["total_images"], 2)
|
||||
|
||||
async def test_equal_timestamps_do_not_regenerate_existing_thumbnails(self):
|
||||
@@ -2142,7 +2121,7 @@ class TestGalleryRootIdentity(ThumbnailTestCase):
|
||||
root = roots[image["root_index"]]
|
||||
for key, path in (
|
||||
("url", root / "same.png"),
|
||||
("thumbnail_url", root / "thumbnails" / "same_thumb.webp"),
|
||||
("thumbnail_url", root / "thumbnails" / "same_thumb.png"),
|
||||
):
|
||||
response = await self.client.get(image[key])
|
||||
self.assertEqual(response.status, 200)
|
||||
|
||||
@@ -408,10 +408,9 @@ class TestQueueReads(ReviewRoutesTestCase):
|
||||
scope = item["target"]["root_scope"]
|
||||
self.assertEqual(
|
||||
item["thumbnail_url"],
|
||||
"/prompt_manager/images/serve/thumbnails/sub/f0_thumb.webp"
|
||||
"/prompt_manager/images/serve/thumbnails/sub/f0_thumb.png"
|
||||
f"?root=0&root_scope={scope}",
|
||||
)
|
||||
# A legacy preview name is still listed until it is converted (#317).
|
||||
thumb = self.output_dir / "thumbnails" / "sub" / "f0_thumb.png"
|
||||
thumb.parent.mkdir(parents=True)
|
||||
write_png(thumb, prompt=ambiguous_graph("cat", "dog"), color=(1, 2, 3))
|
||||
|
||||
@@ -75,8 +75,7 @@ class TestGalleryConfig(unittest.TestCase):
|
||||
{"web_interface": {"images_per_page": 50, "thumbnail_size": 512}}
|
||||
)
|
||||
self.assertEqual(GalleryConfig.IMAGES_PER_PAGE, 50)
|
||||
# #317 R1: the never-read thumbnail_size is ignored (thumbnails.edge).
|
||||
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
|
||||
self.assertEqual(GalleryConfig.THUMBNAIL_SIZE, 512)
|
||||
|
||||
def test_update_performance(self):
|
||||
GalleryConfig.update_config({"performance": {"max_concurrent_processing": 5}})
|
||||
|
||||
@@ -143,15 +143,15 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
|
||||
entry = _entry(self.api, self.output_dir, "sub/new.png")
|
||||
self.assertEqual(
|
||||
entry["thumbnail_url"],
|
||||
f"{SERVE}thumbnails/sub/new_thumb.webp?root=0",
|
||||
f"{SERVE}thumbnails/sub/new_thumb.png?root=0",
|
||||
)
|
||||
self.assertFalse((self.output_dir / "thumbnails").exists(), "no write")
|
||||
|
||||
def test_missing_video_preview_url_is_in_the_configured_format(self):
|
||||
def test_missing_video_preview_is_a_jpeg_url(self):
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"not really a video")
|
||||
entry = _entry(self.api, self.output_dir, "clip.mp4")
|
||||
self.assertEqual(
|
||||
entry["thumbnail_url"], f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
||||
entry["thumbnail_url"], f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
|
||||
)
|
||||
|
||||
def test_listing_says_whether_the_preview_file_exists(self):
|
||||
@@ -160,7 +160,7 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
|
||||
self.assertIs(
|
||||
_entry(self.api, self.output_dir, "a.png")["thumbnail_ready"], False
|
||||
)
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
self.assertIs(
|
||||
_entry(self.api, self.output_dir, "a.png")["thumbnail_ready"], True
|
||||
)
|
||||
@@ -174,11 +174,11 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
|
||||
|
||||
def test_existing_preview_keeps_its_mtime_version(self):
|
||||
make_png(self.output_dir / "a.png", size=(64, 64))
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
|
||||
make_png(self.output_dir / "thumbnails" / "a_thumb.png")
|
||||
entry = _entry(self.api, self.output_dir, "a.png")
|
||||
self.assertRegex(
|
||||
entry["thumbnail_url"],
|
||||
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?v=\d+&root=0$",
|
||||
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
|
||||
)
|
||||
|
||||
async def test_workspace_listing_never_returns_a_null_thumbnail(self):
|
||||
@@ -190,7 +190,7 @@ class TestListingsAlwaysCarryAThumbnailUrl(AutoThumbnailCase):
|
||||
urls = [image["thumbnail_url"] for image in data["images"]]
|
||||
self.assertEqual(len(urls), 3)
|
||||
for url in urls:
|
||||
self.assertIn("_thumb.webp", url)
|
||||
self.assertIn("_thumb.png", url)
|
||||
|
||||
|
||||
class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
@@ -201,33 +201,31 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
resp = await self.client.get(url)
|
||||
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/png")
|
||||
body = await resp.read()
|
||||
thumb = self.output_dir / "thumbnails" / "sub" / "big_thumb.webp"
|
||||
thumb = self.output_dir / "thumbnails" / "sub" / "big_thumb.png"
|
||||
self.assertTrue(thumb.is_file())
|
||||
self.assertEqual(body, thumb.read_bytes())
|
||||
with Image.open(thumb) as img:
|
||||
self.assertEqual(max(img.size), 512)
|
||||
self.assertEqual(max(img.size), auto.AUTO_THUMBNAIL_EDGE)
|
||||
# No temp sibling is ever left next to the preview.
|
||||
self.assertEqual(os.listdir(thumb.parent), ["big_thumb.webp"])
|
||||
self.assertEqual(os.listdir(thumb.parent), ["big_thumb.png"])
|
||||
|
||||
async def test_upper_case_extension_source_is_found(self):
|
||||
make_png(self.output_dir / "Shot.PNG", size=(64, 64))
|
||||
url = _entry(self.api, self.output_dir, "Shot.PNG")["thumbnail_url"]
|
||||
self.assertIn("thumbnails/Shot_thumb.webp", url)
|
||||
self.assertIn("thumbnails/Shot_thumb.png", url)
|
||||
resp = await self.client.get(url)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.png").is_file())
|
||||
|
||||
async def test_video_preview_is_generated_in_the_configured_format(self):
|
||||
# #317: a video's preview is written like an image's (WebP default);
|
||||
# an undecodable clip's preview is the placeholder frame.
|
||||
async def test_video_preview_is_generated_as_jpeg(self):
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"not really a video")
|
||||
url = _entry(self.api, self.output_dir, "clip.mp4")["thumbnail_url"]
|
||||
resp = await self.client.get(url)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.webp").is_file())
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/jpeg")
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.jpg").is_file())
|
||||
|
||||
async def test_concurrent_views_generate_once(self):
|
||||
make_png(self.output_dir / "a.png", size=(256, 256))
|
||||
@@ -300,15 +298,14 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
for name in names:
|
||||
stem = name[:-4]
|
||||
self.assertTrue(
|
||||
(self.output_dir / "thumbnails" / f"{stem}_thumb.webp").is_file()
|
||||
(self.output_dir / "thumbnails" / f"{stem}_thumb.png").is_file()
|
||||
)
|
||||
|
||||
async def test_requests_beyond_the_cap_get_the_fallback_at_once(self):
|
||||
names = [f"c{i}.png" for i in range(auto.ON_DEMAND_LIMIT)]
|
||||
for name in names + ["late.png"]:
|
||||
make_png(self.output_dir / name, size=(64, 64))
|
||||
# Another stem: same-stem files share one preview name (#317 R2).
|
||||
(self.output_dir / "latev.mp4").write_bytes(b"not a video")
|
||||
(self.output_dir / "late.mp4").write_bytes(b"not a video")
|
||||
entered, release, state = self._gate_writes()
|
||||
listings = []
|
||||
real_scandir = auto.os.scandir
|
||||
@@ -330,25 +327,25 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
for n in names
|
||||
]
|
||||
await self._writers(state, auto.ON_DEMAND_LIMIT)
|
||||
image = await self.client.get(f"{SERVE}thumbnails/late_thumb.webp?root=0")
|
||||
image = await self.client.get(f"{SERVE}thumbnails/late_thumb.png?root=0")
|
||||
self.assertEqual(image.status, 200)
|
||||
self.assertEqual(image.headers["Cache-Control"], "no-store")
|
||||
self.assertEqual(
|
||||
await image.read(), (self.output_dir / "late.png").read_bytes()
|
||||
)
|
||||
video = await self.client.get(f"{SERVE}thumbnails/latev_thumb.webp?root=0")
|
||||
video = await self.client.get(f"{SERVE}thumbnails/late_thumb.jpg?root=0")
|
||||
self.assertEqual(video.status, 200)
|
||||
self.assertEqual(video.headers["Content-Type"], "image/jpeg")
|
||||
self.assertEqual(video.headers["Cache-Control"], "no-store")
|
||||
await video.read()
|
||||
missing = await self.client.get(f"{SERVE}thumbnails/Nope_thumb.webp?root=0")
|
||||
missing = await self.client.get(f"{SERVE}thumbnails/Nope_thumb.png?root=0")
|
||||
self.assertEqual(missing.status, 404)
|
||||
# M2: no folder is listed for a request the cap turned away.
|
||||
self.assertEqual(listings, [])
|
||||
release.set()
|
||||
responses = [await task for task in tasks]
|
||||
self.assertEqual([r.status for r in responses], [200] * len(names))
|
||||
self.assertFalse((self.output_dir / "thumbnails" / "late_thumb.webp").exists())
|
||||
self.assertFalse((self.output_dir / "thumbnails" / "late_thumb.png").exists())
|
||||
|
||||
async def test_mixed_case_folder_listing_runs_once_inside_admission(self):
|
||||
make_png(self.output_dir / "Shot.Png", size=(64, 64))
|
||||
@@ -360,13 +357,13 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
return real_scandir(path)
|
||||
|
||||
with patch.object(auto.os, "scandir", counting):
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/Shot_thumb.webp?root=0")
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/Shot_thumb.png?root=0")
|
||||
self.assertEqual(resp.status, 200)
|
||||
# A case-insensitive filesystem (macOS, Windows) finds it by its exact
|
||||
# name; a case-sensitive one lists the folder once.
|
||||
found_by_name = (self.output_dir / "Shot.png").exists()
|
||||
self.assertEqual(len(listings), 0 if found_by_name else 1)
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.png").is_file())
|
||||
|
||||
async def test_broken_file_falls_back_to_the_original_and_is_remembered(self):
|
||||
broken = self.output_dir / "broken.png"
|
||||
@@ -383,9 +380,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
||||
self.assertEqual(await resp.read(), broken.read_bytes())
|
||||
self.assertEqual(calls, ["broken.png"], "a known failure is not retried")
|
||||
self.assertFalse(
|
||||
(self.output_dir / "thumbnails" / "broken_thumb.webp").exists()
|
||||
)
|
||||
self.assertFalse((self.output_dir / "thumbnails" / "broken_thumb.png").exists())
|
||||
|
||||
status = await (
|
||||
await self.client.get("/prompt_manager/images/thumbnails/status")
|
||||
@@ -399,9 +394,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
third = await self.client.get(url)
|
||||
self.assertEqual(third.status, 200)
|
||||
self.assertEqual(calls, ["broken.png", "broken.png"])
|
||||
self.assertTrue(
|
||||
(self.output_dir / "thumbnails" / "broken_thumb.webp").is_file()
|
||||
)
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "broken_thumb.png").is_file())
|
||||
|
||||
async def test_locked_preview_is_not_remembered_as_a_failure(self):
|
||||
make_png(self.output_dir / "a.png", size=(64, 64))
|
||||
@@ -418,10 +411,10 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
|
||||
resp = await self.client.get(url)
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "a_thumb.webp").is_file())
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "a_thumb.png").is_file())
|
||||
|
||||
async def test_no_source_is_a_plain_uncacheable_404(self):
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/nope_thumb.webp?root=0")
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/nope_thumb.png?root=0")
|
||||
self.assertEqual(resp.status, 404)
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
||||
text = await resp.text()
|
||||
@@ -431,13 +424,13 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
def test_drive_unc_and_absolute_segments_are_refused_as_text(self):
|
||||
# M1: a drive or UNC segment would reset the anchor of a joined path.
|
||||
for path in (
|
||||
"thumbnails/D:/x_thumb.webp",
|
||||
"thumbnails/D:x_thumb.webp",
|
||||
"thumbnails/\\\\server\\share/x_thumb.webp",
|
||||
"thumbnails//server/share/x_thumb.webp",
|
||||
"/thumbnails/x_thumb.webp",
|
||||
"thumbnails/a/./x_thumb.webp",
|
||||
"thumbnails/C:\\Users\\x_thumb.webp",
|
||||
"thumbnails/D:/x_thumb.png",
|
||||
"thumbnails/D:x_thumb.png",
|
||||
"thumbnails/\\\\server\\share/x_thumb.png",
|
||||
"thumbnails//server/share/x_thumb.png",
|
||||
"/thumbnails/x_thumb.png",
|
||||
"thumbnails/a/./x_thumb.png",
|
||||
"thumbnails/C:\\Users\\x_thumb.png",
|
||||
):
|
||||
with self.subTest(path=path):
|
||||
self.assertFalse(auto._is_preview_request(path))
|
||||
@@ -449,9 +442,9 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
patch.object(auto.os, "stat") as stat,
|
||||
):
|
||||
for path in (
|
||||
"thumbnails/D:/x_thumb.webp",
|
||||
"thumbnails/\\\\server\\share/x_thumb.webp",
|
||||
"/thumbnails/x_thumb.webp",
|
||||
"thumbnails/D:/x_thumb.png",
|
||||
"thumbnails/\\\\server\\share/x_thumb.png",
|
||||
"/thumbnails/x_thumb.png",
|
||||
):
|
||||
answer = await self.api._serve_missing_thumbnail(
|
||||
[self.output_dir], path
|
||||
@@ -464,8 +457,8 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
async def test_traversal_is_refused_before_any_filesystem_access(self):
|
||||
with patch.object(auto, "_thumbnail_source") as source:
|
||||
for path in (
|
||||
"thumbnails/../../etc/passwd_thumb.webp",
|
||||
"thumbnails/..%2F..%2Fx_thumb.webp",
|
||||
"thumbnails/../../etc/passwd_thumb.png",
|
||||
"thumbnails/..%2F..%2Fx_thumb.png",
|
||||
):
|
||||
resp = await self.client.get(f"{SERVE}{path}?root=0")
|
||||
self.assertIn(resp.status, (403, 404))
|
||||
@@ -475,7 +468,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
outside = Path(self._output_tmp.name) / "outside.png"
|
||||
make_png(outside, size=(64, 64))
|
||||
self._make_symlink(outside, self.output_dir / "link.png")
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/link_thumb.webp?root=0")
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/link_thumb.png?root=0")
|
||||
self.assertEqual(resp.status, 404)
|
||||
self.assertFalse((self.output_dir / "thumbnails").exists())
|
||||
|
||||
@@ -484,7 +477,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
elsewhere = Path(self._output_tmp.name) / "elsewhere"
|
||||
elsewhere.mkdir()
|
||||
self._make_symlink(elsewhere, self.output_dir / "thumbnails")
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/a_thumb.webp?root=0")
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/a_thumb.png?root=0")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
||||
self.assertEqual(os.listdir(elsewhere), [])
|
||||
@@ -531,7 +524,7 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
|
||||
_entry(self.api, self.output_dir, name)["thumbnail_url"]
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
thumb = self.output_dir / "thumbnails" / f"a{index}_thumb.webp"
|
||||
thumb = self.output_dir / "thumbnails" / f"a{index}_thumb.png"
|
||||
self.assertTrue(thumb.is_file())
|
||||
job.finish("finished")
|
||||
|
||||
@@ -572,12 +565,8 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
|
||||
cv2 = MagicMock()
|
||||
cv2.VideoCapture.return_value.isOpened.return_value = False
|
||||
with patch.dict(sys.modules, {"cv2": cv2}):
|
||||
resp = await self.client.get(f"{SERVE}thumbnails/clip_thumb.webp?root=0")
|
||||
# The placeholder frame became the preview file, in the format (#317).
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
made = self.output_dir / "thumbnails" / "clip_thumb.webp"
|
||||
self.assertEqual(await resp.read(), made.read_bytes())
|
||||
await self._assert_placeholder(f"{SERVE}thumbnails/clip_thumb.jpg?root=0")
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.jpg").is_file())
|
||||
|
||||
async def test_a_held_video_preview_is_the_placeholder_and_writes_nothing(self):
|
||||
from py.api.jobs import RESTORE_JOB_KIND
|
||||
@@ -585,7 +574,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
|
||||
self._video()
|
||||
self._hold(RESTORE_JOB_KIND)
|
||||
resp = await self._assert_placeholder(
|
||||
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
||||
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
|
||||
)
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
||||
self.assertFalse((self.output_dir / "thumbnails").exists())
|
||||
@@ -596,7 +585,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
|
||||
elsewhere.mkdir()
|
||||
self._make_symlink(elsewhere, self.output_dir / "thumbnails")
|
||||
resp = await self._assert_placeholder(
|
||||
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
||||
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
|
||||
)
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
||||
self.assertEqual(os.listdir(elsewhere), [])
|
||||
@@ -605,7 +594,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
|
||||
self._video()
|
||||
with patch.object(self.api, "_generate_video_thumbnail", return_value=False):
|
||||
resp = await self._assert_placeholder(
|
||||
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
|
||||
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
|
||||
)
|
||||
self.assertEqual(resp.headers["Cache-Control"], "no-store")
|
||||
|
||||
@@ -664,9 +653,6 @@ class TestWriteSafety(AutoThumbnailCase):
|
||||
|
||||
class TestShutdown(AutoThumbnailCase):
|
||||
async def test_no_thread_remains_after_cleanup_and_capture_cannot_restart(self):
|
||||
from py.api import thumbnail_encode
|
||||
|
||||
self.addCleanup(thumbnail_encode.reset_encoders)
|
||||
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
||||
self.assertEqual(self.api.queue_capture_thumbnail(str(path)), "queued")
|
||||
thumbnailer = self.api._auto_thumbnails()
|
||||
@@ -690,9 +676,6 @@ class TestShutdown(AutoThumbnailCase):
|
||||
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "skipped")
|
||||
restarted = self.api._auto_thumbnailer
|
||||
self.assertTrue(restarted is None or restarted._worker is None)
|
||||
# #317: no encoder process (ffmpeg, cjxl) starts after the cleanup.
|
||||
with self.assertRaises(thumbnail_encode.EncoderClosed):
|
||||
thumbnail_encode.run_encoder([sys.executable, "-c", "pass"])
|
||||
|
||||
async def test_an_idle_worker_leaves(self):
|
||||
path = make_png(self.output_dir / "a.png", size=(64, 64))
|
||||
@@ -706,7 +689,7 @@ class TestShutdown(AutoThumbnailCase):
|
||||
other = make_png(self.output_dir / "b.png", size=(64, 64))
|
||||
self.assertEqual(self.api.queue_capture_thumbnail(str(other)), "queued")
|
||||
await self._idle()
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "b_thumb.webp").is_file())
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "b_thumb.png").is_file())
|
||||
|
||||
async def test_a_reload_stops_the_old_worker(self):
|
||||
entered, release, _state = self._gate_writes()
|
||||
@@ -778,7 +761,7 @@ class TestHistorySharesTheAdmission(AutoThumbnailCase):
|
||||
self.assertEqual(answers["history"], auto.READY)
|
||||
resp = answers["view"]
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/png")
|
||||
self.assertEqual(await resp.read(), dst.read_bytes())
|
||||
|
||||
async def test_history_joining_a_view_flight_gets_the_outcome(self):
|
||||
@@ -790,10 +773,9 @@ class TestHistorySharesTheAdmission(AutoThumbnailCase):
|
||||
from py.api.jobs import RESTORE_JOB_KIND
|
||||
|
||||
make_png(self.output_dir / "c.png", size=(64, 64))
|
||||
# Another stem: same-stem files share one preview name (#317 R2).
|
||||
(self.output_dir / "cv.mp4").write_bytes(b"AV1 stream no decoder here")
|
||||
(self.output_dir / "c.mp4").write_bytes(b"AV1 stream no decoder here")
|
||||
self._hold(RESTORE_JOB_KIND)
|
||||
for name, is_video in (("c.png", False), ("cv.mp4", True)):
|
||||
for name, is_video in (("c.png", False), ("c.mp4", True)):
|
||||
with self.subTest(name=name):
|
||||
src = self.output_dir / name
|
||||
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
|
||||
@@ -815,9 +797,9 @@ class TestHistorySharesTheAdmission(AutoThumbnailCase):
|
||||
|
||||
def test_history_and_serve_route_share_one_flight_key(self):
|
||||
root = self.output_dir
|
||||
preview = root / "thumbnails" / "sub" / "a_thumb.webp"
|
||||
preview = root / "thumbnails" / "sub" / "a_thumb.png"
|
||||
self.assertEqual(
|
||||
auto.flight_key(root, ("thumbnails", "sub", "a_thumb.webp")),
|
||||
auto.flight_key(root, ("thumbnails", "sub", "a_thumb.png")),
|
||||
auto.flight_key(
|
||||
Path(os.path.normcase(os.path.realpath(root))),
|
||||
preview.relative_to(root).parts,
|
||||
@@ -831,7 +813,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
|
||||
outcome = self.api.queue_capture_thumbnail(str(path))
|
||||
self.assertEqual(outcome, "queued")
|
||||
await self._idle()
|
||||
thumb = self.output_dir / "thumbnails" / "run" / "ComfyUI_0001_thumb.webp"
|
||||
thumb = self.output_dir / "thumbnails" / "run" / "ComfyUI_0001_thumb.png"
|
||||
self.assertTrue(thumb.is_file())
|
||||
self.assertIn(
|
||||
"v=",
|
||||
@@ -855,7 +837,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
|
||||
|
||||
async def test_files_outside_the_roots_and_previews_are_ignored(self):
|
||||
outside = make_png(Path(self._output_tmp.name) / "x.png")
|
||||
preview = make_png(self.output_dir / "thumbnails" / "y_thumb.webp")
|
||||
preview = make_png(self.output_dir / "thumbnails" / "y_thumb.png")
|
||||
text = self.output_dir / "notes.txt"
|
||||
text.write_text("x")
|
||||
for path in (outside, preview, text, self.output_dir / "missing.png"):
|
||||
@@ -891,7 +873,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
|
||||
self.assertLessEqual(state["peak"], 2)
|
||||
for i in range(5):
|
||||
self.assertTrue(
|
||||
(self.output_dir / "thumbnails" / f"w{i}_thumb.webp").is_file()
|
||||
(self.output_dir / "thumbnails" / f"w{i}_thumb.png").is_file()
|
||||
)
|
||||
|
||||
async def test_worker_survives_a_failing_write(self):
|
||||
@@ -902,7 +884,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
|
||||
await self._idle()
|
||||
self.api.queue_capture_thumbnail(str(good))
|
||||
await self._idle()
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "good_thumb.webp").is_file())
|
||||
self.assertTrue((self.output_dir / "thumbnails" / "good_thumb.png").is_file())
|
||||
self.assertEqual(self.api._auto_thumbnails().failure_count, 1)
|
||||
|
||||
async def test_worker_writes_nothing_during_a_restore(self):
|
||||
@@ -937,7 +919,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
|
||||
await self._idle()
|
||||
for name in ("first", "second"):
|
||||
self.assertTrue(
|
||||
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
|
||||
(self.output_dir / "thumbnails" / f"{name}_thumb.png").is_file()
|
||||
)
|
||||
|
||||
async def test_a_worker_that_cannot_start_is_started_by_the_next_enqueue(self):
|
||||
@@ -958,7 +940,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
|
||||
await self._idle()
|
||||
for name in ("first", "second"):
|
||||
self.assertTrue(
|
||||
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
|
||||
(self.output_dir / "thumbnails" / f"{name}_thumb.png").is_file()
|
||||
)
|
||||
|
||||
def test_queue_is_bounded(self):
|
||||
@@ -1059,7 +1041,7 @@ class TestScanHandsOffThumbnails(AutoThumbnailCase):
|
||||
await self._idle()
|
||||
for i in range(2):
|
||||
self.assertTrue(
|
||||
(self.output_dir / "thumbnails" / f"s{i}_thumb.webp").is_file()
|
||||
(self.output_dir / "thumbnails" / f"s{i}_thumb.png").is_file()
|
||||
)
|
||||
|
||||
async def test_a_scan_that_added_nothing_does_nothing(self):
|
||||
@@ -1109,12 +1091,12 @@ class TestScanHandsOffThumbnails(AutoThumbnailCase):
|
||||
outcome = await self.api._thumbnails_after_scan(job, [self.output_dir, other])
|
||||
self.assertEqual(outcome, "queued")
|
||||
await self._idle()
|
||||
self.assertTrue((other / "thumbnails" / "o_thumb.webp").is_file())
|
||||
self.assertTrue((other / "thumbnails" / "o_thumb.png").is_file())
|
||||
self.assertFalse((self.output_dir / "thumbnails").exists())
|
||||
|
||||
async def test_nothing_missing_is_none(self):
|
||||
make_png(self.output_dir / "s.png", size=(32, 32))
|
||||
make_png(self.output_dir / "thumbnails" / "s_thumb.webp")
|
||||
make_png(self.output_dir / "thumbnails" / "s_thumb.png")
|
||||
self.assertEqual(
|
||||
await self.api._thumbnails_after_scan(self._scan_job(1)), "none"
|
||||
)
|
||||
|
||||
@@ -1,436 +0,0 @@
|
||||
"""Convert outdated thumbnails (#317): an explicit, cancellable Activity job.
|
||||
|
||||
``POST /prompt_manager/jobs/thumbnails {mode: "convert"}`` runs under the
|
||||
thumbnail job's kind, admission (``guarded``) and worker threads. For every
|
||||
media file whose preview is in another format than the one written now, it
|
||||
writes the new preview from the SOURCE (verified, atomic), then removes that
|
||||
file's old-format previews under ``thumbnails/``. A failure keeps the old
|
||||
preview; previews whose source is gone (orphans) are never touched. The final
|
||||
line reports converted / failed and bytes before → after.
|
||||
|
||||
Ordering is driven by events, never by timers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import os
|
||||
import sys
|
||||
import threading
|
||||
import unittest
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from tests.test_api_images import ThumbnailTestCase, make_png
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_api_images import ThumbnailTestCase, make_png
|
||||
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from py.api import thumbnail_encode # noqa: E402
|
||||
from py.config import PromptManagerConfig # noqa: E402
|
||||
|
||||
JOB = "/prompt_manager/jobs/thumbnails"
|
||||
PLAN = "/prompt_manager/images/thumbnails/plan"
|
||||
STATUS = "/prompt_manager/images/thumbnails/status"
|
||||
WAIT = 10
|
||||
|
||||
|
||||
def big_png(path, size=(512, 512)):
|
||||
"""A noisy PNG: its PNG preview is large, a WebP one much smaller."""
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.effect_noise(size, 40).convert("RGB").save(path, "PNG")
|
||||
return path
|
||||
|
||||
|
||||
class ConvertCase(ThumbnailTestCase):
|
||||
async def asyncSetUp(self):
|
||||
await super().asyncSetUp()
|
||||
saved = PromptManagerConfig.get_config()["thumbnails"]
|
||||
self.addCleanup(PromptManagerConfig.update_config, {"thumbnails": saved})
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
thumbnail_encode.reset_encoders()
|
||||
self.addCleanup(thumbnail_encode.reset_encoders)
|
||||
self.addCleanup(self.api.close_auto_thumbnails)
|
||||
|
||||
def _legacy(self, name, suffix=".png", source=True):
|
||||
"""A media file (unless ``source`` is False) and its old-format preview."""
|
||||
if source:
|
||||
big_png(self.output_dir / f"{name}.png")
|
||||
preview = big_png(self._thumb_path(name, suffix), size=(256, 256))
|
||||
if source:
|
||||
later = (self.output_dir / f"{name}.png").stat().st_mtime + 5
|
||||
os.utime(preview, (later, later))
|
||||
return preview
|
||||
|
||||
async def _convert(self):
|
||||
resp = await self.client.post(JOB, json={"mode": "convert"})
|
||||
body = await resp.json()
|
||||
self.assertEqual(resp.status, 202, body)
|
||||
job = self.api._jobs.get(body["job"]["id"])
|
||||
await asyncio.wait_for(job.task, WAIT)
|
||||
return body["job"], job
|
||||
|
||||
|
||||
class TestConvertOutdated(ConvertCase):
|
||||
async def test_plan_counts_the_outdated_previews(self):
|
||||
self._legacy("a")
|
||||
self._legacy("b")
|
||||
make_png(self.output_dir / "new.png") # missing: not this job's work
|
||||
plan = await (await self.client.get(PLAN, params={"mode": "convert"})).json()
|
||||
self.assertEqual(
|
||||
(plan["mode"], plan["targets"], plan["outdated"]), ("convert", 2, 2)
|
||||
)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_converts_from_the_source_and_removes_the_old_preview(self):
|
||||
old = self._legacy("a")
|
||||
original = (self.output_dir / "a.png").read_bytes()
|
||||
orphan = self._legacy("gone", source=False)
|
||||
make_png(self.output_dir / "new.png")
|
||||
before = old.stat().st_size
|
||||
|
||||
summary, job = await self._convert()
|
||||
|
||||
self.assertEqual(
|
||||
(summary["kind"], summary["admission"], summary["cancellable"]),
|
||||
("thumbnails", "guarded", True),
|
||||
)
|
||||
self.assertEqual(summary["title"], "Convert outdated thumbnails")
|
||||
self.assertEqual(summary["scope"]["mode"], "convert")
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.counters["generated"], 1)
|
||||
self.assertEqual(job.counters["errors"], 0)
|
||||
new = self._thumb_path("a")
|
||||
with Image.open(new) as img:
|
||||
self.assertEqual((img.format, max(img.size)), ("WEBP", 512))
|
||||
self.assertFalse(old.exists(), "the old PNG preview is removed")
|
||||
self.assertEqual((self.output_dir / "a.png").read_bytes(), original)
|
||||
self.assertTrue(orphan.exists(), "an orphan preview is never touched")
|
||||
self.assertFalse(self._thumb_path("new").exists(), "missing is not converted")
|
||||
after = new.stat().st_size
|
||||
self.assertRegex(job.status_line, r"^1 converted · 0 failed · .+ → .+$")
|
||||
self.assertTrue(any("Conversion finished" in line for line in job.log))
|
||||
self.assertLess(after, before)
|
||||
status = await (await self.client.get(STATUS)).json()
|
||||
self.assertEqual((status["outdated"], status["pending"]), (0, 1))
|
||||
|
||||
async def test_a_source_that_fails_keeps_its_old_preview(self):
|
||||
old = self._legacy("bad")
|
||||
(self.output_dir / "bad.png").write_bytes(b"\x89PNG broken")
|
||||
later = old.stat().st_mtime + 10
|
||||
os.utime(old, (later, later))
|
||||
keep = old.read_bytes()
|
||||
|
||||
_, job = await self._convert()
|
||||
|
||||
self.assertEqual(job.counters["errors"], 1)
|
||||
self.assertEqual(job.counters["generated"], 0)
|
||||
self.assertEqual(old.read_bytes(), keep)
|
||||
self.assertFalse(self._thumb_path("bad").exists())
|
||||
self.assertRegex(job.status_line, r"^0 converted · 1 failed")
|
||||
|
||||
async def test_a_video_without_a_frame_keeps_its_old_preview(self):
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"no decoder")
|
||||
old = big_png(self._thumb_path("clip", ".jpg"), size=(64, 64))
|
||||
keep = old.read_bytes()
|
||||
with (
|
||||
patch.object(self.api, "_opencv_frame", return_value=None),
|
||||
patch.object(self.api, "_ffmpeg_frame", return_value=None),
|
||||
):
|
||||
_, job = await self._convert()
|
||||
self.assertEqual(job.counters["errors"], 1)
|
||||
self.assertEqual(old.read_bytes(), keep)
|
||||
self.assertFalse(self._thumb_path("clip").exists(), "never the placeholder")
|
||||
|
||||
async def test_an_old_preview_that_is_a_link_is_left(self):
|
||||
big_png(self.output_dir / "l.png")
|
||||
target = big_png(self.comfy_dir / "elsewhere.png", size=(32, 32))
|
||||
link = self._thumb_path("l", ".png")
|
||||
link.parent.mkdir(parents=True, exist_ok=True)
|
||||
self._make_symlink(target, link)
|
||||
_, job = await self._convert()
|
||||
self.assertTrue(link.is_symlink())
|
||||
self.assertTrue(target.exists())
|
||||
self.assertEqual(job.counters["errors"], 0)
|
||||
|
||||
async def test_a_preview_already_current_only_loses_its_leftovers(self):
|
||||
old = self._legacy("c")
|
||||
current = big_png(self._thumb_path("c"), size=(64, 64))
|
||||
later = old.stat().st_mtime + 5
|
||||
os.utime(current, (later, later))
|
||||
written = current.read_bytes()
|
||||
_, job = await self._convert()
|
||||
self.assertEqual(job.counters["generated"], 1)
|
||||
self.assertEqual(current.read_bytes(), written, "not written again")
|
||||
self.assertFalse(old.exists())
|
||||
|
||||
async def test_cancel_stops_at_a_file_boundary_keeping_what_converted(self):
|
||||
original = PromptManagerConfig.WORKER_THREADS
|
||||
PromptManagerConfig.WORKER_THREADS = 1
|
||||
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
|
||||
olds = [self._legacy(f"p{i}") for i in range(6)]
|
||||
done = []
|
||||
real = self.api._convert_one
|
||||
job_ids = []
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
result = real(*args, **kwargs)
|
||||
done.append(result)
|
||||
if len(done) == 2:
|
||||
self.assertTrue(self.api._jobs.cancel(job_ids[0]))
|
||||
return result
|
||||
|
||||
self.api._convert_one = counting
|
||||
resp = await self.client.post(JOB, json={"mode": "convert"})
|
||||
job_ids.append((await resp.json())["job"]["id"])
|
||||
job = self.api._jobs.get(job_ids[0])
|
||||
await asyncio.wait_for(job.task, WAIT)
|
||||
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.counters["generated"], 2)
|
||||
converted = [p for p in olds if not p.exists()]
|
||||
self.assertEqual(len(converted), 2)
|
||||
for old in olds:
|
||||
webp = old.with_suffix(".webp")
|
||||
self.assertEqual(webp.exists(), not old.exists(), old.name)
|
||||
self.assertTrue(any("Conversion cancelled" in line for line in job.log))
|
||||
|
||||
async def test_refused_beside_a_running_thumbnail_job_or_scan(self):
|
||||
self._legacy("a")
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
self.addCleanup(release.set)
|
||||
real = self.api._convert_one
|
||||
|
||||
def gated(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(WAIT)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
self.api._convert_one = gated
|
||||
resp = await self.client.post(JOB, json={"mode": "convert"})
|
||||
first = (await resp.json())["job"]["id"]
|
||||
loop = asyncio.get_running_loop()
|
||||
self.assertTrue(await loop.run_in_executor(None, entered.wait, WAIT))
|
||||
again = await self.client.post(JOB, json={"mode": "missing"})
|
||||
self.assertEqual(again.status, 409)
|
||||
scan = await self.client.post("/prompt_manager/jobs/scan")
|
||||
self.assertEqual(scan.status, 409)
|
||||
release.set()
|
||||
await asyncio.wait_for(self.api._jobs.get(first).task, WAIT)
|
||||
|
||||
async def test_the_legacy_gallery_stream_never_attaches_to_a_conversion(self):
|
||||
try:
|
||||
from tests.test_api_images import parse_sse
|
||||
except ImportError:
|
||||
from test_api_images import parse_sse
|
||||
|
||||
self._legacy("a")
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
self.addCleanup(release.set)
|
||||
real = self.api._convert_one
|
||||
|
||||
def gated(*args, **kwargs):
|
||||
entered.set()
|
||||
release.wait(WAIT)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
self.api._convert_one = gated
|
||||
resp = await self.client.post(JOB, json={"mode": "convert"})
|
||||
first = (await resp.json())["job"]["id"]
|
||||
loop = asyncio.get_running_loop()
|
||||
self.assertTrue(await loop.run_in_executor(None, entered.wait, WAIT))
|
||||
stream = await self.client.post(self.PROGRESS_URL)
|
||||
events = parse_sse(await stream.text())
|
||||
self.assertEqual(events[-1][0], "error")
|
||||
self.assertIn("Convert outdated thumbnails", events[-1][1]["error"])
|
||||
release.set()
|
||||
await asyncio.wait_for(self.api._jobs.get(first).task, WAIT)
|
||||
|
||||
async def test_nothing_outdated_says_so(self):
|
||||
big_png(self.output_dir / "a.png")
|
||||
_, job = await self._convert()
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.status_line, "No outdated previews to convert")
|
||||
|
||||
async def test_the_source_vanishing_keeps_the_old_preview(self):
|
||||
old = self._legacy("v")
|
||||
real = self.api._generate_one
|
||||
|
||||
def vanish(src, *args, **kwargs):
|
||||
os.remove(src)
|
||||
return real(src, *args, **kwargs)
|
||||
|
||||
self.api._generate_one = vanish
|
||||
_, job = await self._convert()
|
||||
self.assertEqual(job.counters["errors"], 1)
|
||||
self.assertTrue(old.exists(), "a source gone mid-run makes an orphan")
|
||||
|
||||
async def test_same_stem_sources_convert_once_and_count_bytes_once(self):
|
||||
# Review I2 / R2: x.png and x.jpg share x_thumb.webp. The pair is
|
||||
# converted once, from the image first in the source order (.png),
|
||||
# and every removed file's bytes count once.
|
||||
original = PromptManagerConfig.WORKER_THREADS
|
||||
PromptManagerConfig.WORKER_THREADS = 2
|
||||
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
|
||||
from py.api.thumbnail_convert import readable_bytes
|
||||
|
||||
png = self.output_dir / "x.png"
|
||||
Image.new("RGB", (300, 300), (220, 20, 20)).save(png)
|
||||
jpg = self.output_dir / "x.jpg"
|
||||
Image.new("RGB", (300, 300), (20, 20, 220)).save(jpg)
|
||||
old_png = big_png(self._thumb_path("x", ".png"), size=(200, 200))
|
||||
old_jpg = self._thumb_path("x", ".jpg")
|
||||
Image.effect_noise((200, 200), 40).convert("RGB").save(old_jpg, "JPEG")
|
||||
later = max(png.stat().st_mtime, jpg.stat().st_mtime) + 5
|
||||
for path in (old_png, old_jpg):
|
||||
os.utime(path, (later, later))
|
||||
before = old_png.stat().st_size + old_jpg.stat().st_size
|
||||
|
||||
_, job = await self._convert()
|
||||
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(
|
||||
(job.counters["generated"], job.counters["errors"]), (2, 0), job.log
|
||||
)
|
||||
new = self._thumb_path("x")
|
||||
self.assertFalse(old_png.exists() or old_jpg.exists())
|
||||
with Image.open(new) as img:
|
||||
red, _green, blue = img.convert("RGB").getpixel((5, 5))
|
||||
self.assertGreater(red, blue, "the image first in the source order wins")
|
||||
self.assertEqual(
|
||||
job.status_line,
|
||||
f"2 converted · 0 failed · {readable_bytes(before)} → "
|
||||
f"{readable_bytes(new.stat().st_size)}",
|
||||
)
|
||||
|
||||
async def test_an_old_preview_already_gone_counts_as_removed(self):
|
||||
old = self._legacy("g")
|
||||
real = self.api._generate_one
|
||||
|
||||
def remove_first(src, *args, **kwargs):
|
||||
os.remove(old)
|
||||
return real(src, *args, **kwargs)
|
||||
|
||||
self.api._generate_one = remove_first
|
||||
_, job = await self._convert()
|
||||
self.assertEqual((job.counters["generated"], job.counters["errors"]), (1, 0))
|
||||
self.assertRegex(job.status_line, r"^1 converted · 0 failed · 0 B → ")
|
||||
|
||||
async def test_a_current_preview_that_does_not_decode_is_written_again(self):
|
||||
# Review M4: a kept current preview is decoded before old ones go.
|
||||
old = self._legacy("m")
|
||||
current = self._thumb_path("m")
|
||||
current.write_bytes(b"RIFF not a webp")
|
||||
later = old.stat().st_mtime + 5
|
||||
os.utime(current, (later, later))
|
||||
_, job = await self._convert()
|
||||
self.assertEqual((job.counters["generated"], job.counters["errors"]), (1, 0))
|
||||
with Image.open(current) as img:
|
||||
self.assertEqual(img.format, "WEBP")
|
||||
self.assertFalse(old.exists())
|
||||
|
||||
async def test_a_format_change_underneath_stops_the_job(self):
|
||||
# Review I4 (defense in depth): the job never removes previews in a
|
||||
# format chosen after it started.
|
||||
original = PromptManagerConfig.WORKER_THREADS
|
||||
PromptManagerConfig.WORKER_THREADS = 1
|
||||
self.addCleanup(setattr, PromptManagerConfig, "WORKER_THREADS", original)
|
||||
olds = [self._legacy(f"f{i}") for i in range(4)]
|
||||
real = self.api._convert_one
|
||||
calls = []
|
||||
|
||||
def switching(*args, **kwargs):
|
||||
result = real(*args, **kwargs)
|
||||
calls.append(result)
|
||||
if len(calls) == 1:
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "jpeg", "quality": 80, "edge": 512}}
|
||||
)
|
||||
jpeg = self._thumb_path("f3", ".jpg")
|
||||
Image.new("RGB", (16, 16)).save(jpeg, "JPEG")
|
||||
return result
|
||||
|
||||
self.api._convert_one = switching
|
||||
_, job = await self._convert()
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertIn("format changed", job.status_line)
|
||||
self.assertEqual(job.counters["generated"], 1)
|
||||
self.assertTrue(self._thumb_path("f3", ".jpg").exists(), "the new format stays")
|
||||
self.assertEqual(sum(1 for p in olds if p.exists()), 3)
|
||||
|
||||
async def test_the_new_preview_is_never_removed_as_an_old_one(self):
|
||||
# Re-review N1: on a case-insensitive disk x_thumb.JPG (an old
|
||||
# spelling) can be the x_thumb.jpg just written. A hard link stands
|
||||
# in for that second name of one file.
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "jpeg", "quality": 80, "edge": 512}}
|
||||
)
|
||||
src = self.output_dir / "x.jpg"
|
||||
Image.new("RGB", (64, 64), (200, 10, 10)).save(src, "JPEG")
|
||||
alias = self._thumb_path("x", ".JPG")
|
||||
alias.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (16, 16)).save(alias, "JPEG")
|
||||
later = src.stat().st_mtime + 5
|
||||
os.utime(alias, (later, later))
|
||||
real = self.api._generate_one
|
||||
|
||||
def same_file(source, dst, *args, **kwargs):
|
||||
made = real(source, dst, *args, **kwargs)
|
||||
os.remove(alias)
|
||||
os.link(made["path"], alias)
|
||||
return made
|
||||
|
||||
self.api._generate_one = same_file
|
||||
_, job = await self._convert()
|
||||
self.assertTrue(self._thumb_path("x", ".jpg").exists())
|
||||
self.assertTrue(alias.exists(), "the new preview's other name is kept")
|
||||
self.assertEqual(job.counters["errors"], 0)
|
||||
|
||||
def test_same_file_falls_back_to_the_path_without_inode_numbers(self):
|
||||
from py.api import thumbnail_convert as tc
|
||||
|
||||
a = self.output_dir / "a_thumb.jpg"
|
||||
b = self.output_dir / "b_thumb.jpg"
|
||||
for path in (a, b):
|
||||
path.write_bytes(b"x")
|
||||
real_stat = os.stat
|
||||
|
||||
def no_inode(path, *args, **kwargs):
|
||||
result = real_stat(path, *args, **kwargs)
|
||||
return os.stat_result(
|
||||
(result.st_mode, 0, result.st_dev) + tuple(result)[3:]
|
||||
)
|
||||
|
||||
with patch.object(tc.os, "stat", side_effect=no_inode):
|
||||
self.assertFalse(tc._same_file(a, b))
|
||||
self.assertTrue(tc._same_file(a, a))
|
||||
self.assertTrue(tc._same_file(a, self.output_dir / "." / "a_thumb.jpg"))
|
||||
self.assertFalse(tc._same_file(a, self.output_dir / "gone.jpg"))
|
||||
|
||||
async def test_convert_goes_on_past_the_per_batch_cap(self):
|
||||
import py.api.images as images_module
|
||||
|
||||
olds = [self._legacy(f"c{i}") for i in range(8)]
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 3):
|
||||
_, job = await self._convert()
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.counters["generated"], 8)
|
||||
self.assertFalse(any(old.exists() for old in olds))
|
||||
self.assertEqual(job.summary()["total"], 8)
|
||||
self.assertRegex(job.status_line, r"^8 converted · 0 failed · ")
|
||||
self.assertNotIn("next run", job.status_line)
|
||||
|
||||
def test_byte_sizes_are_readable(self):
|
||||
from py.api.thumbnail_convert import readable_bytes
|
||||
|
||||
self.assertEqual(readable_bytes(0), "0 B")
|
||||
self.assertEqual(readable_bytes(1536), "1.5 KB")
|
||||
self.assertEqual(readable_bytes(5 * 1024 * 1024), "5.0 MB")
|
||||
self.assertEqual(readable_bytes(3 * 1024**3), "3.0 GB")
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,525 +0,0 @@
|
||||
"""Thumbnail encoders (#317): WebP, JPEG and an optional JPEG XL encoder.
|
||||
|
||||
``py/api/thumbnail_encode.py`` writes every preview: the first frame fitted
|
||||
to the edge, encoded in the configured format into a temp sibling that is
|
||||
closed, verified to decode and renamed over the preview. JPEG XL needs no new
|
||||
dependency: Pillow (natively or through ``pillow_jxl``), ffmpeg's ``libjxl``
|
||||
or ``cjxl``, in that order. Encoder processes are tracked, bounded and
|
||||
killed on shutdown.
|
||||
"""
|
||||
|
||||
import logging
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import tempfile
|
||||
import threading
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.modules.setdefault("server", MagicMock())
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from PIL import Image # noqa: E402
|
||||
|
||||
from py.api import thumbnail_encode as te # noqa: E402
|
||||
from py.api.thumbnail_settings import ThumbnailSettings # noqa: E402
|
||||
|
||||
JXL_CODESTREAM = b"\xff\x0a"
|
||||
JXL_CONTAINER = b"\x00\x00\x00\x0cJXL \r\n\x87\n"
|
||||
|
||||
|
||||
def _ffmpeg_has_libjxl():
|
||||
try:
|
||||
return te._ffmpeg_has_libjxl()
|
||||
except Exception:
|
||||
return False
|
||||
|
||||
|
||||
class TempCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self.tmp = Path(os.path.realpath(tempfile.mkdtemp()))
|
||||
self.addCleanup(shutil.rmtree, self.tmp, True)
|
||||
te.reset_encoders()
|
||||
self.addCleanup(te.reset_encoders)
|
||||
|
||||
def _image(self, size=(800, 400), color=(200, 30, 30), mode="RGB"):
|
||||
return Image.new(mode, size, color)
|
||||
|
||||
def _leftovers(self):
|
||||
return [p.name for p in self.tmp.rglob("*") if p.name.startswith(".")]
|
||||
|
||||
|
||||
class TestDistance(unittest.TestCase):
|
||||
def test_quality_maps_to_libjxl_distance(self):
|
||||
self.assertAlmostEqual(te.jxl_distance(80), 1.9)
|
||||
self.assertAlmostEqual(te.jxl_distance(85), 1.45)
|
||||
self.assertAlmostEqual(te.jxl_distance(90), 1.0)
|
||||
self.assertAlmostEqual(te.jxl_distance(30), 6.4)
|
||||
self.assertAlmostEqual(te.jxl_distance(1), 23.8676666, places=4)
|
||||
# A preview is never encoded lossless.
|
||||
self.assertAlmostEqual(te.jxl_distance(100), 0.1)
|
||||
|
||||
|
||||
class TestDetection(TempCase):
|
||||
def test_pillow_native_first(self):
|
||||
with patch.dict(Image.SAVE, {"JXL": object()}):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "pillow")
|
||||
|
||||
def test_pillow_plugin_is_imported_when_installed(self):
|
||||
def fake_import(name):
|
||||
if name != "pillow_jxl":
|
||||
raise ImportError(name)
|
||||
Image.SAVE["JXL"] = object()
|
||||
return object()
|
||||
|
||||
self.addCleanup(Image.SAVE.pop, "JXL", None)
|
||||
with patch.object(te.importlib, "import_module", side_effect=fake_import):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "pillow")
|
||||
|
||||
def _no_pillow(self):
|
||||
saved = Image.SAVE.pop("JXL", None)
|
||||
if saved is not None:
|
||||
self.addCleanup(Image.SAVE.__setitem__, "JXL", saved)
|
||||
return patch.object(te.importlib, "import_module", side_effect=ImportError)
|
||||
|
||||
def test_ffmpeg_with_libjxl_then_cjxl_then_none(self):
|
||||
with self._no_pillow():
|
||||
with patch.object(te, "_ffmpeg_has_libjxl", return_value=True):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "ffmpeg")
|
||||
with (
|
||||
patch.object(te, "_ffmpeg_has_libjxl", return_value=False),
|
||||
patch.object(te.shutil, "which", return_value="/usr/bin/cjxl"),
|
||||
):
|
||||
self.assertEqual(te.detect_jxl_encoder(refresh=True), "cjxl")
|
||||
with (
|
||||
patch.object(te, "_ffmpeg_has_libjxl", return_value=False),
|
||||
patch.object(te.shutil, "which", return_value=None),
|
||||
):
|
||||
self.assertIsNone(te.detect_jxl_encoder(refresh=True))
|
||||
status = te.encoder_status()
|
||||
self.assertEqual(
|
||||
status["jxl"],
|
||||
{"available": False, "encoder": None, "reason": te.JXL_UNAVAILABLE},
|
||||
)
|
||||
self.assertIn("pillow-jxl-plugin", te.JXL_UNAVAILABLE)
|
||||
self.assertTrue(status["webp"]["available"])
|
||||
self.assertTrue(status["jpeg"]["available"])
|
||||
|
||||
def test_detection_is_cached_until_refreshed(self):
|
||||
with (
|
||||
self._no_pillow(),
|
||||
patch.object(te, "_ffmpeg_has_libjxl", return_value=True) as probe,
|
||||
):
|
||||
te.detect_jxl_encoder(refresh=True)
|
||||
te.detect_jxl_encoder()
|
||||
te.encoder_status()
|
||||
self.assertEqual(probe.call_count, 1)
|
||||
|
||||
def test_ffmpeg_probe_reads_the_encoder_list(self):
|
||||
listing = " V....D libjxl libjxl JPEG XL (codec jpegxl)\n"
|
||||
done = subprocess.CompletedProcess([], 0, listing, "")
|
||||
with (
|
||||
patch.object(te, "run_encoder", return_value=done),
|
||||
patch.object(te.shutil, "which", return_value="/usr/bin/ffmpeg"),
|
||||
):
|
||||
self.assertTrue(te._ffmpeg_has_libjxl())
|
||||
other = subprocess.CompletedProcess([], 0, " V..... libwebp\n", "")
|
||||
with (
|
||||
patch.object(te, "run_encoder", return_value=other),
|
||||
patch.object(te.shutil, "which", return_value="/usr/bin/ffmpeg"),
|
||||
):
|
||||
self.assertFalse(te._ffmpeg_has_libjxl())
|
||||
with patch.object(te.shutil, "which", return_value=None):
|
||||
self.assertFalse(te._ffmpeg_has_libjxl())
|
||||
|
||||
|
||||
class TestFrame(TempCase):
|
||||
def test_first_frame_of_an_animation_is_a_still(self):
|
||||
path = self.tmp / "anim.gif"
|
||||
frames = [Image.new("RGB", (64, 64), c) for c in ((255, 0, 0), (0, 0, 255))]
|
||||
frames[0].save(path, save_all=True, append_images=frames[1:], duration=50)
|
||||
with Image.open(path) as img:
|
||||
img.seek(1)
|
||||
frame = te.thumbnail_frame(img, 32)
|
||||
self.assertEqual(frame.mode, "RGB")
|
||||
self.assertEqual(frame.size, (32, 32))
|
||||
red, green, blue = frame.getpixel((16, 16))
|
||||
self.assertGreater(red, 200)
|
||||
self.assertLess(blue, 50)
|
||||
|
||||
def test_modes_without_rgb_are_converted_and_fitted(self):
|
||||
for mode, color in (("RGBA", (1, 2, 3, 0)), ("I;16", 300), ("L", 9)):
|
||||
frame = te.thumbnail_frame(self._image((1000, 250), color, mode), 512)
|
||||
self.assertEqual((frame.mode, frame.size), ("RGB", (512, 128)))
|
||||
|
||||
|
||||
class TestWrite(TempCase):
|
||||
def _settings(self, fmt="webp", quality=80, edge=512):
|
||||
return ThumbnailSettings(fmt, quality, edge)
|
||||
|
||||
def test_webp_and_jpeg_are_written_in_their_format(self):
|
||||
for fmt, name, pil in (("webp", "a.webp", "WEBP"), ("jpeg", "a.jpg", "JPEG")):
|
||||
dst = self.tmp / name
|
||||
written = te.write_thumbnail(self._image(), dst, self._settings(fmt))
|
||||
self.assertEqual(written, dst)
|
||||
with Image.open(dst) as img:
|
||||
self.assertEqual(img.format, pil)
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_quality_changes_the_bytes(self):
|
||||
noisy = Image.effect_noise((256, 256), 64).convert("RGB")
|
||||
low = te.write_thumbnail(
|
||||
noisy, self.tmp / "low.webp", self._settings(quality=10)
|
||||
)
|
||||
high = te.write_thumbnail(
|
||||
noisy, self.tmp / "high.webp", self._settings(quality=95)
|
||||
)
|
||||
self.assertLess(low.stat().st_size, high.stat().st_size)
|
||||
|
||||
def test_guard_false_keeps_the_old_preview(self):
|
||||
dst = self.tmp / "a.webp"
|
||||
dst.write_bytes(b"old")
|
||||
with self.assertRaises(te.PreviewWriteHeld):
|
||||
te.write_thumbnail(
|
||||
self._image(), dst, self._settings(), guard=lambda: False
|
||||
)
|
||||
self.assertEqual(dst.read_bytes(), b"old")
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_a_file_that_does_not_decode_is_never_renamed_in(self):
|
||||
dst = self.tmp / "a.webp"
|
||||
dst.write_bytes(b"old")
|
||||
|
||||
def broken(img, tmp, settings, track):
|
||||
Path(tmp).write_bytes(b"not an image")
|
||||
|
||||
with patch.dict(te._WRITERS, {"webp": broken}):
|
||||
with self.assertRaises(te.ThumbnailVerifyError):
|
||||
te.write_thumbnail(self._image(), dst, self._settings())
|
||||
self.assertEqual(dst.read_bytes(), b"old")
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_jxl_without_an_encoder_falls_back_to_webp_and_logs_once(self):
|
||||
with patch.object(te, "detect_jxl_encoder", return_value=None):
|
||||
with self.assertLogs("prompt_manager.thumbnails", logging.WARNING) as logs:
|
||||
first = te.write_thumbnail(
|
||||
self._image(), self.tmp / "a.jxl", self._settings("jxl")
|
||||
)
|
||||
second = te.write_thumbnail(
|
||||
self._image(), self.tmp / "b.jxl", self._settings("jxl")
|
||||
)
|
||||
self.assertEqual((first.name, second.name), ("a.webp", "b.webp"))
|
||||
self.assertEqual(len([m for m in logs.output if "JPEG XL" in m]), 1)
|
||||
with Image.open(first) as img:
|
||||
self.assertEqual(img.format, "WEBP")
|
||||
|
||||
def _write_jxl(self, encoder):
|
||||
dst = self.tmp / f"{encoder}.jxl"
|
||||
with patch.object(te, "detect_jxl_encoder", return_value=encoder):
|
||||
written = te.write_thumbnail(self._image(), dst, self._settings("jxl"))
|
||||
self.assertEqual(written, dst)
|
||||
head = dst.read_bytes()[:12]
|
||||
self.assertTrue(
|
||||
head.startswith(JXL_CODESTREAM) or head.startswith(JXL_CONTAINER), head
|
||||
)
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
@unittest.skipUnless(_ffmpeg_has_libjxl(), "ffmpeg without libjxl")
|
||||
def test_jxl_through_ffmpeg(self):
|
||||
self._write_jxl("ffmpeg")
|
||||
|
||||
@unittest.skipUnless(shutil.which("cjxl"), "no cjxl")
|
||||
def test_jxl_through_cjxl(self):
|
||||
self._write_jxl("cjxl")
|
||||
|
||||
def test_jxl_through_pillow_passes_the_quality(self):
|
||||
seen = {}
|
||||
|
||||
def save_jxl(im, fp, filename):
|
||||
seen.update(im.encoderinfo)
|
||||
fp.write(JXL_CODESTREAM + b"\x00" * 16)
|
||||
|
||||
Image.register_save("PMTESTJXL", save_jxl)
|
||||
self.addCleanup(Image.SAVE.pop, "PMTESTJXL", None)
|
||||
with (
|
||||
patch.object(te, "PILLOW_JXL_FORMAT", "PMTESTJXL"),
|
||||
patch.object(te, "detect_jxl_encoder", return_value="pillow"),
|
||||
):
|
||||
te.write_thumbnail(self._image(), self.tmp / "p.jxl", self._settings("jxl"))
|
||||
self.assertEqual(seen["quality"], 80)
|
||||
self.assertEqual(seen["num_threads"], 1)
|
||||
|
||||
def test_a_failing_jxl_encoder_raises_and_keeps_nothing(self):
|
||||
failed = subprocess.CompletedProcess([], 1, b"", b"boom")
|
||||
with (
|
||||
patch.object(te, "detect_jxl_encoder", return_value="cjxl"),
|
||||
patch.object(te, "run_encoder", return_value=failed),
|
||||
):
|
||||
with self.assertRaises(te.EncoderFailed):
|
||||
te.write_thumbnail(
|
||||
self._image(), self.tmp / "c.jxl", self._settings("jxl")
|
||||
)
|
||||
self.assertFalse((self.tmp / "c.jxl").exists())
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_verify_accepts_a_jxl_signature_when_pillow_cannot_decode(self):
|
||||
path = self.tmp / "s.jxl"
|
||||
path.write_bytes(JXL_CONTAINER + b"\x00" * 8)
|
||||
with patch.object(te, "_pillow_decodes_jxl", return_value=False):
|
||||
te.verify_thumbnail(path, "jxl")
|
||||
path.write_bytes(b"\x89PNG")
|
||||
with self.assertRaises(te.ThumbnailVerifyError):
|
||||
te.verify_thumbnail(path, "jxl")
|
||||
|
||||
|
||||
class TestFfmpegNames(TempCase):
|
||||
"""Review I1: ffmpeg's image2 muxer reads ``%d`` in an output name as a
|
||||
sequence pattern; ``-update 1`` keeps the temp name literal."""
|
||||
|
||||
def test_the_ffmpeg_command_writes_one_literal_file(self):
|
||||
seen = []
|
||||
|
||||
def fake(cmd, **kwargs):
|
||||
seen.append(cmd)
|
||||
Path(cmd[-1]).write_bytes(JXL_CODESTREAM + b"\x00" * 8)
|
||||
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||
|
||||
with (
|
||||
patch.object(te, "detect_jxl_encoder", return_value="ffmpeg"),
|
||||
patch.object(te, "run_encoder", side_effect=fake),
|
||||
patch.object(te, "_pillow_decodes_jxl", return_value=False),
|
||||
):
|
||||
te.write_thumbnail(
|
||||
Image.new("RGB", (8, 8)),
|
||||
self.tmp / "a_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
cmd = seen[0]
|
||||
self.assertEqual(cmd[cmd.index("-update") + 1], "1")
|
||||
self.assertLess(cmd.index("-update"), len(cmd) - 1)
|
||||
|
||||
@unittest.skipUnless(_ffmpeg_has_libjxl(), "ffmpeg without libjxl")
|
||||
def test_a_percent_in_the_name_is_written_under_that_name(self):
|
||||
dst = self.tmp / "50%done_thumb.jxl"
|
||||
with patch.object(te, "detect_jxl_encoder", return_value="ffmpeg"):
|
||||
written = te.write_thumbnail(
|
||||
Image.new("RGB", (32, 32), (10, 200, 10)),
|
||||
dst,
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
self.assertEqual(written, dst)
|
||||
self.assertTrue(dst.is_file())
|
||||
self.assertEqual(self._leftovers(), [], "no stray sequence-named temp")
|
||||
|
||||
|
||||
class TestStrayTemps(TempCase):
|
||||
"""Review I1: a temp left by a crash (or the old ``%`` bug) is swept."""
|
||||
|
||||
def _touch(self, name, age=3600):
|
||||
path = self.tmp / name
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x" * 10)
|
||||
when = path.stat().st_mtime - age
|
||||
os.utime(path, (when, when))
|
||||
return path
|
||||
|
||||
def test_only_old_temp_siblings_are_removed(self):
|
||||
strays = [
|
||||
self._touch(".a_thumb.part-12-34.webp"),
|
||||
self._touch(".501one.part-1-2.jxl"),
|
||||
self._touch("sub/.b_thumb.src-5-6.png"),
|
||||
]
|
||||
kept = [
|
||||
self._touch("a_thumb.webp"),
|
||||
self._touch(".hidden"),
|
||||
self._touch("notes.part-1-2.webp"),
|
||||
self._touch(".c_thumb.part-x-2.webp"),
|
||||
self._touch(".young_thumb.part-7-8.webp", age=0),
|
||||
]
|
||||
# A temp of this process written since it started may be in use.
|
||||
own = self._touch(f".mine_thumb.part-{os.getpid()}-9.webp", age=0)
|
||||
count, size = te.sweep_stray_temps(self.tmp, min_age=60)
|
||||
self.assertEqual((count, size), (3, 30))
|
||||
for path in strays:
|
||||
self.assertFalse(path.exists(), path.name)
|
||||
for path in kept + [own]:
|
||||
self.assertTrue(path.exists(), path.name)
|
||||
|
||||
def test_without_an_age_every_temp_goes_but_links_stay(self):
|
||||
stray = self._touch(".a_thumb.part-1-2.webp", age=0)
|
||||
own = self._touch(f".mine_thumb.part-{os.getpid()}-9.webp", age=0)
|
||||
target = self._touch("target.bin")
|
||||
link = self.tmp / ".l_thumb.part-1-2.webp"
|
||||
try:
|
||||
link.symlink_to(target)
|
||||
except (OSError, NotImplementedError):
|
||||
link = None
|
||||
self.assertEqual(te.sweep_stray_temps(self.tmp, min_age=0)[0], 2)
|
||||
self.assertFalse(stray.exists() or own.exists())
|
||||
if link is not None:
|
||||
self.assertTrue(link.is_symlink())
|
||||
|
||||
def test_an_own_pid_temp_older_than_this_process_is_swept(self):
|
||||
# Re-review N3: under stable PIDs (Docker) the previous run's crash
|
||||
# leftovers carry this process's PID; they predate its start.
|
||||
before = te.PROCESS_STARTED - 3600
|
||||
stale = self._touch(f".old_thumb.part-{os.getpid()}-9.webp")
|
||||
os.utime(stale, (before, before))
|
||||
live = self._touch(f".new_thumb.part-{os.getpid()}-9.webp", age=0)
|
||||
self.assertEqual(te.sweep_stray_temps(self.tmp, min_age=60)[0], 1)
|
||||
self.assertFalse(stale.exists())
|
||||
self.assertTrue(live.exists())
|
||||
|
||||
def test_a_missing_folder_sweeps_nothing(self):
|
||||
self.assertEqual(te.sweep_stray_temps(self.tmp / "none"), (0, 0))
|
||||
|
||||
|
||||
class TestEncoderVanishes(TempCase):
|
||||
"""Review I3: an encoder that disappears after detection is detected
|
||||
again once; with none left, that file is written as WebP and the
|
||||
availability Appearance reads is refreshed."""
|
||||
|
||||
def test_a_missing_binary_falls_back_to_webp_and_refreshes(self):
|
||||
found = iter(["cjxl", None, None])
|
||||
with (
|
||||
patch.object(te, "_detect", side_effect=lambda: next(found)),
|
||||
patch.object(te.shutil, "which", return_value=None),
|
||||
patch.object(te, "run_encoder", side_effect=FileNotFoundError("cjxl")),
|
||||
self.assertLogs("prompt_manager.thumbnails", logging.WARNING),
|
||||
):
|
||||
self.assertTrue(te.encoder_status()["jxl"]["available"])
|
||||
written = te.write_thumbnail(
|
||||
Image.new("RGB", (16, 16)),
|
||||
self.tmp / "v_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
status = te.encoder_status()["jxl"]
|
||||
self.assertEqual(written.name, "v_thumb.webp")
|
||||
with Image.open(written) as img:
|
||||
self.assertEqual(img.format, "WEBP")
|
||||
self.assertEqual((status["available"], status["encoder"]), (False, None))
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_another_encoder_found_again_is_used(self):
|
||||
found = iter(["cjxl", "ffmpeg"])
|
||||
used = []
|
||||
|
||||
def fake(cmd, **kwargs):
|
||||
used.append(cmd[0])
|
||||
if cmd[0] == "cjxl":
|
||||
raise FileNotFoundError("cjxl")
|
||||
Path(cmd[-1]).write_bytes(JXL_CODESTREAM + b"\x00" * 8)
|
||||
return subprocess.CompletedProcess(cmd, 0, b"", b"")
|
||||
|
||||
with (
|
||||
patch.object(te, "_detect", side_effect=lambda: next(found)),
|
||||
patch.object(
|
||||
te.shutil,
|
||||
"which",
|
||||
side_effect=lambda name: None if name == "cjxl" else name,
|
||||
),
|
||||
patch.object(te, "run_encoder", side_effect=fake),
|
||||
patch.object(te, "_pillow_decodes_jxl", return_value=False),
|
||||
):
|
||||
written = te.write_thumbnail(
|
||||
Image.new("RGB", (16, 16)),
|
||||
self.tmp / "w_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
self.assertEqual(written.name, "w_thumb.jxl")
|
||||
self.assertEqual(used, ["cjxl", "ffmpeg"])
|
||||
self.assertEqual(self._leftovers(), [])
|
||||
|
||||
def test_a_failure_with_the_encoder_present_does_not_detect_again(self):
|
||||
# Re-review N2: one bad file never runs a probe (which may time out
|
||||
# under load and turn JPEG XL off).
|
||||
failed = subprocess.CompletedProcess([], 1, b"", b"bad input")
|
||||
with (
|
||||
patch.object(te, "_detect", return_value="cjxl") as detect,
|
||||
patch.object(te.shutil, "which", return_value="/usr/bin/cjxl"),
|
||||
patch.object(te, "run_encoder", return_value=failed),
|
||||
):
|
||||
with self.assertRaises(te.EncoderFailed):
|
||||
te.write_thumbnail(
|
||||
Image.new("RGB", (16, 16)),
|
||||
self.tmp / "x_thumb.jxl",
|
||||
ThumbnailSettings("jxl", 80, 64),
|
||||
)
|
||||
self.assertEqual(detect.call_count, 1, "no second probe")
|
||||
|
||||
def test_no_encoder_found_is_checked_again_on_the_settings_read(self):
|
||||
# Re-review N2: a probe that found nothing (a timeout) recovers.
|
||||
found = iter([None, "cjxl"])
|
||||
with patch.object(te, "_detect", side_effect=lambda: next(found)):
|
||||
self.assertIsNone(te.detect_jxl_encoder())
|
||||
self.assertEqual(te.encoder_status()["jxl"]["encoder"], "cjxl")
|
||||
self.assertEqual(te.detect_jxl_encoder(), "cjxl")
|
||||
|
||||
def test_no_encoder_found_is_checked_again_after_a_while(self):
|
||||
found = iter([None, "ffmpeg"])
|
||||
clock = [1000.0]
|
||||
with (
|
||||
patch.object(te, "_detect", side_effect=lambda: next(found)) as detect,
|
||||
patch.object(te.time, "monotonic", side_effect=lambda: clock[0]),
|
||||
):
|
||||
self.assertIsNone(te.detect_jxl_encoder())
|
||||
self.assertIsNone(te.detect_jxl_encoder(), "cached within the TTL")
|
||||
clock[0] += te.MISSING_ENCODER_TTL + 1
|
||||
self.assertEqual(te.detect_jxl_encoder(), "ffmpeg")
|
||||
self.assertEqual(te.detect_jxl_encoder(), "ffmpeg")
|
||||
self.assertEqual(detect.call_count, 2)
|
||||
|
||||
|
||||
class TestEncoderProcesses(TempCase):
|
||||
SLEEPER = [sys.executable, "-c", "import time; time.sleep(60)"]
|
||||
|
||||
def test_a_process_past_its_timeout_is_killed(self):
|
||||
with self.assertRaises(subprocess.TimeoutExpired):
|
||||
te.run_encoder(self.SLEEPER, timeout=0.5)
|
||||
|
||||
def test_shutdown_kills_running_encoders_and_refuses_new_ones(self):
|
||||
started = threading.Event()
|
||||
tracked = []
|
||||
|
||||
def track(process):
|
||||
tracked.append(process)
|
||||
started.set()
|
||||
|
||||
outcome = {}
|
||||
|
||||
def run():
|
||||
try:
|
||||
outcome["result"] = te.run_encoder(
|
||||
self.SLEEPER, timeout=60, track=track
|
||||
)
|
||||
except Exception as exc: # noqa: BLE001 - recorded for the assertion
|
||||
outcome["error"] = exc
|
||||
|
||||
worker = threading.Thread(target=run)
|
||||
worker.start()
|
||||
self.assertTrue(started.wait(20))
|
||||
te.shutdown_encoders()
|
||||
worker.join(20)
|
||||
self.assertFalse(worker.is_alive())
|
||||
self.assertIsNotNone(tracked[0].poll())
|
||||
self.assertIsInstance(outcome.get("error"), te.EncoderClosed)
|
||||
with self.assertRaises(te.EncoderClosed):
|
||||
te.run_encoder(self.SLEEPER, timeout=1)
|
||||
|
||||
def test_input_is_piped_and_output_returned(self):
|
||||
echo = [
|
||||
sys.executable,
|
||||
"-c",
|
||||
"import sys; sys.stdout.buffer.write(sys.stdin.buffer.read()[::-1])",
|
||||
]
|
||||
done = te.run_encoder(echo, input=b"abc", timeout=30)
|
||||
self.assertEqual((done.returncode, done.stdout), (0, b"cba"))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
@@ -1,444 +0,0 @@
|
||||
"""Every preview writer and lookup follows the thumbnail settings (#317).
|
||||
|
||||
Automatic previews (capture and first view), the Activity job, the legacy
|
||||
gallery routes, the listings, the history route, duplicates, enrichment and
|
||||
the clear all name previews ``<stem>_thumb.<ext of the configured format>``,
|
||||
write them with the configured quality and size limit, and still find older
|
||||
previews (``.png`` and friends) until they are converted.
|
||||
|
||||
A thumbnail is only a grid or cover placeholder: inspection always reads the
|
||||
original, and never resolves a request to a file under ``thumbnails/``.
|
||||
|
||||
Ordering is driven by events, never by timers.
|
||||
"""
|
||||
|
||||
import asyncio
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import subprocess
|
||||
import sys
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
from unittest.mock import patch
|
||||
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
try:
|
||||
from tests.test_api_images import ThumbnailTestCase, make_png, parse_sse
|
||||
except ImportError: # discovered with tests/ as the top-level directory
|
||||
from test_api_images import ThumbnailTestCase, make_png, parse_sse
|
||||
|
||||
from PIL import Image, PngImagePlugin # noqa: E402
|
||||
|
||||
import py.api.images as images_module # noqa: E402
|
||||
import py.api.thumbnail_auto as auto # noqa: E402
|
||||
from py.api import thumbnail_encode # noqa: E402
|
||||
from py.api.admin import _remove_thumbnail # noqa: E402
|
||||
from py.config import PromptManagerConfig # noqa: E402
|
||||
|
||||
SERVE = "/prompt_manager/images/serve/"
|
||||
STATUS = "/prompt_manager/images/thumbnails/status"
|
||||
PLAN = "/prompt_manager/images/thumbnails/plan"
|
||||
JOB = "/prompt_manager/jobs/thumbnails"
|
||||
WAIT = 10
|
||||
HAVE_FFMPEG = bool(shutil.which("ffmpeg"))
|
||||
|
||||
|
||||
def graph(positive="portrait", negative="blurry"):
|
||||
return {
|
||||
"1": {"class_type": "CLIPTextEncode", "inputs": {"text": positive}},
|
||||
"2": {"class_type": "CLIPTextEncode", "inputs": {"text": negative}},
|
||||
"3": {
|
||||
"class_type": "KSampler",
|
||||
"inputs": {"positive": ["1", 0], "negative": ["2", 0]},
|
||||
},
|
||||
}
|
||||
|
||||
|
||||
def png_with_prompt(path, size=(64, 64)):
|
||||
info = PngImagePlugin.PngInfo()
|
||||
info.add_text("prompt", json.dumps(graph()))
|
||||
info.add_text("workflow", json.dumps({"nodes": [], "links": []}))
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
with Image.new("RGB", size, (9, 90, 200)) as image:
|
||||
image.save(path, pnginfo=info)
|
||||
return path
|
||||
|
||||
|
||||
class FormatCase(ThumbnailTestCase):
|
||||
async def asyncSetUp(self):
|
||||
await super().asyncSetUp()
|
||||
self._orig = PromptManagerConfig.get_config()
|
||||
self.addCleanup(PromptManagerConfig.update_config, self._orig)
|
||||
self._settings("webp", 80, 512)
|
||||
thumbnail_encode.reset_encoders()
|
||||
self.addCleanup(thumbnail_encode.reset_encoders)
|
||||
self.addCleanup(self.api.close_auto_thumbnails)
|
||||
|
||||
def _settings(self, fmt, quality, edge):
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": fmt, "quality": quality, "edge": edge}}
|
||||
)
|
||||
|
||||
def _thumb(self, name, suffix=".webp"):
|
||||
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
|
||||
|
||||
async def _idle(self):
|
||||
loop = asyncio.get_running_loop()
|
||||
thumbnailer = self.api._auto_thumbnails()
|
||||
self.assertTrue(await loop.run_in_executor(None, thumbnailer.wait_idle, WAIT))
|
||||
|
||||
async def _capture(self, path):
|
||||
self.assertEqual(self.api.queue_capture_thumbnail(path), "queued")
|
||||
await self._idle()
|
||||
|
||||
async def _finished(self, job_id):
|
||||
job = self.api._jobs.get(job_id)
|
||||
await asyncio.wait_for(job.task, WAIT)
|
||||
return job
|
||||
|
||||
def _format_of(self, path):
|
||||
with Image.open(path) as img:
|
||||
return img.format, img.size
|
||||
|
||||
|
||||
class TestCaptureAndViewFollowTheSettings(FormatCase):
|
||||
async def test_capture_writes_webp_at_the_size_limit(self):
|
||||
self._settings("webp", 80, 200)
|
||||
src = make_png(self.output_dir / "a.png", size=(800, 400))
|
||||
await self._capture(src)
|
||||
self.assertEqual(self._format_of(self._thumb("a")), ("WEBP", (200, 100)))
|
||||
self.assertFalse(self._thumb("a", ".png").exists())
|
||||
|
||||
async def test_capture_writes_jpeg_when_configured(self):
|
||||
self._settings("jpeg", 50, 300)
|
||||
src = make_png(self.output_dir / "sub" / "b.png", size=(600, 600))
|
||||
await self._capture(src)
|
||||
made = self.output_dir / "thumbnails" / "sub" / "b_thumb.jpg"
|
||||
self.assertEqual(self._format_of(made), ("JPEG", (300, 300)))
|
||||
|
||||
async def test_quality_reaches_the_encoder(self):
|
||||
noisy = Image.effect_noise((400, 400), 80).convert("RGB")
|
||||
for name, quality in (("low", 10), ("high", 95)):
|
||||
noisy.save(self.output_dir / f"{name}.png")
|
||||
self._settings("webp", quality, 512)
|
||||
await self._capture(self.output_dir / f"{name}.png")
|
||||
self.assertLess(
|
||||
self._thumb("low").stat().st_size, self._thumb("high").stat().st_size
|
||||
)
|
||||
|
||||
async def test_an_animated_source_previews_as_a_still(self):
|
||||
path = self.output_dir / "anim.gif"
|
||||
frames = [Image.new("RGB", (64, 64), c) for c in ((250, 0, 0), (0, 0, 250))]
|
||||
frames[0].save(path, save_all=True, append_images=frames[1:], duration=40)
|
||||
await self._capture(path)
|
||||
with Image.open(self._thumb("anim")) as img:
|
||||
self.assertEqual(getattr(img, "n_frames", 1), 1)
|
||||
self.assertGreater(img.convert("RGB").getpixel((32, 32))[0], 200)
|
||||
|
||||
async def test_a_video_preview_is_the_configured_format(self):
|
||||
frame = Image.new("RGB", (640, 360), (0, 200, 0))
|
||||
clip = self.output_dir / "clip.mp4"
|
||||
clip.write_bytes(b"not decoded")
|
||||
with patch.object(self.api, "_opencv_frame", return_value=frame):
|
||||
await self._capture(clip)
|
||||
self.assertEqual(self._format_of(self._thumb("clip"))[0], "WEBP")
|
||||
self.assertFalse(self._thumb("clip", ".jpg").exists())
|
||||
|
||||
@unittest.skipUnless(HAVE_FFMPEG, "no ffmpeg")
|
||||
async def test_ffmpeg_video_frame_is_encoded_in_the_format(self):
|
||||
clip = self.output_dir / "real.mp4"
|
||||
subprocess.run(
|
||||
["ffmpeg", "-v", "quiet", "-f", "lavfi", "-i",
|
||||
"color=c=red:s=320x240:d=2", "-y", str(clip)],
|
||||
check=True, timeout=60,
|
||||
) # fmt: skip
|
||||
with patch.object(self.api, "_opencv_frame", return_value=None):
|
||||
await self._capture(clip)
|
||||
fmt, size = self._format_of(self._thumb("real"))
|
||||
self.assertEqual(fmt, "WEBP")
|
||||
self.assertEqual(size, (320, 240))
|
||||
|
||||
async def test_view_makes_the_preview_and_sends_its_type(self):
|
||||
make_png(self.output_dir / "v.png", size=(64, 64))
|
||||
resp = await self.client.get(SERVE + "thumbnails/v_thumb.webp")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(resp.headers["Cache-Control"], "public, max-age=3600")
|
||||
self.assertTrue(self._thumb("v").is_file())
|
||||
|
||||
async def test_a_jxl_preview_is_served_as_image_jxl(self):
|
||||
make_png(self.output_dir / "j.png")
|
||||
thumb = self._thumb("j", ".jxl")
|
||||
thumb.parent.mkdir(parents=True, exist_ok=True)
|
||||
thumb.write_bytes(b"\xff\x0a" + b"\x00" * 30)
|
||||
resp = await self.client.get(SERVE + "thumbnails/j_thumb.jxl")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/jxl")
|
||||
self.assertEqual(resp.headers["Cache-Control"], "public, max-age=3600")
|
||||
|
||||
async def test_a_converted_away_legacy_url_answers_the_current_preview(self):
|
||||
make_png(self.output_dir / "c.png")
|
||||
await self._capture(self.output_dir / "c.png")
|
||||
resp = await self.client.get(SERVE + "thumbnails/c_thumb.png?v=1")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
self.assertFalse(self._thumb("c", ".png").exists())
|
||||
|
||||
async def test_preview_requests_keep_the_text_checks_for_new_extensions(self):
|
||||
accepted = ("thumbnails/a_thumb.jxl", "thumbnails/s/a_thumb.webp")
|
||||
for text in accepted:
|
||||
self.assertTrue(auto._is_preview_request(text), text)
|
||||
refused = (
|
||||
"thumbnails/a_thumb.JXL",
|
||||
"thumbnails\\a_thumb.jxl",
|
||||
"thumbnails/C:a_thumb.webp",
|
||||
"thumbnails//a_thumb.webp",
|
||||
"thumbnails/./a_thumb.jxl",
|
||||
"thumbnails/../a_thumb.jxl",
|
||||
"thumbnails/thumbnails/a_thumb.webp",
|
||||
"thumbnails/a_thumb.txt",
|
||||
"thumbnails/_thumb.webp",
|
||||
)
|
||||
for text in refused:
|
||||
self.assertFalse(auto._is_preview_request(text), text)
|
||||
make_png(self.outside_png())
|
||||
resp = await self.client.get(SERVE + "thumbnails/..%2F..%2Fout_thumb.webp")
|
||||
self.assertIn(resp.status, (403, 404))
|
||||
self.assertFalse((self.comfy_dir / "out_thumb.webp").exists())
|
||||
|
||||
def outside_png(self):
|
||||
return self.comfy_dir / "out.png"
|
||||
|
||||
async def test_jxl_without_an_encoder_writes_webp_once(self):
|
||||
self._settings("jxl", 80, 512)
|
||||
src = make_png(self.output_dir / "x.png", size=(64, 64))
|
||||
calls = []
|
||||
original = self.api._generate_one
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
calls.append(1)
|
||||
return original(*args, **kwargs)
|
||||
|
||||
self.api._generate_one = counting
|
||||
with patch.object(thumbnail_encode, "detect_jxl_encoder", return_value=None):
|
||||
await self._capture(src)
|
||||
self.assertTrue(self._thumb("x").is_file())
|
||||
self.assertFalse(self._thumb("x", ".jxl").exists())
|
||||
self.assertEqual(self.api.queue_capture_thumbnail(src), "fresh")
|
||||
resp = await self.client.get(SERVE + "thumbnails/x_thumb.jxl")
|
||||
self.assertEqual(resp.headers["Content-Type"], "image/webp")
|
||||
self.assertEqual(len(calls), 1)
|
||||
|
||||
|
||||
class TestListingsFindAnyOwnedPreview(FormatCase):
|
||||
def _listed(self, rel):
|
||||
return images_module.media_thumbnail(self.output_dir, Path(rel), 0)
|
||||
|
||||
def test_missing_preview_lists_the_configured_name(self):
|
||||
make_png(self.output_dir / "m.png")
|
||||
url, ready = self._listed("m.png")
|
||||
self.assertEqual(url, SERVE + "thumbnails/m_thumb.webp?root=0")
|
||||
self.assertFalse(ready)
|
||||
|
||||
def test_a_legacy_preview_is_listed_until_a_current_one_exists(self):
|
||||
make_png(self.output_dir / "l.png")
|
||||
make_png(self._thumb("l", ".png"))
|
||||
url, ready = self._listed("l.png")
|
||||
self.assertTrue(url.startswith(SERVE + "thumbnails/l_thumb.png?v="))
|
||||
self.assertTrue(ready)
|
||||
make_png(self._thumb("l"))
|
||||
url, _ready = self._listed("l.png")
|
||||
self.assertTrue(url.startswith(SERVE + "thumbnails/l_thumb.webp?v="))
|
||||
|
||||
def test_a_video_lists_its_legacy_jpg(self):
|
||||
(self.output_dir / "v.mp4").write_bytes(b"x")
|
||||
make_png(self._thumb("v", ".jpg"))
|
||||
url, ready = self._listed("v.mp4")
|
||||
self.assertTrue(url.startswith(SERVE + "thumbnails/v_thumb.jpg?v="))
|
||||
self.assertTrue(ready)
|
||||
|
||||
def test_duplicates_listing_and_enrichment_use_the_existing_preview(self):
|
||||
src = make_png(self.output_dir / "d.png")
|
||||
make_png(self._thumb("d"))
|
||||
described = self.api._describe_media_file(src, self.output_dir, "hash")
|
||||
self.assertTrue(
|
||||
described["thumbnail_url"].startswith(SERVE + "thumbnails/d_thumb.webp")
|
||||
)
|
||||
images = self.api._enrich_images([{"image_path": str(src)}])
|
||||
self.assertTrue(
|
||||
images[0]["thumbnail_url"].startswith(SERVE + "thumbnails/d_thumb.webp")
|
||||
)
|
||||
|
||||
|
||||
class TestPlanStatusAndJobs(FormatCase):
|
||||
async def test_status_counts_missing_outdated_and_ready(self):
|
||||
make_png(self.output_dir / "new.png") # missing
|
||||
make_png(self.output_dir / "old.png")
|
||||
make_png(self._thumb("old", ".png")) # outdated (legacy PNG)
|
||||
make_png(self.output_dir / "cur.png")
|
||||
make_png(self._thumb("cur")) # current
|
||||
body = await (await self.client.get(STATUS)).json()
|
||||
self.assertEqual(
|
||||
(body["pending"], body["outdated"], body["ready"], body["total_images"]),
|
||||
(1, 1, 2, 3),
|
||||
)
|
||||
plan = await (await self.client.get(PLAN, params={"mode": "missing"})).json()
|
||||
self.assertEqual((plan["targets"], plan["fresh"], plan["outdated"]), (1, 1, 1))
|
||||
self.assertEqual(
|
||||
(plan["format"], plan["quality"], plan["size"]), ("webp", 80, 512)
|
||||
)
|
||||
|
||||
async def test_missing_job_writes_the_format_and_leaves_outdated_alone(self):
|
||||
self._settings("webp", 70, 160)
|
||||
make_png(self.output_dir / "n.png", size=(320, 320))
|
||||
make_png(self.output_dir / "o.png")
|
||||
legacy = make_png(self._thumb("o", ".png"))
|
||||
before = legacy.read_bytes()
|
||||
resp = await self.client.post(JOB, json={"mode": "missing"})
|
||||
self.assertEqual(resp.status, 202)
|
||||
job = await self._finished((await resp.json())["job"]["id"])
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.scope["format"], "webp")
|
||||
self.assertEqual(self._format_of(self._thumb("n")), ("WEBP", (160, 160)))
|
||||
self.assertFalse(self._thumb("o").exists())
|
||||
self.assertEqual(legacy.read_bytes(), before)
|
||||
|
||||
async def test_the_legacy_stream_uses_the_server_settings(self):
|
||||
# R3: the gallery's quality preset no longer sizes the preview.
|
||||
self._settings("webp", 80, 200)
|
||||
make_png(self.output_dir / "g.png", size=(800, 800))
|
||||
resp = await self.client.post(self.PROGRESS_URL + "?quality=high")
|
||||
events = dict(parse_sse(await resp.text()))
|
||||
self.assertEqual(events["complete"]["count"], 1)
|
||||
self.assertEqual(self._format_of(self._thumb("g")), ("WEBP", (200, 200)))
|
||||
|
||||
async def test_the_blocking_legacy_generate_uses_the_server_settings(self):
|
||||
self._settings("jpeg", 80, 140)
|
||||
make_png(self.output_dir / "h.png", size=(700, 700))
|
||||
resp = await self.client.post(
|
||||
"/prompt_manager/images/generate-thumbnails", json={"quality": "low"}
|
||||
)
|
||||
self.assertEqual(resp.status, 200)
|
||||
made = self.output_dir / "thumbnails" / "h_thumb.jpg"
|
||||
self.assertEqual(self._format_of(made), ("JPEG", (140, 140)))
|
||||
|
||||
async def test_clear_removes_every_preview_format(self):
|
||||
make_png(self.output_dir / "k.png")
|
||||
for suffix in (".webp", ".png", ".jpg"):
|
||||
make_png(self._thumb("k", suffix))
|
||||
jxl = self._thumb("k", ".jxl")
|
||||
jxl.write_bytes(b"\xff\x0a")
|
||||
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual((await resp.json())["cleared_files"], 4)
|
||||
self.assertTrue((self.output_dir / "k.png").exists())
|
||||
|
||||
|
||||
class TestReviewFixes(FormatCase):
|
||||
async def test_a_legacy_preview_in_another_case_is_outdated(self):
|
||||
# Review M1: X.JPG's older X_thumb.JPG, and y.png's y_thumb.PNG.
|
||||
upper = self.output_dir / "X.JPG"
|
||||
Image.new("RGB", (64, 64)).save(upper, "JPEG")
|
||||
make_png(self.output_dir / "y.png")
|
||||
for name in ("X_thumb.JPG", "y_thumb.PNG"):
|
||||
legacy = self.output_dir / "thumbnails" / name
|
||||
legacy.parent.mkdir(parents=True, exist_ok=True)
|
||||
Image.new("RGB", (32, 32)).save(legacy, "PNG" if "PNG" in name else "JPEG")
|
||||
body = await (await self.client.get(STATUS)).json()
|
||||
self.assertEqual((body["outdated"], body["pending"]), (2, 0))
|
||||
resp = await self.client.post(JOB, json={"mode": "convert"})
|
||||
job = await self._finished((await resp.json())["job"]["id"])
|
||||
self.assertEqual(job.counters["generated"], 2, job.log)
|
||||
names = sorted(os.listdir(self.output_dir / "thumbnails"))
|
||||
self.assertEqual(names, ["X_thumb.webp", "y_thumb.webp"])
|
||||
|
||||
async def test_clear_also_removes_stray_temp_files(self):
|
||||
make_png(self.output_dir / "k.png")
|
||||
make_png(self._thumb("k"))
|
||||
folder = self.output_dir / "thumbnails"
|
||||
for name in (".k_thumb.part-1-2.webp", ".501one.part-3-4.jxl"):
|
||||
(folder / name).write_bytes(b"partial")
|
||||
keep = folder / "notes.txt"
|
||||
keep.write_bytes(b"x")
|
||||
resp = await self.client.post("/prompt_manager/images/clear-thumbnails")
|
||||
self.assertEqual((await resp.json())["cleared_files"], 3)
|
||||
self.assertEqual(os.listdir(folder), ["notes.txt"])
|
||||
|
||||
def test_the_startup_sweep_removes_only_old_temp_files(self):
|
||||
folder = self.output_dir / "thumbnails" / "sub"
|
||||
folder.mkdir(parents=True)
|
||||
old = folder / ".a_thumb.part-1-2.webp"
|
||||
young = folder / ".b_thumb.part-1-2.webp"
|
||||
for path in (old, young):
|
||||
path.write_bytes(b"partial")
|
||||
when = old.stat().st_mtime - 3600
|
||||
os.utime(old, (when, when))
|
||||
self.assertEqual(self.api.sweep_thumbnail_temps(), 1)
|
||||
self.assertFalse(old.exists())
|
||||
self.assertTrue(young.exists())
|
||||
|
||||
|
||||
class TestRemoval(FormatCase):
|
||||
def test_removing_a_source_removes_its_previews_in_every_format(self):
|
||||
make_png(self.output_dir / "r.png")
|
||||
for suffix in (".png", ".webp"):
|
||||
make_png(self._thumb("r", suffix))
|
||||
_remove_thumbnail(self.output_dir, Path("r.png"))
|
||||
self.assertFalse(self._thumb("r", ".png").exists())
|
||||
self.assertFalse(self._thumb("r").exists())
|
||||
|
||||
def test_a_shared_preview_stays_while_another_source_owns_it(self):
|
||||
# R2: r.png and r.mp4 share r_thumb.webp; the PNG's legacy preview is its own.
|
||||
make_png(self.output_dir / "r.png")
|
||||
(self.output_dir / "r.mp4").write_bytes(b"x")
|
||||
make_png(self._thumb("r", ".png"))
|
||||
make_png(self._thumb("r"))
|
||||
_remove_thumbnail(self.output_dir, Path("r.png"))
|
||||
self.assertFalse(self._thumb("r", ".png").exists())
|
||||
self.assertTrue(self._thumb("r").exists())
|
||||
|
||||
|
||||
class TestThumbnailsAreOnlyPlaceholders(FormatCase):
|
||||
"""Maintainer constraint: metadata always comes from the original."""
|
||||
|
||||
async def test_inspection_reads_the_original_not_its_webp_preview(self):
|
||||
png_with_prompt(self.output_dir / "image.png")
|
||||
await self._capture(self.output_dir / "image.png")
|
||||
self.assertTrue(self._thumb("image").is_file())
|
||||
opened = []
|
||||
original_open = Image.open
|
||||
|
||||
def recording(fp, *args, **kwargs):
|
||||
opened.append(str(fp))
|
||||
return original_open(fp, *args, **kwargs)
|
||||
|
||||
with patch.object(Image, "open", recording):
|
||||
resp = await self.client.get(
|
||||
"/prompt_manager/images/inspect/image.png", params={"root": "0"}
|
||||
)
|
||||
body = await resp.json()
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(body["prompts"]["positive"]["text"], "portrait")
|
||||
self.assertEqual(body["prompts"]["negative"]["text"], "blurry")
|
||||
self.assertFalse([p for p in opened if "thumbnails" in p], opened)
|
||||
|
||||
async def test_inspection_never_resolves_a_thumbnail_path(self):
|
||||
png_with_prompt(self.output_dir / "image.png")
|
||||
png_with_prompt(self._thumb("image", ".png"))
|
||||
await self._capture(self.output_dir / "image.png")
|
||||
for rel in (
|
||||
"thumbnails/image_thumb.webp",
|
||||
"thumbnails/image_thumb.png",
|
||||
"Thumbnails/image_thumb.webp",
|
||||
):
|
||||
resp = await self.client.get(
|
||||
f"/prompt_manager/images/inspect/{rel}", params={"root": "0"}
|
||||
)
|
||||
self.assertIn(resp.status, (403, 404), rel)
|
||||
self.assertNotIn("portrait", await resp.text())
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+79
-201
@@ -117,14 +117,6 @@ class ThumbnailJobCase(ThumbnailTestCase):
|
||||
self.addCleanup(patcher.stop)
|
||||
return lines
|
||||
|
||||
def _use(self, fmt="webp", quality=80, edge=512):
|
||||
"""Set the server's thumbnail settings for this test (#317)."""
|
||||
saved = PromptManagerConfig.get_config()["thumbnails"]
|
||||
self.addCleanup(PromptManagerConfig.update_config, {"thumbnails": saved})
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": fmt, "quality": quality, "edge": edge}}
|
||||
)
|
||||
|
||||
def _one_worker(self):
|
||||
original = PromptManagerConfig.WORKER_THREADS
|
||||
PromptManagerConfig.WORKER_THREADS = 1 # batches of 4
|
||||
@@ -157,10 +149,13 @@ class TestThumbnailPlan(ThumbnailJobCase):
|
||||
os.utime(fresh, (source_time + 10, source_time + 10))
|
||||
before = _tree_state(self.comfy_dir)
|
||||
|
||||
self._use("webp", 80, 512)
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 1):
|
||||
missing = await self.client.get(PLAN_URL, params={"mode": "missing"})
|
||||
rebuild = await self.client.get(PLAN_URL, params={"mode": "rebuild"})
|
||||
missing = await self.client.get(
|
||||
PLAN_URL, params={"mode": "missing", "size": "512", "quality": "high"}
|
||||
)
|
||||
rebuild = await self.client.get(
|
||||
PLAN_URL, params={"mode": "rebuild", "size": "256", "quality": "low"}
|
||||
)
|
||||
|
||||
self.assertEqual(missing.status, 200)
|
||||
self.assertEqual(
|
||||
@@ -169,38 +164,40 @@ class TestThumbnailPlan(ThumbnailJobCase):
|
||||
"success": True,
|
||||
"targets": 1,
|
||||
"fresh": 1,
|
||||
"outdated": 0,
|
||||
"remaining": 1,
|
||||
"roots": ["output"],
|
||||
"mode": "missing",
|
||||
"format": "webp",
|
||||
"size": 512,
|
||||
"quality": 80,
|
||||
"quality": "high",
|
||||
},
|
||||
)
|
||||
data = await rebuild.json()
|
||||
# Rebuild regenerates fresh previews too: all three are work, one fits the cap.
|
||||
self.assertEqual((data["targets"], data["fresh"], data["remaining"]), (1, 1, 2))
|
||||
self.assertEqual(data["mode"], "rebuild")
|
||||
self.assertEqual(
|
||||
(data["mode"], data["size"], data["quality"]), ("rebuild", 256, "low")
|
||||
)
|
||||
self.assertEqual(_tree_state(self.comfy_dir), before)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
|
||||
async def test_plan_defaults_to_missing_previews_with_the_settings(self):
|
||||
self._use("webp", 80, 512)
|
||||
async def test_plan_defaults_to_missing_previews_at_the_concept_size(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
data = await (await self.client.get(PLAN_URL)).json()
|
||||
self.assertEqual(
|
||||
(data["mode"], data["format"], data["size"], data["quality"]),
|
||||
("missing", "webp", 512, 80),
|
||||
(data["mode"], data["size"], data["quality"], data["targets"]),
|
||||
("missing", 512, "medium", 1),
|
||||
)
|
||||
self.assertEqual(data["targets"], 1)
|
||||
|
||||
async def test_mode_validated_and_size_quality_ignored(self):
|
||||
# #317: format, quality and size limit are the server's settings; an
|
||||
# older client's size or quality is ignored, an unknown mode refused.
|
||||
self._use("webp", 80, 512)
|
||||
async def test_size_bounds_validated(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
for params in ({"mode": "all"}, {"mode": ""}):
|
||||
for params in (
|
||||
{"size": "127"},
|
||||
{"size": "2049"},
|
||||
{"size": "512.5"},
|
||||
{"size": "big"},
|
||||
{"mode": "all"},
|
||||
{"quality": "ultra"},
|
||||
):
|
||||
with self.subTest(params=params):
|
||||
resp = await self.client.get(PLAN_URL, params=params)
|
||||
body = await resp.json()
|
||||
@@ -208,14 +205,24 @@ class TestThumbnailPlan(ThumbnailJobCase):
|
||||
self.assertEqual(
|
||||
(body["success"], body["code"]), (False, "invalid-request")
|
||||
)
|
||||
for body in ({"mode": "rebuild-all"}, {"mode": None}, []):
|
||||
for body in (
|
||||
{"size": 127},
|
||||
{"size": 2049},
|
||||
{"size": "512"},
|
||||
{"size": 512.0},
|
||||
{"size": True},
|
||||
{"mode": "rebuild-all"},
|
||||
{"quality": None},
|
||||
[],
|
||||
):
|
||||
with self.subTest(body=body):
|
||||
resp = await self.client.post(JOB_URL, json=body)
|
||||
self.assertEqual(resp.status, 400)
|
||||
self.assertEqual((await resp.json())["code"], "invalid-request")
|
||||
resp = await self.client.get(PLAN_URL, params={"size": "9999", "quality": "x"})
|
||||
data = await resp.json()
|
||||
self.assertEqual((data["size"], data["quality"]), (512, 80))
|
||||
for edge in (128, 2048):
|
||||
with self.subTest(edge=edge):
|
||||
resp = await self.client.get(PLAN_URL, params={"size": str(edge)})
|
||||
self.assertEqual(resp.status, 200)
|
||||
self.assertEqual(self.api._jobs.list(), [])
|
||||
self.assertFalse(self._thumb_path("a").exists())
|
||||
|
||||
@@ -226,8 +233,7 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
make_png(self.output_dir / "sub" / "b.png", size=(64, 64))
|
||||
(self.output_dir / "bad.png").write_bytes(b"\x89PNG not really")
|
||||
|
||||
self._use("webp", 80, 200)
|
||||
status, body = await self._start(mode="missing", size=999, quality="high")
|
||||
status, body = await self._start(mode="missing", size=200, quality="medium")
|
||||
|
||||
self.assertEqual(status, 202)
|
||||
summary = body["job"]
|
||||
@@ -238,8 +244,7 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
self.assertTrue(summary["cancellable"])
|
||||
self.assertEqual(set(summary["counters"]), {"generated", "skipped", "errors"})
|
||||
self.assertEqual(
|
||||
summary["scope"],
|
||||
{"mode": "missing", "format": "webp", "size": 200, "quality": 80},
|
||||
summary["scope"], {"mode": "missing", "size": 200, "quality": "medium"}
|
||||
)
|
||||
job = await self._finished(summary["id"])
|
||||
self.assertEqual(job.state, "finished")
|
||||
@@ -274,8 +279,7 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
async def test_rebuild_never_touches_originals(self):
|
||||
for name in ("a", "b"):
|
||||
make_png(self.output_dir / f"{name}.png", size=(400, 400))
|
||||
self._use("webp", 80, 128)
|
||||
_, body = await self._start()
|
||||
_, body = await self._start(size=128)
|
||||
await self._finished(body["job"]["id"])
|
||||
originals = {
|
||||
name: hashlib.sha256(
|
||||
@@ -284,8 +288,7 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
for name in ("a", "b")
|
||||
}
|
||||
|
||||
self._use("webp", 95, 256)
|
||||
_, body = await self._start(mode="rebuild")
|
||||
_, body = await self._start(mode="rebuild", size=256, quality="high")
|
||||
job = await self._finished(body["job"]["id"])
|
||||
|
||||
self.assertEqual(job.counters, {"generated": 2, "skipped": 0, "errors": 0})
|
||||
@@ -312,12 +315,12 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
self.assertEqual(job.counters["errors"], 1)
|
||||
self.assertEqual(self._thumb_path("a").read_bytes(), preview)
|
||||
leftovers = [p.name for p in self._thumb_path("a").parent.iterdir()]
|
||||
self.assertEqual(leftovers, ["a_thumb.webp"]) # no temp file left
|
||||
self.assertEqual(leftovers, ["a_thumb.png"]) # no temp file left
|
||||
|
||||
async def test_rebuild_keeps_an_old_video_preview_instead_of_a_placeholder(self):
|
||||
clip = self.output_dir / "clip.mp4"
|
||||
clip.write_bytes(b"\x00")
|
||||
preview = make_png(self.output_dir / "thumbnails" / "clip_thumb.webp")
|
||||
preview = make_png(self.output_dir / "thumbnails" / "clip_thumb.jpg")
|
||||
old = preview.read_bytes()
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
@@ -336,8 +339,7 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
img.save(self.output_dir / "photo.jpg", "JPEG", quality=95)
|
||||
sizes = {}
|
||||
for quality in ("low", "high"):
|
||||
self._use("jpeg", 40 if quality == "low" else 95, 300)
|
||||
_, body = await self._start(mode="rebuild")
|
||||
_, body = await self._start(mode="rebuild", size=300, quality=quality)
|
||||
await self._finished(body["job"]["id"])
|
||||
thumb = self._thumb_path("photo", ".jpg")
|
||||
with Image.open(thumb) as image:
|
||||
@@ -378,7 +380,7 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
self.assertEqual(len(written), 2)
|
||||
for dst in written:
|
||||
self.assertTrue(dst.is_file())
|
||||
made = sorted((self.output_dir / "thumbnails").glob("*_thumb.webp"))
|
||||
made = sorted((self.output_dir / "thumbnails").glob("*_thumb.png"))
|
||||
self.assertEqual(made, sorted(written))
|
||||
self.assertTrue(any("Thumbnails cancelled" in line for line in job.log))
|
||||
|
||||
@@ -509,16 +511,16 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
entered, release = threading.Event(), threading.Event()
|
||||
self.addCleanup(release.set)
|
||||
checked = []
|
||||
real_state = images_module._preview_state
|
||||
real_fresh = images_module._thumbnail_is_fresh
|
||||
|
||||
def gated_state(src, *args):
|
||||
def gated_fresh(src, dst):
|
||||
checked.append(src.name)
|
||||
if len(checked) == 1:
|
||||
entered.set()
|
||||
release.wait(10)
|
||||
return real_state(src, *args)
|
||||
return real_fresh(src, dst)
|
||||
|
||||
with patch.object(images_module, "_preview_state", gated_state):
|
||||
with patch.object(images_module, "_thumbnail_is_fresh", gated_fresh):
|
||||
stream = asyncio.ensure_future(self.client.post(self.PROGRESS_URL))
|
||||
await self._wait(entered)
|
||||
job = self.api._jobs.active("thumbnails")
|
||||
@@ -565,7 +567,7 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
|
||||
patch.object(images_module, "write_thumbnail", refuse),
|
||||
patch.object(images_module, "_atomic_save", refuse),
|
||||
):
|
||||
_, body = await self._start()
|
||||
job = await self._finished(body["job"]["id"])
|
||||
@@ -584,66 +586,52 @@ class TestThumbnailJob(ThumbnailJobCase):
|
||||
[row] = job.outcomes["errors"]
|
||||
self.assertEqual(row["detail"], images_module.PREVIEW_NOT_WRITABLE)
|
||||
|
||||
async def test_ffmpeg_fallback_keeps_the_aspect_ratio_and_the_settings(self):
|
||||
"""M1: without OpenCV, ffmpeg's frame keeps its aspect ratio; #317: it is
|
||||
read as a PNG on stdout and encoded with the server's settings."""
|
||||
import io
|
||||
|
||||
async def test_ffmpeg_fallback_keeps_quality_and_aspect_ratio(self):
|
||||
"""M1: without OpenCV, ffmpeg keeps the chosen quality and the aspect ratio."""
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"\x00")
|
||||
commands = []
|
||||
frame = io.BytesIO()
|
||||
Image.new("RGB", (640, 360), (0, 99, 0)).save(frame, "PNG")
|
||||
|
||||
def fake_ffmpeg(cmd, **_kwargs):
|
||||
commands.append(cmd)
|
||||
Path(cmd[-1]).write_bytes(b"jpeg")
|
||||
done = type("Done", (), {"returncode": 0})()
|
||||
done.communicate = lambda input=None, timeout=None: (frame.getvalue(), b"")
|
||||
done.poll = lambda: 0
|
||||
done.kill = lambda: None
|
||||
done.communicate = lambda timeout=None: ("", "")
|
||||
return done
|
||||
|
||||
self._use("webp", 70, 320)
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
patch("subprocess.Popen", side_effect=fake_ffmpeg),
|
||||
):
|
||||
_, body = await self._start(mode="rebuild")
|
||||
job = await self._finished(body["job"]["id"])
|
||||
self.assertEqual(job.counters["generated"], 1)
|
||||
cmd = commands[-1]
|
||||
self.assertNotIn("-q:v", cmd)
|
||||
self.assertEqual(
|
||||
cmd[cmd.index("-vf") + 1],
|
||||
"scale='min(iw,320)':'min(ih,320)':force_original_aspect_ratio=decrease",
|
||||
)
|
||||
self.assertEqual(cmd[-5:], ["-f", "image2pipe", "-vcodec", "png", "-"])
|
||||
# Review M6: the input is always read as a file, never a protocol URL.
|
||||
self.assertTrue(cmd[cmd.index("-i") + 1].startswith("file:"))
|
||||
with Image.open(self._thumb_path("clip")) as thumb:
|
||||
self.assertEqual((thumb.format, thumb.size), ("WEBP", (320, 180)))
|
||||
for quality, qscale in (("high", "2"), ("medium", "4"), ("low", "8")):
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
patch("subprocess.Popen", side_effect=fake_ffmpeg),
|
||||
):
|
||||
_, body = await self._start(mode="rebuild", size=640, quality=quality)
|
||||
job = await self._finished(body["job"]["id"])
|
||||
self.assertEqual(job.counters["generated"], 1, quality)
|
||||
cmd = commands[-1]
|
||||
self.assertNotIn("-s", cmd)
|
||||
self.assertEqual(
|
||||
cmd[cmd.index("-vf") + 1],
|
||||
"scale=640:640:force_original_aspect_ratio=decrease",
|
||||
)
|
||||
self.assertEqual(cmd[cmd.index("-q:v") + 1], qscale, quality)
|
||||
|
||||
async def test_the_placeholder_uses_the_settings(self):
|
||||
async def test_the_placeholder_uses_the_chosen_quality(self):
|
||||
(self.output_dir / "clip.mp4").write_bytes(b"\x00")
|
||||
saved = []
|
||||
real_write = images_module.write_thumbnail
|
||||
real_save = images_module._atomic_save
|
||||
|
||||
def recording(frame, dst, settings, **kwargs):
|
||||
saved.append(settings)
|
||||
return real_write(frame, dst, settings, **kwargs)
|
||||
def recording(img, dst, fmt=None, **kwargs):
|
||||
# The encoder settings (guard is the automatic previews' hold check).
|
||||
saved.append({k: v for k, v in kwargs.items() if k != "guard"})
|
||||
return real_save(img, dst, fmt, **kwargs)
|
||||
|
||||
self._use("jpeg", 33, 200)
|
||||
with (
|
||||
patch.dict(sys.modules, {"cv2": None}),
|
||||
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
|
||||
patch.object(images_module, "write_thumbnail", recording),
|
||||
patch.object(images_module, "_atomic_save", recording),
|
||||
):
|
||||
_, body = await self._start()
|
||||
_, body = await self._start(quality="low")
|
||||
await self._finished(body["job"]["id"])
|
||||
self.assertEqual(
|
||||
[(x.format, x.quality, x.edge) for x in saved], [("jpeg", 33, 200)]
|
||||
)
|
||||
with Image.open(self._thumb_path("clip", ".jpg")) as thumb:
|
||||
self.assertEqual(thumb.size, (200, 200))
|
||||
self.assertEqual(saved, [images_module.THUMBNAIL_ENCODINGS["low"]])
|
||||
|
||||
|
||||
# The legacy stream's frames and their keys, as the gallery page reads them.
|
||||
@@ -687,119 +675,11 @@ LEGACY_KEYS = {
|
||||
}
|
||||
|
||||
|
||||
class TestBatchesContinue(ThumbnailJobCase):
|
||||
"""MAX_THUMBNAIL_FILES bounds one batch, not the job: after each batch
|
||||
the job plans again and goes on until nothing is left, it is cancelled,
|
||||
or a batch makes no progress (files that fail every time are never
|
||||
planned again)."""
|
||||
|
||||
def _sources(self, count, broken=False):
|
||||
for i in range(count):
|
||||
path = self.output_dir / f"s{i}.png"
|
||||
if broken:
|
||||
path.write_bytes(b"\x89PNG broken")
|
||||
else:
|
||||
make_png(path, size=(32, 32))
|
||||
|
||||
async def test_one_job_makes_every_preview_past_the_cap(self):
|
||||
self._sources(8)
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 3):
|
||||
status, body = await self._start(mode="missing")
|
||||
self.assertEqual(status, 202)
|
||||
job = await self._finished(body["job"]["id"])
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(job.counters["generated"], 8)
|
||||
for i in range(8):
|
||||
self.assertTrue(self._thumb_path(f"s{i}").is_file(), i)
|
||||
summary = job.summary()
|
||||
self.assertEqual((summary["total"], summary["processed"]), (8, 8))
|
||||
self.assertEqual(summary["progress"], 100)
|
||||
self.assertEqual(job.status_line, "8 previews written")
|
||||
|
||||
async def test_rebuild_goes_through_every_file_once(self):
|
||||
self._sources(5)
|
||||
calls = []
|
||||
real = self.api._generate_batch
|
||||
|
||||
def counting(batch, *args, **kwargs):
|
||||
calls.extend(src.name for src, _dst, _video in batch)
|
||||
return real(batch, *args, **kwargs)
|
||||
|
||||
self.api._generate_batch = counting
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
|
||||
_, body = await self._start(mode="rebuild")
|
||||
job = await self._finished(body["job"]["id"])
|
||||
self.assertEqual(sorted(calls), [f"s{i}.png" for i in range(5)])
|
||||
self.assertEqual(job.counters["generated"], 5)
|
||||
|
||||
async def test_cancel_between_batches_stops_the_job(self):
|
||||
self._sources(8)
|
||||
real = self.api._plan_gallery_thumbnails
|
||||
plans = []
|
||||
job_ids = []
|
||||
|
||||
def cancelling(*args, **kwargs):
|
||||
plans.append(1)
|
||||
if len(plans) == 2:
|
||||
self.assertTrue(self.api._jobs.cancel(job_ids[0]))
|
||||
return real(*args, **kwargs)
|
||||
|
||||
self.api._plan_gallery_thumbnails = cancelling
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 3):
|
||||
_, body = await self._start(mode="missing")
|
||||
job_ids.append(body["job"]["id"])
|
||||
job = await self._finished(job_ids[0])
|
||||
self.assertEqual(job.state, "cancelled")
|
||||
self.assertEqual(job.counters["generated"], 3)
|
||||
self.assertEqual(len(plans), 2)
|
||||
|
||||
async def test_a_batch_where_everything_fails_stops(self):
|
||||
self._sources(8, broken=True)
|
||||
plans = []
|
||||
real = self.api._plan_gallery_thumbnails
|
||||
|
||||
def counting(*args, **kwargs):
|
||||
plans.append(1)
|
||||
return real(*args, **kwargs)
|
||||
|
||||
self.api._plan_gallery_thumbnails = counting
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 3):
|
||||
_, body = await self._start(mode="missing")
|
||||
job = await asyncio.wait_for(self._finished(body["job"]["id"]), 10)
|
||||
self.assertEqual(job.state, "finished")
|
||||
self.assertEqual(len(plans), 1, "no second plan after a batch with no progress")
|
||||
self.assertEqual(job.counters["errors"], 3)
|
||||
self.assertIn("5 more on the next run", job.status_line)
|
||||
|
||||
async def test_failed_files_are_never_planned_again(self):
|
||||
# One bad file per batch still lets the job go on, without retrying it.
|
||||
for i in range(6):
|
||||
path = self.output_dir / f"m{i}.png"
|
||||
if i in (0, 3):
|
||||
path.write_bytes(b"\x89PNG broken")
|
||||
else:
|
||||
make_png(path, size=(32, 32))
|
||||
seen = []
|
||||
real = self.api._generate_batch
|
||||
|
||||
def counting(batch, *args, **kwargs):
|
||||
seen.extend(src.name for src, _dst, _video in batch)
|
||||
return real(batch, *args, **kwargs)
|
||||
|
||||
self.api._generate_batch = counting
|
||||
with patch.object(images_module, "MAX_THUMBNAIL_FILES", 2):
|
||||
_, body = await self._start(mode="missing")
|
||||
job = await self._finished(body["job"]["id"])
|
||||
self.assertEqual(sorted(seen), sorted(f"m{i}.png" for i in range(6)))
|
||||
self.assertEqual((job.counters["generated"], job.counters["errors"]), (4, 2))
|
||||
|
||||
|
||||
class TestLegacyStreamOnTheJob(ThumbnailJobCase):
|
||||
async def test_legacy_progress_events_unchanged_on_success(self):
|
||||
make_png(self.output_dir / "a.png", size=(400, 400))
|
||||
make_png(self.output_dir / "b.png")
|
||||
|
||||
self._use("webp", 80, 200)
|
||||
resp = await self.client.post(f"{self.PROGRESS_URL}?quality=low")
|
||||
events = parse_sse(await resp.text())
|
||||
|
||||
@@ -816,8 +696,7 @@ class TestLegacyStreamOnTheJob(ThumbnailJobCase):
|
||||
{"phase": "scanning", "message": "Scanning for images and videos..."},
|
||||
)
|
||||
with Image.open(self._thumb_path("a")) as thumb:
|
||||
# #317 R3: the server's size limit, not the legacy preset (low = 150).
|
||||
self.assertEqual(max(thumb.size), 200)
|
||||
self.assertEqual(max(thumb.size), 150) # the legacy preset still sizes
|
||||
[job] = self.api._jobs.list()
|
||||
self.assertEqual(
|
||||
(job.kind, job.title, job.state, job.cancellable),
|
||||
@@ -887,8 +766,7 @@ class TestLegacyStreamOnTheJob(ThumbnailJobCase):
|
||||
async def test_legacy_stream_attaches_to_a_running_job(self):
|
||||
make_png(self.output_dir / "a.png")
|
||||
entered, release = self._gate_generation()
|
||||
self._use("webp", 80, 256)
|
||||
_, started = await self._start(mode="missing")
|
||||
_, started = await self._start(mode="missing", size=256)
|
||||
await self._wait(entered)
|
||||
|
||||
job = self.api._jobs.get(started["job"]["id"])
|
||||
|
||||
@@ -1,214 +0,0 @@
|
||||
"""Thumbnail settings and naming (#317): the one module every generator reads.
|
||||
|
||||
``py/api/thumbnail_settings.py`` owns ``thumbnail_format`` / ``thumbnail_quality``
|
||||
/ ``thumbnail_edge`` (``PromptManagerConfig``, config.json ``thumbnails``), the
|
||||
preview's name ``thumbnails/<stem>_thumb.<ext of the format>`` and the lookup
|
||||
that accepts any preview a source owns, preferring the configured format.
|
||||
"""
|
||||
|
||||
import json
|
||||
import os
|
||||
import shutil
|
||||
import sys
|
||||
import tempfile
|
||||
import unittest
|
||||
from pathlib import Path
|
||||
|
||||
from unittest.mock import MagicMock
|
||||
|
||||
sys.modules.setdefault("server", MagicMock())
|
||||
sys.path.insert(0, os.path.dirname(os.path.dirname(os.path.abspath(__file__))))
|
||||
|
||||
from py.api import thumbnail_settings as ts # noqa: E402
|
||||
from py.config import GalleryConfig, PromptManagerConfig # noqa: E402
|
||||
|
||||
|
||||
class ConfigCase(unittest.TestCase):
|
||||
def setUp(self):
|
||||
self._orig = PromptManagerConfig.get_config()
|
||||
self.addCleanup(PromptManagerConfig.update_config, self._orig)
|
||||
|
||||
|
||||
class TestDefaultsAndConfig(ConfigCase):
|
||||
def test_defaults_are_webp_80_512(self):
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
settings = ts.current()
|
||||
self.assertEqual(
|
||||
(settings.format, settings.quality, settings.edge), ("webp", 80, 512)
|
||||
)
|
||||
self.assertEqual(settings.extension, ".webp")
|
||||
self.assertEqual(settings.size, (512, 512))
|
||||
self.assertEqual(
|
||||
(ts.DEFAULT_FORMAT, ts.DEFAULT_QUALITY, ts.DEFAULT_EDGE), ("webp", 80, 512)
|
||||
)
|
||||
|
||||
def test_config_round_trip_in_a_thumbnails_section(self):
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "jpeg", "quality": 55, "edge": 300}}
|
||||
)
|
||||
self.assertEqual(
|
||||
PromptManagerConfig.get_config()["thumbnails"],
|
||||
{"format": "jpeg", "quality": 55, "edge": 300},
|
||||
)
|
||||
self.assertEqual(ts.current().extension, ".jpg")
|
||||
|
||||
def test_hand_edited_bad_values_are_ignored(self):
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
for bad in (
|
||||
{"format": "png"},
|
||||
{"format": 3},
|
||||
{"quality": 0},
|
||||
{"quality": 101},
|
||||
{"quality": True},
|
||||
{"quality": "80"},
|
||||
{"edge": 64},
|
||||
{"edge": 4096},
|
||||
{"edge": 512.5},
|
||||
):
|
||||
PromptManagerConfig.update_config({"thumbnails": bad})
|
||||
self.assertEqual(
|
||||
(ts.current().format, ts.current().quality, ts.current().edge),
|
||||
("webp", 80, 512),
|
||||
)
|
||||
PromptManagerConfig.update_config({"thumbnails": "nonsense"})
|
||||
self.assertEqual(ts.current().format, "webp")
|
||||
|
||||
def test_the_never_read_gallery_thumbnail_size_is_gone(self):
|
||||
# R1: older save_to_file wrote its default 256 into config.json; it
|
||||
# must never size a preview.
|
||||
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
|
||||
PromptManagerConfig.update_config(
|
||||
{"gallery": {"web_interface": {"thumbnail_size": 256}}}
|
||||
)
|
||||
self.assertNotIn("thumbnail_size", GalleryConfig.get_config()["web_interface"])
|
||||
self.assertNotEqual(ts.current().edge, 256)
|
||||
|
||||
def test_load_from_file_reads_the_section(self):
|
||||
tmp = tempfile.mkdtemp()
|
||||
self.addCleanup(shutil.rmtree, tmp, True)
|
||||
path = os.path.join(tmp, "config.json")
|
||||
with open(path, "w") as handle:
|
||||
json.dump({"thumbnails": {"format": "jxl", "quality": 90}}, handle)
|
||||
PromptManagerConfig.load_from_file(path)
|
||||
self.assertEqual((ts.current().format, ts.current().quality), ("jxl", 90))
|
||||
|
||||
|
||||
class TestValidation(unittest.TestCase):
|
||||
def test_format(self):
|
||||
for value in ("webp", "jxl", "jpeg"):
|
||||
self.assertEqual(ts.validate_format(value), value)
|
||||
for value in ("png", "JPEG", "", None, 1):
|
||||
with self.assertRaises(ValueError):
|
||||
ts.validate_format(value)
|
||||
|
||||
def test_quality(self):
|
||||
self.assertEqual(ts.validate_quality(1), 1)
|
||||
self.assertEqual(ts.validate_quality(100), 100)
|
||||
for value in (0, 101, True, 80.0, "80", None):
|
||||
with self.assertRaises(ValueError):
|
||||
ts.validate_quality(value)
|
||||
|
||||
def test_edge(self):
|
||||
self.assertEqual(ts.validate_edge(128), 128)
|
||||
self.assertEqual(ts.validate_edge(2048), 2048)
|
||||
for value in (127, 2049, False, 512.0, "512"):
|
||||
with self.assertRaises(ValueError):
|
||||
ts.validate_edge(value)
|
||||
|
||||
|
||||
class TestNaming(ConfigCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
|
||||
def test_images_and_videos_take_the_configured_extension(self):
|
||||
self.assertEqual(
|
||||
ts.thumbnail_rel(Path("sub/a.PNG")), "thumbnails/sub/a_thumb.webp"
|
||||
)
|
||||
self.assertEqual(
|
||||
ts.thumbnail_rel(Path("clip.mp4")), "thumbnails/clip_thumb.webp"
|
||||
)
|
||||
self.assertEqual(
|
||||
ts.thumbnail_rel(Path("a.png"), ".jxl"), "thumbnails/a_thumb.jxl"
|
||||
)
|
||||
|
||||
def test_ownership(self):
|
||||
# R4: legacy-only extensions belong to a source of that extension.
|
||||
self.assertTrue(ts.owns(".png", ".png"))
|
||||
self.assertTrue(ts.owns(".png", ".PNG"))
|
||||
self.assertFalse(ts.owns(".png", ".mp4"))
|
||||
self.assertFalse(ts.owns(".png", ".jpg"))
|
||||
self.assertTrue(ts.owns(".jpeg", ".jpeg"))
|
||||
self.assertTrue(ts.owns(".gif", ".gif"))
|
||||
for ext in (".webp", ".jxl", ".jpg"):
|
||||
for source in (".png", ".mp4", ".gif", ".webp"):
|
||||
self.assertTrue(ts.owns(ext, source))
|
||||
self.assertFalse(ts.owns(".txt", ".png"))
|
||||
|
||||
def test_candidates_prefer_the_configured_format_then_the_legacy_name(self):
|
||||
self.assertEqual(
|
||||
ts.candidate_extensions(".png", ".webp"), (".webp", ".png", ".jxl", ".jpg")
|
||||
)
|
||||
self.assertEqual(
|
||||
ts.candidate_extensions(".mp4", ".jxl"), (".jxl", ".jpg", ".webp")
|
||||
)
|
||||
self.assertEqual(
|
||||
ts.candidate_extensions(".jpg", ".jpg"), (".jpg", ".webp", ".jxl")
|
||||
)
|
||||
|
||||
def test_content_types(self):
|
||||
self.assertEqual(ts.content_type(Path("a_thumb.webp")), "image/webp")
|
||||
self.assertEqual(ts.content_type(Path("a_thumb.jxl")), "image/jxl")
|
||||
self.assertEqual(ts.content_type(Path("a_thumb.jpg")), "image/jpeg")
|
||||
self.assertEqual(ts.content_type(Path("a_thumb.PNG")), "image/png")
|
||||
self.assertEqual(ts.content_type(Path("a_thumb.gif")), "image/gif")
|
||||
self.assertIsNone(ts.content_type(Path("a.txt")))
|
||||
|
||||
|
||||
class TestLookup(ConfigCase):
|
||||
def setUp(self):
|
||||
super().setUp()
|
||||
PromptManagerConfig.update_config(
|
||||
{"thumbnails": {"format": "webp", "quality": 80, "edge": 512}}
|
||||
)
|
||||
self.root = Path(os.path.realpath(tempfile.mkdtemp()))
|
||||
self.addCleanup(shutil.rmtree, self.root, True)
|
||||
|
||||
def _touch(self, rel):
|
||||
path = self.root / rel
|
||||
path.parent.mkdir(parents=True, exist_ok=True)
|
||||
path.write_bytes(b"x")
|
||||
return path
|
||||
|
||||
def test_missing_preview_is_none(self):
|
||||
self.assertIsNone(ts.existing_thumbnail(self.root, Path("a.png")))
|
||||
|
||||
def test_legacy_preview_is_found_and_the_configured_one_preferred(self):
|
||||
legacy = self._touch("thumbnails/sub/a_thumb.png")
|
||||
self.assertEqual(ts.existing_thumbnail(self.root, Path("sub/a.png")), legacy)
|
||||
fresh = self._touch("thumbnails/sub/a_thumb.webp")
|
||||
self.assertEqual(ts.existing_thumbnail(self.root, Path("sub/a.png")), fresh)
|
||||
self.assertEqual(
|
||||
sorted(ts.owned_thumbnails(self.root, Path("sub/a.png"))),
|
||||
sorted([legacy, fresh]),
|
||||
)
|
||||
|
||||
def test_a_preview_owned_by_another_source_is_not_used(self):
|
||||
self._touch("thumbnails/a_thumb.png")
|
||||
self.assertIsNone(ts.existing_thumbnail(self.root, Path("a.mp4")))
|
||||
video = self._touch("thumbnails/a_thumb.jpg")
|
||||
self.assertEqual(ts.existing_thumbnail(self.root, Path("a.mp4")), video)
|
||||
|
||||
def test_a_folder_or_link_is_no_preview(self):
|
||||
(self.root / "thumbnails" / "a_thumb.webp").mkdir(parents=True)
|
||||
self.assertIsNone(ts.existing_thumbnail(self.root, Path("a.png")))
|
||||
|
||||
|
||||
if __name__ == "__main__":
|
||||
unittest.main()
|
||||
+15
-27
@@ -1,9 +1,9 @@
|
||||
{
|
||||
"admin.js": {
|
||||
"lines": 32,
|
||||
"lines": 31,
|
||||
"branches": 58,
|
||||
"functions": 27,
|
||||
"statements": 32
|
||||
"functions": 26,
|
||||
"statements": 31
|
||||
},
|
||||
"api-client.js": {
|
||||
"lines": 98,
|
||||
@@ -188,7 +188,7 @@
|
||||
"workspace/file-inspector.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 93,
|
||||
"functions": 93,
|
||||
"functions": 92,
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/heading.mjs": {
|
||||
@@ -241,7 +241,7 @@
|
||||
},
|
||||
"workspace/image-tagging.mjs": {
|
||||
"lines": 97,
|
||||
"branches": 92,
|
||||
"branches": 90,
|
||||
"functions": 88,
|
||||
"statements": 97
|
||||
},
|
||||
@@ -338,7 +338,7 @@
|
||||
"workspace/library-location.mjs": {
|
||||
"lines": 94,
|
||||
"branches": 91,
|
||||
"functions": 93,
|
||||
"functions": 92,
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/library-state.mjs": {
|
||||
@@ -372,14 +372,14 @@
|
||||
"statements": 97
|
||||
},
|
||||
"workspace/local-resolver-stages.mjs": {
|
||||
"lines": 95,
|
||||
"lines": 94,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 95
|
||||
"statements": 94
|
||||
},
|
||||
"workspace/local-resolver-support.mjs": {
|
||||
"lines": 96,
|
||||
"branches": 81,
|
||||
"branches": 80,
|
||||
"functions": 98,
|
||||
"statements": 96
|
||||
},
|
||||
@@ -426,10 +426,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/prompt-blocks.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"lines": 90,
|
||||
"branches": 92,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
"statements": 90
|
||||
},
|
||||
"workspace/prompt-inspector.mjs": {
|
||||
"lines": 98,
|
||||
@@ -487,7 +487,7 @@
|
||||
},
|
||||
"workspace/system-controls.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 89,
|
||||
"branches": 87,
|
||||
"functions": 89,
|
||||
"statements": 98
|
||||
},
|
||||
@@ -509,12 +509,6 @@
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system-thumbnails.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 95,
|
||||
"functions": 83,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/system.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 92,
|
||||
@@ -528,16 +522,10 @@
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/thumbnail-review.mjs": {
|
||||
"lines": 98,
|
||||
"lines": 96,
|
||||
"branches": 93,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
},
|
||||
"workspace/thumbnail-support.mjs": {
|
||||
"lines": 98,
|
||||
"branches": 98,
|
||||
"functions": 98,
|
||||
"statements": 98
|
||||
"statements": 96
|
||||
},
|
||||
"workspace/toast.mjs": {
|
||||
"lines": 98,
|
||||
|
||||
@@ -3442,8 +3442,6 @@ Seed: ${this.currentMetadata.seed || 'Unknown'}`;
|
||||
if (image.thumbnail_url) return ApiClient.resolveUrl(image.thumbnail_url);
|
||||
|
||||
// Try to construct thumbnail URL from filename and relative_path
|
||||
// An older release's name: the server answers it with the current preview
|
||||
// (any format the file owns, #317), or makes it on first view.
|
||||
// Thumbnails are stored as: thumbnails/{parent_dir}/{stem}_thumb{ext}
|
||||
// to avoid collisions with same-named files in different directories
|
||||
if (image.filename) {
|
||||
|
||||
@@ -7,7 +7,6 @@ import { navigateTo } from './state.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { openCopyFallback } from './workspace-dialogs.mjs';
|
||||
import { displayPreferences } from './preferences.mjs';
|
||||
import { fallBackToOriginal, thumbnailSource } from './thumbnail-support.mjs';
|
||||
|
||||
const RAW_KEYS = ['prompt', 'workflow', 'parameters', 'comfyassets_prompt_pairs'];
|
||||
const RAW_LIMIT = 'Raw metadata exceeds the response limit. Download the original file to inspect it externally.';
|
||||
@@ -321,16 +320,12 @@ export function mountFileInspector(container, {api, sender, parentScope, variant
|
||||
}
|
||||
/**
|
||||
* The concept's plain cover; the card image opens the viewer (D19). #249: it is the file's preview (a video's
|
||||
* is a still frame) whenever the listing gave one; without one the original, and a video keeps its own element.
|
||||
* #317: a JPEG XL preview this browser cannot show is the original instead, and an image's preview that fails to
|
||||
* load falls back to the original. Only the cover is a preview: metadata and downloads use the original.
|
||||
* is a JPEG frame) whenever the listing gave one; without one the original, and a video keeps its own element.
|
||||
*/
|
||||
function cover(record) {
|
||||
const {file} = record, isVideo = file?.media_type === 'video';
|
||||
const preview = record.preview?.url ? thumbnailSource({url: record.download.url, thumbnail_url: record.preview.url}) : null;
|
||||
if (isVideo && (!preview || preview === record.download.url)) return videoCover(record);
|
||||
const {file} = record, preview = record.preview?.url, isVideo = file?.media_type === 'video';
|
||||
if (isVideo && !preview) return videoCover(record);
|
||||
const node = el('img', undefined, 'detail-cover'); node.src = preview || record.download.url;
|
||||
if (!isVideo) fallBackToOriginal(node, record.download.url, (...args) => contentScope.listen(...args));
|
||||
node.alt = ''; node.decoding = 'async';
|
||||
if (file?.width && file?.height) {node.width = file.width; node.height = file.height;}
|
||||
contentScope.cleanup(() => node.removeAttribute('src'));
|
||||
|
||||
@@ -1,5 +1,4 @@
|
||||
import {projectInspectionPayload} from './inspection-state.mjs';
|
||||
import {jxlSupport} from './thumbnail-support.mjs';
|
||||
|
||||
const object = value => value !== null && typeof value === 'object' && !Array.isArray(value);
|
||||
const uuid = value => typeof value === 'string' && /^[0-9a-f]{8}(?:-[0-9a-f]{4}){3}-[0-9a-f]{12}$/.test(value);
|
||||
@@ -66,10 +65,8 @@ export function toHistoryInspectionRecord(result, target, extractFacts) {
|
||||
const identity = {kind:'reported-location', key:target.location_key, path:target.reference.path_style === 'windows' ? path.replaceAll('\\', '/') : path,
|
||||
occurrence:{...target.occurrence}, history_epoch:target.history_epoch, current_scope:target.current_scope, reference:{...target.reference}};
|
||||
const record = projectInspectionPayload(result, identity, {url:urlFor(target, 'url', 'media'), filename:target.filename}, extractFacts);
|
||||
// #249: the inspector's cover is the recorded output's preview (made by the server on first view). #317: its URL
|
||||
// names no format, so where this browser decoded JPEG XL it says so (`jxl=1`); the server also reads Accept.
|
||||
const cover = urlFor(target, 'thumbnail_url', 'thumbnail');
|
||||
record.preview = {url:jxlSupport() === true ? `${cover}&jxl=1` : cover};
|
||||
// #249: the inspector's cover is the recorded output's preview (made by the server on first view).
|
||||
record.preview = {url:urlFor(target, 'thumbnail_url', 'thumbnail')};
|
||||
return record;
|
||||
}
|
||||
export const reportedLocationAdapter = Object.freeze({
|
||||
|
||||
@@ -9,7 +9,6 @@ import { cardTitle, cardSubtitle, imageKey, mediaRatio } from './images-state.mj
|
||||
import { createWorkspaceIcon } from './icons.mjs';
|
||||
import { createTagButton } from './image-tagging.mjs';
|
||||
import { paintStar } from './images-favorites.mjs';
|
||||
import { fallBackToOriginal, replaceOnError, showsThumbnail, thumbnailSource } from './thumbnail-support.mjs';
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
@@ -28,17 +27,15 @@ function videoMedia(image) {
|
||||
|
||||
/**
|
||||
* The card's media: the thumbnail (a video's is its preview frame), else the image, else a video's own element.
|
||||
* #249: a video whose preview cannot be loaded swaps back to its own element. #317: a JPEG XL thumbnail this browser
|
||||
* cannot show loads the original, and an image's thumbnail that fails to load falls back to the original.
|
||||
* #249: a video whose preview cannot be loaded swaps back to its own element.
|
||||
*/
|
||||
export function cardMedia(image, lazy = false) {
|
||||
const { resolveUrl } = window.ApiClient;
|
||||
if (!showsThumbnail(image)) return videoMedia(image);
|
||||
if (image.is_video && !image.thumbnail_url) return videoMedia(image);
|
||||
const node = el('img');
|
||||
node.src = resolveUrl(thumbnailSource(image));
|
||||
node.src = resolveUrl(image.thumbnail_url || image.url);
|
||||
// replaceWith does nothing for a card no longer in the page.
|
||||
if (image.is_video) replaceOnError(node, () => videoMedia(image));
|
||||
else fallBackToOriginal(node, resolveUrl(image.url));
|
||||
if (image.is_video) node.addEventListener('error', () => node.replaceWith(videoMedia(image)), { once: true });
|
||||
// The concept card image: alt is the card title, with the listing's intrinsic size when known.
|
||||
node.alt = cardTitle(image); node.decoding = 'async'; if (lazy) node.loading = 'lazy';
|
||||
if ([image.width, image.height].every(value => Number.isFinite(value) && value > 0)) {
|
||||
|
||||
@@ -19,7 +19,6 @@ import { mountReview } from './review.mjs';
|
||||
import { createPromptSender } from './prompt-sender.mjs';
|
||||
import { mountSystem } from './system.mjs';
|
||||
import { mountLibraryBanner } from './library-location.mjs';
|
||||
import { detectJxlSupport } from './thumbnail-support.mjs';
|
||||
|
||||
const { createApiClient, resolveUrl } = window.ApiClient;
|
||||
|
||||
@@ -50,8 +49,6 @@ function mountWorkspace() {
|
||||
});
|
||||
// D4: the concept's Theme select; "System" keeps following ComfyUI, then the OS.
|
||||
const theme = mountWorkspaceTheme({ root: document.body, scope });
|
||||
// #317: learn once whether this browser shows JPEG XL thumbnails (cards fall back to originals otherwise).
|
||||
detectJxlSupport();
|
||||
const preference = document.getElementById('theme');
|
||||
preference.value = theme.preference;
|
||||
scope.listen(preference, 'change', () => theme.setPreference(preference.value));
|
||||
|
||||
@@ -71,28 +71,20 @@ function downloadFor(image, required) {
|
||||
}
|
||||
}
|
||||
|
||||
// The server's video extensions (py/api/inspection_service.py VIDEO_EXTENSIONS).
|
||||
// The server's video extensions (py/api/inspection_service.py VIDEO_EXTENSIONS): their preview is a JPEG.
|
||||
const VIDEO_EXTENSIONS = new Set(['.mp4', '.webm', '.avi', '.mov', '.mkv', '.m4v', '.wmv']);
|
||||
// #317: the formats a preview is written in (WebP, JPEG XL, JPEG), for images and videos alike.
|
||||
const WRITTEN_PREVIEW_EXTENSIONS = ['.webp', '.jxl', '.jpg'];
|
||||
|
||||
/**
|
||||
* Every `thumbnails/<path>_thumb<ext>` the server may list as this file's preview (py/api/thumbnail_settings.py
|
||||
* `owns`): a written format, or the name an older release gave it (the source's own extension, `.jpg` for a video).
|
||||
*/
|
||||
function previewPaths(path) {
|
||||
/** `thumbnails/<path>_thumb<ext>` for a listed file, as the server names its preview (images.media_thumbnail_rel). */
|
||||
function previewPath(path) {
|
||||
const slash = path.lastIndexOf('/'), dot = path.lastIndexOf('.');
|
||||
const cut = dot > slash ? dot : path.length, spelled = path.slice(cut), extension = spelled.toLowerCase();
|
||||
const stem = `thumbnails/${path.slice(0, cut)}_thumb`;
|
||||
const legacy = VIDEO_EXTENSIONS.has(extension) ? ['.jpg'] : [extension, spelled];
|
||||
return new Set([...WRITTEN_PREVIEW_EXTENSIONS, ...legacy].filter(Boolean).map(ext => stem + ext));
|
||||
const cut = dot > slash ? dot : path.length, extension = path.slice(cut).toLowerCase();
|
||||
return `thumbnails/${path.slice(0, cut)}_thumb${VIDEO_EXTENSIONS.has(extension) ? '.jpg' : extension}`;
|
||||
}
|
||||
|
||||
/**
|
||||
* #249: the listing's thumbnail of `image` as `{url}` for the inspector's cover, or null. Only the same-origin
|
||||
* serve URL of this file's own preview (any format it owns, #317) under the same root and source scope is used;
|
||||
* anything else keeps the original (a missing preview is made by the server on first view). The cover is only a
|
||||
* placeholder: metadata, downloads and the viewer always use the original.
|
||||
* serve URL of this file's own preview under the same root and source scope is used; anything else keeps the
|
||||
* original (a missing preview is made by the server on first view).
|
||||
*/
|
||||
function previewFor(image) {
|
||||
try {
|
||||
@@ -101,10 +93,9 @@ function previewFor(image) {
|
||||
const reference = new URL(location?.href || 'http://localhost/');
|
||||
const resolveUrl = globalThis.window?.ApiClient?.resolveUrl || globalThis.ApiClient?.resolveUrl || (url => url);
|
||||
const value = resolveUrl(image.thumbnail_url), url = new URL(value, reference);
|
||||
const expected = new Set([...previewPaths(image.path)].map(preview =>
|
||||
new URL(resolveUrl(`/prompt_manager/images/serve/${preview.split('/').map(quoteSegment).join('/')}`), reference).pathname));
|
||||
const expected = new URL(resolveUrl(`/prompt_manager/images/serve/${previewPath(image.path).split('/').map(quoteSegment).join('/')}`), reference);
|
||||
const scope = descriptorScope(image);
|
||||
if (url.origin !== reference.origin || url.username || url.password || url.hash || !expected.has(url.pathname) ||
|
||||
if (url.origin !== reference.origin || url.username || url.password || url.hash || url.pathname !== expected.pathname ||
|
||||
url.searchParams.getAll('root').length !== 1 || url.searchParams.get('root') !== String(image.root_index) ||
|
||||
url.searchParams.getAll('root_scope').length > 1 || (scope !== null && url.searchParams.get('root_scope') !== scope)) return null;
|
||||
return { url: value };
|
||||
|
||||
@@ -15,7 +15,6 @@ import {pageHeading} from './heading.mjs';
|
||||
import {mountToolbar} from './toolbar.mjs';
|
||||
import {libraryFilters,libraryViewState} from './saved-views.mjs';
|
||||
import {openRefineDialog,openContentDialog} from './workspace-dialogs.mjs';
|
||||
import {fallBackToOriginal,replaceOnError,showsThumbnail,thumbnailSource} from './thumbnail-support.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -214,12 +213,9 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
const art=el('div',undefined,'card-art');art.setAttribute('aria-hidden','true');
|
||||
const previews=item.previews || [];
|
||||
for(const image of previews.slice(0,2)) {
|
||||
// #317: a video with no preview, or one this browser cannot show (JPEG XL), keeps its label.
|
||||
if(!showsThumbnail(image)) art.append(el('span','Video preview','small'));
|
||||
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','small'));
|
||||
else {
|
||||
// #317: an image's JPEG XL preview this browser cannot show, or one that fails, loads the original;
|
||||
// a video's preview that fails becomes its label, never a broken image.
|
||||
const img=el('img');img.src=window.ApiClient.resolveUrl(thumbnailSource(image));if(image.is_video) replaceOnError(img,()=>el('span','Video preview','small'));else fallBackToOriginal(img,window.ApiClient.resolveUrl(image.url));img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img);
|
||||
const img=el('img');img.src=window.ApiClient.resolveUrl(image.thumbnail_url || image.url);img.alt='';img.loading='lazy';img.decoding='async';img.width=image.width || 800;img.height=image.height || 600;art.append(img);
|
||||
}
|
||||
}
|
||||
if(!previews.length) art.append(el('span','No available image previews','small'));
|
||||
|
||||
@@ -13,14 +13,10 @@
|
||||
* (`showImageInfo`), "Collapse viewer details by default" (`sidebarCollapsedByDefault`) and the operation "Reset
|
||||
* display defaults", which drafts the gallery's defaults for these browser rows and touches no other key.
|
||||
*
|
||||
* - #317: a second surface, "Thumbnails", holds the server's thumbnail format, quality and size limit
|
||||
* (system-thumbnails.mjs).
|
||||
*
|
||||
* Controls follow the store: a value read later (the host settings, another tab's change) repaints them. A host row
|
||||
* whose value could not be read stays disabled and says so ("Server settings could not be read.").
|
||||
*/
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { mountThumbnailSettings } from './system-thumbnails.mjs';
|
||||
import { preferenceDefaults } from './preferences.mjs';
|
||||
|
||||
export const RESULT_TIMEOUT_MAX = 300;
|
||||
@@ -102,11 +98,8 @@ function paint(input, control, value) {
|
||||
else if (document.activeElement !== input || control.type === 'select') input.value = String(value);
|
||||
}
|
||||
|
||||
/**
|
||||
* Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes: "Browsing & display",
|
||||
* then the server's thumbnail settings (#317, system-thumbnails.mjs).
|
||||
*/
|
||||
export function mountAppearance(container, { scope, drafts, api, activity }) {
|
||||
/** Mount Appearance in `container` (System's `.settings-layout`); returns the section's nodes. */
|
||||
export function mountAppearance(container, { scope, drafts }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
surface.append(el('h2', 'Browsing & display'), el('p', 'One set of defaults; toolbar changes apply to the current view.'));
|
||||
const controls = [];
|
||||
@@ -146,5 +139,5 @@ export function mountAppearance(container, { scope, drafts, api, activity }) {
|
||||
};
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
return [surface, mountThumbnailSettings(container, { scope, drafts, api, activity })];
|
||||
return [surface];
|
||||
}
|
||||
|
||||
@@ -8,8 +8,6 @@
|
||||
* reads 0 before the read answers: a load shows a status line, a failure an alert and Retry. The thumbnail row is
|
||||
* its own read of `/images/thumbnails/status` (a walk of every source): "Checking thumbnails…" until it answers.
|
||||
* D99: its Review thumbnails opens the same "Thumbnail maintenance" dialog as Sources (thumbnail-review.mjs).
|
||||
* #317: it reads "N missing · M outdated · K failed"; while previews are outdated (another format than the
|
||||
* configured one) a second button, "Convert outdated", opens that dialog on its Convert outdated scope (D131).
|
||||
* - D96: the Statistics dialog lists the real runs and average rating per category, then "Library totals".
|
||||
* - D95: the "Health report" lists every check the server ran, Missing files per image source, with a chip per real
|
||||
* result and when it was checked. The check runs from Maintenance's notice ("Run health checks", D123) as a
|
||||
@@ -90,29 +88,10 @@ export function thumbnailTitle(status, sources) {
|
||||
if (status === undefined) return THUMBNAILS_LOADING;
|
||||
const missing = status?.pending;
|
||||
if (!Number.isSafeInteger(missing) || sources?.effective === 0) return 'Thumbnail status unavailable';
|
||||
const parts = [missing === 0 ? 'No missing thumbnails' : `${number(missing)} missing ${noun(missing, 'thumbnail')}`];
|
||||
// #317: previews in another format than the configured one (Convert outdated replaces them).
|
||||
if (convertOffered(status)) parts.push(`${number(status.outdated)} outdated`);
|
||||
if (missing === 0) return 'No missing thumbnails';
|
||||
// #249: files whose automatic preview could not be made this session (they show the original).
|
||||
if (Number.isSafeInteger(status.failed) && status.failed > 0) parts.push(`${number(status.failed)} failed`);
|
||||
return parts.join(' · ');
|
||||
}
|
||||
|
||||
/** #317: whether the status counts previews in an older format (the row then offers Convert outdated). */
|
||||
export function convertOffered(status) {
|
||||
return Number.isSafeInteger(status?.outdated) && status.outdated > 0;
|
||||
}
|
||||
|
||||
/** #317: the row's "Convert outdated" button, present only while previews are outdated (D131). */
|
||||
function paintConvert(row, status, { scope, openConvert }) {
|
||||
const actions = row.querySelector('.row-actions');
|
||||
let button = actions.querySelector('[data-convert]');
|
||||
if (!convertOffered(status)) { button?.remove(); return; }
|
||||
if (button) return;
|
||||
button = createButton('Convert outdated');
|
||||
button.dataset.convert = '';
|
||||
scope.listen(button, 'click', () => openConvert(button));
|
||||
actions.append(button);
|
||||
const failed = Number.isSafeInteger(status.failed) && status.failed > 0 ? ` · ${number(status.failed)} failed` : '';
|
||||
return `${number(missing)} missing ${noun(missing, 'thumbnail')}${failed}`;
|
||||
}
|
||||
|
||||
const SOURCES_SHARED = 'Monitoring and enrichment share one source list.';
|
||||
@@ -389,11 +368,8 @@ export function mountOverview(container, { api, activity, scope, navigate = navi
|
||||
let thumbnailStatus;
|
||||
let thumbnailRow = null;
|
||||
let sources = null;
|
||||
const openConvert = trigger => openThumbnailReview({ api, store: activity, scope, trigger, navigate, mode: 'convert' });
|
||||
const paintThumbnails = () => {
|
||||
if (!thumbnailRow) return;
|
||||
thumbnailRow.querySelector('h3').textContent = thumbnailTitle(thumbnailStatus, sources);
|
||||
paintConvert(thumbnailRow, thumbnailStatus, { scope, openConvert });
|
||||
if (thumbnailRow) thumbnailRow.querySelector('h3').textContent = thumbnailTitle(thumbnailStatus, sources);
|
||||
};
|
||||
|
||||
function swap(nodes) {
|
||||
|
||||
@@ -1,120 +0,0 @@
|
||||
/**
|
||||
* System › Appearance › Thumbnails (#317): the concept README keeps "Thumbnail quality, size limit" as Appearance
|
||||
* settings; they are the server's (`thumbnail_format`, `thumbnail_quality`, `thumbnail_edge`, saved with the settings
|
||||
* revision through the one Save row), so every preview writer follows them. One `section.surface` of the concept's
|
||||
* `.setting-row`s (system-controls.mjs), after "Browsing & display":
|
||||
*
|
||||
* - "Thumbnail format": WebP (default), JPEG XL, JPEG. D130: JPEG XL is not in the concept; it is offered only when
|
||||
* the server found an encoder (`thumbnail_encoders.jxl`), else the option is disabled and the row says why, in the
|
||||
* server's words. A row note also says when this browser cannot show JPEG XL thumbnails (thumbnail-support.mjs):
|
||||
* such a browser shows the originals instead.
|
||||
* - "Thumbnail quality" (1–100) and "Thumbnail size limit" (128–2048 px); a value outside is refused like Appearance's
|
||||
* timeout (no draft, the control highlighted).
|
||||
* - "Convert outdated thumbnails" opens Thumbnail maintenance on its Convert outdated scope; converting is never
|
||||
* automatic. After a save that changed the format, its line says existing previews keep their old format until
|
||||
* converted.
|
||||
*
|
||||
* Host rows stay disabled until the server settings were read, like Appearance's, and say "Server settings could not
|
||||
* be read." while a read failed.
|
||||
*/
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { detectJxlSupport } from './thumbnail-support.mjs';
|
||||
import { openThumbnailReview } from './thumbnail-review.mjs';
|
||||
import { HOST_UNAVAILABLE } from './system-appearance.mjs';
|
||||
|
||||
export const FORMAT_OPTIONS = Object.freeze([['webp', 'WebP'], ['jxl', 'JPEG XL'], ['jpeg', 'JPEG']]);
|
||||
export const QUALITY_LIMIT = Object.freeze({ min: 1, max: 100 });
|
||||
export const EDGE_LIMIT = Object.freeze({ min: 128, max: 2048 });
|
||||
export const BROWSER_NO_JXL = 'This browser can’t show JPEG XL thumbnails; it shows the originals instead.';
|
||||
export const CONVERT_DESCRIPTION = 'Replace previews in an older format from their originals, then remove the old files.';
|
||||
export const FORMAT_SAVED = 'Format saved. Existing previews keep their old format until you convert them.';
|
||||
|
||||
/** A whole number within `{min, max}` from a field's text, else null. */
|
||||
export function wholeNumber(text, { min, max }) {
|
||||
const value = String(text).trim();
|
||||
if (!/^\d+$/.test(value)) return null;
|
||||
const number = Number(value);
|
||||
return number >= min && number <= max ? number : null;
|
||||
}
|
||||
|
||||
function el(tag, text, className) {
|
||||
const node = document.createElement(tag);
|
||||
if (text !== undefined) node.textContent = text;
|
||||
if (className) node.className = className;
|
||||
return node;
|
||||
}
|
||||
|
||||
const ROWS = Object.freeze([
|
||||
['thumbnail-format', 'Thumbnail format', 'WebP is small and fast everywhere. JPEG XL is smaller still where your browser shows it.',
|
||||
'thumbnail_format', { type: 'select', options: FORMAT_OPTIONS }],
|
||||
['thumbnail-quality', 'Thumbnail quality', 'From 1 to 100. 80 keeps previews sharp and small.', 'thumbnail_quality',
|
||||
{ type: 'number', ...QUALITY_LIMIT }],
|
||||
['thumbnail-edge', 'Thumbnail size limit', 'Longest edge in pixels, 128 to 2048. Rebuild all previews to apply a new size.',
|
||||
'thumbnail_edge', { type: 'number', ...EDGE_LIMIT }],
|
||||
]);
|
||||
const LIMITS = Object.freeze({ thumbnail_quality: QUALITY_LIMIT, thumbnail_edge: EDGE_LIMIT });
|
||||
|
||||
/**
|
||||
* Mount the Thumbnails surface; returns it. `support()` resolves whether this browser shows JPEG XL, `openReview`
|
||||
* opens Thumbnail maintenance (`{mode, trigger}`).
|
||||
*/
|
||||
export function mountThumbnailSettings(container, { scope, drafts, api, activity, support = detectJxlSupport,
|
||||
openReview = options => openThumbnailReview({ api, store: activity, scope, ...options }) }) {
|
||||
const surface = el('section', undefined, 'surface');
|
||||
surface.append(el('h2', 'Thumbnails'), el('p', 'Previews in the grid and the inspector cover. Originals are never changed.'));
|
||||
const controls = [];
|
||||
for (const [id, label, description, key, control] of ROWS) {
|
||||
let input;
|
||||
const onChange = value => {
|
||||
if (LIMITS[key]) {
|
||||
value = wholeNumber(input.value, LIMITS[key]);
|
||||
if (value === null) { drafts.reject('host', key); return; }
|
||||
}
|
||||
drafts.set('host', key, value);
|
||||
};
|
||||
const row = settingRow({ id, label, description, control, scope, onChange });
|
||||
input = row.children[1];
|
||||
input.dataset.key = key;
|
||||
surface.append(row);
|
||||
controls.push({ input, key, text: row.children[0] });
|
||||
}
|
||||
const operation = operationRow({ title: 'Convert outdated thumbnails', description: CONVERT_DESCRIPTION,
|
||||
label: 'Convert outdated', scope, onClick: trigger => openReview({ mode: 'convert', trigger }) });
|
||||
surface.append(operation);
|
||||
const operationText = operation.children[0].children[1];
|
||||
|
||||
let browserJxl;
|
||||
let savedFormat;
|
||||
const format = controls[0];
|
||||
const repaint = () => {
|
||||
const encoders = drafts.get('host', 'thumbnail_encoders');
|
||||
for (const { input, key } of controls) {
|
||||
const value = drafts.get('host', key);
|
||||
input.disabled = value === undefined;
|
||||
if (value !== undefined && (document.activeElement !== input || key === 'thumbnail_format')) input.value = String(value);
|
||||
}
|
||||
const jxl = Array.from(format.input.children).find(option => option.value === 'jxl');
|
||||
const unavailable = encoders?.jxl?.available === false;
|
||||
if (jxl) jxl.disabled = unavailable;
|
||||
const failed = drafts.hostState?.() === 'failed';
|
||||
for (const row of controls) {
|
||||
for (const note of row.notes ?? []) note.remove();
|
||||
const lines = [failed && drafts.get('host', row.key) === undefined ? HOST_UNAVAILABLE : null];
|
||||
if (row === format) lines.push(unavailable ? encoders.jxl.reason : null, browserJxl === false ? BROWSER_NO_JXL : null);
|
||||
row.notes = lines.filter(Boolean).map(line => el('p', line, 'small'));
|
||||
row.text.append(...row.notes);
|
||||
}
|
||||
// A format save (not the first read) leaves the existing previews outdated: say so, never convert.
|
||||
const saved = drafts.saved('host').thumbnail_format;
|
||||
if (savedFormat !== undefined && saved !== undefined && saved !== savedFormat) operationText.textContent = FORMAT_SAVED;
|
||||
if (saved !== undefined) savedFormat = saved;
|
||||
};
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
Promise.resolve(support()).then(value => {
|
||||
if (scope.signal.aborted) return;
|
||||
browserJxl = value;
|
||||
repaint();
|
||||
}, () => {});
|
||||
return surface;
|
||||
}
|
||||
@@ -79,9 +79,8 @@ export function systemDrafts(api) {
|
||||
writeRuntime: undelivered,
|
||||
async readHost() {
|
||||
const result = await api.get(SETTINGS_URL, { cache: 'no-store' });
|
||||
// The store reads a missing value as none and a missing revision as unreadable (no host save). #317: which
|
||||
// thumbnail formats the server can write rides along as a read-only value (never drafted or sent).
|
||||
return { values: { ...result.settings, thumbnail_encoders: result.thumbnail_encoders }, revision: result.revision };
|
||||
// The store reads a missing value as none and a missing revision as unreadable (no host save).
|
||||
return { values: result.settings, revision: result.revision };
|
||||
},
|
||||
async writeHost(changes, { expected_revision }) {
|
||||
try {
|
||||
|
||||
@@ -1,67 +1,67 @@
|
||||
/**
|
||||
* Thumbnail review (#249 System S5 Task 14): the concept's "Thumbnail maintenance" dialog (`thumbnail-review`, app.js
|
||||
* 1433–1437), opened by Sources' "Thumbnail generation → Review options", Overview's "Review thumbnails" and
|
||||
* Appearance's "Convert outdated" (#317). It starts the `thumbnails` Activity job (S5 Task 12) with the chosen scope.
|
||||
* 1433–1437), opened by Sources' "Thumbnail generation → Review options" and Overview's "Review thumbnails". It starts
|
||||
* the `thumbnails` Activity job (S5 Task 12) with the chosen options.
|
||||
*
|
||||
* - Scope: "Missing thumbnails only" (`missing`, the default), "Rebuild all (explicit opt-in)" (`rebuild`) or, #317,
|
||||
* "Convert outdated (removes older formats)" (`convert`): previews in another format than the configured one are
|
||||
* written again from their originals and the old files removed. Nothing converts unless chosen here.
|
||||
* - D131: the concept's Quality and Preview size limit fields are gone: previews follow the server's thumbnail
|
||||
* settings (System › Appearance, #317), so a `p.small` line after the plan line names them from the plan's answer
|
||||
* ("Previews are written as WebP · quality 80 · 512 px. …").
|
||||
* - Scope: "Missing thumbnails only" (`missing`, the default) or "Rebuild all (explicit opt-in)" (`rebuild`).
|
||||
* - Quality: "Medium · optimized" / "High · original" / "Low · fastest" (`medium` / `high` / `low`), preselected from
|
||||
* Appearance's Image loading quality (`gallerySettings.imageQuality`, the same three values; Q3).
|
||||
* - Preview size limit: the longest edge, a whole number from 128 to 2048 (concept default 512). A size outside that
|
||||
* marks the field `aria-invalid`, says so in the plan line and sends nothing; Start reports the field's validity.
|
||||
* - D99: a `p.small[role=status]` line before the actions says what the server's plan counts for the chosen scope
|
||||
* (`GET /images/thumbnails/plan?mode=`, read-only): "N previews to generate · M already up to date.", for a rebuild
|
||||
* "Every preview is regenerated: N files.", for a conversion "N previews in an older format to convert.". N counts
|
||||
* the files past the per-batch cap too: the job plans again after each batch (#317). Each scope is read once per dialog. The primary is "Start" (the
|
||||
* concept's "Simulate thumbnail job"): it closes the dialog and starts the job through the Activity store, which
|
||||
* toasts that it started; the job opens in Activity, where its progress lives. A start refused because another job
|
||||
* holds the slot toasts the D57 busy link toast and stays.
|
||||
* (`GET /images/thumbnails/plan`, read-only): "N previews to generate · M already up to date." or, for a rebuild,
|
||||
* "Every preview is regenerated: N files.", plus "· R more on the next run" past the per-run cap. Each scope is read
|
||||
* once per dialog (the size and quality change no count). The primary is "Start" (the concept's "Simulate thumbnail
|
||||
* job"): it closes the dialog and starts the job through the Activity store, which toasts that it started; the job
|
||||
* opens in Activity, where its progress lives. A start refused because another job holds the slot toasts the D57
|
||||
* busy link toast and stays.
|
||||
*/
|
||||
import { createButton } from '../shared/toolkit/core.js';
|
||||
import { closeWorkspaceDialog, field, openContentDialog } from './workspace-dialogs.mjs';
|
||||
import { busyToast } from './activity-scan.mjs';
|
||||
import { readPreferences } from './preferences.mjs';
|
||||
import { showWorkspaceToast } from './toast.mjs';
|
||||
import { navigateTo } from './state.mjs';
|
||||
import { noun } from './plural.mjs';
|
||||
|
||||
export const THUMBNAIL_PLAN_URL = '/prompt_manager/images/thumbnails/plan';
|
||||
export const THUMBNAILS_KIND = 'thumbnails';
|
||||
export const SCOPE_OPTIONS = Object.freeze([['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)'],
|
||||
['convert', 'Convert outdated (removes older formats)']]);
|
||||
export const SCOPE_OPTIONS = Object.freeze([['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)']]);
|
||||
export const QUALITY_OPTIONS = Object.freeze([['medium', 'Medium · optimized'], ['high', 'High · original'], ['low', 'Low · fastest']]);
|
||||
export const SIZE_LIMIT = Object.freeze({ min: 128, max: 2048, value: 512 });
|
||||
export const PLAN_LOADING = 'Counting previews…';
|
||||
export const PLAN_FAILED = 'The previews could not be counted.';
|
||||
export const SIZE_INVALID = `Enter a size from ${SIZE_LIMIT.min} to ${SIZE_LIMIT.max} pixels.`;
|
||||
export const START_FAILED = 'Thumbnail generation could not start. Try again.';
|
||||
export const SETTINGS_UNKNOWN = 'Previews follow the thumbnail settings in System › Appearance.';
|
||||
const FORMAT_LABELS = Object.freeze({ webp: 'WebP', jxl: 'JPEG XL', jpeg: 'JPEG' });
|
||||
const MODES = new Set(SCOPE_OPTIONS.map(([value]) => value));
|
||||
|
||||
const QUALITIES = new Set(QUALITY_OPTIONS.map(([value]) => value));
|
||||
const count = value => value.toLocaleString('en-US');
|
||||
const isCount = value => Number.isSafeInteger(value) && value >= 0;
|
||||
|
||||
/** D131: the server's thumbnail settings from the plan's `{format, quality, size}`. */
|
||||
export function settingsText(plan) {
|
||||
const label = FORMAT_LABELS[plan?.format];
|
||||
if (!label || !isCount(plan.quality) || !isCount(plan.size)) return SETTINGS_UNKNOWN;
|
||||
return `Previews are written as ${label} · quality ${plan.quality} · ${count(plan.size)} px. Change this in System › Appearance.`;
|
||||
/** The preview size for the field's text: a whole number of pixels in range, else null. */
|
||||
export function previewSize(text) {
|
||||
if (typeof text !== 'string' || !/^\d{1,5}$/.test(text)) return null;
|
||||
const size = Number(text);
|
||||
return size >= SIZE_LIMIT.min && size <= SIZE_LIMIT.max ? size : null;
|
||||
}
|
||||
|
||||
/** Q3: Image loading quality preselects the dialog's quality; anything it does not know is Medium. */
|
||||
export function preselectedQuality(imageQuality) {
|
||||
return QUALITIES.has(imageQuality) ? imageQuality : 'medium';
|
||||
}
|
||||
|
||||
/** D99: the plan line for the server's `{mode, targets, fresh, remaining}`; an answer without counts failed. */
|
||||
export function planText(plan) {
|
||||
if (!plan || !MODES.has(plan.mode) || ![plan.targets, plan.fresh, plan.remaining].every(isCount)) {
|
||||
if (!plan || !['missing', 'rebuild'].includes(plan.mode) || ![plan.targets, plan.fresh, plan.remaining].every(isCount)) {
|
||||
return PLAN_FAILED;
|
||||
}
|
||||
// #317: the cap bounds one batch, not the job, which plans again until nothing is left: the line counts it all.
|
||||
const work = plan.targets + plan.remaining;
|
||||
if (plan.mode === 'rebuild') return `Every preview is regenerated: ${count(work)} ${noun(work, 'file')}.`;
|
||||
if (plan.mode === 'convert') {
|
||||
if (!work) return 'No previews in an older format.';
|
||||
return `${count(work)} ${noun(work, 'preview')} in an older format to convert.`;
|
||||
}
|
||||
return `${count(work)} ${noun(work, 'preview')} to generate · ${count(plan.fresh)} already up to date.`;
|
||||
const more = plan.remaining ? ` · ${count(plan.remaining)} more on the next run` : '';
|
||||
if (plan.mode === 'rebuild') return `Every preview is regenerated: ${count(plan.targets)} ${noun(plan.targets, 'file')}${more}.`;
|
||||
return `${count(plan.targets)} ${noun(plan.targets, 'preview')} to generate · ${count(plan.fresh)} already up to date${more}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the thumbnail job with `options` (`{mode}`) and open its view; a refused or failed start says
|
||||
* Start the thumbnail job with `options` (`{mode, size, quality}`) and open its view; a refused or failed start says
|
||||
* why and stays. Nothing is said once the section that started it is gone.
|
||||
*/
|
||||
export async function startThumbnails({ store, scope, options, notify = showWorkspaceToast, navigate = navigateTo }) {
|
||||
@@ -96,47 +96,77 @@ function select(options, value) {
|
||||
return node;
|
||||
}
|
||||
|
||||
const defaultQuality = () => {
|
||||
try {
|
||||
return readPreferences().values.imageQuality;
|
||||
} catch {
|
||||
return undefined;
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Open "Thumbnail maintenance" on the section's `scope`, on scope `mode` (`missing` unless given). `store` is the page's
|
||||
* Activity store; `trigger` gets focus back when the dialog closes.
|
||||
* Open "Thumbnail maintenance" on the section's `scope`. `store` is the page's Activity store; `trigger` gets focus
|
||||
* back when the dialog closes.
|
||||
*/
|
||||
export function openThumbnailReview({ api, store, scope, trigger = null, mode: initial = 'missing', notify = showWorkspaceToast,
|
||||
export function openThumbnailReview({ api, store, scope, trigger = null, quality = defaultQuality(), notify = showWorkspaceToast,
|
||||
navigate = navigateTo }) {
|
||||
const mode = select(SCOPE_OPTIONS, MODES.has(initial) ? initial : 'missing');
|
||||
const mode = select(SCOPE_OPTIONS, 'missing');
|
||||
const encoder = select(QUALITY_OPTIONS, preselectedQuality(quality));
|
||||
const size = el('input');
|
||||
size.type = 'number';
|
||||
size.value = String(SIZE_LIMIT.value);
|
||||
size.min = String(SIZE_LIMIT.min);
|
||||
size.max = String(SIZE_LIMIT.max);
|
||||
const line = el('p', PLAN_LOADING, 'small');
|
||||
line.setAttribute('role', 'status');
|
||||
const settings = el('p', SETTINGS_UNKNOWN, 'small');
|
||||
const cancel = createButton('Cancel');
|
||||
const start = createButton('Start', 'ca-primary');
|
||||
const actions = el('div', undefined, 'dialog-actions');
|
||||
actions.append(cancel, start);
|
||||
|
||||
// The plan per scope: a promise of its answer (null when it failed).
|
||||
// The plan per scope: a promise of its answer (null when it failed); the size and quality change no count.
|
||||
const plans = new Map();
|
||||
let open = true;
|
||||
const readPlan = chosen => api.get(`${THUMBNAIL_PLAN_URL}?${new URLSearchParams({ mode: chosen })}`,
|
||||
{ signal: scope.signal, cache: 'no-store' }).catch(() => null);
|
||||
const chosenSize = () => previewSize(size.value);
|
||||
const validate = () => {
|
||||
const valid = chosenSize() !== null;
|
||||
size.setCustomValidity(valid ? '' : SIZE_INVALID);
|
||||
if (valid) size.removeAttribute('aria-invalid');
|
||||
else size.setAttribute('aria-invalid', 'true');
|
||||
return valid;
|
||||
};
|
||||
const readPlan = chosen => {
|
||||
const query = new URLSearchParams({ mode: chosen, size: String(chosenSize()), quality: encoder.value });
|
||||
return api.get(`${THUMBNAIL_PLAN_URL}?${query}`, { signal: scope.signal, cache: 'no-store' }).catch(() => null);
|
||||
};
|
||||
async function paint() {
|
||||
if (!validate()) { line.textContent = SIZE_INVALID; return; }
|
||||
const chosen = mode.value;
|
||||
if (!plans.has(chosen)) plans.set(chosen, readPlan(chosen));
|
||||
const pending = plans.get(chosen);
|
||||
line.textContent = PLAN_LOADING;
|
||||
const plan = await pending;
|
||||
// A later choice painted its own line meanwhile, or the dialog closed.
|
||||
if (!open || scope.signal.aborted || mode.value !== chosen) return;
|
||||
if (!open || scope.signal.aborted || mode.value !== chosen || !validate()) return;
|
||||
line.textContent = planText(plan);
|
||||
settings.textContent = settingsText(plan);
|
||||
}
|
||||
|
||||
scope.listen(mode, 'change', paint);
|
||||
scope.listen(size, 'input', paint);
|
||||
scope.listen(cancel, 'click', () => closeWorkspaceDialog());
|
||||
scope.listen(start, 'click', () => {
|
||||
const options = { mode: mode.value };
|
||||
if (!validate()) {
|
||||
size.reportValidity();
|
||||
size.focus();
|
||||
return;
|
||||
}
|
||||
const options = { mode: mode.value, size: chosenSize(), quality: encoder.value };
|
||||
closeWorkspaceDialog();
|
||||
startThumbnails({ store, scope, options, notify, navigate });
|
||||
});
|
||||
openContentDialog({ scope, title: 'Thumbnail maintenance', trigger,
|
||||
nodes: [field('Scope', mode, 'thumb-mode'), line, settings, actions],
|
||||
nodes: [field('Scope', mode, 'thumb-mode'), field('Quality', encoder, 'thumb-quality'),
|
||||
field('Preview size limit', size, 'thumb-limit'), line, actions],
|
||||
onClose: () => { open = false; } });
|
||||
paint();
|
||||
}
|
||||
|
||||
@@ -1,89 +0,0 @@
|
||||
/**
|
||||
* Thumbnails this browser can show (#317). A thumbnail is only a grid or cover placeholder; every other view uses the
|
||||
* original.
|
||||
*
|
||||
* - `detectJxlSupport()` decodes a tiny embedded JPEG XL image once per page (`JXL_PROBE`) and remembers the answer
|
||||
* (`jxlSupport()`: undefined until it answered, then true or false).
|
||||
* - `thumbnailSource(image, supported)`: the URL a card or the inspector cover loads: the listing's thumbnail, or the
|
||||
* original when the thumbnail is JPEG XL and this browser cannot decode it. While the answer is unknown the
|
||||
* thumbnail is tried; `fallBackToOriginal` then catches a failure.
|
||||
* - `fallBackToOriginal(img, url)`: a thumbnail `<img>` that fails to load shows the original instead (once).
|
||||
* - `showsThumbnail(image)`: false for a video whose thumbnail is missing or one this browser cannot show (its URL
|
||||
* would be the video itself): the card keeps the video's own element or label, never a broken image.
|
||||
* - `replaceOnError(img, next)`: a video thumbnail `<img>` that fails to load is replaced by `next()` (once).
|
||||
*/
|
||||
|
||||
// A 1×1 JPEG XL image (cjxl -d 1), decoded once to learn whether this browser shows JPEG XL.
|
||||
export const JXL_PROBE = 'data:image/jxl;base64,/woAkAEAE4gCALQAtZ8gAAAVKqOMG7yc6/nyQ4fFtI3rDG21bWEJY7O9MEhIOGwY4s3xwRCTShIA';
|
||||
|
||||
let probe = null;
|
||||
let answer;
|
||||
|
||||
/** Whether `url` names a JPEG XL file (its path, not its query or fragment). */
|
||||
export function isJxlUrl(url) {
|
||||
if (typeof url !== 'string' || !url) return false;
|
||||
const path = url.split(/[?#]/, 1)[0];
|
||||
return path.toLowerCase().endsWith('.jxl');
|
||||
}
|
||||
|
||||
/** The remembered answer: undefined until the probe decoded or failed. */
|
||||
export const jxlSupport = () => answer;
|
||||
|
||||
/** Forget the answer (tests). */
|
||||
export function resetJxlSupport() {
|
||||
probe = null;
|
||||
answer = undefined;
|
||||
}
|
||||
|
||||
/** Decode the embedded JPEG XL once; resolves true when this browser shows it. */
|
||||
export function detectJxlSupport({ createImage = () => new Image() } = {}) {
|
||||
probe ??= new Promise(resolve => {
|
||||
let image;
|
||||
try {
|
||||
image = createImage();
|
||||
} catch {
|
||||
resolve(false);
|
||||
return;
|
||||
}
|
||||
image.onload = () => resolve(true);
|
||||
image.onerror = () => resolve(false);
|
||||
image.src = JXL_PROBE;
|
||||
}).then(supported => {
|
||||
answer = supported;
|
||||
return supported;
|
||||
});
|
||||
return probe;
|
||||
}
|
||||
|
||||
/** The URL a card or cover loads for `image` (`{url, thumbnail_url}`), given the JPEG XL answer. */
|
||||
export function thumbnailSource(image, supported = answer) {
|
||||
const thumbnail = image?.thumbnail_url;
|
||||
if (typeof thumbnail !== 'string' || !thumbnail) return image?.url;
|
||||
if (supported === false && isJxlUrl(thumbnail)) return image.url;
|
||||
return thumbnail;
|
||||
}
|
||||
|
||||
/** Whether a card shows `image`'s thumbnail (always for an image: its original at worst). */
|
||||
export function showsThumbnail(image, supported = answer) {
|
||||
if (!image?.is_video) return true;
|
||||
const source = thumbnailSource(image, supported);
|
||||
return Boolean(source) && source !== image.url;
|
||||
}
|
||||
|
||||
/** A video thumbnail `<img>` that fails to load is replaced by `next()`, once (`listen` as in fallBackToOriginal). */
|
||||
export function replaceOnError(node, next, listen = null) {
|
||||
const swap = () => node.replaceWith(next());
|
||||
if (listen) listen(node, 'error', swap, { once: true });
|
||||
else node.addEventListener('error', swap, { once: true });
|
||||
}
|
||||
|
||||
/**
|
||||
* A thumbnail `<img>` that fails to load shows `original` instead, once. `listen(target, type, handler, options)` is the
|
||||
* caller's scoped listener (a scope's `listen`); without one a plain once-listener is used.
|
||||
*/
|
||||
export function fallBackToOriginal(node, original, listen = null) {
|
||||
if (!original || node.src === original) return;
|
||||
const swap = () => { if (node.src !== original) node.src = original; };
|
||||
if (listen) listen(node, 'error', swap, { once: true });
|
||||
else node.addEventListener('error', swap, { once: true });
|
||||
}
|
||||
Reference in New Issue
Block a user