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

bpatrik / pigallery2 / 19196363317

08 Nov 2025 05:36PM UTC coverage: 68.683% (-0.008%) from 68.691%
19196363317

push

github

bpatrik
Create flags to limit the number of concurrent threads to generate thumbnails

1407 of 2302 branches covered (61.12%)

Branch coverage included in aggregate %.

2 of 3 new or added lines in 2 files covered. (66.67%)

5179 of 7287 relevant lines covered (71.07%)

4230.44 hits per line

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

68.0
/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
  public static init(): void {
20
    if (this.initDone === true) {
47✔
21
      return;
46✔
22
    }
23

24
    Config.Media.Photo.concurrentThumbnailGenerations = Math.max(
1✔
25
      1,
26
      os.cpus().length - 1
27
    );
28

29
    if (Config.Media.Photo.concurrentThumbnailGenerationsLimit > 0) {
1!
NEW
30
      Config.Media.Photo.concurrentThumbnailGenerations = Math.min(Config.Media.Photo.concurrentThumbnailGenerations, Config.Media.Photo.concurrentThumbnailGenerationsLimit);
×
31
    }
32

33
    this.taskQue = new TaskExecuter(
1✔
34
      Config.Media.Photo.concurrentThumbnailGenerations,
35
      (input): Promise<void> => PhotoWorker.render(input)
7✔
36
    );
37

38
    this.initDone = true;
1✔
39
  }
40

41
  public static async generatePersonThumbnail(
42
    person: PersonEntry
43
  ): Promise<string> {
44
    // load parameters
45
    const photo: PhotoDTO = person.cache.sampleRegion.media;
×
46
    const mediaPath = path.join(
×
47
      ProjectPath.ImageFolder,
48
      photo.directory.path,
49
      photo.directory.name,
50
      photo.name
51
    );
52
    const size: number = Config.Media.Photo.personThumbnailSize;
×
53
    const faceRegion = person.cache.sampleRegion.media.metadata.faces.find(f => f.name === person.name);
×
54
    // generate thumbnail path
55
    const thPath = PhotoProcessing.generatePersonThumbnailPath(
×
56
      mediaPath,
57
      faceRegion,
58
      size
59
    );
60

61
    // check if thumbnail already exist
62
    try {
×
63
      await fsp.access(thPath, fsConstants.R_OK);
×
64
      return thPath;
×
65
    } catch (e) {
66
      // ignoring errors
67
    }
68

69
    const margin = {
×
70
      x: Math.round(
71
        faceRegion.box.width *
72
        Config.Media.Photo.personFaceMargin
73
      ),
74
      y: Math.round(
75
        faceRegion.box.height *
76
        Config.Media.Photo.personFaceMargin
77
      ),
78
    };
79

80
    // run on other thread
81
    const input = {
×
82
      type: ThumbnailSourceType.Photo,
83
      mediaPath,
84
      size,
85
      outPath: thPath,
86
      makeSquare: false,
87
      cut: {
88
        left: Math.round(
89
          Math.max(0, faceRegion.box.left - margin.x / 2)
90
        ),
91
        top: Math.round(
92
          Math.max(0, faceRegion.box.top - margin.y / 2)
93
        ),
94
        width: faceRegion.box.width + margin.x,
95
        height: faceRegion.box.height + margin.y,
96
      },
97
      useLanczos3: Config.Media.Photo.useLanczos3,
98
      quality: Config.Media.Photo.quality,
99
      smartSubsample: Config.Media.Photo.smartSubsample,
100
    } as MediaRendererInput;
101
    input.cut.width = Math.min(
×
102
      input.cut.width,
103
      photo.metadata.size.width - input.cut.left
104
    );
105
    input.cut.height = Math.min(
×
106
      input.cut.height,
107
      photo.metadata.size.height - input.cut.top
108
    );
109

110
    await fsp.mkdir(ProjectPath.FacesFolder, {recursive: true});
×
111
    await PhotoProcessing.taskQue.execute(input);
×
112
    return thPath;
×
113
  }
114

