• Home
  • Features
  • Pricing
  • Docs
  • Announcements
  • Sign In

bpatrik / pigallery2 / 27036915953

05 Jun 2026 07:55PM UTC coverage: 68.48% (-0.2%) from 68.69%
27036915953

Pull #1161

github

web-flow
Merge 25039739b into b9d62f73c
Pull Request #1161: fix: display HEIC/DNG/ARW/TIFF photos in non-Safari browsers

1562 of 2556 branches covered (61.11%)

Branch coverage included in aggregate %.

15 of 50 new or added lines in 3 files covered. (30.0%)

1 existing line in 1 file now uncovered.

5562 of 7847 relevant lines covered (70.88%)

4134.31 hits per line

Source File
Press 'n' to go to next uncovered line, 'b' for previous

60.96
/src/backend/model/fileaccess/fileprocessing/PhotoProcessing.ts
1
import * as path from 'path';
1✔
2
import {constants as fsConstants, promises as fsp} from 'fs';
1✔
3
import * as os from 'os';
1✔
4
import * as crypto from 'crypto';
1✔
5
import {ProjectPath} from '../../../ProjectPath';
1✔
6
import {Config} from '../../../../common/config/private/Config';
1✔
7
import {MediaRendererInput, PhotoWorker, SvgRendererInput, ThumbnailSourceType,} from '../PhotoWorker';
1✔
8
import {ITaskExecuter, TaskExecuter} from '../TaskExecuter';
1✔
9
import {FaceRegion, PhotoDTO} from '../../../../common/entities/PhotoDTO';
10
import {SupportedFormats} from '../../../../common/SupportedFormats';
1✔
11
import {PersonEntry} from '../../database/enitites/person/PersonEntry';
12
import {SVGIconConfig} from '../../../../common/config/public/ClientConfig';
13

