fix(faces): face avatars were cropped against a cropped rendition (#1100)
* fix(faces): face avatars were cropped against a cropped rendition Found while triaging #1096, which reported the People manager showing unusable cluster covers — a bare shoulder, the back of a head, a patch of background — and asked for more sample faces to compensate. Most of that is not a detector problem and not a UI limitation. It is a bug. faceCropStyle positions an avatar by scaling the WHOLE frame and offsetting so the face lands centre. That holds only while the rendition shown is the entire image at a uniform scale. Thumbnails are not: imageProcessor.js:93 DEFAULT_THUMBNAIL_FIT = 'inside' migration 040:6 thumbnail_fit seeded to 'cover' imageProcessor.js:229 fit: settings.fit The 'inside' constant is only a fallback for a missing settings row, and the row is seeded on every install — so thumbnails are centre-cropped essentially everywhere, and every face avatar rendered against one is silently offset on any non-square photo. The reporter read the setting as safe because of that constant, and the code comment at :87-92 says the same thing; all three places disagree with what is actually stored. It presents as a bad detector, which is why it survived: the boxes are right, the frame they are drawn against is not. All three surfaces — the admin manager and the guest-facing strip and sheet — now read a preview, which uses fit: 'inside' and is therefore the whole frame. At w=640: plenty for a 64px avatar at DPR 3, and small enough that a strip of a dozen people does not pull a dozen 1920px renditions. Face scanning already calls ensurePreviewImage for anything it scans, so a preview exists for every photo that has a face. Adds the admin preview route the manager needed; the gallery already had one. Both whitelist ?w= the same way. The first version of the call-site test passed with every surface still reading thumbnail_url, because an import alone satisfied it. It now matches inside the src={...} expression, and each of the three surfaces was individually reverted to confirm the test fails. * fix(faces): size the face tier by bbox, and keep admin_preview auth The face half of the external review; the tier-key and long-edge fixes live on the #1099 branch this is stacked on. Face avatars used one fixed 640 tier. In a 6000px group shot a 200px face is ~21px there, and faceCropStyle then blows that up ~9x to fill a 64px avatar at DPR 3 — mush, and indistinguishable from the mis-positioning bug this PR exists to fix. The tier is now derived from the bbox's share of the frame, so a face across a hall gets 1920 and a close-up still gets 640. The synthesized face URL also dropped admin_preview. verifyGalleryAccess only accepts the admin cookie when admin_preview=1 is on the request (middleware/gallery.js:28), and the preview flow deliberately mints no gallery JWT — so every avatar 401'd in exactly the mode an admin uses to check a gallery before sending it to a client. --------- Co-authored-by: Paul Nothaft <[email protected]>
This commit is contained in:
co-authored by
Paul Nothaft
parent
56034af0f0
commit
b3a7ab27ea
@@ -1341,6 +1341,58 @@ router.get('/:eventId/thumbnail/:photoId', adminAuth, requirePermission('photos.
|
||||
}
|
||||
});
|
||||
|
||||
/**
|
||||
* Aspect-preserved rendition for admin surfaces that need one.
|
||||
*
|
||||
* The face avatars need this specifically. faceCropStyle positions a crop by
|
||||
* scaling the WHOLE frame and offsetting so the face lands centre, which only
|
||||
* works while the rendition is the entire image at a uniform scale. Thumbnails
|
||||
* are not: thumbnail_fit is seeded to 'cover' (migration 040), so they are
|
||||
* centre-cropped and every face avatar rendered against one is silently
|
||||
* offset. Previews use fit: 'inside', so they are safe.
|
||||
*
|
||||
* ?w= is whitelisted the same way the gallery route's is — an open parameter
|
||||
* would let anyone fill the disk with renditions.
|
||||
*/
|
||||
router.get('/:eventId/preview/:photoId', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
const { eventId, photoId } = req.params;
|
||||
|
||||
const photo = await db('photos').where({ id: photoId, event_id: eventId }).first();
|
||||
if (!photo) return res.status(404).json({ error: 'Photo not found' });
|
||||
|
||||
if (photo.processing_status === 'pending' || photo.processing_status === 'processing') {
|
||||
res.setHeader('Retry-After', '2');
|
||||
return res.status(503).json({ error: 'Preview not ready', status: photo.processing_status });
|
||||
}
|
||||
|
||||
const { PREVIEW_WIDTHS, normalizeTierWidth, ensurePreviewImageAtWidth, ensurePreviewImage } =
|
||||
require('../services/imageProcessor');
|
||||
const tierWidth = normalizeTierWidth(req.query.w, PREVIEW_WIDTHS);
|
||||
|
||||
const previewPath = tierWidth
|
||||
? (await ensurePreviewImageAtWidth(photo, tierWidth)) || (await ensurePreviewImage(photo))
|
||||
: await ensurePreviewImage(photo);
|
||||
|
||||
if (!previewPath) {
|
||||
return res.status(404).json({ error: 'Preview generation failed' });
|
||||
}
|
||||
|
||||
const storage = getStorage();
|
||||
const stat = await storage.stat(previewPath);
|
||||
if (!stat) return res.status(404).json({ error: 'Preview not found' });
|
||||
|
||||
res.setHeader('Content-Type', 'image/jpeg');
|
||||
res.setHeader('Cache-Control', 'private, max-age=3600');
|
||||
res.setHeader('Cross-Origin-Resource-Policy', 'cross-origin');
|
||||
res.setHeader('Content-Length', stat.size);
|
||||
(await storage.get(previewPath)).pipe(res);
|
||||
} catch (error) {
|
||||
logger.error('Error serving admin preview:', error);
|
||||
errorResponse(res, error, 500, 'Failed to serve preview');
|
||||
}
|
||||
});
|
||||
|
||||
// Debug endpoint to check photo existence
|
||||
router.get('/:eventId/debug', adminAuth, requirePermission('photos.view'), requireEventOwnership, async (req, res) => {
|
||||
try {
|
||||
|
||||
Reference in New Issue
Block a user