115
  public static generateConvertedPath(mediaPath: string, size: number): string {
116
    const file = path.basename(mediaPath);
919✔
117
    const animated = Config.Media.Photo.animateGif && path.extname(mediaPath).toLowerCase() == '.gif';
919✔
118
    return path.join(
919✔
119
      ProjectPath.TranscodedFolder,
120
      ProjectPath.getRelativePathToImages(path.dirname(mediaPath)),
121
      file + '_' + size + 'q' + Config.Media.Photo.quality +
122
      (animated ? 'anim' : '') +
919✔
123
      (Config.Media.Photo.smartSubsample ? 'cs' : '') +
919!
124
      PhotoProcessing.CONVERTED_EXTENSION
125
    );
126
  }
127

128
  public static generatePersonThumbnailPath(
129
    mediaPath: string,
130
    faceRegion: FaceRegion,
131
    size: number
132
  ): string {
133
    return path.join(
×
134
      ProjectPath.FacesFolder,
135
      crypto
136
        .createHash('md5')
137
        .update(
138
          mediaPath +
139
          '_' +
140
          faceRegion.name +
141
          '_' +
142
          faceRegion.box.left +
143
          '_' +
144
          faceRegion.box.top
145
        )
146
        .digest('hex') +
147
      '_' +
148
      size +
149
      '_' + Config.Media.Photo.personFaceMargin +
150
      PhotoProcessing.CONVERTED_EXTENSION
151
    );
152
  }
153

154
  /**
155
   * Tells if the path is valid with the current config
156
   * @param convertedPath
157
   */
158
  public static async isValidConvertedPath(
159
    convertedPath: string
160
  ): Promise<boolean> {
161
    const origFilePath = path.join(
5✔
162
      ProjectPath.ImageFolder,
163
      path.relative(
164
        ProjectPath.TranscodedFolder,
165
        convertedPath.substring(0, convertedPath.lastIndexOf('_'))
166
      )
167
    );
168

169
    if (path.extname(convertedPath) !== PhotoProcessing.CONVERTED_EXTENSION) {
5!
170
      return false;
×
171
    }
172
    let nextIndex = convertedPath.lastIndexOf('_') + 1;
5✔
173

174
    const sizeStr = convertedPath.substring(
5✔
175
      nextIndex,
176
      convertedPath.lastIndexOf('q')
177
    );
178
    nextIndex = convertedPath.lastIndexOf('q') + 1;
5✔
179

180
    const size = parseInt(sizeStr, 10);
5✔
181

182
    if (
5✔
183
      (size + '').length !== sizeStr.length ||
10✔
184
      (Config.Media.Photo.thumbnailSizes.indexOf(size) === -1)
185
    ) {
186
      return false;
1✔
187
    }
188

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

192
    const quality = parseInt(qualityStr, 10);
4✔
193

194
    if ((quality + '').length !== qualityStr.length ||
4!
195
      quality !== Config.Media.Photo.quality) {
196
      return false;
×
197
    }
198

199

200
    nextIndex += qualityStr.length;
4✔
201

202

203
    const lowerExt = path.extname(origFilePath).toLowerCase();
4✔
204
    const shouldBeAnimated = Config.Media.Photo.animateGif && lowerExt == '.gif';
4✔
205
    if (shouldBeAnimated) {
4!
206
      if (convertedPath.substring(
×
207
        nextIndex,
208
        nextIndex + 'anim'.length
209
      ) != 'anim') {
210
        return false;
×
211
      }
212
      nextIndex += 'anim'.length;
×
213
    }
214

215

216
    if (Config.Media.Photo.smartSubsample) {
4✔
217
      if (convertedPath.substring(
4!
218
        nextIndex,
219
        nextIndex + 2
220
      ) != 'cs') {
221
        return false;
×
222
      }
223
      nextIndex += 2;
4✔
224
    }
225

226
    if (convertedPath.substring(
4!
227
      nextIndex
228
    ).toLowerCase() !== path.extname(convertedPath)) {
229
      return false;
×
230
    }
231

232

233
    try {
4✔
234
      await fsp.access(origFilePath, fsConstants.R_OK);
4✔
235
    } catch (e) {
236
      return false;
2✔
237
    }
238

239
    return true;
2✔
240
  }
