- ffmpeg writes JPEG XL with -update 1, so a % in a file name is never an image-sequence pattern, and reads videos as file:<path>. The clear and a startup sweep remove temp files that interrupted writes left behind. - Convert outdated converts each group of same-stem files once, from the first source in the R2 order. An old preview that is already gone counts as removed, and each file's bytes count once. A current preview is only kept if it decodes. - An encoder that fails makes the write detect encoders again once. Another encoder is used if one is found; with none, the file is written as WebP and the availability Appearance shows is refreshed. - Changing the thumbnail format, quality or size while Convert outdated runs returns 409 busy. If the format still changes underneath, the job stops without removing anything. - Legacy preview spellings are matched case-insensitively, so X_thumb.JPG counts as outdated. - History's cover uses the same lookup as Images. It serves .jxl only when Accept lists image/jxl, and otherwise the original or the placeholder. - A video whose thumbnail the browser cannot show keeps its own element (Images) or its label (Library).
90 lines
3.9 KiB
JavaScript
90 lines
3.9 KiB
JavaScript
/**
|
||
* Thumbnails this browser can show (#317). A thumbnail is only a grid or cover placeholder; every other view uses the
|
||
* original.
|
||
*
|
||
* - `detectJxlSupport()` decodes a tiny embedded JPEG XL image once per page (`JXL_PROBE`) and remembers the answer
|
||
* (`jxlSupport()`: undefined until it answered, then true or false).
|
||
* - `thumbnailSource(image, supported)`: the URL a card or the inspector cover loads: the listing's thumbnail, or the
|
||
* original when the thumbnail is JPEG XL and this browser cannot decode it. While the answer is unknown the
|
||
* thumbnail is tried; `fallBackToOriginal` then catches a failure.
|
||
* - `fallBackToOriginal(img, url)`: a thumbnail `<img>` that fails to load shows the original instead (once).
|
||
* - `showsThumbnail(image)`: false for a video whose thumbnail is missing or one this browser cannot show (its URL
|
||
* would be the video itself): the card keeps the video's own element or label, never a broken image.
|
||
* - `replaceOnError(img, next)`: a video thumbnail `<img>` that fails to load is replaced by `next()` (once).
|
||
*/
|
||
|
||
// A 1×1 JPEG XL image (cjxl -d 1), decoded once to learn whether this browser shows JPEG XL.
|
||
export const JXL_PROBE = 'data:image/jxl;base64,/woAkAEAE4gCALQAtZ8gAAAVKqOMG7yc6/nyQ4fFtI3rDG21bWEJY7O9MEhIOGwY4s3xwRCTShIA';
|
||
|
||
let probe = null;
|
||
let answer;
|
||
|
||
/** Whether `url` names a JPEG XL file (its path, not its query or fragment). */
|
||
export function isJxlUrl(url) {
|
||
if (typeof url !== 'string' || !url) return false;
|
||
const path = url.split(/[?#]/, 1)[0];
|
||
return path.toLowerCase().endsWith('.jxl');
|
||
}
|
||
|
||
/** The remembered answer: undefined until the probe decoded or failed. */
|
||
export const jxlSupport = () => answer;
|
||
|
||
/** Forget the answer (tests). */
|
||
export function resetJxlSupport() {
|
||
probe = null;
|
||
answer = undefined;
|
||
}
|
||
|
||
/** Decode the embedded JPEG XL once; resolves true when this browser shows it. */
|
||
export function detectJxlSupport({ createImage = () => new Image() } = {}) {
|
||
probe ??= new Promise(resolve => {
|
||
let image;
|
||
try {
|
||
image = createImage();
|
||
} catch {
|
||
resolve(false);
|
||
return;
|
||
}
|
||
image.onload = () => resolve(true);
|
||
image.onerror = () => resolve(false);
|
||
image.src = JXL_PROBE;
|
||
}).then(supported => {
|
||
answer = supported;
|
||
return supported;
|
||
});
|
||
return probe;
|
||
}
|
||
|
||
/** The URL a card or cover loads for `image` (`{url, thumbnail_url}`), given the JPEG XL answer. */
|
||
export function thumbnailSource(image, supported = answer) {
|
||
const thumbnail = image?.thumbnail_url;
|
||
if (typeof thumbnail !== 'string' || !thumbnail) return image?.url;
|
||
if (supported === false && isJxlUrl(thumbnail)) return image.url;
|
||
return thumbnail;
|
||
}
|
||
|
||
/** Whether a card shows `image`'s thumbnail (always for an image: its original at worst). */
|
||
export function showsThumbnail(image, supported = answer) {
|
||
if (!image?.is_video) return true;
|
||
const source = thumbnailSource(image, supported);
|
||
return Boolean(source) && source !== image.url;
|
||
}
|
||
|
||
/** A video thumbnail `<img>` that fails to load is replaced by `next()`, once (`listen` as in fallBackToOriginal). */
|
||
export function replaceOnError(node, next, listen = null) {
|
||
const swap = () => node.replaceWith(next());
|
||
if (listen) listen(node, 'error', swap, { once: true });
|
||
else node.addEventListener('error', swap, { once: true });
|
||
}
|
||
|
||
/**
|
||
* A thumbnail `<img>` that fails to load shows `original` instead, once. `listen(target, type, handler, options)` is the
|
||
* caller's scoped listener (a scope's `listen`); without one a plain once-listener is used.
|
||
*/
|
||
export function fallBackToOriginal(node, original, listen = null) {
|
||
if (!original || node.src === original) return;
|
||
const swap = () => { if (node.src !== original) node.src = original; };
|
||
if (listen) listen(node, 'error', swap, { once: true });
|
||
else node.addEventListener('error', swap, { once: true });
|
||
}
|