Compare commits

..
Author SHA1 Message Date
Vito Sansevero 8ddcfe27e1 Merge remote-tracking branch 'origin/main' into feat/317-thumbnail-format 2026-10-24 20:39:16 -07:00
Vito 9601837aa9 fix(system): never close another thread's library connection during a switch (#321)
* fix(system): never close another thread's library connection during a switch

Move library closed every thread's old connection from the mover thread,
including one whose owner was inside a statement waiting for the old
file's write lock. Closing a sqlite3 connection under a running
sqlite3_step is a use-after-free: Windows CI died with "access violation"
in record_prompt_use, and on Linux the writer got a MemoryError once its
busy timeout ran out.

Managed connections now count the calls their owner is inside.
Retiring one from another thread closes it at once only when idle;
otherwise it is interrupted (sqlite3_interrupt is thread-safe) and its
owner closes it as the call ends. A retired connection refuses new work
and never commits, so a write that waited for the lock is rolled back
and survive_library_switch runs it again on the new library. The mover
retires connections while it holds the locks and waits for the owners
to close them only after releasing them, before the old files are
renamed or a failed copy is removed. Cursors are closed before the
connection so Python 3.10/3.11 release the file handle too.

* test(tagging): wait for in-flight pages before reading the loaded range

The scroll-position test read the loaded count right after scrolling the
continuation out of view, while a listing page the continuation had
already requested could still be in flight. When it landed the count
grew (80 -> 120 in CI) and the stability poll failed. The test now waits
a frame for any scheduled load to start and then for every listing
request to finish before it reads the count.

* fix(system): make retiring a library connection race-free and cheap

Review round for the cross-thread close fix:

- cursor() now records its cursor under the lock _close_now copies the
  cursor set under, and refuses once the connection is retired; a cursor
  opened while the connection closed raised "Set changed size during
  iteration" out of retire() on Python 3.10-3.12. retire() never raises,
  retire_connections keeps going when one connection fails, and the mover
  never reports a completed switch as failed because retiring or awaiting
  old connections did.
- The restore's backup target is marked in use (ManagedConnection.using),
  and in_transaction and create_function are guarded.
- Iterating a cursor marks the connection in use once per fetchmany(128)
  chunk instead of once per row: a 1000-row scan costs 1.10x a plain
  cursor instead of 2.7x (3.14) / 3.8x (3.10).
- Document what an interrupt does not end, the statements a retired
  connection cannot hold back, close() from another thread returning
  before the file is closed, and the await timeout ending as old-in-use.
2026-10-24 20:39:08 -07:00
Vito Sansevero 726b7b70da fix(thumbnails): keep going past the per-batch cap until every preview is made
The missing, rebuild and Convert outdated jobs stopped after
MAX_THUMBNAIL_FILES (10,000) files and said "N more on the next run", but
nothing ever started that next run. The cap now bounds one batch. After
each batch the job plans again, leaving out every file it already worked
on, and continues until nothing is left, the job is cancelled, or a batch
makes no progress. That last case stops on files that fail every time
instead of looping over them.

Activity progress counts against the overall total, and the final line and
the legacy stream's complete frame report every batch. Admission, worker
threads, cancellation and the #316 rules are unchanged. The Thumbnail
maintenance dialog now counts all the work instead of saying "more on the
next run".
2026-10-24 16:57:03 -07:00
Vito Sansevero af0482a12d fix(history): let the cover ask for JPEG XL when this browser decodes it
Firefox can decode JPEG XL without listing image/jxl in Accept. When the
workspace's probe decodes JPEG XL, it adds jxl=1 to the History cover URL.
The server serves a .jxl preview when that flag is set or when Accept
lists image/jxl. In every other case it falls back as before. The flag
accepts only 0 or 1, and the response stays no-store.
2026-10-24 16:02:43 -07:00
Vito Sansevero 7c82fdb180 fix(thumbnails): keep the new preview, probe encoders only when gone, sweep reused PIDs
- Convert never removes a path that is the preview it just wrote. It
  compares the device and inode, or the resolved and normcased paths when
  the disk has no inode numbers. This covers x_thumb.JPG on a
  case-insensitive disk.
- A JPEG XL encode failure re-runs encoder detection only when the
  encoder's binary or module is gone. A "no encoder" result is checked
  again on the settings read, on a jxl save, and after 60 s, so one slow
  probe no longer turns JPEG XL off until a restart.
- The startup sweep now also removes temp files that carry this process's
  PID but predate its start. Containers reuse PIDs.
2026-10-24 15:35:43 -07:00
Vito Sansevero 1e9ed87454 fix(thumbnails): address the final review of the thumbnail format work
- ffmpeg writes JPEG XL with -update 1, so a % in a file name is never an
  image-sequence pattern, and reads videos as file:<path>. The clear and a
  startup sweep remove temp files that interrupted writes left behind.
- Convert outdated converts each group of same-stem files once, from the
  first source in the R2 order. An old preview that is already gone counts
  as removed, and each file's bytes count once. A current preview is only
  kept if it decodes.
- An encoder that fails makes the write detect encoders again once. Another
  encoder is used if one is found; with none, the file is written as WebP
  and the availability Appearance shows is refreshed.
- Changing the thumbnail format, quality or size while Convert outdated runs
  returns 409 busy. If the format still changes underneath, the job stops
  without removing anything.
- Legacy preview spellings are matched case-insensitively, so X_thumb.JPG
  counts as outdated.
- History's cover uses the same lookup as Images. It serves .jxl only when
  Accept lists image/jxl, and otherwise the original or the placeholder.
- A video whose thumbnail the browser cannot show keeps its own element
  (Images) or its label (Library).
2026-10-24 15:07:57 -07:00
Vito Sansevero ca8171ec38 test(thumbnails): accept the legacy preview's spelling a case-insensitive disk finds
On Windows and macOS the '.jpg' lookup finds an older release's
b_thumb.JPG first, so the listed URL spells '.jpg'; it names the same
file there. The test now accepts either spelling and checks the URL
serves that preview.
2026-10-24 14:17:43 -07:00
Vito Sansevero 2ed085f8d7 fix(thumbnails): say when the thumbnail settings could not be read
Like Appearance's other server rows, the Thumbnails rows show 'Server
settings could not be read.' while the read failed, instead of only
staying disabled. Firefox exposed the gap: its JPEG XL note arrived
first and changed the row notes the page workflow counted.
2026-10-24 14:08:12 -07:00
Vito Sansevero 2d0e962f1d test(thumbnails): coverage floors for the thumbnail modules and WebP in page workflows
The two new frontend modules enter the coverage baseline (98% lines);
other floors only rise. The visual fixture's preview is WebP and
Appearance has its Thumbnails surface in the System shell check.
2026-10-24 13:57:12 -07:00
Vito Sansevero 3f98b57aef feat(thumbnails): thumbnail settings in Appearance and Convert outdated in the workspace
System > Appearance gains a Thumbnails surface: format (WebP, JPEG XL,
JPEG), quality and size limit, saved to the server with the settings
revision. JPEG XL is offered only when the server has an encoder, else
the option is disabled with the server's reason (D130); a note says
when this browser cannot show JPEG XL. Saving a new format converts
nothing: the row then says previews keep their old format.

Thumbnail maintenance gains the Convert outdated scope and names the
server's settings instead of its own quality and size fields (D131).
Overview's thumbnail row reads N missing, M outdated, K failed and
offers Convert outdated while previews are outdated.

The workspace decodes a tiny JPEG XL once: where it cannot, cards, the
Library and the inspector cover load the original for a .jxl preview,
and any preview that fails to load falls back to the original. The
viewer and downloads keep using the original (regression test).
README explains enabling JPEG XL; TESTING.md records D130 and D131.
2026-10-24 13:42:31 -07:00
Vito Sansevero 80da6f125e feat(thumbnails): Convert outdated thumbnails as an Activity job
POST /jobs/thumbnails {mode: "convert"} starts "Convert outdated
thumbnails" under the thumbnail job's kind, guarded admission, worker
threads and cancel at a file boundary. For each file whose preview is
in another format, it writes the new preview from the source (atomic,
verified; a video with no decodable frame fails instead of getting the
placeholder), then removes that file's old-format previews in its
thumbnails folder. A failure keeps the old preview; orphan previews
and links are never touched. The final line reports converted, failed
and bytes before and after. The plan counts it with mode=convert; the
legacy gallery stream refuses to attach to a running conversion.
2026-10-24 13:25:57 -07:00
Vito Sansevero 1ba8052cfa Merge remote-tracking branch 'origin/main' into feat/317-thumbnail-format
# Conflicts:
#	py/config.py
2026-10-24 13:19:56 -07:00
Vito Sansevero 737d3c8845 feat(thumbnails): every preview writer and lookup follows the thumbnail settings
Automatic previews (capture and first view), the thumbnail job, the
legacy gallery routes and the history route write <stem>_thumb.webp
(or .jxl/.jpg) with the configured quality and size limit, for images
and videos alike: video frames, ffmpeg's included (a PNG on stdout,
never upscaled), and the placeholder are encoded like images.

Listings, duplicates, enrichment, the autotag scan and the history
route show any preview the file owns, the current format first, so
older _thumb.png previews keep working until converted. A request for
an older name answers the current preview. Previews are served with
an explicit image/webp or image/jxl type and the same cache headers.

The plan and status count missing, outdated (another format) and
ready previews; the missing job never writes beside an outdated one.
Removing a file removes its previews in every format; the clear
removes .webp and .jxl too. Encoder processes are killed at shutdown.

A preview is only a placeholder: inspection refuses paths under
thumbnails/ and always reads the original, and the inspector cover
accepts the listing's preview in any format the file owns.
2026-10-24 13:19:24 -07:00
Vito Sansevero dc8b86751e feat(thumbnails): thumbnail format, quality and size as server settings
Thumbnail format (webp default, jxl, jpeg), quality (1-100, default 80)
and size limit (128-2048 px, default 512) are server settings in the
config.json thumbnails section, saved with the settings revision. The
never-read gallery thumbnail_size is gone: older saves wrote its unused
default 256 into config.json.