241

242

243
  static async convertedPhotoExist(
244
    mediaPath: string,
245
    size: number
246
  ): Promise<boolean> {
247
    // generate thumbnail path
248
    const outPath = PhotoProcessing.generateConvertedPath(mediaPath, size);
×
249

250
    // check if file already exist
251
    try {
×
252
      await fsp.access(outPath, fsConstants.R_OK);
×
253
      return true;
×
254
    } catch (e) {
255
      // ignoring errors
256
    }
257
    return false;
×
258
  }
259

260

261
  public static async generateThumbnail(
262
    mediaPath: string,
263
    size: number,
264
    sourceType: ThumbnailSourceType,
265
    makeSquare: boolean
266
  ): Promise<string> {
267
    // generate thumbnail path
268
    const outPath = PhotoProcessing.generateConvertedPath(mediaPath, size);
4✔
269

270
    // check if file already exist
271
    try {
4✔
272
      await fsp.access(outPath, fsConstants.R_OK);
4✔
273
      return outPath;
×
274
    } catch (e) {
275
      // ignoring errors
276
    }
277

278
    // run on other thread
279
    const input = {
4✔
280
      type: sourceType,
281
      mediaPath,
282
      size,
283
      outPath,
284
      makeSquare,
285
      useLanczos3: Config.Media.Photo.useLanczos3,
286
      quality: Config.Media.Photo.quality,
287
      smartSubsample: Config.Media.Photo.smartSubsample,
288
      sharpOptions: Config.Media.Photo.sharpOptions,
289
      animate: Config.Media.Photo.animateGif
290
    } as MediaRendererInput;
291

292
    const outDir = path.dirname(input.outPath);
4✔
293

294
    await fsp.mkdir(outDir, {recursive: true});
4✔
295
    await this.taskQue.execute(input);
4✔
296
    return outPath;
4✔
297
  }
298

299
  public static isPhoto(fullPath: string): boolean {
300
    const extension = path.extname(fullPath).toLowerCase();
743✔
301
    return SupportedFormats.WithDots.Photos.indexOf(extension) !== -1;
743✔
302
  }
303

304
  public static async renderSVG(
305
    svgIcon: SVGIconConfig,
306
    outPath: string,
307
    color = '#000'
2✔
308
  ): Promise<string> {
309

310
    // Generate hash from SVG content and color to create unique filename
311
    const contentHash = crypto
3✔
312
      .createHash('md5')
313
      .update(JSON.stringify(svgIcon) + color)
314
      .digest('hex')
315
      .substring(0, 8);
316

317
    // Update outPath to include hash
318
    const ext = path.extname(outPath);
3✔
319
    const baseName = path.basename(outPath, ext);
3✔
320
    const dir = path.dirname(outPath);
3✔
321
    const hashedOutPath = path.join(dir, `${baseName}_${contentHash}${ext}`);
3✔
322

323
    // check if file already exist
324
    try {
3✔
325
      await fsp.access(hashedOutPath, fsConstants.R_OK);
3✔
326
      return hashedOutPath;
×
327
    } catch (e) {
328
      // ignoring errors
329
    }
330

331
    const size = 256;
3✔
332
    // run on other thread
333
    const input = {
3✔
334
      type: ThumbnailSourceType.Photo,
335
      svgString: `<svg fill="${color}" width="${size}" height="${size}" xmlns="http://www.w3.org/2000/svg"
336
viewBox="${svgIcon.viewBox || '0 0 512 512'}">d="${svgIcon.items}</svg>`,
3!
337
      size: size,
338
      outPath: hashedOutPath,
339
      makeSquare: false,
340
      animate: false,
341
      useLanczos3: Config.Media.Photo.useLanczos3,
342
      quality: Config.Media.Photo.quality,
343
      smartSubsample: Config.Media.Photo.smartSubsample,
344
    } as SvgRendererInput;
345

346
    const outDir = path.dirname(input.outPath);
3✔
347

348
    await fsp.mkdir(outDir, {recursive: true});
3✔
349
    await this.taskQue.execute(input);
3✔
350
    return hashedOutPath;
3✔
351
  }
352

353
}
354

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