Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
53 changes: 51 additions & 2 deletions src/backend/middlewares/thumbnail/ThumbnailGeneratorMWs.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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);
Expand Down Expand Up @@ -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<void> {
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 {
Expand Down Expand Up @@ -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;
}
Expand Down
27 changes: 15 additions & 12 deletions src/backend/model/fileaccess/PhotoWorker.ts
Original file line number Diff line number Diff line change
Expand Up @@ -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) {
Expand Down
104 changes: 90 additions & 14 deletions src/backend/model/fileaccess/fileprocessing/PhotoProcessing.ts
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,28 @@
private static taskQue: ITaskExecuter<MediaRendererInput | SvgRendererInput, void> = 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<boolean> {
try {
const stat = await fsp.stat(filePath);

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
return stat.size > 0;
} catch (e) {
return false;
}
}

public static init(): void {
if (this.initDone === true) {
return;
Expand Down Expand Up @@ -60,8 +82,9 @@

// 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
}
Expand Down Expand Up @@ -247,14 +270,7 @@
// 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);
}


Expand All @@ -269,12 +285,13 @@

// 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
}

Check failure

Code scanning / CodeQL

Uncontrolled data used in path expression High

This path depends on a
user-provided value
.
// run on other thread
const input = {
type: sourceType,
Expand All @@ -296,6 +313,64 @@
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<string> {
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;
Expand All @@ -321,8 +396,9 @@

// 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
}
Expand Down
2 changes: 2 additions & 0 deletions src/backend/routes/GalleryRouter.ts
Original file line number Diff line number Diff line change
Expand Up @@ -76,6 +76,7 @@ export class GalleryRouter {

// specific part
GalleryMWs.loadFile,
ThumbnailGeneratorMWs.convertPhoto,
ServerTimingMWs.addServerTiming,
RenderingMWs.renderFile
);
Expand Down Expand Up @@ -171,6 +172,7 @@ export class GalleryRouter {
GalleryMWs.parseSearchQuery,
GalleryMWs.getRandomImage,
GalleryMWs.loadFile,
ThumbnailGeneratorMWs.convertPhoto,
ServerTimingMWs.addServerTiming,
RenderingMWs.renderFile
);
Expand Down
Loading