1
0
mirror of https://github.com/bpatrik/pigallery2.git synced 2025-02-15 14:03:35 +02:00

implementing basic faces page

This commit is contained in:
Patrik J. Braun 2019-02-14 18:25:55 -05:00
parent b9efea7620
commit 4fc965d10f
32 changed files with 476 additions and 59 deletions

View File

@ -0,0 +1,46 @@
import {NextFunction, Request, Response} from 'express';
import {ErrorCodes, ErrorDTO} from '../../common/entities/Error';
import {ObjectManagerRepository} from '../model/ObjectManagerRepository';
const LOG_TAG = '[PersonMWs]';
export class PersonMWs {
public static async listPersons(req: Request, res: Response, next: NextFunction) {
try {
req.resultPipe = await ObjectManagerRepository.getInstance()
.PersonManager.getAll();
return next();
} catch (err) {
return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Error during listing the directory', err));
}
}
public static async getSamplePhoto(req: Request, res: Response, next: NextFunction) {
if (!req.params.name) {
return next();
}
const name = req.params.name;
try {
const photo = await ObjectManagerRepository.getInstance()
.PersonManager.getSamplePhoto(name);
if (photo === null) {
return next();
}
req.resultPipe = photo;
return next();
} catch (err) {
return next(new ErrorDTO(ErrorCodes.GENERAL_ERROR, 'Error during listing the directory', err));
}
}
}

View File

