* feat(gallery): responsive grid thumbnails (#1095) The half of #1095 that #1099 deliberately left out. Grid tiles are ~175 CSS px at the mobile 2-column default — about 530 device px on a DPR-3 phone — so the 300px thumbnail is upscaled ~1.8x and faces visibly mush. Backend mirrors the preview tiers exactly: ?w= on the gallery thumbnail route, whitelisted to 300/600/900, cached by width in storage, never written to photos.thumbnail_path, and keyed by photo id for every source type — basenames are not unique across events and a tier is served from a cache hit without re-reading the source, which is how the preview tiers nearly leaked one gallery's photo into another. The tier is in the ETag, or a client holding the 300px file gets a 304 for its 600px request. Cleanup and regenerate invalidation are wired the same way. generateThumbnail now takes width/height overrides; it keeps the configured `fit`, because the grid renders with object-cover and tiers that were framed differently would visibly jump as the viewport changes. The srcset only advertises tiers the SOURCE can fill. Thumbnails are generated withoutEnlargement, so a 400px original asked for 900 comes back at 400 — advertising "900w" would have the browser pick that candidate and upscale it, which is the reported softness made worse. That exact trap is why this was held back from #1099; the photo's own dimensions are now the guard, measured on the SHORT edge because thumbnails are square and a 4000x600 panorama can still only fill a 600 tile. A source that clears only one tier gets no srcset at all rather than a single pointless candidate. Two things this surfaced, both worth knowing separately: `npx tsc --noEmit` type-checks NOTHING in this project — the root tsconfig is `files: []` with project references, so the real command is `tsc -b`, which is what build:check runs. Under tsc -b the repo has 43 files with pre-existing type errors; this branch adds none, and the one error in a file I touched (PeopleManagerModal:91) is on main already and unrelated to the line I changed. * fix(gallery): wire grid tiers into the component that actually renders The srcSet landed in PhotoGrid.tsx, which nothing imports — GalleryView renders PhotoGridWithLayouts, and every grid layout funnels its tile through the shared PhotoCard. The frontend half of #1095 shipped nothing. Moved to PhotoCard, and switched from srcSet to a single sized URL, the same shape PhotoLightbox already uses for preview tiers. AuthenticatedImage fetches its src with the gallery bearer token and renders the blob; an <img> carrying a w-descriptor srcSet ignores src entirely, so that fetch would have been discarded and the browser would have issued its own — unauthenticated, and resolved against the page origin rather than the configured API host. One URL keeps the auth path and halves the requests. The tier comes from the tile's measured width via the IntersectionObserver entry, read on the same render that reveals the image so nothing is fetched twice. Column counts differ per layout and shift again with thumbnailScale, so the breakpoint table is only a fallback. Also closes what the tier cache leaked or served stale: - ensureThumbnailAtWidth short-circuits videos. Their thumbnail is a poster frame, so the tier path handed the video file to Sharp — after downloading it in full on S3, uncached, once per request. - The ETag names the tier actually served, not the one requested. A fallback to the canonical thumbnail was caching a 300px image under a 900px key. - Tier height scales from the configured aspect ratio instead of forcing a square; with fit:'cover' a 300x200 canonical and a 600x600 tier are two different crops and the photo reframed between tiers. - The canonical short-circuit compares against the configured thumbnail_width, not the 300 default, so a 600px install stops generating duplicate tiers. - Tier invalidation on /admin/thumbnails/regenerate, above the local-file check that skips S3 and external rows. - Tier cleanup in replacePhoto and deleteEventCascade. Both derive keys from the photo row, so the rows have to be read before they change or vanish. Preview tiers had the same two holes and are swept alongside. The clamp no longer drops a tier when the source falls between them: a 400px short edge asked for 600 returns all 400 pixels, where clamping to 300 threw 100 of them away. Backend 18 tier tests, frontend 22. Full suites green: 293 backend across the touched areas, 185 frontend, build clean, no new type errors. * fix(gallery): measure the tile, and stop regenerating the w300 tier Follow-up to the review of #1095. Closes the three items left open there, plus a defect the previous commit introduced. **The w300 tier regenerated on every request.** Decoupling the canonical short-circuit from the hardcoded 300 left generateThumbnail still tagging against DEFAULT_THUMBNAIL_WIDTH. On an install with thumbnail_width=600 a w=300 request wrote `thumb_<name>` while the caller probed for `thumb_w300_<name>`: the cache never hit, so every request re-downloaded the original and ran Sharp, and the file it left behind was in no cleanup list. The tag now follows the configured width, and thumbnailTierKeys lists all three widths — which one is canonical is a setting, so excluding 300 stranded exactly the file a 600-configured install generates. **The tier is chosen from the tile's measured width.** The observer entry only exists for `lazy` cards, and Mosaic, Masonry and Timeline don't pass it — Mosaic is 1-up on mobile where Grid is 2-up, so they are the layouts a breakpoint guess gets most wrong. Measured in a layout effect and gated: the image is not rendered until the width is known, so AuthenticatedImage never mounts with a src it has to replace. Attaching the observer ref unconditionally instead refetches every tile, since React flushes passive effects before the sync re-render a layout effect triggers — removing the gate makes the new single-request test fail, which is how that was confirmed rather than assumed. **Gallery Premium has its own card** and never reached the shared one, so its tiles kept pulling the canonical thumbnail. MasonryPhotoAlbum already hands the laid-out width to the render prop, so it needed no measurement. **Event rename orphaned tiers.** The key embeds the basename, so the DB update is the point past which the old keys cannot be derived. Dropped inside the filename-changed branch, not the loop body: unconditional would fire four storage deletes per photo on every rename, 20k calls against S3 for a 5,000-photo event that merely had its slug adjusted. Preview tiers had the same hole and are swept alongside. Carousel is the seventh layout and deliberately gets no tiering: its filmstrip thumbs are 80 CSS px, under the canonical 300 even at DPR 3. Tests: first PhotoCard suite (6), backend tier suite 21. Both new behaviours mutation-checked — reverting the width tag, the render gate, the measurement, or the rename sweep each fails a test. Full suites green: 298 backend across the touched areas, 191 frontend, build clean, no new type or lint findings. * fix(gallery): mount masonry cards once, into a measured layout Found while capturing screenshots for this PR, by attributing every thumbnail request to a photo id rather than eyeballing the grid. Masonry columns mode starts at 3 columns and runs its greedy distribution off a hardcoded 300px estimate until the container has been measured. Cards mounted into that guess are torn down when it settles — photos move to a different parent column, so React unmounts them — and since #1095 each mount picks its tier from its own width, the two mounts request two DIFFERENT urls. Measured on a 1440px desktop, production build, 62 photos: before 45 photos fetched at canonical AND w600, 17 stuck on w600 107 requests after 62 photos, canonical only, 62 requests Mobile was already landing on one tier either way, so both mounts produced the same url and the second was a cache hit — which is why it looked clean and the desktop case did not. The fix is the gate the rows/justified mode in this same file already applies for the same reason (line 346): hold the cards back until containerWidth is known. Only columns mode was missing it. Grid and Justified take their column counts from CSS breakpoints, so they have no transient measured value to discard and are unaffected. Worth noting this was NOT visible on main: without tiering both mounts request the same url, so the browser cache absorbs the duplicate. Tiering is what turns a harmless remount into a second download — the regression is this PR's, which is why it is fixed here rather than deferred. Frontend suite 194 passed (3 new). Mutation-checked: removing the gate fails the mount-once and placeholder tests. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
00776234fd
commit
887bdbe6e5
@@ -224,6 +224,16 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
throw err;
|
||||
}
|
||||
|
||||
// Responsive tiers (#1095 / #492) live in the top-level thumbnails/ and
|
||||
// previews/ directories, not under the event folder the filesystem sweep
|
||||
// below removes, and their keys are derived from the photo rows — which the
|
||||
// transaction is about to delete. So they are read here, while the rows
|
||||
// still exist, and swept after the commit; miss that window and every tier
|
||||
// this event generated is orphaned with nothing left to derive its key from.
|
||||
const tieredPhotos = await db('photos')
|
||||
.where('event_id', eventId)
|
||||
.select('id', 'path', 'filename', 'source_origin', 'external_relpath');
|
||||
|
||||
await db.transaction(async (trx) => {
|
||||
// 1. Delete activity logs (audit trail)
|
||||
await trx('activity_logs').where('event_id', eventId).del();
|
||||
@@ -292,6 +302,19 @@ async function deleteEventCascade(eventId, adminContext) {
|
||||
}
|
||||
});
|
||||
|
||||
// Tier sweep, post-commit and best-effort for the same reason as the folder
|
||||
// removal above: an orphaned derivative is recoverable noise, a rolled-back
|
||||
// delete is not.
|
||||
try {
|
||||
const { deleteThumbnailTiers, deletePreviewTiers } = require('../../services/imageProcessor');
|
||||
for (const photo of tieredPhotos) {
|
||||
await deleteThumbnailTiers(photo);
|
||||
await deletePreviewTiers(photo);
|
||||
}
|
||||
} catch (tierErr) {
|
||||
logger.warn('Failed to delete responsive tiers during cascade delete', { eventId, error: tierErr.message });
|
||||
}
|
||||
|
||||
// Audit trail (outside the transaction so a logging failure can't undo
|
||||
// the actual delete).
|
||||
await logActivity('event_deleted',
|
||||
|
||||
@@ -682,6 +682,7 @@ router.delete('/:eventId/photos/:photoId', adminAuth, requirePermission('photos.
|
||||
// independently, on demand — so keying their cleanup off preview_path
|
||||
// would strand exactly the photos that were only ever viewed on a phone.
|
||||
await require('../services/imageProcessor').deletePreviewTiers(photo);
|
||||
await require('../services/imageProcessor').deleteThumbnailTiers(photo);
|
||||
|
||||
// Delete pre-generated watermark if exists
|
||||
if (photo.watermark_path) {
|
||||
@@ -846,6 +847,7 @@ router.post('/:eventId/photos/bulk-delete', adminAuth, requirePermission('photos
|
||||
// Outside the guard: a tier can exist when the canonical rendition never
|
||||
// did, so keying cleanup off preview_path would strand phone-only photos.
|
||||
await require('../services/imageProcessor').deletePreviewTiers(photo);
|
||||
await require('../services/imageProcessor').deleteThumbnailTiers(photo);
|
||||
if (photo.watermark_path) {
|
||||
await watermarkGeneratorService.deleteForPhoto(photo.id);
|
||||
}
|
||||
|
||||
@@ -125,7 +125,11 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
|
||||
try {
|
||||
const { eventId } = req.body; // Optional: regenerate for specific event only
|
||||
|
||||
let query = db('photos').select('id', 'event_id', 'path');
|
||||
// source_origin/external_relpath/filename are selected for
|
||||
// deleteThumbnailTiers below — it derives the tier keys from the same
|
||||
// fields ensureThumbnailAtWidth used to write them.
|
||||
let query = db('photos')
|
||||
.select('id', 'event_id', 'path', 'source_origin', 'external_relpath', 'filename');
|
||||
if (eventId) {
|
||||
query = query.where('event_id', eventId);
|
||||
}
|
||||
@@ -151,7 +155,21 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
|
||||
try {
|
||||
const storagePath = getStoragePath();
|
||||
const originalPath = path.join(storagePath, 'events/active', photo.path);
|
||||
|
||||
|
||||
// Drop the responsive tiers first (#1095), same as the preview
|
||||
// endpoint below. They are cached by width outside thumbnail_path
|
||||
// and their key carries no settings version, so regenerating only
|
||||
// the canonical rendition leaves phones served the old fit, quality
|
||||
// or format indefinitely — which is exactly what this endpoint is
|
||||
// invoked to undo after a settings change.
|
||||
//
|
||||
// Above the fs.access below, not after it: that check only passes
|
||||
// for managed photos on a local filesystem. On S3, and for external
|
||||
// or reference rows, it fails and skips the photo — so invalidating
|
||||
// after it would leave stale tiers on precisely the deployments
|
||||
// where they are hardest to notice.
|
||||
await require('../services/imageProcessor').deleteThumbnailTiers(photo);
|
||||
|
||||
// Check if original file exists
|
||||
try {
|
||||
await fs.access(originalPath);
|
||||
@@ -160,7 +178,7 @@ router.post('/regenerate', adminAuth, requirePermission('photos.edit'), async (r
|
||||
errorCount++;
|
||||
continue;
|
||||
}
|
||||
|
||||
|
||||
// Regenerate thumbnail
|
||||
const thumbnailPath = await generateThumbnail(originalPath, { regenerate: true });
|
||||
|
||||
|
||||
@@ -2251,7 +2251,26 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
}
|
||||
|
||||
// Ensure thumbnail exists and is valid, regenerate if needed
|
||||
const thumbnailPath = await ensureThumbnail(photo);
|
||||
// Responsive tier (#1095), whitelisted the same way the preview route's
|
||||
// is. Unrecognised or absent falls through to the canonical 300px
|
||||
// thumbnail, so existing clients are untouched.
|
||||
const { THUMBNAIL_WIDTHS, normalizeTierWidth, ensureThumbnailAtWidth } =
|
||||
require('../services/imageProcessor');
|
||||
const thumbTier = normalizeTierWidth(req.query.w, THUMBNAIL_WIDTHS);
|
||||
|
||||
const thumbnailPath = thumbTier
|
||||
? (await ensureThumbnailAtWidth(photo, thumbTier)) || (await ensureThumbnail(photo))
|
||||
: await ensureThumbnail(photo);
|
||||
|
||||
// What was actually resolved, not what was asked for. A tier request can
|
||||
// land on the canonical thumbnail — generation failed, or the row is a
|
||||
// video — and stamping the requested tier into the ETag below would then
|
||||
// have the client cache a 300px image under its 900px key for the full
|
||||
// max-age, with no way to notice.
|
||||
const servedTier = thumbTier && thumbnailPath
|
||||
&& path.basename(thumbnailPath).startsWith(`thumb_w${thumbTier}_`)
|
||||
? thumbTier
|
||||
: null;
|
||||
|
||||
if (!thumbnailPath) {
|
||||
logger.error(`Failed to generate thumbnail for photo ${photoId}`);
|
||||
@@ -2285,7 +2304,10 @@ router.get('/:slug/thumbnail/:photoId',
|
||||
const watermarkHash = watermarkSettings?.enabled
|
||||
? `-wm${watermarkSettings.opacity}${watermarkSettings.position}${watermarkSettings.size}`
|
||||
: '-nowm';
|
||||
const etag = `"thumb-${photoId}-${mtimeMs}${watermarkHash}"`;
|
||||
// Tier in the ETag, same reason as the preview route: without it a
|
||||
// client holding the 300px thumbnail gets a 304 for its 600px request
|
||||
// and renders the small one, which is this feature inverted.
|
||||
const etag = `"thumb-${photoId}-${servedTier || 'def'}-${mtimeMs}${watermarkHash}"`;
|
||||
|
||||
// Check if client has valid cached version
|
||||
if (req.headers['if-none-match'] === etag) {
|
||||
|
||||
@@ -222,6 +222,7 @@ async function archiveEvent(event) {
|
||||
// Outside the guard: a tier can exist when the canonical rendition never
|
||||
// did, so keying cleanup off preview_path would strand phone-only photos.
|
||||
await require('./imageProcessor').deletePreviewTiers(photo);
|
||||
await require('./imageProcessor').deleteThumbnailTiers(photo);
|
||||
// Best effort: remove watermarked variants too if a refactor added them.
|
||||
if (photo.watermark_path) {
|
||||
await storage.delete(photo.watermark_path).catch(() => {});
|
||||
|
||||
@@ -181,6 +181,20 @@ class EventRenameService {
|
||||
logger.warn('Could not rename photo file', { oldFilename, error: error.message });
|
||||
}
|
||||
}
|
||||
|
||||
// Responsive tiers (#1095 / #492) are keyed off the basename, so the
|
||||
// update below is the point past which the old keys can no longer be
|
||||
// derived — a later delete or archive would compute the new ones and
|
||||
// leave these in storage forever. Dropped rather than renamed: they
|
||||
// are a pure cache and the next request regenerates.
|
||||
//
|
||||
// Inside this branch, not the loop body: only a filename change moves
|
||||
// the key. Sweeping unconditionally would fire four storage deletes
|
||||
// per photo on every rename, which is 20k calls against S3 for a
|
||||
// 5,000-photo event that merely had its slug adjusted.
|
||||
const imageProcessor = require('./imageProcessor');
|
||||
await imageProcessor.deleteThumbnailTiers(photo);
|
||||
await imageProcessor.deletePreviewTiers(photo);
|
||||
}
|
||||
|
||||
// Update database record
|
||||
|
||||
@@ -212,13 +212,24 @@ const contentTypeFor = (format) => {
|
||||
async function generateThumbnail(imagePath, options = {}) {
|
||||
const sourceBasename = path.basename(imagePath);
|
||||
const outputBasename = options.outputBasename || sourceBasename;
|
||||
const thumbnailFilename = `thumb_${outputBasename}`;
|
||||
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
|
||||
const storage = getStorage();
|
||||
|
||||
// Get thumbnail settings
|
||||
const settings = await getThumbnailSettings();
|
||||
|
||||
// Tag against the CONFIGURED canonical width, not the 300 default — the tag
|
||||
// has to agree with the key ensureThumbnailAtWidth probed for. On an install
|
||||
// with thumbnail_width=600 a w=300 request used to write `thumb_<name>` while
|
||||
// the caller looked for `thumb_w300_<name>`: the cache never hit, so every
|
||||
// single request re-downloaded the original and ran Sharp, and the file it
|
||||
// left behind was in no cleanup list.
|
||||
const canonicalWidth = settings.width || DEFAULT_THUMBNAIL_WIDTH;
|
||||
const widthTag = options.width && options.width !== canonicalWidth
|
||||
? `w${options.width}_`
|
||||
: '';
|
||||
const thumbnailFilename = `thumb_${widthTag}${outputBasename}`;
|
||||
const thumbnailRelKey = path.posix.join('thumbnails', thumbnailFilename);
|
||||
|
||||
// Force regeneration: drop the existing object before writing the new one
|
||||
if (options.regenerate) {
|
||||
await storage.delete(thumbnailRelKey).catch(() => {});
|
||||
@@ -241,7 +252,12 @@ async function generateThumbnail(imagePath, options = {}) {
|
||||
// Strip EXIF/metadata from thumbnails (privacy: prevent GPS leak etc.)
|
||||
sharpInstance = sharpInstance.withMetadata(false);
|
||||
|
||||
sharpInstance = sharpInstance.resize(settings.width, settings.height, {
|
||||
// options.width/height override the admin setting for responsive tiers
|
||||
// (#1095). The configured `fit` is kept deliberately: the grid renders
|
||||
// with object-cover, so every tier must be cropped the same way or the
|
||||
// browser would swap between differently-framed images as the viewport
|
||||
// changes.
|
||||
sharpInstance = sharpInstance.resize(options.width || settings.width, options.height || settings.height, {
|
||||
withoutEnlargement: true,
|
||||
fit: settings.fit,
|
||||
position: 'center'
|
||||
@@ -731,6 +747,119 @@ async function deletePreviewTiers(photo) {
|
||||
await Promise.all(previewTierKeys(photo).map((k) => storage.delete(k).catch(() => {})));
|
||||
}
|
||||
|
||||
/**
|
||||
* Thumbnail storage keys for every responsive tier of a photo (#1095).
|
||||
* Mirrors previewTierKeys — see there for why they are derived rather than
|
||||
* tracked.
|
||||
*
|
||||
* Every width is listed, the canonical one included, and deliberately: which
|
||||
* width is canonical depends on the thumbnail_width setting, so on a
|
||||
* 600-configured install it is w300 that exists as a tier file. Reading the
|
||||
* setting here would make the whole cleanup path async for no gain — deleting
|
||||
* a key that was never written is already a swallowed no-op, so the inclusive
|
||||
* list is both simpler and the one that cannot strand a file.
|
||||
*/
|
||||
function thumbnailTierKeys(photo) {
|
||||
if (!photo) return [];
|
||||
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 THUMBNAIL_WIDTHS
|
||||
.map((w) => path.posix.join('thumbnails', `thumb_w${w}_${outputBasename}`));
|
||||
}
|
||||
|
||||
async function deleteThumbnailTiers(photo) {
|
||||
const storage = getStorage();
|
||||
await Promise.all(thumbnailTierKeys(photo).map((k) => storage.delete(k).catch(() => {})));
|
||||
}
|
||||
|
||||
/**
|
||||
* A thumbnail at a specific tier width (#1095).
|
||||
*
|
||||
* Same contract as ensurePreviewImageAtWidth: pure cache, keyed by width,
|
||||
* never written to photos.thumbnail_path. The key is scoped by photo id for
|
||||
* every source type — basenames are not unique across events, and a tier is
|
||||
* served from a cache hit without re-reading the source, so an unscoped key
|
||||
* would hand one gallery's photo to another.
|
||||
*/
|
||||
async function ensureThumbnailAtWidth(photo, width) {
|
||||
if (!width) return ensureThumbnail(photo);
|
||||
|
||||
// Against the CONFIGURED canonical width, not the 300 default. An install
|
||||
// that set thumbnail_width to 600 already has a 600px thumbnail; generating
|
||||
// a w600 tier for it would download the original and run Sharp to produce a
|
||||
// byte-equivalent duplicate, once per photo.
|
||||
const settings = await getThumbnailSettings();
|
||||
const canonicalWidth = settings.width || DEFAULT_THUMBNAIL_WIDTH;
|
||||
if (width === canonicalWidth) return ensureThumbnail(photo);
|
||||
|
||||
// Videos never take the tier path. Their thumbnail is a poster frame from
|
||||
// videoProcessor, not a resize of the stored file, so the code below would
|
||||
// hand the video itself to Sharp — after withLocalCopy has downloaded the
|
||||
// whole thing on an S3 backend. Nothing caches that failure, so a crawler
|
||||
// walking ?w= over a gallery of videos repeats the download every request.
|
||||
if (photo.media_type === 'video' || String(photo.mime_type || '').startsWith('video/')) {
|
||||
return ensureThumbnail(photo);
|
||||
}
|
||||
|
||||
const { resolvePhotoStorageKey, resolvePhotoFilePath } = require('./photoResolver');
|
||||
const storage = getStorage();
|
||||
|
||||
let event;
|
||||
try {
|
||||
event = await db('events').where('id', photo.event_id).first();
|
||||
} catch (e) {
|
||||
return null;
|
||||
}
|
||||
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}`
|
||||
);
|
||||
const outputBasename = `p${photo.id}_${sourceBasename}`;
|
||||
const key = path.posix.join('thumbnails', `thumb_w${width}_${outputBasename}`);
|
||||
|
||||
try {
|
||||
if (await storage.stat(key)) return key;
|
||||
} catch (e) {
|
||||
// regenerate below
|
||||
}
|
||||
|
||||
// Scale the height from the configured aspect ratio rather than forcing a
|
||||
// square. Thumbnails are square on a default install, but the settings API
|
||||
// accepts any width/height in 50..1000 — and with fit:'cover' a 300x200
|
||||
// canonical next to a 600x600 tier are two different crops, so the photo
|
||||
// would visibly reframe as the tile size changes.
|
||||
const height = Math.round(width * (settings.height / canonicalWidth));
|
||||
|
||||
try {
|
||||
if (isExternal) {
|
||||
const localPath = resolvePhotoFilePath(event, photo);
|
||||
return await generateThumbnail(localPath, {
|
||||
regenerate: true, outputBasename, width, height,
|
||||
});
|
||||
}
|
||||
const sourceKey = resolvePhotoStorageKey(event, photo);
|
||||
if (!sourceKey) return null;
|
||||
return await withLocalCopy(sourceKey, async (localPath) => {
|
||||
const proc = await withProcessableImage(localPath, sourceKey);
|
||||
try {
|
||||
return await generateThumbnail(proc.path, {
|
||||
regenerate: true, outputBasename, width, height,
|
||||
});
|
||||
} finally {
|
||||
proc.cleanup();
|
||||
}
|
||||
});
|
||||
} catch (e) {
|
||||
logger.warn(`Thumbnail tier w${width} failed for photo ${photo.id}: ${e.message}`);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
async function ensurePreviewImageAtWidth(photo, width) {
|
||||
if (!width || width === DEFAULT_PREVIEW_LONG_EDGE) return ensurePreviewImage(photo);
|
||||
|
||||
@@ -981,6 +1110,9 @@ async function resizeToBox(inputBuffer, box, options = {}) {
|
||||
|
||||
module.exports = {
|
||||
ensurePreviewImageAtWidth,
|
||||
ensureThumbnailAtWidth,
|
||||
thumbnailTierKeys,
|
||||
deleteThumbnailTiers,
|
||||
previewTierKeys,
|
||||
deletePreviewTiers,
|
||||
PREVIEW_WIDTHS,
|
||||
|
||||
@@ -10,7 +10,10 @@ const path = require('path');
|
||||
const fsp = require('fs/promises');
|
||||
const sharp = require('sharp');
|
||||
const { db } = require('../database/db');
|
||||
const { generateThumbnail, extractCaptureDate, withProcessableImage } = require('./imageProcessor');
|
||||
const {
|
||||
generateThumbnail, extractCaptureDate, withProcessableImage,
|
||||
deleteThumbnailTiers, deletePreviewTiers,
|
||||
} = require('./imageProcessor');
|
||||
const { generatePhotoFilename } = require('../utils/filenameSanitizer');
|
||||
const watermarkGeneratorService = require('./watermarkGeneratorService');
|
||||
const { getStorage } = require('./storage');
|
||||
@@ -98,6 +101,12 @@ async function replacePhoto(existingPhoto, newFileTempPath, { originalFilename,
|
||||
if (existingPhoto.thumbnail_path && existingPhoto.thumbnail_path !== thumbnailPath) {
|
||||
await storage.delete(existingPhoto.thumbnail_path).catch(() => {});
|
||||
}
|
||||
// Responsive tiers, keyed off the OLD row (#1095 / #492). Their key embeds
|
||||
// the basename, which the update below replaces — so this is the last
|
||||
// moment they can be derived at all. Miss it and a later delete or archive
|
||||
// computes keys from the new basename and leaves them in storage forever.
|
||||
await deleteThumbnailTiers(existingPhoto);
|
||||
await deletePreviewTiers(existingPhoto);
|
||||
try {
|
||||
await watermarkGeneratorService.deleteForPhoto(existingPhoto.id);
|
||||
} catch {
|
||||
|
||||
Reference in New Issue
Block a user