- 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).
102 lines
5.6 KiB
JavaScript
102 lines
5.6 KiB
JavaScript
// #317: JPEG XL thumbnails in browsers that cannot decode them. The workspace decodes a tiny embedded JPEG XL once;
|
|
// where it fails, cards and the inspector cover use the original instead, and any thumbnail <img> that fails to load
|
|
// falls back to the original. A thumbnail is only a placeholder: the original is what every other view uses.
|
|
import test from 'node:test';
|
|
import assert from 'node:assert/strict';
|
|
|
|
const support = await import('../../web/js/workspace/thumbnail-support.mjs');
|
|
|
|
/** A fake Image whose decode answers `ok` once its src is set. */
|
|
function fakeImage(ok, log) {
|
|
return () => {
|
|
const image = {};
|
|
Object.defineProperty(image, 'src', {
|
|
set(value) { log.push(value); queueMicrotask(() => (ok ? image.onload?.() : image.onerror?.())); },
|
|
});
|
|
return image;
|
|
};
|
|
}
|
|
|
|
test('a .jxl thumbnail URL is recognised by its path, never its query', () => {
|
|
assert.equal(support.isJxlUrl('/prompt_manager/images/serve/thumbnails/a_thumb.jxl?v=1&root=0'), true);
|
|
assert.equal(support.isJxlUrl('/prompt_manager/images/serve/thumbnails/a_thumb.JXL'), true);
|
|
assert.equal(support.isJxlUrl('/prompt_manager/images/serve/thumbnails/a_thumb.webp?x=.jxl'), false);
|
|
assert.equal(support.isJxlUrl('/x/a.jxl#frag'), true);
|
|
for (const value of [null, undefined, '', 42]) assert.equal(support.isJxlUrl(value), false);
|
|
});
|
|
|
|
test('decode support is probed once with the embedded image and remembered', async () => {
|
|
support.resetJxlSupport();
|
|
const log = [];
|
|
assert.equal(support.jxlSupport(), undefined, 'unknown before the probe');
|
|
const first = support.detectJxlSupport({ createImage: fakeImage(true, log) });
|
|
const again = support.detectJxlSupport({ createImage: fakeImage(false, log) });
|
|
assert.equal(await first, true);
|
|
assert.equal(await again, true);
|
|
assert.equal(log.length, 1, 'one probe');
|
|
assert.match(log[0], /^data:image\/jxl;base64,\/woA/);
|
|
assert.equal(support.jxlSupport(), true);
|
|
support.resetJxlSupport();
|
|
assert.equal(await support.detectJxlSupport({ createImage: fakeImage(false, log) }), false);
|
|
assert.equal(support.jxlSupport(), false);
|
|
support.resetJxlSupport();
|
|
assert.equal(await support.detectJxlSupport({ createImage: () => { throw new Error('no Image'); } }), false);
|
|
support.resetJxlSupport();
|
|
});
|
|
|
|
test('cards use the original for a JPEG XL thumbnail this browser cannot show', () => {
|
|
const image = { url: '/serve/a.png?root=0', thumbnail_url: '/serve/thumbnails/a_thumb.jxl?root=0' };
|
|
const webp = { ...image, thumbnail_url: '/serve/thumbnails/a_thumb.webp?root=0' };
|
|
assert.equal(support.thumbnailSource(image, false), image.url);
|
|
assert.equal(support.thumbnailSource(image, true), image.thumbnail_url);
|
|
assert.equal(support.thumbnailSource(image, undefined), image.thumbnail_url, 'unknown: try it, the error falls back');
|
|
assert.equal(support.thumbnailSource(webp, false), webp.thumbnail_url);
|
|
assert.equal(support.thumbnailSource({ url: image.url }, false), image.url);
|
|
assert.equal(support.thumbnailSource({ url: image.url, thumbnail_url: null }, true), image.url);
|
|
});
|
|
|
|
test('a thumbnail that fails to load falls back to the original once', () => {
|
|
const listeners = {};
|
|
const node = { src: '/serve/thumbnails/a_thumb.jxl',
|
|
addEventListener(type, handler, options) { listeners[type] = { handler, options }; } };
|
|
support.fallBackToOriginal(node, '/serve/a.png');
|
|
assert.equal(listeners.error.options.once, true);
|
|
listeners.error.handler();
|
|
assert.equal(node.src, '/serve/a.png');
|
|
const original = { src: '/serve/a.png', addEventListener: () => assert.fail('no listener for the original itself') };
|
|
support.fallBackToOriginal(original, '/serve/a.png');
|
|
const listen = [];
|
|
const scoped = { src: '/serve/thumbnails/b_thumb.webp', isConnected: true };
|
|
support.fallBackToOriginal(scoped, '/serve/b.png', (target, type, handler, options) => listen.push([target, type, handler, options]));
|
|
assert.equal(listen[0][1], 'error');
|
|
listen[0][2]();
|
|
assert.equal(scoped.src, '/serve/b.png');
|
|
});
|
|
|
|
test('a video card shows its thumbnail only when this browser can show it (review M2)', () => {
|
|
const video = { is_video: true, url: '/serve/c.mp4?root=0', thumbnail_url: '/serve/thumbnails/c_thumb.jxl?root=0' };
|
|
assert.equal(support.showsThumbnail(video, false), false, 'JPEG XL unsupported: the video keeps its own element');
|
|
assert.equal(support.showsThumbnail(video, true), true);
|
|
assert.equal(support.showsThumbnail(video, undefined), true, 'unknown: try it, the error swaps');
|
|
assert.equal(support.showsThumbnail({ ...video, thumbnail_url: '/serve/thumbnails/c_thumb.webp' }, false), true);
|
|
assert.equal(support.showsThumbnail({ ...video, thumbnail_url: null }, true), false);
|
|
assert.equal(support.showsThumbnail({ url: '/serve/a.png', thumbnail_url: '/serve/thumbnails/a_thumb.jxl' }, false), true,
|
|
'an image always shows something: its original');
|
|
});
|
|
|
|
test('a video thumbnail that fails to load is replaced once, never left broken', () => {
|
|
const listeners = {};
|
|
let replaced = null;
|
|
const node = { addEventListener(type, handler, options) { listeners[type] = { handler, options }; },
|
|
replaceWith(next) { replaced = next; } };
|
|
support.replaceOnError(node, () => 'label');
|
|
assert.equal(listeners.error.options.once, true);
|
|
listeners.error.handler();
|
|
assert.equal(replaced, 'label');
|
|
const listen = [];
|
|
support.replaceOnError(node, () => 'other', (...args) => listen.push(args));
|
|
assert.equal(listen[0][1], 'error');
|
|
listen[0][2]();
|
|
assert.equal(replaced, 'other');
|
|
});
|