@ -14,6 +14,7 @@ import {RendererInput, ThumbnailSourceType, ThumbnailWorker} from '../../model/t
import {MediaDTO} from '../../../common/entities/MediaDTO';
import {ITaskExecuter, TaskExecuter} from '../../model/threading/TaskExecuter';
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
export class ThumbnailGeneratorMWs {
@ -73,6 +74,65 @@ export class ThumbnailGeneratorMWs {
}
public static async generatePersonThumbnail(req: Request, res: Response, next: NextFunction) {
if (!req.resultPipe) {
return next();
}
// load parameters
const photo: PhotoDTO = req.resultPipe;
if (!photo.metadata.faces || photo.metadata.faces.length !== 1) {
return next(new ErrorDTO(ErrorCodes.THUMBNAIL_GENERATION_ERROR, 'Photo does not contain a face'));
}
// load parameters
const mediaPath = path.resolve(ProjectPath.ImageFolder, photo.directory.path, photo.directory.name, photo.name);
const size: number = Config.Client.Thumbnail.personThumbnailSize;
const personName = photo.metadata.faces[0].name;
// generate thumbnail path
const thPath = path.join(ProjectPath.ThumbnailFolder, ThumbnailGeneratorMWs.generatePersonThumbnailName(mediaPath, personName, size));
req.resultPipe = thPath;
// check if thumbnail already exist
if (fs.existsSync(thPath) === true) {
return next();
}
// create thumbnail folder if not exist
if (!fs.existsSync(ProjectPath.ThumbnailFolder)) {
fs.mkdirSync(ProjectPath.ThumbnailFolder);
}
const margin = {
x: Math.round(photo.metadata.faces[0].box.width * (Config.Server.thumbnail.personFaceMargin)),
y: Math.round(photo.metadata.faces[0].box.height * (Config.Server.thumbnail.personFaceMargin))
};
// run on other thread
const input = <RendererInput>{
type: ThumbnailSourceType.Image,
mediaPath: mediaPath,
size: size,
thPath: thPath,
makeSquare: false,
cut: {
x: Math.round(Math.max(0, photo.metadata.faces[0].box.x - margin.x / 2)),
y: Math.round(Math.max(0, photo.metadata.faces[0].box.y - margin.y / 2)),
width: photo.metadata.faces[0].box.width + margin.x,
height: photo.metadata.faces[0].box.height + margin.y
},
qualityPriority: Config.Server.thumbnail.qualityPriority
};
try {
await ThumbnailGeneratorMWs.taskQue.execute(input);
return next();
} catch (error) {
return next(new ErrorDTO(ErrorCodes.THUMBNAIL_GENERATION_ERROR,
'Error during generating face thumbnail: ' + input.mediaPath, error.toString()));
}
}
public static generateThumbnailFactory(sourceType: ThumbnailSourceType) {
return (req: Request, res: Response, next: NextFunction) => {
if (!req.resultPipe) {
@ -106,6 +166,14 @@ export class ThumbnailGeneratorMWs {
};
}
public static generateThumbnailName(mediaPath: string, size: number): string {
return crypto.createHash('md5').update(mediaPath).digest('hex') + '_' + size + '.jpg';
}
public static generatePersonThumbnailName(mediaPath: string, personName: string, size: number): string {
return crypto.createHash('md5').update(mediaPath + '_' + personName).digest('hex') + '_' + size + '.jpg';
}
private static addThInfoTODir(directory: DirectoryDTO) {
if (typeof directory.media !== 'undefined') {
ThumbnailGeneratorMWs.addThInfoToPhotos(directory.media);
@ -177,9 +245,5 @@ export class ThumbnailGeneratorMWs {
'Error during generating thumbnail: ' + input.mediaPath, error.toString()));
}
}
public static generateThumbnailName(mediaPath: string, size: number): string {
return crypto.createHash('md5').update(mediaPath).digest('hex') + '_' + size + '.jpg';
}
}

View File

@ -1,10 +1,17 @@
import {PersonEntry} from '../sql/enitites/PersonEntry';
import {MediaDTO} from '../../../common/entities/MediaDTO';
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
export interface IPersonManager {
getAll(): Promise<PersonEntry[]>;
getSamplePhoto(name: string): Promise<PhotoDTO>;
get(name: string): Promise<PersonEntry>;
saveAll(names: string[]): Promise<void>;
keywordsToPerson(media: MediaDTO[]): Promise<void>;
updateCounts(): Promise<void>;
}

View File

@ -1,7 +1,17 @@
import {IPersonManager} from '../interfaces/IPersonManager';
import {MediaDTO} from '../../../common/entities/MediaDTO';
import {PersonEntry} from '../sql/enitites/PersonEntry';
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
export class PersonManager implements IPersonManager {
getAll(): Promise<PersonEntry[]> {
throw new Error('Method not implemented.');
}
getSamplePhoto(name: string): Promise<PhotoDTO> {
throw new Error('Method not implemented.');
}
export class IndexingTaskManager implements IPersonManager {
keywordsToPerson(media: MediaDTO[]): Promise<void> {
throw new Error('Method not implemented.');
}
@ -13,4 +23,8 @@ export class IndexingTaskManager implements IPersonManager {
saveAll(names: string[]): Promise<void> {
throw new Error('not supported by memory DB');
}
updateCounts(): Promise<void> {
throw new Error('not supported by memory DB');
}
}

View File

@ -289,6 +289,7 @@ export class IndexingManager implements IIndexingManager {
await this.saveChildDirs(connection, currentDirId, scannedDirectory);
await this.saveMedia(connection, currentDirId, scannedDirectory.media);
await this.saveMetaFiles(connection, currentDirId, scannedDirectory);
await ObjectManagerRepository.getInstance().PersonManager.updateCounts();
} catch (e) {
throw e;
} finally {

View File

@ -3,13 +3,34 @@ import {SQLConnection} from './SQLConnection';
import {PersonEntry} from './enitites/PersonEntry';
import {MediaDTO} from '../../../common/entities/MediaDTO';
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
import {MediaEntity} from './enitites/MediaEntity';
import {FaceRegionEntry} from './enitites/FaceRegionEntry';
const LOG_TAG = '[PersonManager]';
export class PersonManager implements IPersonManager {
persons: PersonEntry[] = [];
async getSamplePhoto(name: string): Promise<PhotoDTO> {
const connection = await SQLConnection.getConnection();
const rawAndEntities = await connection.getRepository(MediaEntity).createQueryBuilder('media')
.limit(1)
.leftJoinAndSelect('media.directory', 'directory')
.leftJoinAndSelect('media.metadata.faces', 'faces')
.leftJoinAndSelect('faces.person', 'person')
.where('person.name LIKE :name COLLATE utf8_general_ci', {name: '%' + name + '%'}).getRawAndEntities();
if (rawAndEntities.entities.length === 0) {
return null;
}
const media: PhotoDTO = rawAndEntities.entities[0];
media.metadata.faces = [FaceRegionEntry.fromRawToDTO(rawAndEntities.raw[0])];
return media;
}
async loadAll(): Promise<void> {
const connection = await SQLConnection.getConnection();
const personRepository = connection.getRepository(PersonEntry);
@ -17,6 +38,11 @@ export class PersonManager implements IPersonManager {
}
async getAll(): Promise<PersonEntry[]> {
await this.loadAll();
return this.persons;
}
// TODO dead code, remove it
async keywordsToPerson(media: MediaDTO[]) {
await this.loadAll();
@ -30,7 +56,7 @@ export class PersonManager implements IPersonManager {
if (personKeywords.length === 0) {
return;
}
// remove persons
// remove persons from keywords
m.metadata.keywords = m.metadata.keywords.filter(k => !personFilter(k));
m.metadata.faces = m.metadata.faces || [];
personKeywords.forEach((pk: string) => {
@ -81,4 +107,10 @@ export class PersonManager implements IPersonManager {
}
public async updateCounts() {
const connection = await SQLConnection.getConnection();
await connection.query('update person_entry set count = ' +
' (select COUNT(1) from face_region_entry where face_region_entry.personId = person_entry.id)');
}
}

View File

@ -1,10 +1,11 @@
import {Column, Entity, OneToMany, PrimaryGeneratedColumn, Unique, Index} from 'typeorm';
import {Column, Entity, Index, OneToMany, PrimaryGeneratedColumn, Unique} from 'typeorm';
import {FaceRegionEntry} from './FaceRegionEntry';
import {PersonDTO} from '../../../../common/entities/PersonDTO';
@Entity()
@Unique(['name'])
export class PersonEntry {
export class PersonEntry implements PersonDTO {
@Index()
@PrimaryGeneratedColumn({unsigned: true})
id: number;
@ -12,6 +13,9 @@ export class PersonEntry {
@Column()
name: string;
@Column('int', {unsigned: true, default: 0})
count: number;
@OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.person)
public faces: FaceRegionEntry[];
}

View File

@ -99,7 +99,7 @@ export class DiskMangerWorker {
metadata: await MetadataLoader.loadVideoMetadata(fullFilePath)
});
} catch (e) {
Logger.warn('Media loading error, skipping: ' + file);
Logger.warn('Media loading error, skipping: ' + file + ', reason: ' + e.toString());
}
} else if (photosOnly === false && Config.Client.MetaFile.enabled === true &&

View File

@ -214,6 +214,9 @@ export class MetadataLoader {
x: Math.round(regionBox['stArea:x'] * metadata.size.width),
y: Math.round(regionBox['stArea:y'] * metadata.size.height)
};
// convert center base box to corner based box
box.x = Math.max(0, box.x - box.width / 2);
box.y = Math.max(0, box.y - box.height / 2);
faces.push({name: name, box: box});
}
}

View File

@ -47,6 +47,12 @@ export interface RendererInput {
makeSquare: boolean;
thPath: string;
qualityPriority: boolean;
cut?: {
x: number,
y: number,
width: number,
height: number
};
}
export class VideoRendererFactory {
@ -140,6 +146,15 @@ export class ImageRendererFactory {
*/
const ratio = image.bitmap.height / image.bitmap.width;
const algo = input.qualityPriority === true ? Jimp.RESIZE_BEZIER : Jimp.RESIZE_NEAREST_NEIGHBOR;
if (input.cut) {
image.crop(
input.cut.x,
input.cut.y,
input.cut.width,
input.cut.height
);
}
if (input.makeSquare === false) {
const newWidth = Math.sqrt((input.size * input.size) / ratio);
@ -167,7 +182,6 @@ export class ImageRendererFactory {
const sharp = require('sharp');
sharp.cache(false);
return async (input: RendererInput): Promise<void> => {
Logger.silly('[SharpThRenderer] rendering thumbnail:' + input.mediaPath);
const image: Sharp = sharp(input.mediaPath, {failOnError: false});
const metadata: Metadata = await image.metadata();
@ -183,6 +197,15 @@ export class ImageRendererFactory {
*/
const ratio = metadata.height / metadata.width;
const kernel = input.qualityPriority === true ? sharp.kernel.lanczos3 : sharp.kernel.nearest;
if (input.cut) {
image.extract({
top: input.cut.y,
left: input.cut.x,
width: input.cut.width,
height: input.cut.height
});
}
if (input.makeSquare === false) {
const newWidth = Math.round(Math.sqrt((input.size * input.size) / ratio));
image.resize(newWidth, null, {

View File

@ -0,0 +1,34 @@
import {AuthenticationMWs} from '../middlewares/user/AuthenticationMWs';
import {Express} from 'express';
import {RenderingMWs} from '../middlewares/RenderingMWs';
import {UserRoles} from '../../common/entities/UserDTO';
import {PersonMWs} from '../middlewares/PersonMWs';
import {ThumbnailGeneratorMWs} from '../middlewares/thumbnail/ThumbnailGeneratorMWs';
export class PersonRouter {
public static route(app: Express) {
this.addPersons(app);
this.getPersonThumbnail(app);
}
private static addPersons(app: Express) {
app.get(['/api/person'],
AuthenticationMWs.authenticate,
AuthenticationMWs.authorise(UserRoles.User),
PersonMWs.listPersons,
RenderingMWs.renderResult
);
}
private static getPersonThumbnail(app: Express) {
app.get(['/api/person/:name/thumbnail'],
AuthenticationMWs.authenticate,
AuthenticationMWs.authorise(UserRoles.User),
PersonMWs.getSamplePhoto,
ThumbnailGeneratorMWs.generatePersonThumbnail,
RenderingMWs.renderFile
);
}
}

View File

@ -79,7 +79,7 @@ export class PublicRouter {
});
app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/search*'],
app.get(['/', '/login', '/gallery*', '/share*', '/admin', '/duplicates', '/faces', '/search*'],
AuthenticationMWs.tryAuthenticate,
setLocale,
renderIndex

View File

@ -22,6 +22,7 @@ import {NotificationRouter} from './routes/NotificationRouter';
import {ConfigDiagnostics} from './model/diagnostics/ConfigDiagnostics';
import {Localizations} from './model/Localizations';
import {CookieNames} from '../common/CookieNames';
import {PersonRouter} from './routes/PersonRouter';
const _session = require('cookie-session');
@ -32,42 +33,6 @@ export class Server {
private app: _express.Express;
private server: HttpServer;
/**
* Event listener for HTTP server "error" event.
*/
private onError = (error: any) => {
if (error.syscall !== 'listen') {
Logger.error(LOG_TAG, 'Server error', error);
throw error;
}
const bind = Config.Server.host + ':' + Config.Server.port;
// handle specific listen error with friendly messages
switch (error.code) {
case 'EACCES':
Logger.error(LOG_TAG, bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
Logger.error(LOG_TAG, bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
};
/**
* Event listener for HTTP server "listening" event.
*/
private onListening = () => {
const addr = this.server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
Logger.info(LOG_TAG, 'Listening on ' + bind);
};
constructor() {
if (!(process.env.NODE_ENV === 'production')) {
Logger.debug(LOG_TAG, 'Running in DEBUG mode, set env variable NODE_ENV=production to disable ');
@ -125,6 +90,7 @@ export class Server {
UserRouter.route(this.app);
GalleryRouter.route(this.app);
PersonRouter.route(this.app);
SharingRouter.route(this.app);
AdminRouter.route(this.app);
NotificationRouter.route(this.app);
@ -146,6 +112,43 @@ export class Server {
}
/**
* Event listener for HTTP server "error" event.
*/
private onError = (error: any) => {
if (error.syscall !== 'listen') {
Logger.error(LOG_TAG, 'Server error', error);
throw error;
}
const bind = Config.Server.host + ':' + Config.Server.port;
// handle specific listen error with friendly messages
switch (error.code) {
case 'EACCES':
Logger.error(LOG_TAG, bind + ' requires elevated privileges');
process.exit(1);
break;
case 'EADDRINUSE':
Logger.error(LOG_TAG, bind + ' is already in use');
process.exit(1);
break;
default:
throw error;
}
};
/**
* Event listener for HTTP server "listening" event.
*/
private onListening = () => {
const addr = this.server.address();
const bind = typeof addr === 'string'
? 'pipe ' + addr
: 'port ' + addr.port;
Logger.info(LOG_TAG, 'Listening on ' + bind);
};
}

View File

@ -1 +1 @@
export const DataStructureVersion = 10;
export const DataStructureVersion = 11;

View File

@ -39,6 +39,7 @@ export interface ThumbnailConfig {
folder: string;
processingLibrary: ThumbnailProcessingLib;
qualityPriority: boolean;
personFaceMargin: number; // in ration [0-1]
}
export interface SharingConfig {

View File

@ -25,7 +25,8 @@ export class PrivateConfigClass extends PublicConfigClass implements IPrivateCon
thumbnail: {
folder: 'demo/TEMP',
processingLibrary: ThumbnailProcessingLib.sharp,
qualityPriority: true
qualityPriority: true,
personFaceMargin: 0.6
},
log: {
level: LogLevel.info,

View File

@ -40,7 +40,8 @@ export module ClientConfig {
export interface ThumbnailConfig {
iconSize: number;
thumbnailSizes: Array<number>;
personThumbnailSize: number;
thumbnailSizes: number[];
concurrentThumbnailGenerations: number;
}
@ -100,7 +101,8 @@ export class PublicConfigClass {
Thumbnail: {
concurrentThumbnailGenerations: 1,
thumbnailSizes: [200, 400, 600],
iconSize: 45
iconSize: 45,
personThumbnailSize: 200
},
Search: {
enabled: true,

View File

@ -0,0 +1,6 @@
export interface PersonDTO {
id: number;
name: string;
count: number;
}

View File

@ -74,6 +74,9 @@ import {DuplicateService} from './duplicates/duplicates.service';
import {DuplicateComponent} from './duplicates/duplicates.component';
import {DuplicatesPhotoComponent} from './duplicates/photo/photo.duplicates.component';
import {SeededRandomService} from './model/seededRandom.service';
import {FacesComponent} from './faces/faces.component';
import {FacesService} from './faces/faces.service';
import {FaceComponent} from './faces/face/face.component';
@Injectable()
@ -133,6 +136,7 @@ export function translationsFactory(locale: string) {
LoginComponent,
ShareLoginComponent,
GalleryComponent,
FacesComponent,
// misc
FrameComponent,
LanguageComponent,
@ -152,6 +156,8 @@ export function translationsFactory(locale: string) {
AdminComponent,
InfoPanelLightboxComponent,
RandomQueryBuilderGalleryComponent,
// Face
FaceComponent,
// Settings
UserMangerSettingsComponent,
DatabaseSettingsComponent,
@ -194,6 +200,7 @@ export function translationsFactory(locale: string) {
OverlayService,
QueryService,
DuplicateService,
FacesService,
{
provide: TRANSLATIONS,
useFactory: translationsFactory,

View File

@ -6,6 +6,7 @@ import {AdminComponent} from './admin/admin.component';
import {ShareLoginComponent} from './sharelogin/share-login.component';
import {QueryParams} from '../../common/QueryParams';
import {DuplicateComponent} from './duplicates/duplicates.component';
import {FacesComponent} from './faces/faces.component';
export function galleryMatcherFunction(
segments: UrlSegment[]): UrlMatchResult | null {
@ -55,6 +56,10 @@ const ROUTES: Routes = [
path: 'duplicates',
component: DuplicateComponent
},
{
path: 'faces',
component: FacesComponent
},
{
matcher: galleryMatcherFunction,
component: GalleryComponent

View File

@ -0,0 +1,56 @@
a {
position: relative;
}
.photo-container {
border: 2px solid #333;
width: 180px;
height: 180px;
background-color: #bbbbbb;
}
.no-image {
color: #7f7f7f;
font-size: 80px;
top: calc(50% - 40px);
left: calc(50% - 40px);
}
.photo {
width: 100%;
height: 100%;
background-size: cover;
background-position: center;
}
.button {
border: 0;
padding: 0;
text-align: left;
}
.info {
background-color: rgba(0, 0, 0, 0.6);
color: white;
font-size: medium;
position: absolute;
bottom: 0;
left: 0;
padding: 5px;
width: 100%;
}
a:hover .info {
background-color: rgba(0, 0, 0, 0.8);
}
a:hover .photo-container {
border-color: #000;
}
.person-name {
width: 180px;
white-space: normal;
}

View File

@ -0,0 +1,20 @@
<a class="button btn btn-default"
[routerLink]="['/search', person.name, {type: SearchTypes[SearchTypes.person]}]"
style="display: inline-block;">
<div class="photo-container"
[style.width.px]="200"
[style.height.px]="200">
<div class="photo"
[style.background-image]="getSanitizedThUrl()"></div>
</div>
<!--Info box -->
<div #info class="info">
<div class="person-name">{{person.name}} ({{person.count}})</div>
</div>
</a>

View File

@ -0,0 +1,31 @@
import {Component, Input} from '@angular/core';
import {RouterLink} from '@angular/router';
import {PersonDTO} from '../../../../common/entities/PersonDTO';
import {SearchTypes} from '../../../../common/entities/AutoCompleteItem';
import {DomSanitizer} from '@angular/platform-browser';
@Component({
selector: 'app-face',
templateUrl: './face.component.html',
styleUrls: ['./face.component.css'],
providers: [RouterLink],
})
export class FaceComponent {
@Input() person: PersonDTO;
SearchTypes = SearchTypes;
constructor(private _sanitizer: DomSanitizer) {
}
getSanitizedThUrl() {
return this._sanitizer.bypassSecurityTrustStyle('url(' +
encodeURI('/api/person/' + this.person.name + '/thumbnail')
.replace(/\(/g, '%28')
.replace(/'/g, '%27')
.replace(/\)/g, '%29') + ')');
}
}

View File

@ -0,0 +1,4 @@
app-face {
margin: 2px;
display: inline-block;
}

View File

@ -0,0 +1,7 @@
<app-frame>
<div body class="container-fluid">
<app-face *ngFor="let person of facesService.persons.value"
[person]="person"></app-face>
</div>
</app-frame>

View File

@ -0,0 +1,21 @@
import {Component} from '@angular/core';
import {FacesService} from './faces.service';
import {QueryService} from '../model/query.service';
@Component({
selector: 'app-faces',
templateUrl: './faces.component.html',
styleUrls: ['./faces.component.css']
})
export class FacesComponent {
constructor(public facesService: FacesService,
public queryService: QueryService) {
this.facesService.getPersons().catch(console.error);
}
}

View File

@ -0,0 +1,20 @@
import {Injectable} from '@angular/core';
import {NetworkService} from '../model/network/network.service';
import {BehaviorSubject} from 'rxjs';
import {PersonDTO} from '../../../common/entities/PersonDTO';
@Injectable()
export class FacesService {
public persons: BehaviorSubject<PersonDTO[]>;
constructor(private networkService: NetworkService) {
this.persons = new BehaviorSubject<PersonDTO[]>(null);
}
public async getPersons() {
this.persons.next((await this.networkService.getJson<PersonDTO[]>('/person')).sort((a, b) => a.name.localeCompare(b.name)));
}
}

View File

@ -22,10 +22,10 @@
<a [routerLink]="['/search', item.text, {type: SearchTypes[item.type]}]">
<span [ngSwitch]="item.type">
<span *ngSwitchCase="SearchTypes.photo" class="oi oi-image"></span>
<span *ngSwitchCase="SearchTypes.person" class="oi oi-person"></span>
<span *ngSwitchCase="SearchTypes.video" class="oi oi-video"></span>
<span *ngSwitchCase="SearchTypes.directory" class="oi oi-folder"></span>
<span *ngSwitchCase="SearchTypes.keyword" class="oi oi-tag"></span>
<span *ngSwitchCase="SearchTypes.person" class="oi oi-person"></span>
<span *ngSwitchCase="SearchTypes.position" class="oi oi-map-marker"></span>
</span>
{{item.preText}}<strong>{{item.highLightText}}</strong>{{item.postText}}

View File

@ -35,6 +35,7 @@ export class SettingsService {
Thumbnail: {
concurrentThumbnailGenerations: null,
iconSize: 30,
personThumbnailSize: 200,
thumbnailSizes: []
},
Sharing: {
@ -92,6 +93,7 @@ export class SettingsService {
port: 80,
host: '0.0.0.0',
thumbnail: {
personFaceMargin: 0.1,
folder: '',
qualityPriority: true,
processingLibrary: ThumbnailProcessingLib.sharp

View File

@ -52,6 +52,9 @@ export class ThumbnailSettingsComponent
if (v.value.toLowerCase() === 'sharp') {
v.value += ' ' + this.i18n('(recommended)');
}
if (v.value.toLowerCase() === 'gm') {
v.value += ' ' + this.i18n('(deprecated)');
}
return v;
});
this.ThumbnailProcessingLib = ThumbnailProcessingLib;

View File

@ -15,8 +15,8 @@
"box": {
"height": 2,
"width": 2,
"x": 8,
"y": 4
"x": 7,
"y": 3
},
"name": "squirrel"
},
@ -24,8 +24,8 @@
"box": {
"height": 3,
"width": 2,
"x": 5,
"y": 5
"x": 4,
"y": 3.5
},
"name": "special_chars űáéúőóüío?._:"
}

View File

@ -14,8 +14,8 @@ describe('PersonManager', () => {
it('should upgrade keywords to person', async () => {
const pm = new PersonManager();
pm.loadAll = () => Promise.resolve();
pm.persons = [{name: 'Han Solo', id: 0, faces: []},
{name: 'Anakin', id: 2, faces: []}];
pm.persons = [{name: 'Han Solo', id: 0, faces: [], count: 0},
{name: 'Anakin', id: 2, faces: [], count: 0}];
const p_noFaces = <PhotoDTO>{
metadata: {