From 25039739b867516a126988f652c7bd18494cf180 Mon Sep 17 00:00:00 2001 From: Minh Vu Date: Fri, 5 Jun 2026 12:52:39 -0700 Subject: [PATCH] fix: display HEIC/DNG/ARW/TIFF photos in non-Safari browsers Two issues fixed: 1. Convert non-browser-native formats to WebP for full-res viewing. HEIC, DNG, ARW, and TIFF files were served raw to the browser, but only Safari supports HEIC natively. Now these formats are converted to full-resolution WebP on first access and cached in TranscodedFolder. 2. Check file size when validating cached thumbnails. Previously the code used fsp.access()/fs.existsSync() which only checks existence. Failed conversions that produced 0-byte files were treated as valid thumbnails, permanently breaking those photos. Now all cache checks verify stat.size > 0. --- .../thumbnail/ThumbnailGeneratorMWs.ts | 53 ++++++++- src/backend/model/fileaccess/PhotoWorker.ts | 27 +++-- .../fileprocessing/PhotoProcessing.ts | 104 +++++++++++++++--- src/backend/routes/GalleryRouter.ts | 2 + 4 files changed, 158 insertions(+), 28 deletions(-) diff --git a/src/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts b/src/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts index 1bae9d883..9e7d60fa8 100644 --- a/src/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts +++ b/src/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts @@ -93,7 +93,14 @@ export class ThumbnailGeneratorMWs { size ); - item.missingThumbnail = !fs.existsSync(thPath); + let thExists = false; + try { + const stat = fs.statSync(thPath); + thExists = stat.size > 0; + } catch (e) { + // file doesn't exist + } + item.missingThumbnail = !thExists; } } catch (error) { res.status(500); @@ -212,6 +219,40 @@ export class ThumbnailGeneratorMWs { }; } + /** + * Converts non-browser-native photo formats (e.g. HEIC, DNG, ARW, TIFF) + * to WebP at full resolution for display in the browser. + * Browser-native formats (JPG, PNG, WebP, etc.) pass through unchanged. + */ + public static async convertPhoto( + req: Request, + res: Response, + next: NextFunction + ): Promise { + if (!req.resultPipe) { + return next(); + } + + const mediaPath = req.resultPipe as string; + + if (!PhotoProcessing.needsConversion(mediaPath)) { + return next(); + } + + try { + req.resultPipe = await PhotoProcessing.generateConvertedPhoto(mediaPath); + return next(); + } catch (error) { + return next( + new ErrorDTO( + ErrorCodes.THUMBNAIL_GENERATION_ERROR, + 'Error during converting photo: ' + mediaPath, + error.toString() + ) + ); + } + } + private static addThInfoTODir( directory: ParentDirectoryDTO | SubDirectoryDTO ): void { @@ -242,7 +283,15 @@ export class ThumbnailGeneratorMWs { fullMediaPath, entry.size ); - if (fs.existsSync(thPath) !== true) { + // Check both existence and non-zero size to catch failed conversions + let valid = false; + try { + const stat = fs.statSync(thPath); + valid = stat.size > 0; + } catch (e) { + // file doesn't exist + } + if (!valid) { if (typeof photo.missingThumbnails === 'undefined') { photo.missingThumbnails = 0; } diff --git a/src/backend/model/fileaccess/PhotoWorker.ts b/src/backend/model/fileaccess/PhotoWorker.ts index bd4ca4d64..cb8b02431 100644 --- a/src/backend/model/fileaccess/PhotoWorker.ts +++ b/src/backend/model/fileaccess/PhotoWorker.ts @@ -161,22 +161,25 @@ export class ImageRendererFactory { if (input.cut) { image.extract(input.cut); } - if (input.makeSquare === false) { - if (metadata.height > metadata.width) { - image.resize(Math.min(input.size, metadata.width), null, { - kernel, - }); + // size 0 means keep original dimensions (full-res conversion) + if (input.size > 0) { + if (input.makeSquare === false) { + if (metadata.height > metadata.width) { + image.resize(Math.min(input.size, metadata.width), null, { + kernel, + }); + } else { + image.resize(null, Math.min(input.size, metadata.height), { + kernel, + }); + } } else { - image.resize(null, Math.min(input.size, metadata.height), { + image.resize(input.size, input.size, { kernel, + position: sharp.gravity.centre, + fit: 'cover', }); } - } else { - image.resize(input.size, input.size, { - kernel, - position: sharp.gravity.centre, - fit: 'cover', - }); } let processedImg: sharp.Sharp; if ((input as MediaRendererInput).mediaPath) { diff --git a/src/backend/model/fileaccess/fileprocessing/PhotoProcessing.ts b/src/backend/model/fileaccess/fileprocessing/PhotoProcessing.ts index 1906cda0f..ccd37ff29 100644 --- a/src/backend/model/fileaccess/fileprocessing/PhotoProcessing.ts +++ b/src/backend/model/fileaccess/fileprocessing/PhotoProcessing.ts @@ -16,6 +16,28 @@ export class PhotoProcessing { private static taskQue: ITaskExecuter = null; private static readonly CONVERTED_EXTENSION = '.webp'; + /** + * Photo formats that browsers can display natively (no conversion needed). + * Formats not in this list (e.g. HEIC, DNG, ARW, TIFF) will be converted + * to WebP when serving the full-resolution image. + */ + public static readonly BROWSER_NATIVE_FORMATS = [ + '.gif', '.jpeg', '.jpg', '.jpe', '.png', '.webp', '.svg', '.avif', + ]; + + /** + * Checks if a converted/thumbnail file exists and is non-empty. + * A 0-byte file indicates a previous failed conversion and should be regenerated. + */ + private static async convertedFileExists(filePath: string): Promise { + try { + const stat = await fsp.stat(filePath); + return stat.size > 0; + } catch (e) { + return false; + } + } + public static init(): void { if (this.initDone === true) { return; @@ -60,8 +82,9 @@ export class PhotoProcessing { // check if thumbnail already exist try { - await fsp.access(thPath, fsConstants.R_OK); - return thPath; + if (await PhotoProcessing.convertedFileExists(thPath)) { + return thPath; + } } catch (e) { // ignoring errors } @@ -247,14 +270,7 @@ export class PhotoProcessing { // generate thumbnail path const outPath = PhotoProcessing.generateConvertedPath(mediaPath, size); - // check if file already exist - try { - await fsp.access(outPath, fsConstants.R_OK); - return true; - } catch (e) { - // ignoring errors - } - return false; + return PhotoProcessing.convertedFileExists(outPath); } @@ -269,8 +285,9 @@ export class PhotoProcessing { // check if file already exist try { - await fsp.access(outPath, fsConstants.R_OK); - return outPath; + if (await PhotoProcessing.convertedFileExists(outPath)) { + return outPath; + } } catch (e) { // ignoring errors } @@ -296,6 +313,64 @@ export class PhotoProcessing { return outPath; } + /** + * Returns true if the photo format is not natively supported by browsers + * and needs to be converted to WebP for display (e.g. HEIC, DNG, ARW, TIFF). + */ + public static needsConversion(mediaPath: string): boolean { + const ext = path.extname(mediaPath).toLowerCase(); + return PhotoProcessing.BROWSER_NATIVE_FORMATS.indexOf(ext) === -1; + } + + public static generateConvertedFullResPath(mediaPath: string): string { + const file = path.basename(mediaPath); + return path.join( + ProjectPath.TranscodedFolder, + ProjectPath.getRelativePathToImages(path.dirname(mediaPath)), + file + '_fullres' + + 'q' + Config.Media.Photo.quality + + (Config.Media.Photo.smartSubsample ? 'cs' : '') + + PhotoProcessing.CONVERTED_EXTENSION + ); + } + + /** + * Converts a non-browser-native photo (e.g. HEIC) to WebP at full resolution. + * The converted file is cached in the TranscodedFolder. + */ + public static async generateConvertedPhoto( + mediaPath: string + ): Promise { + const outPath = PhotoProcessing.generateConvertedFullResPath(mediaPath); + + // check if converted file already exists + try { + if (await PhotoProcessing.convertedFileExists(outPath)) { + return outPath; + } + } catch (e) { + // needs conversion + } + + const input = { + type: ThumbnailSourceType.Photo, + mediaPath, + size: 0, // 0 = no resize, keep original dimensions + outPath, + makeSquare: false, + useLanczos3: Config.Media.Photo.useLanczos3, + quality: Config.Media.Photo.quality, + smartSubsample: Config.Media.Photo.smartSubsample, + sharpOptions: Config.Media.Photo.sharpOptions, + animate: false, + } as MediaRendererInput; + + const outDir = path.dirname(input.outPath); + await fsp.mkdir(outDir, {recursive: true}); + await this.taskQue.execute(input); + return outPath; + } + public static isPhoto(fullPath: string): boolean { const extension = path.extname(fullPath).toLowerCase(); return SupportedFormats.WithDots.Photos.indexOf(extension) !== -1; @@ -321,8 +396,9 @@ export class PhotoProcessing { // check if the file already exists try { - await fsp.access(hashedOutPath, fsConstants.R_OK); - return hashedOutPath; + if (await PhotoProcessing.convertedFileExists(hashedOutPath)) { + return hashedOutPath; + } } catch (e) { // ignoring errors } diff --git a/src/backend/routes/GalleryRouter.ts b/src/backend/routes/GalleryRouter.ts index 046ecb27a..835c0a46d 100644 --- a/src/backend/routes/GalleryRouter.ts +++ b/src/backend/routes/GalleryRouter.ts @@ -76,6 +76,7 @@ export class GalleryRouter { // specific part GalleryMWs.loadFile, + ThumbnailGeneratorMWs.convertPhoto, ServerTimingMWs.addServerTiming, RenderingMWs.renderFile ); @@ -171,6 +172,7 @@ export class GalleryRouter { GalleryMWs.parseSearchQuery, GalleryMWs.getRandomImage, GalleryMWs.loadFile, + ThumbnailGeneratorMWs.convertPhoto, ServerTimingMWs.addServerTiming, RenderingMWs.renderFile );