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.
This commit is contained in:
@@ -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
|
||||
|
||||
+113
-29
@@ -2163,20 +2163,21 @@ 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 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.
|
||||
`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);
|
||||
@@ -2201,8 +2202,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 +2220,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 +2249,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 +2285,63 @@ 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) keeps showing
|
||||
until it is converted. Two files with one stem in a folder share one preview name
|
||||
(images first). 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.
|
||||
- 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; if the encoder disappears later, previews are written as WebP (logged once)
|
||||
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), then removes that file's old-format
|
||||
previews (regular files in its `thumbnails/` folder that the source owns, the source
|
||||
still present). A failure keeps the old preview; orphans (source gone) and links are
|
||||
never touched. 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). 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 +2369,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 +2389,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
|
||||
@@ -2476,6 +2536,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
|
||||
|
||||
@@ -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([]);
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
});
|
||||
|
||||
@@ -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();
|
||||
}
|
||||
@@ -669,7 +719,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');
|
||||
|
||||
@@ -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');
|
||||
}
|
||||
});
|
||||
|
||||
@@ -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()
|
||||
|
||||
@@ -0,0 +1,58 @@
|
||||
// #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');
|
||||
}));
|
||||
@@ -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,183 @@
|
||||
// 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, 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 () => ({ 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(); }
|
||||
});
|
||||
@@ -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 (#317)', async () => {
|
||||
const { planText } = await reviewModule;
|
||||
assert.equal(planText({ mode: 'convert', targets: 17500, fresh: 2000, outdated: 17500, remaining: 7500 }),
|
||||
'17,500 previews in an older format to convert · 7,500 more on the next run.');
|
||||
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 () => {
|
||||
@@ -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,74 @@
|
||||
// #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');
|
||||
});
|
||||
@@ -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) {
|
||||
|
||||
@@ -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'));
|
||||
|
||||
@@ -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, 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);
|
||||
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 });
|
||||
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)) {
|
||||
|
||||
@@ -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));
|
||||
|
||||
@@ -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,thumbnailSource} from './thumbnail-support.mjs';
|
||||
|
||||
let shelfVisit=null;
|
||||
// Every confirmed receipt advances this; a shelf mounted before it never restores its cached stream.
|
||||
@@ -215,7 +216,8 @@ export function mountLibrary(main,api,parentScope,{positiveId,savedState=null,on
|
||||
for(const image of previews.slice(0,2)) {
|
||||
if(image.is_video && !image.thumbnail_url) 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: a JPEG XL preview this browser cannot show, or one that fails, loads the original.
|
||||
const img=el('img');img.src=window.ApiClient.resolveUrl(thumbnailSource(image));if(!image.is_video) 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'));
|
||||
|
||||
@@ -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 })];
|
||||
}
|
||||
|
||||
@@ -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) {
|
||||
|
||||
@@ -0,0 +1,118 @@
|
||||
/**
|
||||
* 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.
|
||||
*/
|
||||
import { settingRow, operationRow } from './system-controls.mjs';
|
||||
import { detectJxlSupport } from './thumbnail-support.mjs';
|
||||
import { openThumbnailReview } from './thumbnail-review.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 notes = [];
|
||||
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;
|
||||
for (const note of notes.splice(0)) note.remove();
|
||||
const lines = [unavailable ? encoders.jxl.reason : null, browserJxl === false ? BROWSER_NO_JXL : null];
|
||||
for (const line of lines.filter(Boolean)) {
|
||||
const note = el('p', line, 'small');
|
||||
format.text.append(note);
|
||||
notes.push(note);
|
||||
}
|
||||
// A format save (not the first read) leaves the existing previews outdated: say so, never convert.
|
||||
const saved = drafts.saved('host').thumbnail_format;
|
||||
if (savedFormat !== undefined && saved !== undefined && saved !== savedFormat) operationText.textContent = FORMAT_SAVED;
|
||||
if (saved !== undefined) savedFormat = saved;
|
||||
};
|
||||
scope.cleanup(drafts.subscribe(repaint));
|
||||
repaint();
|
||||
Promise.resolve(support()).then(value => {
|
||||
if (scope.signal.aborted) return;
|
||||
browserJxl = value;
|
||||
repaint();
|
||||
}, () => {});
|
||||
return surface;
|
||||
}
|
||||
@@ -79,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 {
|
||||
|
||||
@@ -1,67 +1,66 @@
|
||||
/**
|
||||
* 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.", plus
|
||||
* "· R more on the next run" past the per-run cap. 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}.`;
|
||||
if (plan.mode === 'convert') {
|
||||
if (!plan.targets && !plan.remaining) return 'No previews in an older format.';
|
||||
return `${count(plan.targets)} ${noun(plan.targets, 'preview')} in an older format to convert${more}.`;
|
||||
}
|
||||
return `${count(plan.targets)} ${noun(plan.targets, 'preview')} to generate · ${count(plan.fresh)} already up to date${more}.`;
|
||||
}
|
||||
|
||||
/**
|
||||
* Start the thumbnail job with `options` (`{mode, 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 +95,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();
|
||||
}
|
||||
|
||||
@@ -0,0 +1,72 @@
|
||||
/**
|
||||
* 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).
|
||||
*/
|
||||
|
||||
// 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;
|
||||
}
|
||||
|
||||
/**
|
||||
* A thumbnail `<img>` that fails to load shows `original` instead, once. `listen(target, type, handler, options)` is the
|
||||
* caller's scoped listener (a scope's `listen`); without one a plain once-listener is used.
|
||||
*/
|
||||
export function fallBackToOriginal(node, original, listen = null) {
|
||||
if (!original || node.src === original) return;
|
||||
const swap = () => { if (node.src !== original) node.src = original; };
|
||||
if (listen) listen(node, 'error', swap, { once: true });
|
||||
else node.addEventListener('error', swap, { once: true });
|
||||
}
|
||||
Reference in New Issue
Block a user