14
export class PhotoProcessing {
1✔
15
  private static initDone = false;
1✔
16
  private static taskQue: ITaskExecuter<MediaRendererInput | SvgRendererInput, void> = null;
1✔
17
  private static readonly CONVERTED_EXTENSION = '.webp';
1✔
18

19
  /**
20
   * Photo formats that browsers can display natively (no conversion needed).
21
   * Formats not in this list (e.g. HEIC, DNG, ARW, TIFF) will be converted
22
   * to WebP when serving the full-resolution image.
23
   */
24
  public static readonly BROWSER_NATIVE_FORMATS = [
1✔
25
    '.gif', '.jpeg', '.jpg', '.jpe', '.png', '.webp', '.svg', '.avif',
26
  ];
27

28
  /**
29
   * Checks if a converted/thumbnail file exists and is non-empty.
30
   * A 0-byte file indicates a previous failed conversion and should be regenerated.
31
   */
32
  private static async convertedFileExists(filePath: string): Promise<boolean> {
33
    try {
8✔
34
      const stat = await fsp.stat(filePath);
8✔
NEW
35
      return stat.size > 0;
×
36
    } catch (e) {
37
      return false;
8✔
38
    }
39
  }
40

41
  public static init(): void {
42
    if (this.initDone === true) {
51✔
43
      return;
50✔
44
    }
45

46
    Config.Media.Photo.concurrentThumbnailGenerations = Math.max(
1✔
47
      1,
48
      os.cpus().length - 1
49
    );
50

51
    if (Config.Media.Photo.concurrentThumbnailGenerationsLimit > 0) {
1!
52
      Config.Media.Photo.concurrentThumbnailGenerations = Math.min(Config.Media.Photo.concurrentThumbnailGenerations, Config.Media.Photo.concurrentThumbnailGenerationsLimit);
×
53
    }
54

55
    this.taskQue = new TaskExecuter(
1✔
56
      Config.Media.Photo.concurrentThumbnailGenerations,
57
      (input): Promise<void> => PhotoWorker.render(input)
8✔
58
    );
59

60
    this.initDone = true;
1✔
61
  }
62

63
  public static async generatePersonThumbnail(
64
    person: PersonEntry
65
  ): Promise<string> {
66
    // load parameters
67
    const photo: PhotoDTO = person.cache.sampleRegion.media;
×
68
    const mediaPath = path.join(
×
69
      ProjectPath.ImageFolder,
70
      photo.directory.path,
71
      photo.directory.name,
72
      photo.name
73
    );
74
    const size: number = Config.Media.Photo.personThumbnailSize;
×
75
    const faceRegion = person.cache.sampleRegion.media.metadata.faces.find(f => f.name === person.name);
×
76
    // generate thumbnail path
77
    const thPath = PhotoProcessing.generatePersonThumbnailPath(
×
78
      mediaPath,
79
      faceRegion,
80
      size
81
    );
82

83
    // check if thumbnail already exist
84
    try {
×
NEW
85
      if (await PhotoProcessing.convertedFileExists(thPath)) {
×
NEW
86
        return thPath;
×
87
      }
88
    } catch (e) {
89
      // ignoring errors
90
    }
91

92
    const margin = {
×
93
      x: Math.round(
94
        faceRegion.box.width *
95
        Config.Media.Photo.personFaceMargin
96
      ),
97
      y: Math.round(
98
        faceRegion.box.height *
99
        Config.Media.Photo.personFaceMargin
100
      ),
101
    };
102

103
    // run on other thread
104
    const input = {
×
105
      type: ThumbnailSourceType.Photo,
106
      mediaPath,
107
      size,
108
      outPath: thPath,
109
      makeSquare: false,
110
      cut: {
111
        left: Math.round(
112
          Math.max(0, faceRegion.box.left - margin.x / 2)
113
        ),
114
        top: Math.round(
115
          Math.max(0, faceRegion.box.top - margin.y / 2)
116
        ),
117
        width: faceRegion.box.width + margin.x,
118
        height: faceRegion.box.height + margin.y,
119
      },
120
      useLanczos3: Config.Media.Photo.useLanczos3,
121
      quality: Config.Media.Photo.quality,
122
      smartSubsample: Config.Media.Photo.smartSubsample,
123
    } as MediaRendererInput;
124
    input.cut.width = Math.min(
×
125
      input.cut.width,
126
      photo.metadata.size.width - input.cut.left
127
    );
128
    input.cut.height = Math.min(
×
129
      input.cut.height,
130
      photo.metadata.size.height - input.cut.top
131
    );
132

133
    await fsp.mkdir(ProjectPath.FacesFolder, {recursive: true});
×
134
    await PhotoProcessing.taskQue.execute(input);
×
135
    return thPath;
×
136
  }
137

138
  public static generateConvertedPath(mediaPath: string, size: number): string {
139
    const file = path.basename(mediaPath);
939✔
140
    const animated = Config.Media.Photo.animateGif && path.extname(mediaPath).toLowerCase() == '.gif';
939✔
141
    return path.join(
939✔
142
      ProjectPath.TranscodedFolder,
143
      ProjectPath.getRelativePathToImages(path.dirname(mediaPath)),
144
      file + '_' + size + 'q' + Config.Media.Photo.quality +
145
      (animated ? 'anim' : '') +
939✔
146
      (Config.Media.Photo.smartSubsample ? 'cs' : '') +
939!
147
      PhotoProcessing.CONVERTED_EXTENSION
148
    );
149
  }
150

151
  public static generatePersonThumbnailPath(
152
    mediaPath: string,
153
    faceRegion: FaceRegion,
154
    size: number
155
  ): string {
156
    return path.join(
×
157
      ProjectPath.FacesFolder,
158
      crypto
159
        .createHash('md5')
160
        .update(
161
          mediaPath +
162
          '_' +
163
          faceRegion.name +
164
          '_' +
165
          faceRegion.box.left +
166
          '_' +
167
          faceRegion.box.top
168
        )
169
        .digest('hex') +
170
      '_' +
171
      size +
172
      '_' + Config.Media.Photo.personFaceMargin +
173
      PhotoProcessing.CONVERTED_EXTENSION
174
    );
175
  }
176

177
  /**
178
   * Tells if the path is valid with the current config
179
   * @param convertedPath
180
   */
181
  public static async isValidConvertedPath(
182
    convertedPath: string
183
  ): Promise<boolean> {
184
    const origFilePath = path.join(
5✔
185
      ProjectPath.ImageFolder,
186
      path.relative(
187
        ProjectPath.TranscodedFolder,
188
        convertedPath.substring(0, convertedPath.lastIndexOf('_'))
189
      )
190
    );
191

192
    if (path.extname(convertedPath) !== PhotoProcessing.CONVERTED_EXTENSION) {
5!
193
      return false;
×
194
    }
195
    let nextIndex = convertedPath.lastIndexOf('_') + 1;
5✔
196

197
    const sizeStr = convertedPath.substring(
5✔
198
      nextIndex,
199
      convertedPath.lastIndexOf('q')
200
    );
201
    nextIndex = convertedPath.lastIndexOf('q') + 1;
5✔
202

203
    const size = parseInt(sizeStr, 10);
5✔
204

205
    if (
5✔
206
      (size + '').length !== sizeStr.length ||
10✔
207
      (Config.Media.Photo.thumbnailSizes.indexOf(size) === -1)
208
    ) {
209
      return false;
1✔
210
    }
211

212
    const qualityStr = convertedPath.substring(nextIndex,
4✔
213
      nextIndex + convertedPath.substring(nextIndex).search(/[A-Za-z]/)); // end of quality string
214

215
    const quality = parseInt(qualityStr, 10);
4✔
216

217
    if ((quality + '').length !== qualityStr.length ||
4!
218
      quality !== Config.Media.Photo.quality) {
219
      return false;
×
220
    }
221

222

223
    nextIndex += qualityStr.length;
4✔
224

225

226
    const lowerExt = path.extname(origFilePath).toLowerCase();
4✔
227
    const shouldBeAnimated = Config.Media.Photo.animateGif && lowerExt == '.gif';
4✔
228
    if (shouldBeAnimated) {
4!
229
      if (convertedPath.substring(
×
230
        nextIndex,
231
        nextIndex + 'anim'.length
232
      ) != 'anim') {
233
        return false;
×
234
      }
235
      nextIndex += 'anim'.length;
×
236
    }
237

238

239
    if (Config.Media.Photo.smartSubsample) {
4✔
240
      if (convertedPath.substring(
4!
241
        nextIndex,
242
        nextIndex + 2
243
      ) != 'cs') {
244
        return false;
×
245
      }
246
      nextIndex += 2;
4✔
247
    }
248

249
    if (convertedPath.substring(
4!
250
      nextIndex
251
    ).toLowerCase() !== path.extname(convertedPath)) {
252
      return false;
×
253
    }
254

255

256
    try {
4✔
257
      await fsp.access(origFilePath, fsConstants.R_OK);
4✔
258
    } catch (e) {
259
      return false;
2✔
260
    }
261

262
    return true;
2✔
263
  }
264

265

266
  static async convertedPhotoExist(
267
    mediaPath: string,
268
    size: number
269
  ): Promise<boolean> {
270
    // generate thumbnail path
271
    const outPath = PhotoProcessing.generateConvertedPath(mediaPath, size);
×
272

NEW
273
    return PhotoProcessing.convertedFileExists(outPath);
×
274
  }
275

276

277
  public static async generateThumbnail(
278
    mediaPath: string,
279
    size: number,
280
    sourceType: ThumbnailSourceType,
281
    makeSquare: boolean
282
  ): Promise<string> {
283
    // generate thumbnail path
284
    const outPath = PhotoProcessing.generateConvertedPath(mediaPath, size);
4✔
285

286
    // check if file already exist
287
    try {
4✔
288
      if (await PhotoProcessing.convertedFileExists(outPath)) {
4!
NEW
289
        return outPath;
×
290
      }
291
    } catch (e) {
292
      // ignoring errors
293
    }
294

295
    // run on other thread
296
    const input = {
4✔
297
      type: sourceType,
298
      mediaPath,
299
      size,
300
      outPath,
301
      makeSquare,
302
      useLanczos3: Config.Media.Photo.useLanczos3,
303
      quality: Config.Media.Photo.quality,
304
      smartSubsample: Config.Media.Photo.smartSubsample,
305
      sharpOptions: Config.Media.Photo.sharpOptions,
306
      animate: Config.Media.Photo.animateGif
307
    } as MediaRendererInput;
308

309
    const outDir = path.dirname(input.outPath);
4✔
310

311
    await fsp.mkdir(outDir, {recursive: true});
4✔
312
    await this.taskQue.execute(input);
4✔
313
    return outPath;
4✔
314
  }
315

316
  /**
317
   * Returns true if the photo format is not natively supported by browsers
318
   * and needs to be converted to WebP for display (e.g. HEIC, DNG, ARW, TIFF).
319
   */
320
  public static needsConversion(mediaPath: string): boolean {
NEW
321
    const ext = path.extname(mediaPath).toLowerCase();
×
NEW
322
    return PhotoProcessing.BROWSER_NATIVE_FORMATS.indexOf(ext) === -1;
×
323
  }
324

325
  public static generateConvertedFullResPath(mediaPath: string): string {
NEW
326
    const file = path.basename(mediaPath);
×
NEW
327
    return path.join(
×
328
      ProjectPath.TranscodedFolder,
329
      ProjectPath.getRelativePathToImages(path.dirname(mediaPath)),
330
      file + '_fullres' +
331
      'q' + Config.Media.Photo.quality +
332
      (Config.Media.Photo.smartSubsample ? 'cs' : '') +
×
333
      PhotoProcessing.CONVERTED_EXTENSION
334
    );
335
  }
336

337
  /**
338
   * Converts a non-browser-native photo (e.g. HEIC) to WebP at full resolution.
339
   * The converted file is cached in the TranscodedFolder.
340
   */
341
  public static async generateConvertedPhoto(
342
    mediaPath: string
343
  ): Promise<string> {
NEW
344
    const outPath = PhotoProcessing.generateConvertedFullResPath(mediaPath);
×
345

346
    // check if converted file already exists
NEW
347
    try {
×
NEW
348
      if (await PhotoProcessing.convertedFileExists(outPath)) {
×
NEW
349
        return outPath;
×
350
      }
351
    } catch (e) {
352
      // needs conversion
353
    }
354

NEW
355
    const input = {
×
356
      type: ThumbnailSourceType.Photo,
357
      mediaPath,
358
      size: 0, // 0 = no resize, keep original dimensions
359
      outPath,
360
      makeSquare: false,
361
      useLanczos3: Config.Media.Photo.useLanczos3,
362
      quality: Config.Media.Photo.quality,
363
      smartSubsample: Config.Media.Photo.smartSubsample,
364
      sharpOptions: Config.Media.Photo.sharpOptions,
365
      animate: false,
366
    } as MediaRendererInput;
367

NEW
368
    const outDir = path.dirname(input.outPath);
×
NEW
369
    await fsp.mkdir(outDir, {recursive: true});
×
NEW
370
    await this.taskQue.execute(input);
×
NEW
371
    return outPath;
×
372
  }
373

374
  public static isPhoto(fullPath: string): boolean {
375
    const extension = path.extname(fullPath).toLowerCase();
869✔
376
    return SupportedFormats.WithDots.Photos.indexOf(extension) !== -1;
869✔
377
  }
378

379
  public static async renderSVG(
380
    svgIcon: SVGIconConfig,
381
    outPath: string,
382
    color = '#000'
3✔
383
  ): Promise<string> {
384
    // Generate hash from SVG content and color to create unique filename
385
    const contentHash = crypto
4✔
386
      .createHash('md5')
387
      .update(JSON.stringify(svgIcon) + color)
388
      .digest('hex')
389
      .substring(0, 8);
390

391
    // Update outPath to include hash
392
    const ext = path.extname(outPath);
4✔
393
    const baseName = path.basename(outPath, ext);
4✔
394
    const dir = path.dirname(outPath);
4✔
395
    const hashedOutPath = path.join(dir, `${baseName}_${contentHash}${ext}`);
4✔
396

397
    // check if the file already exists
398
    try {
4✔
399
      if (await PhotoProcessing.convertedFileExists(hashedOutPath)) {
4!
NEW
400
        return hashedOutPath;
×
401
      }
402
    } catch (e) {
403
      // ignoring errors
404
    }
405

406
    const size = 256;
4✔
407
    // run on other thread
408
    const input = {
4✔
409
      type: ThumbnailSourceType.Photo,
410
      svgString: `<svg fill="${color}" width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg"
411
viewBox="${svgIcon.viewBox || '0 0 512 512'}">d="${svgIcon.items}</svg>`,
4!
412
      size: size,
413
      outPath: hashedOutPath,
414
      makeSquare: false,
415
      animate: false,
416
      useLanczos3: Config.Media.Photo.useLanczos3,
417
      quality: Config.Media.Photo.quality,
418
      smartSubsample: Config.Media.Photo.smartSubsample,
419
    } as SvgRendererInput;
420

421
    const outDir = path.dirname(input.outPath);
4✔
422

423
    await fsp.mkdir(outDir, {recursive: true});
4✔
424
    await this.taskQue.execute(input);
4✔
425
    return hashedOutPath;
4✔
426
  }
427

428
}
429

STATUS · Troubleshooting · Open an Issue · Sales · Support · CAREERS · ENTERPRISE · START FREE · SCHEDULE DEMO
ANNOUNCEMENTS · TWITTER · TOS & SLA · Supported CI Services · What's a CI service? · Automated Testing

© 2026 Coveralls, Inc