One module names previews (<stem>_thumb.webp/.jxl/.jpg) and finds the
preview a source owns, preferring the configured format; legacy names
are still found. The encoder module writes every preview atomically and
verifies it decodes. JPEG XL needs no new dependency: Pillow (natively
or with pillow_jxl), ffmpeg with libjxl, or cjxl; without one the
settings refuse jxl (400 jxl-unavailable) and GET says why. Encoder
processes are tracked, bounded and killed at shutdown.
2026-10-24 12:41:06 -07:00
62 changed files with 5488 additions and 941 deletions
+17
View File
@@ -775,6 +775,23 @@ 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
+146 -32
View File
@@ -2163,20 +2163,27 @@ 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&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
/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
`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. `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).
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.
`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);
@@ -2184,7 +2191,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" past the cap), "No previews to generate · N already up to date" (or "No
next run" when a batch made no progress), "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`).
@@ -2201,8 +2208,9 @@ 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, its quality preset as the size, medium encoding)
or attaches to the running one, and streams the job's unchanged `status`/`start`/
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`/
`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
@@ -2218,18 +2226,17 @@ 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
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
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
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 are unchanged.
link toast and stays. The legacy gallery's thumbnail tools keep working on the same
server settings.
Automatic previews (#249, `py/api/thumbnail_auto.py`). The approved mock has no
repeated global thumbnail prompt (`docs/concepts/workspace/README.md`), so the
@@ -2248,13 +2255,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 job's defaults (512 px, medium). When it cannot be made an
link) and is made with the server's thumbnail settings (#317). 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 thumbnails · M failed"); I/O errors and a preview another program holds
("N missing · M outdated · K 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
@@ -2284,6 +2291,87 @@ 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
@@ -2311,7 +2399,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–D129 assigned):
Recorded deviations (plan D60–D67, D79–D81 renumbered D92–D99 and D111–D113; D121–D131 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
@@ -2331,9 +2419,11 @@ 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 failed" when automatic previews
failed this session) or "Thumbnail status unavailable"; Review thumbnails
opens the thumbnail dialog (D99). "One display configuration" says "Grid
`/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
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
@@ -2401,8 +2491,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.", with "· R more on the next run" past the
per-run cap; "Counting previews…" while it is read and "The previews could not be
"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
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
@@ -2476,6 +2566,30 @@ 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
+9
View File
@@ -87,6 +87,15 @@ 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)
+14 -7
View File
@@ -9,6 +9,7 @@ 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
@@ -50,6 +51,7 @@ 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
@@ -295,6 +297,7 @@ class PromptManagerAPI(
JobRoutesMixin,
ImageTagJobsMixin,
ThumbnailJobsMixin,
ThumbnailConvertMixin,
AutoThumbnailMixin,
SystemRoutesMixin,
LibraryLocationRoutesMixin,
@@ -638,16 +641,20 @@ class PromptManagerAPI(
f"{url_quote(rel_path.as_posix(), safe='/')}"
)
# Check for thumbnail
rel_no_ext = rel_path.with_suffix("")
thumb_rel = (
f"thumbnails/{rel_no_ext.as_posix()}_thumb{rel_path.suffix}"
)
# Any preview the file owns, the current format first (#317)
from . import thumbnail_settings
from .prompts import thumbnail_url_for
thumbnail_url = thumbnail_url_for(
thumb_rel, output_path / thumb_rel
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)
if thumbnail_url:
image["thumbnail_url"] = thumbnail_url
break # Found matching root, stop searching
+43 -28
View File
@@ -18,6 +18,7 @@ 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,
@@ -146,39 +147,48 @@ def _physical_file_key(path, stat):
return ("path", os.path.normcase(os.path.realpath(str(path))))
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 _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 _remove_thumbnail(output_path, rel_path, logger=None):
"""Remove the thumbnail of ``rel_path`` under ``output_path``, if any.
"""Remove the previews of ``rel_path`` under ``output_path``, if any.
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.
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.
"""
output_path = Path(output_path)
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:
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
if logger is not None:
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
logger.debug(f"Deleted associated thumbnail: {thumbnail_path}")
return removed
async def _read_json(request):
@@ -845,8 +855,13 @@ class AdminRoutesMixin:
extension = media_path.suffix.lower()
is_video = extension in VIDEO_EXTENSIONS
thumb_rel = _thumbnail_rel_path(rel_path, ".jpg" if is_video else extension)
thumbnail_url = thumbnail_url_for(thumb_rel, output_path / thumb_rel)
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)
return {
"id": str(hash(str(media_path))),
+10 -6
View File
@@ -10,6 +10,7 @@ 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
@@ -792,13 +793,16 @@ class AutotagRoutesMixin:
rel_path = image_path.relative_to(output_path)
thumbnail_url = None
if has_thumbnails:
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
# Any preview the file owns, the current format first (#317)
existing = thumbnail_settings.existing_thumbnail(
output_path, rel_path
)
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,
+14
View File
@@ -20,6 +20,7 @@ from .history_current import (
_OpenedCurrentMedia,
_current_media_response,
_prepare_current_media,
accepts_jxl,
_reauthorize_current_target,
candidate_locator,
resolve_current_candidate,
@@ -142,6 +143,18 @@ 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:
@@ -794,6 +807,7 @@ 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:
+81 -26
View File
@@ -15,7 +15,9 @@ 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 (
@@ -239,14 +241,19 @@ def resolve_current_candidate(
def _open_current_target(
target: Path, namespace: Path, scope: CurrentOutputScope
target: Path, namespace: Path, scope: CurrentOutputScope, preview: bool = False
) -> BinaryIO:
"""Open only the canonical target and verify authority against the actual handle."""
"""Open only the canonical target and verify authority against the actual handle.
A *preview* (a file under ``thumbnails/``) may also be JPEG XL (#317)."""
if _canonical(target) != target:
raise CurrentLocationUnavailable("not_authorized", stale=True)
if not _authorized(target, namespace, scope):
raise CurrentLocationUnavailable("not_authorized")
if target.suffix.lower() not in SERVABLE_EXTENSIONS:
suffix = target.suffix.lower()
if suffix not in SERVABLE_EXTENSIONS and not (
preview and suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
):
raise CurrentLocationUnavailable("unsupported_media")
flags = (
os.O_RDONLY
@@ -319,6 +326,61 @@ 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,
@@ -326,6 +388,7 @@ 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``.
@@ -333,36 +396,28 @@ 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:
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)
chosen = _cover_target(resolved, ensure_preview, placeholder, accepts_jxl)
if isinstance(chosen, _OpenedCurrentMedia):
return chosen
if chosen is not None:
target, is_preview = chosen, True
handle = _open_current_target(target, resolved.namespace, scope, is_preview)
try:
return _OpenedCurrentMedia(
handle,
os.fstat(handle.fileno()).st_size,
mimetypes.guess_type(target.name)[0] or "application/octet-stream",
thumbnail_settings.content_type(target)
or mimetypes.guess_type(target.name)[0]
or "application/octet-stream",
)
except BaseException:
handle.close()
+356 -268
View File
@@ -6,10 +6,11 @@ import binascii
import functools
import hashlib
import hmac
import io
import json
import os
import re
import threading
import subprocess # nosec B404 - ffmpeg frames through run_encoder, fixed argv
import time as _time
import urllib.parse
from bisect import bisect_left, bisect_right
@@ -21,6 +22,19 @@ 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
@@ -212,6 +226,13 @@ 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)
@@ -256,55 +277,10 @@ def _thumbnail_batch_size(workers):
return max(4, workers * 2)
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}
# Preview files are written by thumbnail_encode in the configured format
# (thumbnail_settings, #317); these names stay importable from here.
_temp_sibling = temp_sibling
_replace_unless_held = replace_unless_held
# A preview that could not be replaced (#249 S5 M6). Windows refuses to rename
# over a file another process has open (WinError 5 or 32), such as a preview
@@ -321,26 +297,15 @@ def _preview_write_error(exc):
return PREVIEW_NOT_WRITABLE
def _ffmpeg_qscale(encoding):
"""ffmpeg's ``-q:v`` for a THUMBNAIL_ENCODINGS entry."""
return str(_FFMPEG_QSCALE.get(encoding["quality"], 4))
def _write_image_thumbnail(src, dst, settings, guard=None, track=None):
"""Write *src*'s preview into *dst* with *settings* (blocking, atomic).
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.
The first frame, fitted inside the edge, in the configured format;
returns the path written (see thumbnail_encode.write_thumbnail).
"""
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
with Image.open(src) as img:
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)
frame = thumbnail_frame(img, settings.edge)
return write_thumbnail(frame, dst, settings, guard=guard, track=track)
def _video_placeholder_image(thumbnail_size):
@@ -452,6 +417,58 @@ 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:
@@ -774,39 +791,48 @@ def _iter_media_files(output_path, max_files):
yield root_path / name
def media_thumbnail_rel(rel_path):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*.
def media_thumbnail_rel(rel_path, settings=None):
"""``thumbnails/<rel>_thumb<ext>`` (POSIX) for the media file *rel_path*:
the name the preview gets when it is written now (#317), images and
videos alike (``writer_settings``' extension)."""
settings = settings or writer_settings()
return thumbnail_settings.thumbnail_rel(rel_path, settings.extension)
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 existing_media_thumbnail(output_path, rel_path):
"""The existing preview *rel_path* owns that a listing or cover shows
(the format written now first, then older ones), or None. The Images
listing and History's cover use this one lookup (review M5)."""
return thumbnail_settings.existing_thumbnail(
output_path, rel_path, writer_settings().extension
)
def media_thumbnail(output_path, rel_path, root_index, *, root_scope=None):
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (one
blocking stat). The Images listing, the Needs review queue cards and the
"""``(thumbnail_url, ready)`` for *rel_path* under *output_path* (a few
blocking stats). The Images listing, the Needs review queue cards and the
Library use the same URL (#249 F, M10).
An existing preview'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.
An existing preview the file owns (the configured format first, then an
older name such as ``_thumb.png``, #317) is listed with its mtime as
``v=`` and ``ready`` True. A missing one is still listed under the name
it will get (without ``v=``): the serve route makes it on first view and
falls back when it cannot (py/api/thumbnail_auto.py), so no card loads the
full file because its preview was not made yet. Media inside a nested
``thumbnails`` folder is never previewed: ``(None, False)``, and its card
shows the original.
"""
rel_path = Path(rel_path)
if "thumbnails" in rel_path.parts[:-1]:
return None, False
thumb_rel = media_thumbnail_rel(rel_path)
thumbnail_url = thumbnail_url_for(thumb_rel, Path(output_path) / thumb_rel)
existing = existing_media_thumbnail(output_path, rel_path)
thumbnail_url = None
if existing is not None:
thumb_rel = thumbnail_settings.thumbnail_stem_rel(rel_path) + existing.suffix
thumbnail_url = thumbnail_url_for(thumb_rel, existing)
ready = thumbnail_url is not None
if thumbnail_url is None:
thumb_rel = media_thumbnail_rel(rel_path)
quoted = urllib.parse.quote(thumb_rel, safe="/")
thumbnail_url = f"/prompt_manager/images/serve/{quoted}?root={root_index}"
else:
@@ -1733,6 +1759,11 @@ 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
@@ -1758,7 +1789,12 @@ class ImageRoutesMixin:
filepath = request.match_info["filepath"]
if _has_traversal(filepath):
return _forbidden()
if Path(filepath).suffix.lower() not in SERVABLE_EXTENSIONS:
suffix = Path(filepath).suffix.lower()
if suffix not in SERVABLE_EXTENSIONS and not (
# A JPEG XL preview is served from thumbnails/ only (#317).
suffix in thumbnail_settings.THUMBNAIL_EXTENSIONS
and _is_thumbnail_cache_path(filepath)
):
return _forbidden("Only media files can be served")
output_dirs = self._select_root(roots, request.query.get("root"))
@@ -1873,22 +1909,14 @@ 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:
@@ -1902,8 +1930,6 @@ 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(
@@ -1912,7 +1938,7 @@ class ImageRoutesMixin:
)
result = await self._run_in_executor(
self._generate_gallery_thumbnails_sync, output_dirs, thumbnail_size
self._generate_gallery_thumbnails_sync, output_dirs
)
self.invalidate_gallery_cache()
return web.json_response(result)
@@ -1923,30 +1949,30 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(e)}, status=500
)
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, thumbnail_size):
def _generate_thumbnails_sync(self, output_path, thumbnails_dir, settings=None):
"""Blocking thumbnail generation loop (run in executor)."""
settings = writer_settings(settings)
start = _time.monotonic()
plan = self._plan_thumbnails(output_path, thumbnails_dir)
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
plan = self._plan_thumbnails(output_path, thumbnails_dir, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(thumbnails_dir)
return payload
def _generate_gallery_thumbnails_sync(
self, output_dirs: list[Path], thumbnail_size: tuple[int, int]
self, output_dirs: list[Path], settings=None
) -> dict:
settings = writer_settings(settings)
start = _time.monotonic()
plan = self._plan_gallery_thumbnails(output_dirs)
payload = self._execute_thumbnail_plan(plan, thumbnail_size, start)
plan = self._plan_gallery_thumbnails(output_dirs, settings=settings)
payload = self._execute_thumbnail_plan(plan, settings, start)
payload["thumbnails_path"] = _public_path(output_dirs[0] / "thumbnails")
return payload
def _execute_thumbnail_plan(
self, plan: dict, thumbnail_size: tuple[int, int], start: float
) -> dict:
def _execute_thumbnail_plan(self, plan: dict, settings, start: float) -> dict:
targets = plan["targets"]
stats = _new_thumbnail_stats()
stats["skipped"] = plan["skipped"]
for result in self._generate_batch(targets, thumbnail_size):
for result in self._generate_batch(targets, settings):
_record_thumbnail_result(stats, result)
payload = _thumbnail_complete_payload(
len(targets) + plan["skipped"] + plan["remaining"],
@@ -1960,41 +1986,52 @@ class ImageRoutesMixin:
return payload
def _plan_gallery_thumbnails(
self, output_dirs: list[Path], rebuild: bool = False, stop=None
self,
output_dirs: list[Path],
mode: str = "missing",
stop=None,
settings=None,
exclude=None,
) -> dict:
"""Plan every gallery root with one shared per-run generation cap.
"""Plan every gallery root with one shared per-batch generation cap.
See ``_plan_thumbnails``; ``fresh_files`` keeps at most
MAX_FRESH_LISTED files across the roots. ``stop()`` true ends the walk
at the next file (a cancelled job); the partial plan is returned.
*exclude* (a set of source paths) leaves files a job already worked
on out of a later batch's plan.
"""
combined = {
"targets": [],
"skipped": 0,
"remaining": 0,
"fresh": 0,
"fresh_files": [],
}
settings = writer_settings(settings)
combined = {key: 0 for key in _PLAN_COUNTS}
combined.update(targets=[], fresh_files=[])
for root in output_dirs:
if stop is not None and stop():
break
root = Path(root)
plan = self._plan_thumbnails(
root, root / "thumbnails", rebuild=rebuild, stop=stop
root,
root / "thumbnails",
mode=mode,
stop=stop,
settings=settings,
exclude=exclude,
)
available = max(0, MAX_THUMBNAIL_FILES - len(combined["targets"]))
combined["targets"].extend(plan["targets"][:available])
combined["skipped"] += plan["skipped"]
combined["fresh"] += plan["fresh"]
for key in _PLAN_COUNTS:
combined[key] += plan[key]
room = max(0, MAX_FRESH_LISTED - len(combined["fresh_files"]))
combined["fresh_files"].extend(plan["fresh_files"][:room])
combined["remaining"] += plan["remaining"] + max(
0, len(plan["targets"]) - available
)
combined["remaining"] += max(0, len(plan["targets"]) - available)
return combined
async def get_thumbnail_status(self, request: web.Request) -> web.Response:
"""Count pending thumbnails off-loop without generating files."""
"""Count previews off-loop without generating files.
``pending`` previews are missing, ``outdated`` ones exist in another
format than the one written now (Convert outdated, #317) and ``ready``
counts every file with a preview, outdated or not.
"""
try:
output_dirs = self._get_all_output_dirs()
plan = await self._run_in_executor(
@@ -2008,6 +2045,8 @@ 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()[
@@ -2022,76 +2061,90 @@ class ImageRoutesMixin:
{"success": False, "error": _safe_error(exc)}, status=500
)
def _plan_thumbnails(self, output_path, thumbnails_dir, rebuild=False, stop=None):
"""Decide which media files need a thumbnail (blocking walk).
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).
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.
Each file is ``fresh`` (its preview in the format written now is at
least as new as the file, and no other owned preview is left),
``outdated`` (an owned preview in another format: a legacy
``_thumb.png`` or a format switched away from, #317) or ``missing``.
``mode`` picks the targets: ``missing`` the missing ones (fresh and
outdated are ``skipped`` and never queued, so a large library that is
mostly done only pays for the files that changed), ``rebuild`` every
file, ``convert`` the outdated ones (missing ones are left to the
missing run). Returns ``{"targets": [(src, dst, is_video)], "skipped",
"remaining", "fresh", "outdated", "missing", "fresh_files": [src]}``;
``fresh_files`` lists the first MAX_FRESH_LISTED fresh files. The walk
is bounded like the gallery listing (MAX_SCAN_DEPTH, MAX_GALLERY_FILES,
no symlinks), each preview folder is listed once, and at most
MAX_THUMBNAIL_FILES targets are queued per batch (a job plans again
after each batch); ``remaining`` counts the targets beyond that cap.
Sources in *exclude* (worked on by an earlier batch) are left out.
``stop()`` true ends the walk before the next file.
"""
settings = writer_settings(settings)
output_path = Path(output_path)
targets = []
skipped = 0
remaining = 0
fresh = 0
fresh_files = []
thumbnails_dir = Path(thumbnails_dir)
plan = {key: 0 for key in _PLAN_COUNTS}
plan.update(targets=[], fresh_files=[])
folders = {}
for src in _iter_media_files(output_path, MAX_GALLERY_FILES):
if stop is not None and stop():
break
suffix = src.suffix.lower()
is_video = suffix in VIDEO_EXTENSIONS
if exclude and src in exclude:
continue
rel_no_ext = src.relative_to(output_path).with_suffix("")
thumb_suffix = ".jpg" if is_video else suffix
dst = thumbnails_dir / f"{rel_no_ext.as_posix()}_thumb{thumb_suffix}"
stem = f"{rel_no_ext.name}{thumbnail_settings.THUMB_SUFFIX}"
dst = thumbnails_dir / rel_no_ext.parent / (stem + settings.extension)
if not _is_within(dst, thumbnails_dir):
self.logger.warning(f"Skipping thumbnail outside safe dir: {src.name}")
continue
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
targets.append((src, dst, is_video))
if remaining:
self.logger.warning(
f"Thumbnail run capped at {MAX_THUMBNAIL_FILES} new files; "
f"{remaining} more will be generated on the next run"
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")
)
return {
"targets": targets,
"skipped": skipped,
"remaining": remaining,
"fresh": fresh,
"fresh_files": fresh_files,
}
if not target:
plan["skipped"] += 1
if state == "fresh" and len(plan["fresh_files"]) < MAX_FRESH_LISTED:
plan["fresh_files"].append(src)
continue
if len(plan["targets"]) >= MAX_THUMBNAIL_FILES:
plan["remaining"] += 1
continue
is_video = src.suffix.lower() in VIDEO_EXTENSIONS
plan["targets"].append((src, dst, is_video))
if plan["remaining"]:
self.logger.warning(
f"Thumbnail batch capped at {MAX_THUMBNAIL_FILES} files; "
f"{plan['remaining']} more are left for the next batch"
)
return plan
def _thumbnail_targets(self, output_path, thumbnails_dir):
"""Pending (src, dst, is_video) triples; see _plan_thumbnails."""
return self._plan_thumbnails(output_path, thumbnails_dir)["targets"]
def _generate_batch(self, targets, thumbnail_size, **options):
def _generate_batch(self, targets, settings, **options):
"""Create thumbnails for ``targets`` on the configured worker threads.
Blocking; returns one result dict per target, in order. ``options``
go to ``_generate_one`` (``encoding``, ``rebuild``, ``stop``).
go to ``_generate_one`` (``rebuild``, ``stop``).
"""
def one(target):
src, dst, is_video = target
return self._generate_one(src, dst, thumbnail_size, is_video, **options)
return self._generate_one(src, dst, settings, is_video, **options)
return map_parallel(one, targets, _worker_threads())
@@ -2099,48 +2152,55 @@ class ImageRoutesMixin:
self,
src,
dst,
thumbnail_size,
settings,
is_video,
encoding=None,
rebuild=False,
stop=None,
guard=None,
track=None,
placeholder=True,
):
"""Create one thumbnail (blocking). Never raises; returns a result dict.
``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).
*settings* (``thumbnail_settings.ThumbnailSettings``) give the format,
quality and edge; *dst* takes the format's extension. ``stop()`` true
before the file starts gives ``action: "cancelled"`` and touches
nothing (a job's file boundary). ``rebuild`` writes a fresh preview
again and never replaces an existing video preview with the
placeholder. Every write is atomic and verified, so a failure keeps
the old preview. ``guard()`` false just before the rename gives
``action: "error"`` with ``held: True`` and writes nothing;
``track(process)`` is handed every encoder process (both for automatic
previews, #249). An error result carries ``decode: True`` when the
file itself cannot be decoded (an I/O error does not). A generated
result names the file written as ``path``. Without ``placeholder``
(Convert outdated) a video with no decodable frame fails instead.
"""
result = {
"file": src.name,
"dir": src.parent.name,
"type": "video" if is_video else "image",
}
dst = Path(dst)
if dst.suffix != settings.extension:
dst = dst.with_suffix(settings.extension)
try:
if stop is not None and stop():
return {**result, "action": "cancelled"}
if not src.is_file():
return {**result, "action": "error", "error": "File no longer exists"}
if not rebuild and _thumbnail_is_fresh(src, dst):
return {**result, "action": "skipped"}
return {**result, "action": "skipped", "path": str(dst)}
dst.parent.mkdir(parents=True, exist_ok=True)
if is_video:
made = self._generate_video_thumbnail(
src,
dst,
thumbnail_size,
encoding,
settings,
keep_existing=rebuild,
guard=guard,
track=track,
placeholder=placeholder,
)
if not made:
return {
@@ -2149,9 +2209,10 @@ class ImageRoutesMixin:
"error": "Video decode failed",
"decode": True,
}
written = made if isinstance(made, Path) else dst
else:
_write_image_thumbnail(src, dst, thumbnail_size, encoding, guard)
return {**result, "action": "generated"}
written = _write_image_thumbnail(src, dst, settings, guard, track)
return {**result, "action": "generated", "path": str(written)}
except PreviewWriteHeld:
return {**result, "action": "error", "error": PREVIEW_HELD, "held": True}
except UnidentifiedImageError:
@@ -2186,62 +2247,70 @@ class ImageRoutesMixin:
self,
video_path,
thumbnail_path,
thumbnail_size,
encoding=None,
settings,
keep_existing=False,
*,
guard=None,
track=None,
placeholder=True,
):
"""Generate thumbnail from video file. Returns True if successful.
"""Write a video's preview; returns the path written, or False.
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.
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.
"""
encoding = encoding or DEFAULT_THUMBNAIL_ENCODING
try:
frame = self._opencv_frame(video_path, thumbnail_size)
frame = self._opencv_frame(video_path, settings.size)
source = "OpenCV"
if frame is None:
frame = self._ffmpeg_frame(video_path, settings.size, track)
source = "ffmpeg"
if frame is not None:
_atomic_save(frame, thumbnail_path, "JPEG", guard=guard, **encoding)
self.logger.debug(
f"Generated video thumbnail using OpenCV: {thumbnail_path}"
written = write_thumbnail(
thumbnail_frame(frame, settings.edge),
thumbnail_path,
settings,
guard=guard,
track=track,
)
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.debug(
f"Generated video thumbnail using {source}: {written}"
)
return written
if not placeholder or (keep_existing and thumbnail_path.exists()):
self.logger.warning(
f"No frame decoded for {video_path.name}; keeping its preview"
)
return False
# Last resort: the placeholder frame.
try:
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}"
written = write_thumbnail(
_video_placeholder_image(settings.size),
thumbnail_path,
settings,
guard=guard,
track=track,
)
return True
except (PermissionError, PreviewWriteHeld):
self.logger.debug(f"Generated placeholder video thumbnail: {written}")
return written
except (PermissionError, PreviewWriteHeld, EncoderClosed):
raise
except Exception as e:
self.logger.warning(
f"Failed to create placeholder thumbnail for {video_path}: {e}"
)
return False
except (PermissionError, PreviewWriteHeld):
except (PermissionError, PreviewWriteHeld, EncoderClosed):
raise
except Exception as e:
self.logger.error(
@@ -2275,61 +2344,52 @@ class ImageRoutesMixin:
self.logger.debug(f"OpenCV could not read {video_path}: {e}")
return None
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)
def _ffmpeg_frame(self, video_path, thumbnail_size, track=None):
"""ffmpeg's frame at 1 s, fitted to *thumbnail_size*, as a PIL image
(a PNG on stdout, never a temp file), or None. EncoderClosed (the
shutdown) propagates."""
cmd = [
"ffmpeg",
"-v",
"error",
"-i",
str(video_path),
# Read as a file, never as a protocol URL (review M6).
f"file:{video_path}",
"-ss",
"00:00:01",
"-vframes",
"1",
"-vf",
f"scale={thumbnail_size[0]}:{thumbnail_size[1]}"
# Fit inside the edge, never upscale a small clip.
f"scale='min(iw,{thumbnail_size[0]})':'min(ih,{thumbnail_size[1]})'"
":force_original_aspect_ratio=decrease",
"-q:v",
_ffmpeg_qscale(encoding),
"-y",
str(tmp_path),
"-f",
"image2pipe",
"-vcodec",
"png",
"-",
]
try:
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
done = run_encoder(cmd, track=track)
except EncoderClosed:
raise
except (OSError, subprocess.SubprocessError) as e:
self.logger.debug(f"ffmpeg could not read {video_path}: {e}")
return None
if done.returncode != 0 or not done.stdout:
detail = done.stderr or b""
if isinstance(detail, bytes):
detail = detail.decode("utf-8", "replace")
detail = detail[-300:]
self.logger.warning(f"ffmpeg failed for {video_path}: {detail}")
return None
try:
frame = Image.open(io.BytesIO(done.stdout))
frame.load()
return frame
except Exception as e:
self.logger.warning(f"ffmpeg gave no readable frame for {video_path}: {e}")
return None
async def clear_thumbnails(self, request: web.Request) -> web.Response:
"""Clear generated thumbnails across gallery roots, preserving originals.
@@ -2391,6 +2451,25 @@ 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]:
@@ -2418,12 +2497,18 @@ 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 GALLERY_IMAGE_EXTENSIONS
or path.suffix.lower()
not in thumbnail_settings.THUMBNAIL_EXTENSIONS
or path.is_symlink()
):
continue
@@ -2746,6 +2831,9 @@ 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:
+74 -1
View File
@@ -13,6 +13,7 @@ checks one path and writes nothing.
"""
import contextlib
import functools
import os
from aiohttp import web
@@ -23,9 +24,11 @@ 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 JobBusy
from .jobs import THUMBNAILS_JOB_KIND, JobBusy
try:
from ...utils.validators import validate_result_timeout, validate_worker_threads
@@ -41,7 +44,12 @@ _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"
@@ -176,12 +184,15 @@ 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,
@@ -189,6 +200,9 @@ 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 "",
@@ -295,6 +309,26 @@ 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(
@@ -355,6 +389,33 @@ 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).
@@ -491,6 +552,18 @@ 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):
+46 -24
View File
@@ -33,9 +33,11 @@ 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 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.
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.
Holds: automatic previews only write files under ``thumbnails/`` and never
touch the database, like the thumbnail job, so they run beside a scan, image
@@ -63,11 +65,10 @@ 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,
@@ -83,16 +84,20 @@ from .jobs import (
JobBusy,
)
from .settings_routes import SOURCE_CHANGE_JOB_KIND
from .thumbnail_jobs import DEFAULT_THUMBNAIL_EDGE, ThumbnailOptions
from .thumbnail_encode import shutdown_encoders, writer_settings
from .thumbnail_jobs import ThumbnailOptions
try:
from ...utils.parallel import map_parallel
except ImportError: # tests import the package top-level
from utils.parallel import map_parallel
AUTO_THUMBNAIL_EDGE = DEFAULT_THUMBNAIL_EDGE
AUTO_THUMBNAIL_SIZE = (AUTO_THUMBNAIL_EDGE, AUTO_THUMBNAIL_EDGE)
AUTO_THUMBNAIL_ENCODING = THUMBNAIL_ENCODINGS["medium"]
# 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))
# 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.
@@ -146,8 +151,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(AUTO_THUMBNAIL_SIZE).save(
buffer, "JPEG", **AUTO_THUMBNAIL_ENCODING
_video_placeholder_image(PLACEHOLDER_SIZE).save(
buffer, "JPEG", quality=PLACEHOLDER_QUALITY, optimize=True
)
return buffer.getvalue()
@@ -171,7 +176,7 @@ def _is_preview_request(filepath):
stem.endswith("_thumb")
and len(stem) > len("_thumb")
and extension == extension.lower()
and extension in GALLERY_IMAGE_EXTENSIONS
and extension in thumbnail_settings.THUMBNAIL_EXTENSIONS
)
@@ -184,12 +189,27 @@ def _regular_file(path):
def _candidates(parts):
"""``(folder parts, base, extensions)`` of a checked preview request."""
"""``(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)."""
stem, extension = os.path.splitext(parts[-1])
wanted = [extension] + (sorted(VIDEO_EXTENSIONS) if extension == ".jpg" else [])
wanted = [
source
for source in _SOURCE_ORDER
if source in MEDIA_EXTENSIONS and thumbnail_settings.owns(extension, source)
]
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)
@@ -207,11 +227,12 @@ 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 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.
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.
"""
root = Path(root)
folder_parts, base, wanted = _candidates(parts)
@@ -224,7 +245,7 @@ def _thumbnail_source(root, parts):
return None
rel = Path(*folder_parts, found.name)
dst = root / media_thumbnail_rel(rel)
if dst != root.joinpath(*parts):
if dst != _current_preview(root, parts):
return None
if not _is_within(root / "thumbnails", root) or not _is_within(
dst, root / "thumbnails"
@@ -605,6 +626,8 @@ 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
@@ -614,9 +637,8 @@ class AutoThumbnailMixin:
return self._generate_one(
src,
dst,
AUTO_THUMBNAIL_SIZE,
writer_settings(),
is_video,
encoding=AUTO_THUMBNAIL_ENCODING,
stop=stop,
guard=guard,
track=track,
@@ -730,7 +752,7 @@ class AutoThumbnailMixin:
if quick is None:
continue
return self._preview_response(BUSY, quick[0], None, quick[1])
dst = root.joinpath(*parts)
dst = _current_preview(root, parts)
if outcome == READY:
return self._file_response(dst)
located = await gate.loop.run_in_executor(
@@ -807,7 +829,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", AUTO_THUMBNAIL_EDGE, "medium")
options = ThumbnailOptions("missing", writer_settings())
try:
self._start_thumbnail_job(options)
except JobBusy as busy:
+405
View File
@@ -0,0 +1,405 @@
"""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
+524
View File
@@ -0,0 +1,524 @@
"""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
+143 -118
View File
@@ -1,29 +1,32 @@
"""Thumbnail generation as an Activity job (#249 System S5, Task 12).
"""Thumbnail generation as an Activity job (#249 System S5, Task 12; #317).
- ``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``.
- ``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``.
``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.
``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.
The legacy gallery stream ``POST /images/generate-thumbnails/progress``
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.
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.
"""
import asyncio
@@ -36,7 +39,6 @@ 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,
@@ -48,24 +50,13 @@ 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")
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}"
)
THUMBNAIL_MODES = ("missing", "rebuild", "convert")
_OPTIONS_ERROR = 'mode must be "missing", "rebuild" or "convert"'
CANCELLED_ERROR = "Thumbnail generation was cancelled"
CANCELLED_BEFORE_WRITING = "Cancelled before any preview was written"
NOTHING_TO_PREVIEW = "No images or videos to preview"
@@ -80,56 +71,35 @@ _LOG_PREFIXES = {"generated": "+", "errors": "!"}
@dataclass(frozen=True)
class ThumbnailOptions:
"""One run's choices: the mode, the preview's longest edge and quality."""
"""One run's choices: the mode and the thumbnail settings it writes with."""
mode: str
edge: int
quality: str
@property
def size(self):
return (self.edge, self.edge)
@property
def encoding(self):
return THUMBNAIL_ENCODINGS[self.quality]
settings: ThumbnailSettings
@property
def rebuild(self):
return self.mode == "rebuild"
def scope(self):
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
return {
"mode": self.mode,
"format": self.settings.format,
"quality": self.settings.quality,
"size": self.settings.edge,
}
def thumbnail_options(raw, *, from_query=False):
"""``(ThumbnailOptions, None)`` from a body or query, or ``(None, message)``.
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.
An absent mode is ``missing``; the settings are the server's now.
"""
if not isinstance(raw, dict) and not hasattr(raw, "get"):
return None, _OPTIONS_ERROR
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:
mode = raw.get("mode", "missing")
if mode not in THUMBNAIL_MODES:
return None, _OPTIONS_ERROR
if edge is None:
return None, _OPTIONS_ERROR
return ThumbnailOptions(mode, edge, quality), None
return ThumbnailOptions(mode, writer_settings()), None
def _invalid(message):
@@ -168,7 +138,10 @@ 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, rebuild=options.rebuild
self._plan_gallery_thumbnails,
output_dirs,
options.mode,
settings=options.settings,
)
except Exception:
self.logger.exception("Thumbnail plan failed")
@@ -185,6 +158,7 @@ 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(),
@@ -215,14 +189,20 @@ 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."""
"""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
return self._jobs.start(
THUMBNAILS_JOB_KIND,
THUMBNAIL_JOB_TITLE,
CONVERT_JOB_TITLE if convert else THUMBNAIL_JOB_TITLE,
admission="guarded",
cancellable=True,
scope=options.scope(),
runner=functools.partial(self._run_thumbnail_job, options=options),
runner=functools.partial(runner, options=options),
groups=THUMBNAIL_GROUP_NAMES,
)
@@ -265,7 +245,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.edge} px · {options.quality}"
f"{options.settings.describe()}"
)
job.publish(
_frame(
@@ -273,41 +253,84 @@ class ThumbnailJobsMixin:
{"phase": "scanning", "message": "Scanning for images and videos..."},
)
)
plan = await self._run_in_executor(
self._plan_gallery_thumbnails,
output_dirs,
rebuild=options.rebuild,
stop=lambda: job.cancelled,
)
plan = await self._plan_thumbnail_batch(job, options, output_dirs)
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 = await self._generate_thumbnail_targets(job, options, plan, roots)
stats, start, left, attempted = await self._generate_in_batches(
job, options, plan, roots, output_dirs
)
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(plan["targets"]) + plan["skipped"] + plan["remaining"],
len(attempted) + plan["skipped"] + left,
stats,
start,
remaining=plan["remaining"],
processed=len(plan["targets"]),
remaining=left,
processed=len(attempted),
)
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"
)
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")
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."""
@@ -335,10 +358,10 @@ class ThumbnailJobsMixin:
if plan["skipped"]:
job.log_line(f"= {plan['skipped']:,} previews already up to date")
async def _generate_thumbnail_targets(self, job, options, plan, roots):
"""Generate ``plan``'s targets in worker batches, publishing each file."""
@staticmethod
def _publish_thumbnail_start(job, plan, total):
"""The legacy stream's start frame, with the job's overall total."""
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"
@@ -354,7 +377,7 @@ class ThumbnailJobsMixin:
{
"total_images": total,
"skipped": plan["skipped"],
"remaining": plan["remaining"],
"remaining": 0,
"phase": "processing",
"image_count": total - video_count,
"video_count": video_count,
@@ -362,28 +385,30 @@ class ThumbnailJobsMixin:
},
)
)
stats = _new_thumbnail_stats()
stats["skipped"] = plan["skipped"]
start = time.monotonic()
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"]
batch_size = _thumbnail_batch_size(_worker_threads())
generate = {
"encoding": options.encoding,
"rebuild": options.rebuild,
"stop": lambda: job.cancelled,
}
index = 0
batches = 0
for batch_start in range(0, total, batch_size):
for batch_start in range(0, len(targets), 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.size, **generate
self._generate_batch, batch, options.settings, **generate
)
for (src, _dst, _video), result in zip(batch, results):
if result["action"] == "cancelled":
continue
index += 1
run["index"] += 1
index, total = run["index"], max(run["total"], run["index"])
_record_thumbnail_result(stats, result)
self._record_thumbnail_outcome(job, src, result, roots)
job.update(processed=index, progress=index * 100 // total)
@@ -400,14 +425,13 @@ class ThumbnailJobsMixin:
_thumbnail_progress_payload(index, total, stats, result, start),
)
)
batches += 1
run["batches"] += 1
job.update(
status_line=_scan_status_line(
index, total, batches, time.monotonic() - start
run["index"], run["total"], run["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):
@@ -430,14 +454,13 @@ class ThumbnailJobsMixin:
async def generate_thumbnails_with_progress(self, request):
"""The gallery's thumbnail stream (POST, server-sent events).
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
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
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={
@@ -449,7 +472,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(thumbnail_size)
job, started, busy = self._legacy_stream_job()
if busy is not None:
message = f"{busy.title} already running"
await self._emit_sse(
@@ -476,19 +499,21 @@ class ThumbnailJobsMixin:
await events.aclose()
return response
def _legacy_stream_job(self, thumbnail_size):
def _legacy_stream_job(self):
"""``(job, started, busy)`` for the legacy stream, synchronously.
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.
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.
"""
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", thumbnail_size[0], "medium")
options = ThumbnailOptions("missing", writer_settings())
return self._start_thumbnail_job(options), True, None
+228
View File
@@ -0,0 +1,228 @@
"""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())
+32 -5
View File
@@ -202,7 +202,6 @@ 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
@@ -227,7 +226,6 @@ class GalleryConfig:
# Web interface settings
IMAGES_PER_PAGE = 20
THUMBNAIL_SIZE = 256
ENABLE_SEARCH = True
ENABLE_METADATA_VIEW = True
@@ -267,7 +265,6 @@ 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,
},
@@ -330,8 +327,6 @@ 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:
@@ -669,6 +664,15 @@ 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."""
@@ -725,6 +729,11 @@ 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(),
@@ -870,6 +879,8 @@ 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"])
@@ -881,6 +892,22 @@ 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:
+6 -1
View File
@@ -356,7 +356,12 @@ 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))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
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)
)
if include_video:
# Seed after thumbnail generation to exercise a real unthumbnailed video.
video = root / "output" / "clip.webm"
+24 -16
View File
@@ -482,6 +482,8 @@ 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'
@@ -530,7 +532,8 @@ 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(['operations.0', 'operations.length', 'rows.11', 'rows.12', 'rows.13', 'rows.length', 'surface.tree'].sort());
.toEqual(['layout.1', 'layout.2', 'layout.length', '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.
@@ -842,13 +845,16 @@ 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").
* 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)".
*/
function mapThumbnailConcept(reference, plan) {
const [cancel, start] = reference.actions.buttons;
const tree = [...reference.tree];
tree.splice(tree.indexOf('div.dialog-actions'), 0, 'p.small');
return { ...reference, tree,
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,
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' }] } };
@@ -874,12 +880,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')).toHaveText(/ already up to date\.$/);
await expect(page.locator('dialog#modal[open] #modal-body > p.small').first()).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&size=512&quality=medium');
const thumbnailPlan = request => serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing');
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) => {
@@ -902,22 +908,24 @@ 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 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']);
// 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());
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-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(() => { 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(() => { 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.prepend(select.lastElementChild); select.selectedIndex = 0; });
await page.evaluate(() => { const select = document.querySelector('#thumb-mode'); select.append(select.firstElementChild); 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-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']));
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']));
await undoStyle();
expect(diffInventory(expected, await page.evaluate(measureThumbnailReview))).toEqual([]);
});
+2 -2
View File
@@ -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\.jpg\?root=0/);
expect(media.images.find(image => image.filename === 'clip.webm').thumbnail_url).toMatch(/\/thumbnails\/clip_thumb\.webp\?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\.jpg\?/);
await expect(cover).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.webp\?/);
await expect(details(page).locator('video')).toHaveCount(0);
await cover.evaluate(element => { window.__oldInspectorCover = element; });
await rescan(page);
+45 -43
View File
@@ -331,27 +331,25 @@ function thumbnailRequests(page) {
const missingLine = plan => `${planCount(plan.targets)} ${plan.targets === 1 ? 'preview' : 'previews'} to generate · ` +
`${planCount(plan.fresh)} already up to date.`;
test('Review options shows the plan counts for the chosen scope/quality/size before Start', async ({ page, request }) => {
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 }) => {
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&size=512&quality=high`);
const plan = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=missing`);
expect(plan.targets).toBe(3);
const review = await openThumbnailReview(page);
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']);
// 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('#thumb-mode')).toHaveValue('missing');
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');
const [line, settings] = [review.locator('#modal-body > p.small').first(), review.locator('#modal-body > p.small').last()];
await expect(line).toHaveAttribute('role', 'status');
await expect(line).toHaveText(missingLine(plan));
expect(seen.plans).toEqual([{ mode: 'missing', size: '512', quality: 'high' }]);
await expect(settings).toHaveText(SETTINGS_LINE);
expect(seen.plans).toEqual([{ mode: 'missing' }]);
await expect(review.locator('.dialog-actions > button')).toHaveText(['Cancel', 'Start']);
// Reviewing starts nothing; Cancel closes.
await review.getByRole('button', { name: 'Cancel', exact: true }).click();
@@ -360,63 +358,67 @@ test('Review options shows the plan counts for the chosen scope/quality/size bef
expect((await serverJson(request, '/prompt_manager/jobs')).jobs).toEqual([]);
});
test('size outside 128–2048 reports validity and requests nothing', async ({ page, request }) => {
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);
const seen = thumbnailRequests(page);
const review = await openThumbnailReview(page);
const line = review.locator('#modal-body > p.small');
const line = review.locator('#modal-body > p.small').first();
await expect(line).toHaveText(/ already up to date\.$/);
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');
}
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([]);
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([]);
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);
}
});
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', size: 256, quality: 'low' }]);
expect(seen.starts).toEqual([{ mode: 'missing' }]);
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', size: 256, quality: 'low' } });
expect(job).toMatchObject({ kind: 'thumbnails', title: 'Thumbnail generation',
scope: { mode: 'missing', format: 'webp', size: 512, quality: 80 } });
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&size=512&quality=medium`);
const rebuild = await serverJson(request, `${THUMBNAIL_PLAN_URL}?mode=rebuild`);
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');
const line = review.locator('#modal-body > p.small').first();
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', size: '512', quality: 'medium' }, { mode: 'rebuild', size: '512', quality: 'medium' }]);
expect(seen.plans).toEqual([{ mode: 'missing' }, { mode: 'rebuild' }]);
// Back and forth reads each scope once.
await review.locator('#thumb-mode').selectOption('missing');
await expect(line).toHaveText(/ already up to date\.$/);
@@ -425,7 +427,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', size: 512, quality: 'medium' }]);
expect(seen.starts).toEqual([{ mode: 'rebuild' }]);
const id = page.url().split('/').pop();
await expect.poll(async () => (await serverJson(request, `/prompt_manager/jobs/${id}`)).job.state).toBe('finished');
});
@@ -433,7 +435,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')).toHaveText('The previews could not be counted.');
await expect(review.locator('#modal-body > p.small').first()).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();
});
+61 -9
View File
@@ -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&size=512&quality=medium');
const plan = await serverJson(request, '/prompt_manager/images/thumbnails/plan?mode=missing');
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', 'Quality', 'Preview size limit']);
await expect(review.locator('#modal-body > p.small')).toHaveText(`2 previews to generate · ${number(plan.fresh)} already up to date.`);
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.`);
// Escape closes it and focus returns to the row's button.
await page.keyboard.press('Escape');
await expect(review).toHaveCount(0);
@@ -254,11 +254,61 @@ 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', size: 512, quality: 'medium' }]);
expect(starts).toEqual([{ mode: 'missing' }]);
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();
@@ -383,7 +433,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')).toHaveText('Browsing & display');
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2').first()).toHaveText('Browsing & display');
// The host rows are enabled once the server settings answered.
await expect(page.locator('#timeout')).toBeEnabled();
}
@@ -600,14 +650,16 @@ 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`);
for (const id of ['open-mode', 'timeout']) {
// #317: the Thumbnails rows are server settings too.
for (const id of ['open-mode', 'timeout', 'thumbnail-format', 'thumbnail-quality', 'thumbnail-edge']) {
const row = page.locator('#workspace-main .setting-row').filter({ has: page.locator(`#${id}`) });
await expect(row.locator('p.small')).toHaveText('Server settings could not be read.');
await expect(row.locator('p.small').first()).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')).toHaveCount(2);
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);
});
test('stale revision shows the conflict status', async ({ page, request }) => {
@@ -669,7 +721,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')).toHaveText('Browsing & display');
await expect(page.locator('#workspace-main .settings-layout > section.surface > h2').first()).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');
+39 -4
View File
@@ -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\.png\?root=0/);
for (const image of fresh) expect(image.thumbnail_url).toMatch(/\/thumbnails\/unthumbnailed\/plain_\d{3}_thumb\.webp\?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\.png\?/);
await expect(cover).toHaveAttribute('src', /\/thumbnails\/unthumbnailed\/plain_001_thumb\.webp\?/);
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\.jpg\?/);
await expect(card.locator('.media-art > .image-open > img')).toHaveAttribute('src', /\/thumbnails\/clip_thumb\.webp\?/);
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.jpg'), route => route.fulfill({ status: 404, body: '' }));
await page.route(url => url.pathname.endsWith('/thumbnails/clip_thumb.webp'), 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,3 +133,38 @@ 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');
}
});
+2 -1
View File
@@ -173,7 +173,8 @@ 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();
expect(thumbnail.headers()['content-type']).toBe('image/png');
// #317: previews are written as WebP by default.
expect(thumbnail.headers()['content-type']).toBe('image/webp');
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);
+1 -1
View File
@@ -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', 'div.save-row'],
.toEqual({ overview: ['div.summary-metrics', 'div.two-col', 'div.save-row'], appearance: ['section.surface', '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']);
+6 -1
View File
@@ -513,7 +513,12 @@ 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)}")
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
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.invalidate_gallery_cache()
return {
"epoch": epoch,
+53
View File
@@ -29,6 +29,13 @@ 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``).
"""
@@ -72,7 +79,12 @@ 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] = {}
@@ -101,7 +113,11 @@ 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
@@ -186,6 +202,43 @@ 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()
+6 -1
View File
@@ -148,7 +148,12 @@ 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"})
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
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.invalidate_gallery_cache()
return {
"positive_id": positive_id,
+6 -1
View File
@@ -164,5 +164,10 @@ 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))
api._generate_gallery_thumbnails_sync(api._get_all_output_dirs(), (150, 150))
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.invalidate_gallery_cache()
+19
View File
@@ -40,3 +40,22 @@ 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();}
});
@@ -0,0 +1,71 @@
// #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,6 +913,26 @@ 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,
+26
View File
@@ -324,3 +324,29 @@ 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,6 +59,18 @@ 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');
@@ -0,0 +1,195 @@
// 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(); }
});
+29 -28
View File
@@ -1,35 +1,36 @@
// 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 option values the job takes, the preview size
// check, the Image loading quality preselection (Q3), the D99 plan line and the start outcome.
// `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.
const test = require('node:test');
const assert = require('node:assert/strict');
const reviewModule = import('../../web/js/workspace/thumbnail-review.mjs');
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('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('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('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('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('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('the plan line counts what a missing-only run generates and what is already up to date (D99)', async () => {
@@ -38,7 +39,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 }),
'10,000 previews to generate · 5 already up to date · 2,500 more on the next run.');
'12,500 previews to generate · 5 already up to date.');
});
test('Rebuild all says every preview is regenerated (D99)', async () => {
@@ -46,7 +47,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,000 files · 3 more on the next run.');
'Every preview is regenerated: 10,003 files.');
});
test('an answer without counts is a failed plan, never zeros', async () => {
@@ -64,9 +65,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: 'rebuild', size: 1024, quality: 'high' },
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'convert' },
navigate: hash => navigated.push(hash), notify: () => assert.fail('no toast of its own') });
assert.deepEqual(calls, [['thumbnails', { mode: 'rebuild', size: 1024, quality: 'high' }]]);
assert.deepEqual(calls, [['thumbnails', { mode: 'convert' }]]);
assert.deepEqual(navigated, [`#/activity/${'a'.repeat(32)}`]);
});
@@ -74,7 +75,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', size: 512, quality: 'medium' },
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing' },
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)}` }, '.']]);
});
@@ -83,11 +84,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', size: 512, quality: 'medium' },
await startThumbnails({ store, scope: scopeOf(), options: { mode: 'missing' },
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', size: 512, quality: 'medium' },
await startThumbnails({ store, scope: { signal: gone.signal }, options: { mode: 'missing' },
navigate: () => assert.fail('stays'), notify: () => assert.fail('silent') });
});
@@ -0,0 +1,101 @@
// #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');
});
+162
View File
@@ -1328,6 +1328,168 @@ 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):
+9 -2
View File
@@ -1133,10 +1133,17 @@ 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(
data["images"][1]["thumbnail_url"],
r"^/prompt_manager/images/serve/thumbnails/sub/b_thumb\.JPG\?v=\d+$",
thumb_url,
r"^/prompt_manager/images/serve/thumbnails/sub/b_thumb\.(JPG|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"
)
+104 -3
View File
@@ -14,6 +14,7 @@ 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
@@ -431,10 +432,11 @@ 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.
preview = self.output_dir / "thumbnails" / "clip_thumb.jpg"
# 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"
response = await self.current(descriptor["thumbnail_url"])
self.assertEqual(response.headers["Content-Type"], "image/jpeg")
self.assertEqual(response.headers["Content-Type"], "image/webp")
self.assertEqual(await response.read(), preview.read_bytes())
self.assertLess(preview.stat().st_size, len(payload))
make_png(preview)
@@ -1173,6 +1175,105 @@ 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")
+70 -49
View File
@@ -33,7 +33,8 @@ 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 # noqa: E402
import py.api.images as images_module
from py.api.thumbnail_settings import ThumbnailSettings # 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
@@ -397,7 +398,7 @@ class ThumbnailTestCase(ImageAPITestCase):
self.api._run_in_executor = spy
return calls
def _thumb_path(self, name, suffix=".png"):
def _thumb_path(self, name, suffix=".webp"):
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
@@ -496,7 +497,8 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
self.assertNotIn("_generate_one", calls)
self.assertIn("_plan_gallery_thumbnails", calls)
async def test_capped_run_reports_remaining_and_the_next_run_finishes(self):
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).
for name in ("a", "b", "c"):
make_png(self.output_dir / f"{name}.png", size=(64, 64))
@@ -509,18 +511,19 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
)
names = [e for e, _ in first]
self.assertEqual(names.count("progress"), 2) # only pending files are work
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
)
done = dict(first)["complete"]
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["count"], 3)
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"):
@@ -588,14 +591,15 @@ class TestThumbnailProgressStream(ThumbnailTestCase):
events = parse_sse(await resp.text())
self.assertEqual(events[-1][0], "error")
async def test_unknown_quality_falls_back_to_medium(self):
make_png(self.output_dir / "one.png", size=(400, 400))
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))
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), 300)
self.assertEqual(max(thumb.size), 512)
class TestGenerateThumbnailsPost(ThumbnailTestCase):
@@ -910,7 +914,8 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
make_png(self.output_dir / "a.png", size=(64, 64))
thumbs = self.output_dir / "thumbnails"
thumbs.mkdir()
dst = thumbs / "a_thumb.png"
dst = thumbs / "a_thumb.webp"
settings = ThumbnailSettings("webp", 80, 32)
def crash_mid_write(self_img, fp, *args, **kwargs):
# Simulates an interrupted encoder: bytes on disk, then failure.
@@ -920,14 +925,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, (32, 32)
self.output_dir / "a.png", dst, settings
)
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, (32, 32))
images_module._write_image_thumbnail(self.output_dir / "a.png", dst, settings)
self.assertTrue(dst.is_file())
self.assertEqual(os.listdir(thumbs), ["a_thumb.png"])
self.assertEqual(os.listdir(thumbs), ["a_thumb.webp"])
def test_thumbnail_url_is_versioned_by_its_mtime(self):
import os
@@ -935,7 +940,9 @@ 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", (32, 32)
self.output_dir,
self.output_dir / "thumbnails",
ThumbnailSettings("webp", 80, 32),
)
thumb = self._thumb_path("a")
@@ -944,7 +951,7 @@ class TestThumbnailFilesAreNeverSeenHalfWritten(ThumbnailTestCase):
)
self.assertRegex(
entry["thumbnail_url"],
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?v=\d+&root=0$",
)
first = int(re.search(r"v=(\d+)", entry["thumbnail_url"]).group(1))
self.assertEqual(first, int(thumb.stat().st_mtime))
@@ -960,7 +967,9 @@ 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", (32, 32)
self.output_dir,
self.output_dir / "thumbnails",
ThumbnailSettings("webp", 80, 32),
)
entry = images_module._output_image_entry(
self.output_dir / "a.png", self.output_dir, 0
@@ -969,7 +978,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/png")
self.assertEqual(resp.headers["Content-Type"], "image/webp")
async def test_missing_image_404_is_not_cacheable(self):
resp = await self.client.request(
@@ -1024,7 +1033,9 @@ 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", (64, 64)
self.output_dir,
self.output_dir / "thumbnails",
ThumbnailSettings("webp", 80, 64),
)
for name in ("d", "e", "f"):
make_png(self.output_dir / f"{name}.png")
@@ -1083,11 +1094,6 @@ 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."""
@@ -1458,7 +1464,7 @@ class TestGalleryListing(ImageRouteCoverageCase):
class TestThumbnailUnits(ImageRouteCoverageCase):
SIZE = (64, 64)
SIZE = ThumbnailSettings("webp", 80, 64)
def test_generate_one_reports_vanished_source(self):
result = self.api._generate_one(
@@ -1471,11 +1477,9 @@ 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", ".jpg"), self.SIZE, True)
ok = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
with patch.object(self.api, "_generate_video_thumbnail", return_value=False):
bad = self.api._generate_one(
src, self._dst("clip", ".jpg"), self.SIZE, True
)
bad = self.api._generate_one(src, self._dst("clip"), self.SIZE, True)
self.assertEqual((ok["action"], ok["type"]), ("generated", "video"))
self.assertEqual(bad["action"], "error")
@@ -1498,7 +1502,7 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
)
self.assertEqual(result["count"], 2)
self.assertTrue(self._dst("alpha").is_file())
self.assertTrue(self._dst("photo", ".jpg").is_file())
self.assertTrue(self._dst("photo").is_file())
async def test_emit_sse_swallows_write_errors(self):
response = MagicMock()
@@ -1521,14 +1525,15 @@ class TestThumbnailUnits(ImageRouteCoverageCase):
)
self.assertEqual(status, 500)
def _dst(self, name, suffix=".png"):
def _dst(self, name, suffix=".webp"):
return self.output_dir / "thumbnails" / f"{name}_thumb{suffix}"
class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
"""_generate_video_thumbnail degrades: cv2 -> ffmpeg -> placeholder."""
SIZE = (48, 48)
# JPEG keeps these checks format-specific; #317 settings carry the edge.
SIZE = ThumbnailSettings("jpeg", 85, 48)
@property
def video(self):
@@ -1581,7 +1586,7 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
)
self.assertTrue(ok)
with Image.open(self.thumb) as img:
self.assertEqual((img.format, img.size), ("JPEG", self.SIZE))
self.assertEqual((img.format, img.size), ("JPEG", self.SIZE.size))
def test_cv2_crash_is_contained(self):
cv2 = MagicMock()
@@ -1614,18 +1619,26 @@ 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)
self.assertEqual(img.size, self.SIZE.size)
@staticmethod
def _process(returncode, stderr=""):
def _process(returncode, stderr=b"", stdout=b""):
process = MagicMock(returncode=returncode)
process.communicate.return_value = ("", stderr)
process.communicate.return_value = (stdout, 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, "no stream")),
patch("subprocess.Popen", return_value=self._process(1, b"no stream")),
):
ok = self.api._generate_video_thumbnail(self.video, self.thumb, self.SIZE)
self.assertTrue(ok)
@@ -1634,18 +1647,23 @@ class TestVideoThumbnailFallbacks(ImageRouteCoverageCase):
def test_ffmpeg_success(self):
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", return_value=self._process(0)) as run,
patch(
"subprocess.Popen",
return_value=self._process(0, stdout=self._png_frame()),
) 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):
Path(cmd[-1]).write_bytes(b"\xff\xd8 frame")
return self._process(0)
return self._process(0, stdout=self._png_frame())
with (
patch.dict(sys.modules, {"cv2": None}),
@@ -2061,7 +2079,7 @@ class TestThumbnailStartupStatus(ThumbnailTestCase):
):
with self.subTest(route=route):
for root in (self.output_dir, second):
for thumb in (root / "thumbnails").glob("*_thumb.png"):
for thumb in (root / "thumbnails").glob("*_thumb.webp"):
thumb.unlink()
response = await self.client.post(route, json={})
if route == self.PROGRESS_URL:
@@ -2069,7 +2087,7 @@ class TestThumbnailStartupStatus(ThumbnailTestCase):
else:
data = await response.json()
self.assertEqual(data["count"], 2)
self.assertTrue((second / "thumbnails" / "b_thumb.png").exists())
self.assertTrue((second / "thumbnails" / "b_thumb.webp").exists())
status = await (await self.client.get(self.STATUS_URL)).json()
self.assertEqual(status["pending"], 0)
self.assertEqual(status["total_images"], 2)
@@ -2089,10 +2107,13 @@ 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"]
self.assertEqual(data["count"], 1)
self.assertEqual(data["remaining"], 1)
# 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["total_images"], 2)
async def test_equal_timestamps_do_not_regenerate_existing_thumbnails(self):
@@ -2121,7 +2142,7 @@ class TestGalleryRootIdentity(ThumbnailTestCase):
root = roots[image["root_index"]]
for key, path in (
("url", root / "same.png"),
("thumbnail_url", root / "thumbnails" / "same_thumb.png"),
("thumbnail_url", root / "thumbnails" / "same_thumb.webp"),
):
response = await self.client.get(image[key])
self.assertEqual(response.status, 200)
+2 -1
View File
@@ -408,9 +408,10 @@ class TestQueueReads(ReviewRoutesTestCase):
scope = item["target"]["root_scope"]
self.assertEqual(
item["thumbnail_url"],
"/prompt_manager/images/serve/thumbnails/sub/f0_thumb.png"
"/prompt_manager/images/serve/thumbnails/sub/f0_thumb.webp"
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))
+2 -1
View File
@@ -75,7 +75,8 @@ class TestGalleryConfig(unittest.TestCase):
{"web_interface": {"images_per_page": 50, "thumbnail_size": 512}}
)
self.assertEqual(GalleryConfig.IMAGES_PER_PAGE, 50)
self.assertEqual(GalleryConfig.THUMBNAIL_SIZE, 512)
# #317 R1: the never-read thumbnail_size is ignored (thumbnails.edge).
self.assertFalse(hasattr(GalleryConfig, "THUMBNAIL_SIZE"))
def test_update_performance(self):
GalleryConfig.update_config({"performance": {"max_concurrent_processing": 5}})
+81 -63
View File
@@ -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.png?root=0",
f"{SERVE}thumbnails/sub/new_thumb.webp?root=0",
)
self.assertFalse((self.output_dir / "thumbnails").exists(), "no write")
def test_missing_video_preview_is_a_jpeg_url(self):
def test_missing_video_preview_url_is_in_the_configured_format(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.jpg?root=0"
entry["thumbnail_url"], f"{SERVE}thumbnails/clip_thumb.webp?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.png")
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
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.png")
make_png(self.output_dir / "thumbnails" / "a_thumb.webp")
entry = _entry(self.api, self.output_dir, "a.png")
self.assertRegex(
entry["thumbnail_url"],
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.png\?v=\d+&root=0$",
r"^/prompt_manager/images/serve/thumbnails/a_thumb\.webp\?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.png", url)
self.assertIn("_thumb.webp", url)
class TestThumbnailOnFirstView(AutoThumbnailCase):
@@ -201,31 +201,33 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Content-Type"], "image/png")
self.assertEqual(resp.headers["Content-Type"], "image/webp")
body = await resp.read()
thumb = self.output_dir / "thumbnails" / "sub" / "big_thumb.png"
thumb = self.output_dir / "thumbnails" / "sub" / "big_thumb.webp"
self.assertTrue(thumb.is_file())
self.assertEqual(body, thumb.read_bytes())
with Image.open(thumb) as img:
self.assertEqual(max(img.size), auto.AUTO_THUMBNAIL_EDGE)
self.assertEqual(max(img.size), 512)
# No temp sibling is ever left next to the preview.
self.assertEqual(os.listdir(thumb.parent), ["big_thumb.png"])
self.assertEqual(os.listdir(thumb.parent), ["big_thumb.webp"])
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.png", url)
self.assertIn("thumbnails/Shot_thumb.webp", url)
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
async def test_video_preview_is_generated_as_jpeg(self):
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.
(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/jpeg")
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.jpg").is_file())
self.assertEqual(resp.headers["Content-Type"], "image/webp")
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.webp").is_file())
async def test_concurrent_views_generate_once(self):
make_png(self.output_dir / "a.png", size=(256, 256))
@@ -298,14 +300,15 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
for name in names:
stem = name[:-4]
self.assertTrue(
(self.output_dir / "thumbnails" / f"{stem}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"{stem}_thumb.webp").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))
(self.output_dir / "late.mp4").write_bytes(b"not a video")
# Another stem: same-stem files share one preview name (#317 R2).
(self.output_dir / "latev.mp4").write_bytes(b"not a video")
entered, release, state = self._gate_writes()
listings = []
real_scandir = auto.os.scandir
@@ -327,25 +330,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.png?root=0")
image = await self.client.get(f"{SERVE}thumbnails/late_thumb.webp?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/late_thumb.jpg?root=0")
video = await self.client.get(f"{SERVE}thumbnails/latev_thumb.webp?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.png?root=0")
missing = await self.client.get(f"{SERVE}thumbnails/Nope_thumb.webp?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.png").exists())
self.assertFalse((self.output_dir / "thumbnails" / "late_thumb.webp").exists())
async def test_mixed_case_folder_listing_runs_once_inside_admission(self):
make_png(self.output_dir / "Shot.Png", size=(64, 64))
@@ -357,13 +360,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.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/Shot_thumb.webp?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.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "Shot_thumb.webp").is_file())
async def test_broken_file_falls_back_to_the_original_and_is_remembered(self):
broken = self.output_dir / "broken.png"
@@ -380,7 +383,9 @@ 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.png").exists())
self.assertFalse(
(self.output_dir / "thumbnails" / "broken_thumb.webp").exists()
)
status = await (
await self.client.get("/prompt_manager/images/thumbnails/status")
@@ -394,7 +399,9 @@ 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.png").is_file())
self.assertTrue(
(self.output_dir / "thumbnails" / "broken_thumb.webp").is_file()
)
async def test_locked_preview_is_not_remembered_as_a_failure(self):
make_png(self.output_dir / "a.png", size=(64, 64))
@@ -411,10 +418,10 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
resp = await self.client.get(url)
self.assertEqual(resp.status, 200)
self.assertTrue((self.output_dir / "thumbnails" / "a_thumb.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "a_thumb.webp").is_file())
async def test_no_source_is_a_plain_uncacheable_404(self):
resp = await self.client.get(f"{SERVE}thumbnails/nope_thumb.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/nope_thumb.webp?root=0")
self.assertEqual(resp.status, 404)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
text = await resp.text()
@@ -424,13 +431,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.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",
"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",
):
with self.subTest(path=path):
self.assertFalse(auto._is_preview_request(path))
@@ -442,9 +449,9 @@ class TestThumbnailOnFirstView(AutoThumbnailCase):
patch.object(auto.os, "stat") as stat,
):
for path in (
"thumbnails/D:/x_thumb.png",
"thumbnails/\\\\server\\share/x_thumb.png",
"/thumbnails/x_thumb.png",
"thumbnails/D:/x_thumb.webp",
"thumbnails/\\\\server\\share/x_thumb.webp",
"/thumbnails/x_thumb.webp",
):
answer = await self.api._serve_missing_thumbnail(
[self.output_dir], path
@@ -457,8 +464,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.png",
"thumbnails/..%2F..%2Fx_thumb.png",
"thumbnails/../../etc/passwd_thumb.webp",
"thumbnails/..%2F..%2Fx_thumb.webp",
):
resp = await self.client.get(f"{SERVE}{path}?root=0")
self.assertIn(resp.status, (403, 404))
@@ -468,7 +475,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.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/link_thumb.webp?root=0")
self.assertEqual(resp.status, 404)
self.assertFalse((self.output_dir / "thumbnails").exists())
@@ -477,7 +484,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.png?root=0")
resp = await self.client.get(f"{SERVE}thumbnails/a_thumb.webp?root=0")
self.assertEqual(resp.status, 200)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertEqual(os.listdir(elsewhere), [])
@@ -524,7 +531,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.png"
thumb = self.output_dir / "thumbnails" / f"a{index}_thumb.webp"
self.assertTrue(thumb.is_file())
job.finish("finished")
@@ -565,8 +572,12 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
cv2 = MagicMock()
cv2.VideoCapture.return_value.isOpened.return_value = False
with patch.dict(sys.modules, {"cv2": cv2}):
await self._assert_placeholder(f"{SERVE}thumbnails/clip_thumb.jpg?root=0")
self.assertTrue((self.output_dir / "thumbnails" / "clip_thumb.jpg").is_file())
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())
async def test_a_held_video_preview_is_the_placeholder_and_writes_nothing(self):
from py.api.jobs import RESTORE_JOB_KIND
@@ -574,7 +585,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
self._video()
self._hold(RESTORE_JOB_KIND)
resp = await self._assert_placeholder(
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertFalse((self.output_dir / "thumbnails").exists())
@@ -585,7 +596,7 @@ class TestVideoPreviewsNeverFallBackToTheVideo(AutoThumbnailCase):
elsewhere.mkdir()
self._make_symlink(elsewhere, self.output_dir / "thumbnails")
resp = await self._assert_placeholder(
f"{SERVE}thumbnails/clip_thumb.jpg?root=0"
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
self.assertEqual(os.listdir(elsewhere), [])
@@ -594,7 +605,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.jpg?root=0"
f"{SERVE}thumbnails/clip_thumb.webp?root=0"
)
self.assertEqual(resp.headers["Cache-Control"], "no-store")
@@ -653,6 +664,9 @@ 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()
@@ -676,6 +690,9 @@ 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))
@@ -689,7 +706,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.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "b_thumb.webp").is_file())
async def test_a_reload_stops_the_old_worker(self):
entered, release, _state = self._gate_writes()
@@ -761,7 +778,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/png")
self.assertEqual(resp.headers["Content-Type"], "image/webp")
self.assertEqual(await resp.read(), dst.read_bytes())
async def test_history_joining_a_view_flight_gets_the_outcome(self):
@@ -773,9 +790,10 @@ class TestHistorySharesTheAdmission(AutoThumbnailCase):
from py.api.jobs import RESTORE_JOB_KIND
make_png(self.output_dir / "c.png", size=(64, 64))
(self.output_dir / "c.mp4").write_bytes(b"AV1 stream no decoder here")
# 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._hold(RESTORE_JOB_KIND)
for name, is_video in (("c.png", False), ("c.mp4", True)):
for name, is_video in (("c.png", False), ("cv.mp4", True)):
with self.subTest(name=name):
src = self.output_dir / name
url = _entry(self.api, self.output_dir, name)["thumbnail_url"]
@@ -797,9 +815,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.png"
preview = root / "thumbnails" / "sub" / "a_thumb.webp"
self.assertEqual(
auto.flight_key(root, ("thumbnails", "sub", "a_thumb.png")),
auto.flight_key(root, ("thumbnails", "sub", "a_thumb.webp")),
auto.flight_key(
Path(os.path.normcase(os.path.realpath(root))),
preview.relative_to(root).parts,
@@ -813,7 +831,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.png"
thumb = self.output_dir / "thumbnails" / "run" / "ComfyUI_0001_thumb.webp"
self.assertTrue(thumb.is_file())
self.assertIn(
"v=",
@@ -837,7 +855,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.png")
preview = make_png(self.output_dir / "thumbnails" / "y_thumb.webp")
text = self.output_dir / "notes.txt"
text.write_text("x")
for path in (outside, preview, text, self.output_dir / "missing.png"):
@@ -873,7 +891,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
self.assertLessEqual(state["peak"], 2)
for i in range(5):
self.assertTrue(
(self.output_dir / "thumbnails" / f"w{i}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"w{i}_thumb.webp").is_file()
)
async def test_worker_survives_a_failing_write(self):
@@ -884,7 +902,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.png").is_file())
self.assertTrue((self.output_dir / "thumbnails" / "good_thumb.webp").is_file())
self.assertEqual(self.api._auto_thumbnails().failure_count, 1)
async def test_worker_writes_nothing_during_a_restore(self):
@@ -919,7 +937,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
await self._idle()
for name in ("first", "second"):
self.assertTrue(
(self.output_dir / "thumbnails" / f"{name}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
)
async def test_a_worker_that_cannot_start_is_started_by_the_next_enqueue(self):
@@ -940,7 +958,7 @@ class TestThumbnailOnCapture(AutoThumbnailCase):
await self._idle()
for name in ("first", "second"):
self.assertTrue(
(self.output_dir / "thumbnails" / f"{name}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"{name}_thumb.webp").is_file()
)
def test_queue_is_bounded(self):
@@ -1041,7 +1059,7 @@ class TestScanHandsOffThumbnails(AutoThumbnailCase):
await self._idle()
for i in range(2):
self.assertTrue(
(self.output_dir / "thumbnails" / f"s{i}_thumb.png").is_file()
(self.output_dir / "thumbnails" / f"s{i}_thumb.webp").is_file()
)
async def test_a_scan_that_added_nothing_does_nothing(self):
@@ -1091,12 +1109,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.png").is_file())
self.assertTrue((other / "thumbnails" / "o_thumb.webp").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.png")
make_png(self.output_dir / "thumbnails" / "s_thumb.webp")
self.assertEqual(
await self.api._thumbnails_after_scan(self._scan_job(1)), "none"
)
+436
View File
@@ -0,0 +1,436 @@
"""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()
+525
View File
@@ -0,0 +1,525 @@
"""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()
+444
View File
@@ -0,0 +1,444 @@
"""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()
+201 -79
View File
@@ -117,6 +117,14 @@ 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
@@ -149,13 +157,10 @@ 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", "size": "512", "quality": "high"}
)
rebuild = await self.client.get(
PLAN_URL, params={"mode": "rebuild", "size": "256", "quality": "low"}
)
missing = await self.client.get(PLAN_URL, params={"mode": "missing"})
rebuild = await self.client.get(PLAN_URL, params={"mode": "rebuild"})
self.assertEqual(missing.status, 200)
self.assertEqual(
@@ -164,40 +169,38 @@ class TestThumbnailPlan(ThumbnailJobCase):
"success": True,
"targets": 1,
"fresh": 1,
"outdated": 0,
"remaining": 1,
"roots": ["output"],
"mode": "missing",
"format": "webp",
"size": 512,
"quality": "high",
"quality": 80,
},
)
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"], data["size"], data["quality"]), ("rebuild", 256, "low")
)
self.assertEqual(data["mode"], "rebuild")
self.assertEqual(_tree_state(self.comfy_dir), before)
self.assertEqual(self.api._jobs.list(), [])
async def test_plan_defaults_to_missing_previews_at_the_concept_size(self):
async def test_plan_defaults_to_missing_previews_with_the_settings(self):
self._use("webp", 80, 512)
make_png(self.output_dir / "a.png")
data = await (await self.client.get(PLAN_URL)).json()
self.assertEqual(
(data["mode"], data["size"], data["quality"], data["targets"]),
("missing", 512, "medium", 1),
(data["mode"], data["format"], data["size"], data["quality"]),
("missing", "webp", 512, 80),
)
self.assertEqual(data["targets"], 1)
async def test_size_bounds_validated(self):
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)
make_png(self.output_dir / "a.png")
for params in (
{"size": "127"},
{"size": "2049"},
{"size": "512.5"},
{"size": "big"},
{"mode": "all"},
{"quality": "ultra"},
):
for params in ({"mode": "all"}, {"mode": ""}):
with self.subTest(params=params):
resp = await self.client.get(PLAN_URL, params=params)
body = await resp.json()
@@ -205,24 +208,14 @@ class TestThumbnailPlan(ThumbnailJobCase):
self.assertEqual(
(body["success"], body["code"]), (False, "invalid-request")
)
for body in (
{"size": 127},
{"size": 2049},
{"size": "512"},
{"size": 512.0},
{"size": True},
{"mode": "rebuild-all"},
{"quality": None},
[],
):
for body in ({"mode": "rebuild-all"}, {"mode": 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")
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)
resp = await self.client.get(PLAN_URL, params={"size": "9999", "quality": "x"})
data = await resp.json()
self.assertEqual((data["size"], data["quality"]), (512, 80))
self.assertEqual(self.api._jobs.list(), [])
self.assertFalse(self._thumb_path("a").exists())
@@ -233,7 +226,8 @@ 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")
status, body = await self._start(mode="missing", size=200, quality="medium")
self._use("webp", 80, 200)
status, body = await self._start(mode="missing", size=999, quality="high")
self.assertEqual(status, 202)
summary = body["job"]
@@ -244,7 +238,8 @@ class TestThumbnailJob(ThumbnailJobCase):
self.assertTrue(summary["cancellable"])
self.assertEqual(set(summary["counters"]), {"generated", "skipped", "errors"})
self.assertEqual(
summary["scope"], {"mode": "missing", "size": 200, "quality": "medium"}
summary["scope"],
{"mode": "missing", "format": "webp", "size": 200, "quality": 80},
)
job = await self._finished(summary["id"])
self.assertEqual(job.state, "finished")
@@ -279,7 +274,8 @@ 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))
_, body = await self._start(size=128)
self._use("webp", 80, 128)
_, body = await self._start()
await self._finished(body["job"]["id"])
originals = {
name: hashlib.sha256(
@@ -288,7 +284,8 @@ class TestThumbnailJob(ThumbnailJobCase):
for name in ("a", "b")
}
_, body = await self._start(mode="rebuild", size=256, quality="high")
self._use("webp", 95, 256)
_, body = await self._start(mode="rebuild")
job = await self._finished(body["job"]["id"])
self.assertEqual(job.counters, {"generated": 2, "skipped": 0, "errors": 0})
@@ -315,12 +312,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.png"]) # no temp file left
self.assertEqual(leftovers, ["a_thumb.webp"]) # 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.jpg")
preview = make_png(self.output_dir / "thumbnails" / "clip_thumb.webp")
old = preview.read_bytes()
with (
patch.dict(sys.modules, {"cv2": None}),
@@ -339,7 +336,8 @@ class TestThumbnailJob(ThumbnailJobCase):
img.save(self.output_dir / "photo.jpg", "JPEG", quality=95)
sizes = {}
for quality in ("low", "high"):
_, body = await self._start(mode="rebuild", size=300, quality=quality)
self._use("jpeg", 40 if quality == "low" else 95, 300)
_, body = await self._start(mode="rebuild")
await self._finished(body["job"]["id"])
thumb = self._thumb_path("photo", ".jpg")
with Image.open(thumb) as image:
@@ -380,7 +378,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.png"))
made = sorted((self.output_dir / "thumbnails").glob("*_thumb.webp"))
self.assertEqual(made, sorted(written))
self.assertTrue(any("Thumbnails cancelled" in line for line in job.log))
@@ -511,16 +509,16 @@ class TestThumbnailJob(ThumbnailJobCase):
entered, release = threading.Event(), threading.Event()
self.addCleanup(release.set)
checked = []
real_fresh = images_module._thumbnail_is_fresh
real_state = images_module._preview_state
def gated_fresh(src, dst):
def gated_state(src, *args):
checked.append(src.name)
if len(checked) == 1:
entered.set()
release.wait(10)
return real_fresh(src, dst)
return real_state(src, *args)
with patch.object(images_module, "_thumbnail_is_fresh", gated_fresh):
with patch.object(images_module, "_preview_state", gated_state):
stream = asyncio.ensure_future(self.client.post(self.PROGRESS_URL))
await self._wait(entered)
job = self.api._jobs.active("thumbnails")
@@ -567,7 +565,7 @@ class TestThumbnailJob(ThumbnailJobCase):
with (
patch.dict(sys.modules, {"cv2": None}),
patch("subprocess.Popen", side_effect=FileNotFoundError("no ffmpeg")),
patch.object(images_module, "_atomic_save", refuse),
patch.object(images_module, "write_thumbnail", refuse),
):
_, body = await self._start()
job = await self._finished(body["job"]["id"])
@@ -586,52 +584,66 @@ class TestThumbnailJob(ThumbnailJobCase):
[row] = job.outcomes["errors"]
self.assertEqual(row["detail"], images_module.PREVIEW_NOT_WRITABLE)
async def test_ffmpeg_fallback_keeps_quality_and_aspect_ratio(self):
"""M1: without OpenCV, ffmpeg keeps the chosen quality and the aspect ratio."""
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
(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 timeout=None: ("", "")
done.communicate = lambda input=None, timeout=None: (frame.getvalue(), b"")
done.poll = lambda: 0
done.kill = lambda: None
return done
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)
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)))
async def test_the_placeholder_uses_the_chosen_quality(self):
async def test_the_placeholder_uses_the_settings(self):
(self.output_dir / "clip.mp4").write_bytes(b"\x00")
saved = []
real_save = images_module._atomic_save
real_write = images_module.write_thumbnail
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)
def recording(frame, dst, settings, **kwargs):
saved.append(settings)
return real_write(frame, dst, settings, **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, "_atomic_save", recording),
patch.object(images_module, "write_thumbnail", recording),
):
_, body = await self._start(quality="low")
_, body = await self._start()
await self._finished(body["job"]["id"])
self.assertEqual(saved, [images_module.THUMBNAIL_ENCODINGS["low"]])
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))
# The legacy stream's frames and their keys, as the gallery page reads them.
@@ -675,11 +687,119 @@ 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())
@@ -696,7 +816,8 @@ class TestLegacyStreamOnTheJob(ThumbnailJobCase):
{"phase": "scanning", "message": "Scanning for images and videos..."},
)
with Image.open(self._thumb_path("a")) as thumb:
self.assertEqual(max(thumb.size), 150) # the legacy preset still sizes
# #317 R3: the server's size limit, not the legacy preset (low = 150).
self.assertEqual(max(thumb.size), 200)
[job] = self.api._jobs.list()
self.assertEqual(
(job.kind, job.title, job.state, job.cancellable),
@@ -766,7 +887,8 @@ 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()
_, started = await self._start(mode="missing", size=256)
self._use("webp", 80, 256)
_, started = await self._start(mode="missing")
await self._wait(entered)
job = self.api._jobs.get(started["job"]["id"])
+214
View File
@@ -0,0 +1,214 @@
"""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()
+27 -15
View File
@@ -1,9 +1,9 @@
{
"admin.js": {
"lines": 31,
"lines": 32,
"branches": 58,
"functions": 26,
"statements": 31
"functions": 27,
"statements": 32
},
"api-client.js": {
"lines": 98,
@@ -188,7 +188,7 @@
"workspace/file-inspector.mjs": {
"lines": 96,
"branches": 93,
"functions": 92,
"functions": 93,
"statements": 96
},
"workspace/heading.mjs": {
@@ -241,7 +241,7 @@
},
"workspace/image-tagging.mjs": {
"lines": 97,
"branches": 90,
"branches": 92,
"functions": 88,
"statements": 97
},
@@ -338,7 +338,7 @@
"workspace/library-location.mjs": {
"lines": 94,
"branches": 91,
"functions": 92,
"functions": 93,
"statements": 94
},
"workspace/library-state.mjs": {
@@ -372,14 +372,14 @@
"statements": 97
},
"workspace/local-resolver-stages.mjs": {
"lines": 94,
"lines": 95,
"branches": 93,
"functions": 98,
"statements": 94
"statements": 95
},
"workspace/local-resolver-support.mjs": {
"lines": 96,
"branches": 80,
"branches": 81,
"functions": 98,
"statements": 96
},
@@ -426,10 +426,10 @@
"statements": 98
},
"workspace/prompt-blocks.mjs": {
"lines": 90,
"branches": 92,
"lines": 98,
"branches": 95,
"functions": 98,
"statements": 90
"statements": 98
},
"workspace/prompt-inspector.mjs": {
"lines": 98,
@@ -487,7 +487,7 @@
},
"workspace/system-controls.mjs": {
"lines": 98,
"branches": 87,
"branches": 89,
"functions": 89,
"statements": 98
},
@@ -509,6 +509,12 @@
"functions": 98,
"statements": 98
},
"workspace/system-thumbnails.mjs": {
"lines": 98,
"branches": 95,
"functions": 83,
"statements": 98
},
"workspace/system.mjs": {
"lines": 98,
"branches": 92,
@@ -522,10 +528,16 @@
"statements": 98
},
"workspace/thumbnail-review.mjs": {
"lines": 96,
"lines": 98,
"branches": 93,
"functions": 98,
"statements": 96
"statements": 98
},
"workspace/thumbnail-support.mjs": {
"lines": 98,
"branches": 98,
"functions": 98,
"statements": 98
},
"workspace/toast.mjs": {
"lines": 98,
+2
View File
@@ -3442,6 +3442,8 @@ 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) {
+8 -3
View File
@@ -7,6 +7,7 @@ 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.';
@@ -320,12 +321,16 @@ 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 JPEG frame) whenever the listing gave one; without one the original, and a video keeps its own element.
* 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.
*/
function cover(record) {
const {file} = record, preview = record.preview?.url, isVideo = file?.media_type === 'video';
if (isVideo && !preview) return videoCover(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 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'));
+5 -2
View File
@@ -1,4 +1,5 @@
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);
@@ -65,8 +66,10 @@ 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).
record.preview = {url:urlFor(target, 'thumbnail_url', 'thumbnail')};
// #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};
return record;
}
export const reportedLocationAdapter = Object.freeze({
+7 -4
View File
@@ -9,6 +9,7 @@ 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);
@@ -27,15 +28,17 @@ 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.
* #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.
*/
export function cardMedia(image, lazy = false) {
const { resolveUrl } = window.ApiClient;
if (image.is_video && !image.thumbnail_url) return videoMedia(image);
if (!showsThumbnail(image)) return videoMedia(image);
const node = el('img');
node.src = resolveUrl(image.thumbnail_url || image.url);
node.src = resolveUrl(thumbnailSource(image));
// replaceWith does nothing for a card no longer in the page.
if (image.is_video) node.addEventListener('error', () => node.replaceWith(videoMedia(image)), { once: true });
if (image.is_video) replaceOnError(node, () => videoMedia(image));
else fallBackToOriginal(node, resolveUrl(image.url));
// 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)) {
+3
View File
@@ -19,6 +19,7 @@ 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;
@@ -49,6 +50,8 @@ 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));
+18 -9
View File
@@ -71,20 +71,28 @@ function downloadFor(image, required) {
}
}
// The server's video extensions (py/api/inspection_service.py VIDEO_EXTENSIONS): their preview is a JPEG.
// The server's video extensions (py/api/inspection_service.py VIDEO_EXTENSIONS).
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'];
/** `thumbnails/<path>_thumb<ext>` for a listed file, as the server names its preview (images.media_thumbnail_rel). */
function previewPath(path) {
/**
* 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) {
const slash = path.lastIndexOf('/'), dot = path.lastIndexOf('.');
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}`;
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));
}
/**
* #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 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).
* 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.
*/
function previewFor(image) {
try {
@@ -93,9 +101,10 @@ 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 URL(resolveUrl(`/prompt_manager/images/serve/${previewPath(image.path).split('/').map(quoteSegment).join('/')}`), 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 scope = descriptorScope(image);
if (url.origin !== reference.origin || url.username || url.password || url.hash || url.pathname !== expected.pathname ||
if (url.origin !== reference.origin || url.username || url.password || url.hash || !expected.has(url.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 };
+6 -2
View File
@@ -15,6 +15,7 @@ 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.
@@ -213,9 +214,12 @@ 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)) {
if(image.is_video && !image.thumbnail_url) art.append(el('span','Video preview','small'));
// #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'));
else {
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);
// #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);
}
}
if(!previews.length) art.append(el('span','No available image previews','small'));
+10 -3
View File
@@ -13,10 +13,14 @@
* (`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;
@@ -98,8 +102,11 @@ 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. */
export function mountAppearance(container, { scope, drafts }) {
/**
* 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 }) {
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 = [];
@@ -139,5 +146,5 @@ export function mountAppearance(container, { scope, drafts }) {
};
scope.cleanup(drafts.subscribe(repaint));
repaint();
return [surface];
return [surface, mountThumbnailSettings(container, { scope, drafts, api, activity })];
}
+28 -4
View File
@@ -8,6 +8,8 @@
* 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
@@ -88,10 +90,29 @@ 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';
if (missing === 0) return 'No missing thumbnails';
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`);
// #249: files whose automatic preview could not be made this session (they show the original).
const failed = Number.isSafeInteger(status.failed) && status.failed > 0 ? ` · ${number(status.failed)} failed` : '';
return `${number(missing)} missing ${noun(missing, 'thumbnail')}${failed}`;
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 SOURCES_SHARED = 'Monitoring and enrichment share one source list.';
@@ -368,8 +389,11 @@ 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) thumbnailRow.querySelector('h3').textContent = thumbnailTitle(thumbnailStatus, sources);
if (!thumbnailRow) return;
thumbnailRow.querySelector('h3').textContent = thumbnailTitle(thumbnailStatus, sources);
paintConvert(thumbnailRow, thumbnailStatus, { scope, openConvert });
};
function swap(nodes) {
+120
View File
@@ -0,0 +1,120 @@
/**
* 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;
}
+3 -2
View File
@@ -79,8 +79,9 @@ 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).
return { values: result.settings, revision: result.revision };
// 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 };
},
async writeHost(changes, { expected_revision }) {
try {
+46 -76
View File
@@ -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" and Overview's "Review thumbnails". It starts
* the `thumbnails` Activity job (S5 Task 12) with the chosen options.
* 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.
*
* - 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.
* - 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. …").
* - 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`, 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.
* (`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.
*/
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)']]);
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 SCOPE_OPTIONS = Object.freeze([['missing', 'Missing thumbnails only'], ['rebuild', 'Rebuild all (explicit opt-in)'],
['convert', 'Convert outdated (removes older formats)']]);
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;
/** 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';
/** 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.`;
}
/** D99: the plan line for the server's `{mode, targets, fresh, remaining}`; an answer without counts failed. */
export function planText(plan) {
if (!plan || !['missing', 'rebuild'].includes(plan.mode) || ![plan.targets, plan.fresh, plan.remaining].every(isCount)) {
if (!plan || !MODES.has(plan.mode) || ![plan.targets, plan.fresh, plan.remaining].every(isCount)) {
return PLAN_FAILED;
}
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}.`;
// #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.`;
}
/**
* Start the thumbnail job with `options` (`{mode, size, quality}`) and open its view; a refused or failed start says
* Start the thumbnail job with `options` (`{mode}`) 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,77 +96,47 @@ function select(options, value) {
return node;
}
const defaultQuality = () => {
try {
return readPreferences().values.imageQuality;
} catch {
return undefined;
}
};
/**
* Open "Thumbnail maintenance" on the section's `scope`. `store` is the page's Activity store; `trigger` gets focus
* back when the dialog closes.
* 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.
*/
export function openThumbnailReview({ api, store, scope, trigger = null, quality = defaultQuality(), notify = showWorkspaceToast,
export function openThumbnailReview({ api, store, scope, trigger = null, mode: initial = 'missing', notify = showWorkspaceToast,
navigate = navigateTo }) {
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 mode = select(SCOPE_OPTIONS, MODES.has(initial) ? initial : 'missing');
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 size and quality change no count.
// The plan per scope: a promise of its answer (null when it failed).
const plans = new Map();
let open = true;
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);
};
const readPlan = chosen => api.get(`${THUMBNAIL_PLAN_URL}?${new URLSearchParams({ mode: chosen })}`,
{ 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 || !validate()) return;
if (!open || scope.signal.aborted || mode.value !== chosen) 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', () => {
if (!validate()) {
size.reportValidity();
size.focus();
return;
}
const options = { mode: mode.value, size: chosenSize(), quality: encoder.value };
const options = { mode: mode.value };
closeWorkspaceDialog();
startThumbnails({ store, scope, options, notify, navigate });
});
openContentDialog({ scope, title: 'Thumbnail maintenance', trigger,
nodes: [field('Scope', mode, 'thumb-mode'), field('Quality', encoder, 'thumb-quality'),
field('Preview size limit', size, 'thumb-limit'), line, actions],
nodes: [field('Scope', mode, 'thumb-mode'), line, settings, actions],
onClose: () => { open = false; } });
paint();
}
+89
View File
@@ -0,0 +1,89 @@
/**
* 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 });
}