fix(previews): preserve alpha and animation in the preview tier (#1176)
* fix(gallery): stop the lightbox loading originals to display a photo (#1166) Stable twin of #1169. The lightbox read preview_url, which the server only emits once an admin has flipped lightbox_preview_enabled — off by default. So a stock install fell straight through to url, the untouched original: a reporter measured 16.5 MB for a photo whose preview is 345 KB. The lightbox renders its neighbours too, so opening one photo pulled three originals. slideshow_url is the same /preview/:id URL, watermark query included, and has been emitted unconditionally for images since #1015. Preferring it fixes every existing install with no migration and no admin action. Two other surfaces bypass PhotoLightbox entirely and had the same bug: - premium galleries build their own slides with `src: photo.url`. Fixing that also required carrying the photo id on the slide, because the download handler recovered the photo by matching slide.src against photo.url — a derivative src would have made Download a silent no-op. - the Story layout rendered the full original as its GRID TILE, at object-cover in a small card, and its hero rendered one as a full-bleed background when hero_url exists for exactly that. Cards now use the preview tier (not the thumbnail: thumbnail_fit is seeded to 'cover', so a thumbnail would be cropped a second time and reframe every photo) and only load once within 200px of the viewport, since every card mounts at page load. GIF, APNG and PNG keep the original: generatePreviewImage encodes JPEG, which has neither a second frame nor an alpha channel. The backend fix that removes this list is the next commit in this stack. Divergence from the main twin: no responsive `?w=` tiers. #1095 is main-only, so `lightboxImageUrl` here selects a URL and nothing more. It lives in `imageTiers.ts` under the same path main uses, so that backporting #1095 later merges into this file rather than landing beside it. Verified on this branch: 8 new tests; frontend suite 21 files / 113 tests, tsc clean. * fix(gallery): make the Story hero fix actually work on external galleries (#1166) External review. Same two fixes as the main twin. hero_url was inert for external media. ensureHeroImage only ever called resolvePhotoStorageKey, which returns null for external/reference photos by design — and that null was handed straight to withLocalCopy, which throws, so the hero route caught it and redirected to the full ORIGINAL. #1078 fixed exactly this shape for ensurePreviewImage and nobody carried it across. It stayed invisible until this PR pointed the Story hero at hero_url: on a managed gallery that is a real saving, on a reference-mode gallery it quietly changed nothing. Needed one extra piece here that main already had: generateHeroImage on this branch ignores outputBasename and always derives the key from the source basename, so two events referencing the same NAS filename would clobber each other's hero. It now honours the option, matching generateThumbnail and generatePreviewImage. The format bypass trusted mime_type, which is not trustworthy: migration 039 backfilled every pre-existing photo to image/jpeg regardless of what it was, and adminExternalMedia inserts rows with no mime_type at all — so a mislabelled PNG sailed past the guard and came back flattened. It now checks the filename extension as well. * fix(previews): preserve alpha and animation in the preview tier Stable twin of #1171. Stacked on the #1166 twin, whose format bypass this removes. generatePreviewImage encoded JPEG unconditionally. JPEG has no alpha channel and no second frame, so a transparent PNG came back flattened onto a solid background and an animated GIF came back as its first frame — for every consumer of this tier, not just the lightbox. It was only invisible by default because the lightbox served originals. Sources with alpha, or more than one page, are now encoded as WebP, which carries both and is still far smaller than the original. Ordinary photos stay JPEG. - the output extension matches what was written. A PNG source previously produced `preview_foo.png` holding JPEG bytes; harmless while the route hard-coded image/jpeg, wrong once the encoding varies. Existing keys keep working — they are still JPEG and still served as such. - the preview route derives Content-Type from the key. With nosniff set, mislabelling would show a broken image rather than being silently corrected. The watermark branch re-encodes to JPEG and now says so. The frontend guess-by-MIME goes away entirely, including the case it could never get right: a still and an animated WebP declare the same type. Divergence from the main twin: no width-tier case. The responsive `?w=` renditions (#1095) are main-only, so this branch has a single canonical preview per photo. Verified on this branch: 5 new backend tests against real Sharp output; frontend 21 files / 114 tests; full backend suite leaves the same 5 pre-existing failures as origin/stable. * fix(previews): retire the legacy preview keys, and stop mislabelling watermarked ones External review. Same two defects as the main twin. Legacy keys collide with the new naming. The old generator kept the SOURCE basename verbatim while always writing JPEG, so a `.webp` upload produced `previews/preview_shot.webp` holding a JPEG. The claim that pre-existing keys have no .webp suffix was simply wrong. The route now derives Content-Type from the key and the response carries nosniff, so every photo uploaded as WebP would have rendered as a broken image in the lightbox. Legacy `.png` keys are wrong the other way: flattened JPEGs of what may have been transparent sources, which isPreviewValid would have let stand forever. Migration 178 clears photos.preview_path outright — all of it, not just the suspicious extensions, because a `.jpg` key can equally be a flattened rendition and nothing in the key says so. Previews regenerate lazily on next view under the new encoder. The watermark branch mislabelled its output. applyWatermark PRESERVES the source format on this branch too (watermarkService.js: png stays png, webp stays webp), and its input is the preview — so the output already matches the key the header was derived from. Forcing image/jpeg mislabelled every watermarked WebP preview, and nosniff means the browser would not correct it. Numbered 178, not 176: this stack does not carry the external-media migrations, but that stack takes 176 and 177 on this same branch, and two files sharing a numeric prefix would be confusing even though both would run. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
75facb4d67
commit
9ffbe2f98f
@@ -0,0 +1,55 @@
|
||||
/**
|
||||
* Migration 178: drop preview keys written by the old generator.
|
||||
*
|
||||
* generatePreviewImage used to keep the SOURCE basename verbatim, extension and
|
||||
* all, while always writing JPEG bytes. So a `.webp` upload produced
|
||||
* `previews/preview_shot.webp` holding a JPEG, and a `.png` upload produced
|
||||
* `preview_logo.png` holding a JPEG.
|
||||
*
|
||||
* That was harmless while the preview route hard-coded `Content-Type:
|
||||
* image/jpeg`. It stopped being harmless the moment the encoding started
|
||||
* varying: the route now reads the extension, so a legacy `.webp` key is
|
||||
* announced as `image/webp` while containing JPEG — and the response carries
|
||||
* `X-Content-Type-Options: nosniff`, so the browser will not quietly correct
|
||||
* it. The lightbox shows a broken image for every photo that happened to be
|
||||
* uploaded as WebP.
|
||||
*
|
||||
* The legacy `.png`-keyed previews are wrong in the other direction: they are
|
||||
* flattened JPEGs of what may have been a transparent source, which is the
|
||||
* defect the new encoder fixes and which `isPreviewValid` would otherwise let
|
||||
* stand forever.
|
||||
*
|
||||
* Clearing the column is the whole repair. Previews are lazily regenerated by
|
||||
* ensurePreviewImage on the next open, under the new naming and the new
|
||||
* encoder, so the only cost is one regeneration per photo that is actually
|
||||
* viewed. Nothing is deleted from storage — a migration is the wrong place to
|
||||
* reach into a backend that may be S3 — so the old objects linger as
|
||||
* unreferenced bytes, which the storage breakdown counts honestly.
|
||||
*
|
||||
* Deliberately clears ALL of them, not just the ones whose extension looks
|
||||
* suspicious. A `.jpg`-keyed legacy preview is byte-correct, but it may still
|
||||
* be a flattened rendition of a transparent or animated source, and there is
|
||||
* no way to tell from the key. One lazy regeneration is cheaper than reasoning
|
||||
* about which of them lied.
|
||||
*/
|
||||
|
||||
exports.up = async function(knex) {
|
||||
if (!(await knex.schema.hasTable('photos'))) return;
|
||||
if (!(await knex.schema.hasColumn('photos', 'preview_path'))) return;
|
||||
|
||||
const cleared = await knex('photos')
|
||||
.whereNotNull('preview_path')
|
||||
.update({ preview_path: null });
|
||||
|
||||
if (cleared) {
|
||||
console.log(`178_reset_legacy_preview_paths: cleared ${cleared} preview key(s); they regenerate on next view`);
|
||||
}
|
||||
};
|
||||
|
||||
/**
|
||||
* Irreversible by design, and harmless: the column held a cache key, and the
|
||||
* cache rebuilds itself. There is nothing to restore.
|
||||
*/
|
||||
exports.down = async function() {
|
||||
console.log('178_reset_legacy_preview_paths: rollback is a no-op (preview keys are a regenerable cache)');
|
||||
};
|
||||
Reference in New Issue
Block a user