fix(images): probe and clean up preview tiers under the extension the encoder actually wrote (#1355)

generatePreviewImage rewrites the output extension to match the encoding
it chose, .jpg or .webp for alpha and multi-frame sources. The tier
lookup in ensurePreviewImageAtWidth and the cleanup list in
previewTierKeys kept the SOURCE extension instead, so for anything but a
lowercase .jpg source the stat never matched: every tier request for a
.png, .JPG, .jpeg, .heic or RAW photo re-ran Sharp, and cleanup never
found the files it left behind, which accumulated for the life of the
install.

Both now derive every key the tier can live under: the .jpg and .webp
candidates, plus the source-extension key last so tiers written before
the rewrite are still found by lookup and by cleanup.

Follow-up to issue 1020, where the mismatch was identified during review.

Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
Paul Nothaft
2026-09-08 08:41:53 +02:00
committed by GitHub
co-authored by Paul Nothaft
parent c97341e454
commit acb25a9a1c
3 changed files with 208 additions and 20 deletions
+52 -19
View File
@@ -1021,14 +1021,53 @@ async function isPreviewValid(previewPath) {
*/
function previewTierKeys(photo) {
if (!photo) return [];
return PREVIEW_WIDTHS
.filter((w) => w !== DEFAULT_PREVIEW_LONG_EDGE)
.flatMap((w) => previewTierKeyCandidates(photo, w));
}
/**
* The output basename every preview tier of a photo is written under.
*
* ALWAYS scoped by photo id, managed rows included. Basenames are not unique
* across events — two galleries can each hold an IMG_0001.jpg — and because a
* tier is served straight from a cache hit without re-reading the source, a
* collision hands one gallery's photo to another. Scoping by id is what makes
* the cache safe to trust; it is not a tidiness choice.
*/
function previewTierBasename(photo) {
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const sourceBasename = path.basename(
(isExternal ? (photo.external_relpath || photo.filename) : photo.path) || `photo-${photo.id}`
);
const outputBasename = `p${photo.id}_${sourceBasename}`;
return PREVIEW_WIDTHS
.filter((w) => w !== DEFAULT_PREVIEW_LONG_EDGE)
.map((w) => path.posix.join('previews', `preview_w${w}_${outputBasename}`));
return `p${photo.id}_${sourceBasename}`;
}
/**
* Every storage key one preview tier of a photo can live under, most likely
* first.
*
* generatePreviewImage rewrites the extension to match the encoding it chose
* — `.jpg`, or `.webp` for a source with alpha or more than one frame — and
* which one that is cannot be known without probing the source, which is the
* work the cache exists to skip. The lookup used to probe a single key that
* kept the SOURCE extension, so for anything but a lowercase `.jpg` source
* (`.png`, `.JPG`, `.heic`, RAW) it never matched what had been written: every
* tier request re-ran Sharp, and cleanup, deriving the same key, never found
* the files it left behind.
*
* The source-extension key stays in the list, last: previews written before
* the extension rewrite carry it (JPEG bytes under a `.png` name, still
* served as JPEG), and they have to be found by cleanup as well as lookup.
*/
function previewTierKeyCandidates(photo, width) {
const outputBasename = previewTierBasename(photo);
const stem = `preview_w${width}_`;
const base = outputBasename.replace(/\.[^./\\]+$/, '');
const keys = [`${stem}${base}.jpg`, `${stem}${base}.webp`];
const legacy = `${stem}${outputBasename}`;
if (!keys.includes(legacy)) keys.push(legacy);
return keys.map((k) => path.posix.join('previews', k));
}
/** Best-effort removal of every responsive tier for a photo. */
@@ -1196,23 +1235,17 @@ async function ensurePreviewImageAtWidthUnguarded(photo, width) {
if (!event) return null;
const isExternal = photo.source_origin === 'external' || photo.source_origin === 'reference';
const sourceBasename = path.basename(
(isExternal ? (photo.external_relpath || photo.filename) : photo.path) || `photo-${photo.id}`
);
// ALWAYS scoped by photo id, managed rows included. Basenames are not unique
// across events — two galleries can each hold an IMG_0001.jpg — and because a
// tier is served straight from a cache hit without re-reading the source, a
// collision hands one gallery's photo to another. Scoping by id is what makes
// the cache safe to trust; it is not a tidiness choice.
const outputBasename = `p${photo.id}_${sourceBasename}`;
const key = path.posix.join('previews', `preview_w${width}_${outputBasename}`);
const outputBasename = previewTierBasename(photo);
// Cache hit: nothing to do. This is the common path once a gallery has been
// browsed at a given size.
try {
if (await storage.stat(key)) return key;
} catch (e) {
// fall through and regenerate
// browsed at a given size. Every key the tier can have been written under
// is probed — see previewTierKeyCandidates for why there is more than one.
for (const key of previewTierKeyCandidates(photo, width)) {
try {
if (await storage.stat(key)) return key;
} catch (e) {
// fall through to the next candidate, then regenerate
}
}
try {