9ffbe2f98f
* 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 <paul@MacStudio-von-Paul.local>
135 lines
5.7 KiB
JavaScript
135 lines
5.7 KiB
JavaScript
/**
|
|
* The preview tier must not destroy what it is previewing.
|
|
*
|
|
* 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: the slideshow (#1015), admin
|
|
* previews, and the face avatars that read it as a whole-frame rendition.
|
|
*
|
|
* Driven against real Sharp output, because the whole question is what is in
|
|
* the encoded bytes.
|
|
*/
|
|
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const os = require('os');
|
|
const sharp = require('sharp');
|
|
|
|
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
|
const storageModule = require('../../src/services/storage');
|
|
|
|
/** A 2x2 GIF89a with two frames and a NETSCAPE loop block. */
|
|
const ANIMATED_GIF = Buffer.from([
|
|
0x47, 0x49, 0x46, 0x38, 0x39, 0x61,
|
|
0x02, 0x00, 0x02, 0x00,
|
|
0xF0, 0x00, 0x00,
|
|
0x00, 0x00, 0x00, 0xFF, 0xFF, 0xFF,
|
|
0x21, 0xFF, 0x0B, 0x4E, 0x45, 0x54, 0x53, 0x43, 0x41, 0x50, 0x45,
|
|
0x32, 0x2E, 0x30, 0x03, 0x01, 0x00, 0x00, 0x00,
|
|
0x21, 0xF9, 0x04, 0x00, 0x0A, 0x00, 0x00, 0x00,
|
|
0x2C, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00,
|
|
0x02, 0x02, 0x44, 0x01, 0x00,
|
|
0x21, 0xF9, 0x04, 0x00, 0x0A, 0x00, 0x00, 0x00,
|
|
0x2C, 0x00, 0x00, 0x00, 0x00, 0x02, 0x00, 0x02, 0x00, 0x00,
|
|
0x02, 0x02, 0x4C, 0x01, 0x00,
|
|
0x3B,
|
|
]);
|
|
|
|
// No width-tier case here: the responsive `?w=` renditions (#1095) are
|
|
// main-only, so this branch has a single canonical preview per photo.
|
|
describe('generatePreviewImage encodes for the source (#1166 follow-up)', () => {
|
|
let storage; let storageRoot; let srcDir; let imageProcessor;
|
|
|
|
beforeAll(async () => {
|
|
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-prevfmt-store-'));
|
|
srcDir = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-prevfmt-src-'));
|
|
storage = new LocalFsStorage({ root: storageRoot });
|
|
await storage.init();
|
|
storageModule.setStorageForTesting(storage);
|
|
|
|
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
|
imageProcessor = require('../../src/services/imageProcessor');
|
|
}, 30000);
|
|
|
|
afterAll(async () => {
|
|
storageModule.resetStorage();
|
|
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
|
await fs.rm(srcDir, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
const outMeta = async (key) => sharp(storage.resolveLocalPath(key)).metadata();
|
|
|
|
it('keeps transparency, as WebP, for a PNG with alpha', async () => {
|
|
const src = path.join(srcDir, 'logo.png');
|
|
await sharp({
|
|
create: { width: 800, height: 600, channels: 4, background: { r: 0, g: 0, b: 0, alpha: 0 } },
|
|
}).png().toFile(src);
|
|
|
|
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
|
|
|
|
expect(key).toBe('previews/preview_logo.webp');
|
|
const meta = await outMeta(key);
|
|
expect(meta.format).toBe('webp');
|
|
// The regression, stated directly: JPEG would have flattened this.
|
|
expect(meta.hasAlpha).toBe(true);
|
|
});
|
|
|
|
it('keeps every frame, as WebP, for an animated GIF', async () => {
|
|
const src = path.join(srcDir, 'wave.gif');
|
|
// Hand-assembled rather than produced by Sharp: writing a multi-page image
|
|
// needs pageHeight threaded through the pipeline, and a fixture that
|
|
// silently comes out single-page would make this test pass for the wrong
|
|
// reason. 2x2, two frames, black then white.
|
|
await fs.writeFile(src, ANIMATED_GIF);
|
|
// Precondition: the fixture really is animated.
|
|
expect((await sharp(src, { animated: true }).metadata()).pages).toBe(2);
|
|
|
|
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
|
|
|
|
expect(key).toBe('previews/preview_wave.webp');
|
|
const meta = await sharp(storage.resolveLocalPath(key), { animated: true }).metadata();
|
|
expect(meta.format).toBe('webp');
|
|
// The regression, stated directly: JPEG kept only the first frame.
|
|
expect(meta.pages).toBe(2);
|
|
});
|
|
|
|
it('still writes plain JPEG for an ordinary photo', async () => {
|
|
// The common path must not pay for the two cases above: JPEG is smaller
|
|
// than WebP at the quality this tier uses, and every existing preview is
|
|
// one.
|
|
const src = path.join(srcDir, 'shot.jpg');
|
|
await sharp({ create: { width: 2400, height: 1600, channels: 3, background: { r: 90, g: 90, b: 90 } } })
|
|
.jpeg().toFile(src);
|
|
|
|
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
|
|
|
|
expect(key).toBe('previews/preview_shot.jpg');
|
|
const meta = await outMeta(key);
|
|
expect(meta.format).toBe('jpeg');
|
|
// 2400x1600 capped at the 1920 long edge, aspect preserved — unchanged.
|
|
expect([meta.width, meta.height]).toEqual([1920, 1280]);
|
|
});
|
|
|
|
it('names the output for what it wrote, not for the source', async () => {
|
|
// A PNG source used to produce `preview_x.png` holding JPEG bytes. Harmless
|
|
// while the route hard-coded image/jpeg; wrong once the encoding varies,
|
|
// and the route now reads the extension.
|
|
const src = path.join(srcDir, 'opaque.png');
|
|
await sharp({ create: { width: 400, height: 400, channels: 3, background: { r: 1, g: 2, b: 3 } } })
|
|
.png().toFile(src);
|
|
|
|
const key = await imageProcessor.generatePreviewImage(src, { regenerate: true });
|
|
|
|
expect(key).toBe('previews/preview_opaque.jpg');
|
|
expect((await outMeta(key)).format).toBe('jpeg');
|
|
});
|
|
|
|
it('returns null on an unreadable source instead of throwing', async () => {
|
|
const src = path.join(srcDir, 'not-an-image.jpg');
|
|
await fs.writeFile(src, 'plain text');
|
|
|
|
await expect(imageProcessor.generatePreviewImage(src, { regenerate: true })).resolves.toBeNull();
|
|
});
|
|
});
|