acb25a9a1c
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 <paul@MacStudio-von-Paul.local>
149 lines
6.6 KiB
JavaScript
149 lines
6.6 KiB
JavaScript
/**
|
|
* Preview tier lookup and cleanup must agree with what the generator writes
|
|
* (#1020 follow-up).
|
|
*
|
|
* generatePreviewImage rewrites the extension to match the encoding (`.jpg`,
|
|
* or `.webp` for alpha / multi-frame sources). ensurePreviewImageAtWidth and
|
|
* previewTierKeys kept the SOURCE extension, so for a `.png`, `.JPG`, `.heic`
|
|
* or RAW source the tier was generated on every single request — the stat
|
|
* never matched — and cleanup never found the files, which piled up in
|
|
* storage for the life of the install.
|
|
*
|
|
* Driven against real Sharp output, because the whole question is which
|
|
* extension the encoder actually chose.
|
|
*/
|
|
const path = require('path');
|
|
const fs = require('fs').promises;
|
|
const os = require('os');
|
|
const sharp = require('sharp');
|
|
|
|
jest.mock('../../src/database/db', () => {
|
|
const state = { event: null };
|
|
const api = (table) => {
|
|
if (table === 'events') return { where: () => ({ first: async () => state.event }) };
|
|
if (table === 'photos') return { where: () => ({ update: async () => 1 }) };
|
|
if (table === 'app_settings') return { whereIn: () => ({ select: async () => [] }) };
|
|
throw new Error(`unexpected table in test: ${table}`);
|
|
};
|
|
api.__state = state;
|
|
return { db: api };
|
|
});
|
|
|
|
const LocalFsStorage = require('../../src/services/storage/LocalFsStorage');
|
|
const storageModule = require('../../src/services/storage');
|
|
const { db } = require('../../src/database/db');
|
|
|
|
const EVENT = { id: 21, slug: 'fmt-ev', source_mode: 'managed' };
|
|
let nextId = 5000;
|
|
|
|
describe('preview tier keys follow the encoded extension', () => {
|
|
let storage; let storageRoot; let imageProcessor; let puts;
|
|
|
|
beforeAll(async () => {
|
|
storageRoot = await fs.mkdtemp(path.join(os.tmpdir(), 'picpeak-tierkeys-'));
|
|
storage = new LocalFsStorage({ root: storageRoot });
|
|
await storage.init();
|
|
const origPut = storage.put.bind(storage);
|
|
storage.put = async (key, ...rest) => { puts.push(key); return origPut(key, ...rest); };
|
|
storageModule.setStorageForTesting(storage);
|
|
delete require.cache[require.resolve('../../src/services/imageProcessor')];
|
|
imageProcessor = require('../../src/services/imageProcessor');
|
|
}, 30000);
|
|
|
|
beforeEach(() => { puts = []; db.__state.event = EVENT; });
|
|
|
|
afterAll(async () => {
|
|
storageModule.resetStorage();
|
|
await fs.rm(storageRoot, { recursive: true, force: true }).catch(() => {});
|
|
});
|
|
|
|
async function photoWith(name, { alpha = false } = {}) {
|
|
const id = nextId++;
|
|
const rel = `${EVENT.slug}/${name}`;
|
|
const abs = path.join(storageRoot, 'events/active', rel);
|
|
await fs.mkdir(path.dirname(abs), { recursive: true });
|
|
const pipeline = sharp({
|
|
create: {
|
|
width: 2000, height: 1400, channels: alpha ? 4 : 3,
|
|
background: alpha ? { r: 10, g: 20, b: 30, alpha: 0.5 } : { r: 10, g: 20, b: 30 },
|
|
},
|
|
});
|
|
if (/\.png$/i.test(name)) await pipeline.png().toFile(abs);
|
|
else if (/\.webp$/i.test(name)) await pipeline.webp().toFile(abs);
|
|
else await pipeline.jpeg().toFile(abs);
|
|
return { id, event_id: EVENT.id, source_origin: 'managed', path: rel, filename: name };
|
|
}
|
|
|
|
it.each([
|
|
['opaque PNG', 'photo.png', false, '.jpg'],
|
|
['transparent PNG', 'photo-alpha.png', true, '.webp'],
|
|
['uppercase JPG', 'IMG_0001.JPG', false, '.jpg'],
|
|
['jpeg spelled out', 'photo.jpeg', false, '.jpg'],
|
|
['lowercase jpg', 'photo.jpg', false, '.jpg'],
|
|
])('%s: the second request is a cache hit, not a second generation', async (_label, name, alpha, ext) => {
|
|
const photo = await photoWith(name, { alpha });
|
|
|
|
const first = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
|
expect(first).toBe(`previews/preview_w640_p${photo.id}_${name.replace(/\.[^.]+$/, '')}${ext}`);
|
|
expect(puts).toEqual([first]);
|
|
|
|
const second = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
|
expect(second).toBe(first);
|
|
expect(puts).toHaveLength(1);
|
|
});
|
|
|
|
it.each([
|
|
['opaque PNG', 'cleanup.png', false],
|
|
['transparent PNG', 'cleanup-alpha.png', true],
|
|
['uppercase JPG', 'CLEANUP.JPG', false],
|
|
])('%s: previewTierKeys covers the generated key, so deletePreviewTiers removes it', async (_label, name, alpha) => {
|
|
const photo = await photoWith(name, { alpha });
|
|
const k640 = await imageProcessor.ensurePreviewImageAtWidth(photo, 640);
|
|
const k1280 = await imageProcessor.ensurePreviewImageAtWidth(photo, 1280);
|
|
expect(await storage.stat(k640)).toBeTruthy();
|
|
expect(await storage.stat(k1280)).toBeTruthy();
|
|
|
|
const keys = imageProcessor.previewTierKeys(photo);
|
|
expect(keys).toEqual(expect.arrayContaining([k640, k1280]));
|
|
|
|
await imageProcessor.deletePreviewTiers(photo);
|
|
expect(await storage.stat(k640)).toBeNull();
|
|
expect(await storage.stat(k1280)).toBeNull();
|
|
});
|
|
|
|
it('a tier written before the extension rewrite is still found, by lookup and by cleanup', async () => {
|
|
// JPEG bytes under the source's `.png` name — what generatePreviewImage
|
|
// produced before it started rewriting the extension. Served as JPEG by
|
|
// the route (the Content-Type comes from the `.webp` suffix only).
|
|
const photo = await photoWith('legacy.png');
|
|
const legacyKey = `previews/preview_w640_p${photo.id}_legacy.png`;
|
|
await storage.put(legacyKey, await sharp({ create: { width: 640, height: 448, channels: 3, background: '#123' } }).jpeg().toBuffer());
|
|
puts = [];
|
|
|
|
expect(await imageProcessor.ensurePreviewImageAtWidth(photo, 640)).toBe(legacyKey);
|
|
expect(puts).toHaveLength(0);
|
|
|
|
expect(imageProcessor.previewTierKeys(photo)).toContain(legacyKey);
|
|
await imageProcessor.deletePreviewTiers(photo);
|
|
expect(await storage.stat(legacyKey)).toBeNull();
|
|
});
|
|
|
|
it('never lists the canonical 1920 rendition, which preview_path owns', () => {
|
|
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.png', source_origin: 'managed' });
|
|
expect(keys.some((k) => k.includes('w1920'))).toBe(false);
|
|
expect(keys.every((k) => k.includes('p9_'))).toBe(true);
|
|
// Both encodings plus the legacy source-extension key, per width.
|
|
expect(keys).toEqual([
|
|
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp', 'previews/preview_w640_p9_a.png',
|
|
'previews/preview_w1280_p9_a.jpg', 'previews/preview_w1280_p9_a.webp', 'previews/preview_w1280_p9_a.png',
|
|
]);
|
|
});
|
|
|
|
it('does not duplicate the legacy key when the source already is a lowercase .jpg', () => {
|
|
const keys = imageProcessor.previewTierKeys({ id: 9, path: 'e/a.jpg', source_origin: 'managed' });
|
|
expect(keys.filter((k) => k.includes('w640'))).toEqual([
|
|
'previews/preview_w640_p9_a.jpg', 'previews/preview_w640_p9_a.webp',
|
|
]);
|
|
});
|
|
});
|