mirror of
https://github.com/xuthus83/pigallery2.git
synced 2025-01-14 14:43:17 +08:00
implementing basic faces page
This commit is contained in:
parent
b9efea7620
commit
4fc965d10f
46
backend/middlewares/PersonMWs.ts
Normal file
46
backend/middlewares/PersonMWs.ts
Normal 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));
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -14,6 +14,7 @@ import {RendererInput, ThumbnailSourceType, ThumbnailWorker} from '../../model/t
|
|||||||
|
|
||||||
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
||||||
import {ITaskExecuter, TaskExecuter} from '../../model/threading/TaskExecuter';
|
import {ITaskExecuter, TaskExecuter} from '../../model/threading/TaskExecuter';
|
||||||
|
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
|
||||||
|
|
||||||
|
|
||||||
export class ThumbnailGeneratorMWs {
|
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) {
|
public static generateThumbnailFactory(sourceType: ThumbnailSourceType) {
|
||||||
return (req: Request, res: Response, next: NextFunction) => {
|
return (req: Request, res: Response, next: NextFunction) => {
|
||||||
if (!req.resultPipe) {
|
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) {
|
private static addThInfoTODir(directory: DirectoryDTO) {
|
||||||
if (typeof directory.media !== 'undefined') {
|
if (typeof directory.media !== 'undefined') {
|
||||||
ThumbnailGeneratorMWs.addThInfoToPhotos(directory.media);
|
ThumbnailGeneratorMWs.addThInfoToPhotos(directory.media);
|
||||||
@ -177,9 +245,5 @@ export class ThumbnailGeneratorMWs {
|
|||||||
'Error during generating thumbnail: ' + input.mediaPath, error.toString()));
|
'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';
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -1,10 +1,17 @@
|
|||||||
import {PersonEntry} from '../sql/enitites/PersonEntry';
|
import {PersonEntry} from '../sql/enitites/PersonEntry';
|
||||||
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
||||||
|
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
|
||||||
|
|
||||||
export interface IPersonManager {
|
export interface IPersonManager {
|
||||||
|
getAll(): Promise<PersonEntry[]>;
|
||||||
|
|
||||||
|
getSamplePhoto(name: string): Promise<PhotoDTO>;
|
||||||
|
|
||||||
get(name: string): Promise<PersonEntry>;
|
get(name: string): Promise<PersonEntry>;
|
||||||
|
|
||||||
saveAll(names: string[]): Promise<void>;
|
saveAll(names: string[]): Promise<void>;
|
||||||
|
|
||||||
keywordsToPerson(media: MediaDTO[]): Promise<void>;
|
keywordsToPerson(media: MediaDTO[]): Promise<void>;
|
||||||
|
|
||||||
|
updateCounts(): Promise<void>;
|
||||||
}
|
}
|
||||||
|
@ -1,7 +1,17 @@
|
|||||||
import {IPersonManager} from '../interfaces/IPersonManager';
|
import {IPersonManager} from '../interfaces/IPersonManager';
|
||||||
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
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> {
|
keywordsToPerson(media: MediaDTO[]): Promise<void> {
|
||||||
throw new Error('Method not implemented.');
|
throw new Error('Method not implemented.');
|
||||||
}
|
}
|
||||||
@ -13,4 +23,8 @@ export class IndexingTaskManager implements IPersonManager {
|
|||||||
saveAll(names: string[]): Promise<void> {
|
saveAll(names: string[]): Promise<void> {
|
||||||
throw new Error('not supported by memory DB');
|
throw new Error('not supported by memory DB');
|
||||||
}
|
}
|
||||||
|
|
||||||
|
updateCounts(): Promise<void> {
|
||||||
|
throw new Error('not supported by memory DB');
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -289,6 +289,7 @@ export class IndexingManager implements IIndexingManager {
|
|||||||
await this.saveChildDirs(connection, currentDirId, scannedDirectory);
|
await this.saveChildDirs(connection, currentDirId, scannedDirectory);
|
||||||
await this.saveMedia(connection, currentDirId, scannedDirectory.media);
|
await this.saveMedia(connection, currentDirId, scannedDirectory.media);
|
||||||
await this.saveMetaFiles(connection, currentDirId, scannedDirectory);
|
await this.saveMetaFiles(connection, currentDirId, scannedDirectory);
|
||||||
|
await ObjectManagerRepository.getInstance().PersonManager.updateCounts();
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
throw e;
|
throw e;
|
||||||
} finally {
|
} finally {
|
||||||
|
@ -3,13 +3,34 @@ import {SQLConnection} from './SQLConnection';
|
|||||||
import {PersonEntry} from './enitites/PersonEntry';
|
import {PersonEntry} from './enitites/PersonEntry';
|
||||||
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
import {MediaDTO} from '../../../common/entities/MediaDTO';
|
||||||
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
|
import {PhotoDTO} from '../../../common/entities/PhotoDTO';
|
||||||
|
import {MediaEntity} from './enitites/MediaEntity';
|
||||||
|
import {FaceRegionEntry} from './enitites/FaceRegionEntry';
|
||||||
|
|
||||||
const LOG_TAG = '[PersonManager]';
|
const LOG_TAG = '[PersonManager]';
|
||||||
|
|
||||||
export class PersonManager implements IPersonManager {
|
export class PersonManager implements IPersonManager {
|
||||||
|
|
||||||
persons: PersonEntry[] = [];
|
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> {
|
async loadAll(): Promise<void> {
|
||||||
const connection = await SQLConnection.getConnection();
|
const connection = await SQLConnection.getConnection();
|
||||||
const personRepository = connection.getRepository(PersonEntry);
|
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
|
// TODO dead code, remove it
|
||||||
async keywordsToPerson(media: MediaDTO[]) {
|
async keywordsToPerson(media: MediaDTO[]) {
|
||||||
await this.loadAll();
|
await this.loadAll();
|
||||||
@ -30,7 +56,7 @@ export class PersonManager implements IPersonManager {
|
|||||||
if (personKeywords.length === 0) {
|
if (personKeywords.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
// remove persons
|
// remove persons from keywords
|
||||||
m.metadata.keywords = m.metadata.keywords.filter(k => !personFilter(k));
|
m.metadata.keywords = m.metadata.keywords.filter(k => !personFilter(k));
|
||||||
m.metadata.faces = m.metadata.faces || [];
|
m.metadata.faces = m.metadata.faces || [];
|
||||||
personKeywords.forEach((pk: string) => {
|
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)');
|
||||||
|
}
|
||||||
|
|
||||||
}
|
}
|
||||||
|
@ -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 {FaceRegionEntry} from './FaceRegionEntry';
|
||||||
|
import {PersonDTO} from '../../../../common/entities/PersonDTO';
|
||||||
|
|
||||||
|
|
||||||
@Entity()
|
@Entity()
|
||||||
@Unique(['name'])
|
@Unique(['name'])
|
||||||
export class PersonEntry {
|
export class PersonEntry implements PersonDTO {
|
||||||
@Index()
|
@Index()
|
||||||
@PrimaryGeneratedColumn({unsigned: true})
|
@PrimaryGeneratedColumn({unsigned: true})
|
||||||
id: number;
|
id: number;
|
||||||
@ -12,6 +13,9 @@ export class PersonEntry {
|
|||||||
@Column()
|
@Column()
|
||||||
name: string;
|
name: string;
|
||||||
|
|
||||||
|
@Column('int', {unsigned: true, default: 0})
|
||||||
|
count: number;
|
||||||
|
|
||||||
@OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.person)
|
@OneToMany(type => FaceRegionEntry, faceRegion => faceRegion.person)
|
||||||
public faces: FaceRegionEntry[];
|
public faces: FaceRegionEntry[];
|
||||||
}
|
}
|
||||||
|
@ -99,7 +99,7 @@ export class DiskMangerWorker {
|
|||||||
metadata: await MetadataLoader.loadVideoMetadata(fullFilePath)
|
metadata: await MetadataLoader.loadVideoMetadata(fullFilePath)
|
||||||
});
|
});
|
||||||
} catch (e) {
|
} 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 &&
|
} else if (photosOnly === false && Config.Client.MetaFile.enabled === true &&
|
||||||
|
@ -214,6 +214,9 @@ export class MetadataLoader {
|
|||||||
x: Math.round(regionBox['stArea:x'] * metadata.size.width),
|
x: Math.round(regionBox['stArea:x'] * metadata.size.width),
|
||||||
y: Math.round(regionBox['stArea:y'] * metadata.size.height)
|
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});
|
faces.push({name: name, box: box});
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
@ -47,6 +47,12 @@ export interface RendererInput {
|
|||||||
makeSquare: boolean;
|
makeSquare: boolean;
|
||||||
thPath: string;
|
thPath: string;
|
||||||
qualityPriority: boolean;
|
qualityPriority: boolean;
|
||||||
|
cut?: {
|
||||||
|
x: number,
|
||||||
|
y: number,
|
||||||
|
width: number,
|
||||||
|
height: number
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
export class VideoRendererFactory {
|
export class VideoRendererFactory {
|
||||||
@ -140,6 +146,15 @@ export class ImageRendererFactory {
|
|||||||
*/
|
*/
|
||||||
const ratio = image.bitmap.height / image.bitmap.width;
|
const ratio = image.bitmap.height / image.bitmap.width;
|
||||||
const algo = input.qualityPriority === true ? Jimp.RESIZE_BEZIER : Jimp.RESIZE_NEAREST_NEIGHBOR;
|
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) {
|
if (input.makeSquare === false) {
|
||||||
const newWidth = Math.sqrt((input.size * input.size) / ratio);
|
const newWidth = Math.sqrt((input.size * input.size) / ratio);
|
||||||
|
|
||||||
@ -167,7 +182,6 @@ export class ImageRendererFactory {
|
|||||||
const sharp = require('sharp');
|
const sharp = require('sharp');
|
||||||
sharp.cache(false);
|
sharp.cache(false);
|
||||||
return async (input: RendererInput): Promise<void> => {
|
return async (input: RendererInput): Promise<void> => {
|
||||||
|
|
||||||
Logger.silly('[SharpThRenderer] rendering thumbnail:' + input.mediaPath);
|
Logger.silly('[SharpThRenderer] rendering thumbnail:' + input.mediaPath);
|
||||||
const image: Sharp = sharp(input.mediaPath, {failOnError: false});
|
const image: Sharp = sharp(input.mediaPath, {failOnError: false});
|
||||||
const metadata: Metadata = await image.metadata();
|
const metadata: Metadata = await image.metadata();
|
||||||
@ -183,6 +197,15 @@ export class ImageRendererFactory {
|
|||||||
*/
|
*/
|
||||||
const ratio = metadata.height / metadata.width;
|
const ratio = metadata.height / metadata.width;
|
||||||
const kernel = input.qualityPriority === true ? sharp.kernel.lanczos3 : sharp.kernel.nearest;
|
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) {
|
if (input.makeSquare === false) {
|
||||||
const newWidth = Math.round(Math.sqrt((input.size * input.size) / ratio));
|
const newWidth = Math.round(Math.sqrt((input.size * input.size) / ratio));
|
||||||
image.resize(newWidth, null, {
|
image.resize(newWidth, null, {
|
||||||
|
34
backend/routes/PersonRouter.ts
Normal file
34
backend/routes/PersonRouter.ts
Normal 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
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -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,
|
AuthenticationMWs.tryAuthenticate,
|
||||||
setLocale,
|
setLocale,
|
||||||
renderIndex
|
renderIndex
|
||||||
|
@ -22,6 +22,7 @@ import {NotificationRouter} from './routes/NotificationRouter';
|
|||||||
import {ConfigDiagnostics} from './model/diagnostics/ConfigDiagnostics';
|
import {ConfigDiagnostics} from './model/diagnostics/ConfigDiagnostics';
|
||||||
import {Localizations} from './model/Localizations';
|
import {Localizations} from './model/Localizations';
|
||||||
import {CookieNames} from '../common/CookieNames';
|
import {CookieNames} from '../common/CookieNames';
|
||||||
|
import {PersonRouter} from './routes/PersonRouter';
|
||||||
|
|
||||||
const _session = require('cookie-session');
|
const _session = require('cookie-session');
|
||||||
|
|
||||||
@ -32,42 +33,6 @@ export class Server {
|
|||||||
private app: _express.Express;
|
private app: _express.Express;
|
||||||
private server: HttpServer;
|
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() {
|
constructor() {
|
||||||
if (!(process.env.NODE_ENV === 'production')) {
|
if (!(process.env.NODE_ENV === 'production')) {
|
||||||
Logger.debug(LOG_TAG, 'Running in DEBUG mode, set env variable NODE_ENV=production to disable ');
|
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);
|
UserRouter.route(this.app);
|
||||||
GalleryRouter.route(this.app);
|
GalleryRouter.route(this.app);
|
||||||
|
PersonRouter.route(this.app);
|
||||||
SharingRouter.route(this.app);
|
SharingRouter.route(this.app);
|
||||||
AdminRouter.route(this.app);
|
AdminRouter.route(this.app);
|
||||||
NotificationRouter.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);
|
||||||
|
};
|
||||||
|
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
|
@ -1 +1 @@
|
|||||||
export const DataStructureVersion = 10;
|
export const DataStructureVersion = 11;
|
||||||
|
@ -39,6 +39,7 @@ export interface ThumbnailConfig {
|
|||||||
folder: string;
|
folder: string;
|
||||||
processingLibrary: ThumbnailProcessingLib;
|
processingLibrary: ThumbnailProcessingLib;
|
||||||
qualityPriority: boolean;
|
qualityPriority: boolean;
|
||||||
|
personFaceMargin: number; // in ration [0-1]
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SharingConfig {
|
export interface SharingConfig {
|
||||||
|
@ -25,7 +25,8 @@ export class PrivateConfigClass extends PublicConfigClass implements IPrivateCon
|
|||||||
thumbnail: {
|
thumbnail: {
|
||||||
folder: 'demo/TEMP',
|
folder: 'demo/TEMP',
|
||||||
processingLibrary: ThumbnailProcessingLib.sharp,
|
processingLibrary: ThumbnailProcessingLib.sharp,
|
||||||
qualityPriority: true
|
qualityPriority: true,
|
||||||
|
personFaceMargin: 0.6
|
||||||
},
|
},
|
||||||
log: {
|
log: {
|
||||||
level: LogLevel.info,
|
level: LogLevel.info,
|
||||||
|
@ -40,7 +40,8 @@ export module ClientConfig {
|
|||||||
|
|
||||||
export interface ThumbnailConfig {
|
export interface ThumbnailConfig {
|
||||||
iconSize: number;
|
iconSize: number;
|
||||||
thumbnailSizes: Array<number>;
|
personThumbnailSize: number;
|
||||||
|
thumbnailSizes: number[];
|
||||||
concurrentThumbnailGenerations: number;
|
concurrentThumbnailGenerations: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -100,7 +101,8 @@ export class PublicConfigClass {
|
|||||||
Thumbnail: {
|
Thumbnail: {
|
||||||
concurrentThumbnailGenerations: 1,
|
concurrentThumbnailGenerations: 1,
|
||||||
thumbnailSizes: [200, 400, 600],
|
thumbnailSizes: [200, 400, 600],
|
||||||
iconSize: 45
|
iconSize: 45,
|
||||||
|
personThumbnailSize: 200
|
||||||
},
|
},
|
||||||
Search: {
|
Search: {
|
||||||
enabled: true,
|
enabled: true,
|
||||||
|
6
common/entities/PersonDTO.ts
Normal file
6
common/entities/PersonDTO.ts
Normal file
@ -0,0 +1,6 @@
|
|||||||
|
export interface PersonDTO {
|
||||||
|
id: number;
|
||||||
|
name: string;
|
||||||
|
count: number;
|
||||||
|
}
|
||||||
|
|
@ -74,6 +74,9 @@ import {DuplicateService} from './duplicates/duplicates.service';
|
|||||||
import {DuplicateComponent} from './duplicates/duplicates.component';
|
import {DuplicateComponent} from './duplicates/duplicates.component';
|
||||||
import {DuplicatesPhotoComponent} from './duplicates/photo/photo.duplicates.component';
|
import {DuplicatesPhotoComponent} from './duplicates/photo/photo.duplicates.component';
|
||||||
import {SeededRandomService} from './model/seededRandom.service';
|
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()
|
@Injectable()
|
||||||
@ -133,6 +136,7 @@ export function translationsFactory(locale: string) {
|
|||||||
LoginComponent,
|
LoginComponent,
|
||||||
ShareLoginComponent,
|
ShareLoginComponent,
|
||||||
GalleryComponent,
|
GalleryComponent,
|
||||||
|
FacesComponent,
|
||||||
// misc
|
// misc
|
||||||
FrameComponent,
|
FrameComponent,
|
||||||
LanguageComponent,
|
LanguageComponent,
|
||||||
@ -152,6 +156,8 @@ export function translationsFactory(locale: string) {
|
|||||||
AdminComponent,
|
AdminComponent,
|
||||||
InfoPanelLightboxComponent,
|
InfoPanelLightboxComponent,
|
||||||
RandomQueryBuilderGalleryComponent,
|
RandomQueryBuilderGalleryComponent,
|
||||||
|
// Face
|
||||||
|
FaceComponent,
|
||||||
// Settings
|
// Settings
|
||||||
UserMangerSettingsComponent,
|
UserMangerSettingsComponent,
|
||||||
DatabaseSettingsComponent,
|
DatabaseSettingsComponent,
|
||||||
@ -194,6 +200,7 @@ export function translationsFactory(locale: string) {
|
|||||||
OverlayService,
|
OverlayService,
|
||||||
QueryService,
|
QueryService,
|
||||||
DuplicateService,
|
DuplicateService,
|
||||||
|
FacesService,
|
||||||
{
|
{
|
||||||
provide: TRANSLATIONS,
|
provide: TRANSLATIONS,
|
||||||
useFactory: translationsFactory,
|
useFactory: translationsFactory,
|
||||||
|
@ -6,6 +6,7 @@ import {AdminComponent} from './admin/admin.component';
|
|||||||
import {ShareLoginComponent} from './sharelogin/share-login.component';
|
import {ShareLoginComponent} from './sharelogin/share-login.component';
|
||||||
import {QueryParams} from '../../common/QueryParams';
|
import {QueryParams} from '../../common/QueryParams';
|
||||||
import {DuplicateComponent} from './duplicates/duplicates.component';
|
import {DuplicateComponent} from './duplicates/duplicates.component';
|
||||||
|
import {FacesComponent} from './faces/faces.component';
|
||||||
|
|
||||||
export function galleryMatcherFunction(
|
export function galleryMatcherFunction(
|
||||||
segments: UrlSegment[]): UrlMatchResult | null {
|
segments: UrlSegment[]): UrlMatchResult | null {
|
||||||
@ -55,6 +56,10 @@ const ROUTES: Routes = [
|
|||||||
path: 'duplicates',
|
path: 'duplicates',
|
||||||
component: DuplicateComponent
|
component: DuplicateComponent
|
||||||
},
|
},
|
||||||
|
{
|
||||||
|
path: 'faces',
|
||||||
|
component: FacesComponent
|
||||||
|
},
|
||||||
{
|
{
|
||||||
matcher: galleryMatcherFunction,
|
matcher: galleryMatcherFunction,
|
||||||
component: GalleryComponent
|
component: GalleryComponent
|
||||||
|
56
frontend/app/faces/face/face.component.css
Normal file
56
frontend/app/faces/face/face.component.css
Normal 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;
|
||||||
|
}
|
20
frontend/app/faces/face/face.component.html
Normal file
20
frontend/app/faces/face/face.component.html
Normal 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>
|
||||||
|
|
31
frontend/app/faces/face/face.component.ts
Normal file
31
frontend/app/faces/face/face.component.ts
Normal 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') + ')');
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
||||||
|
|
4
frontend/app/faces/faces.component.css
Normal file
4
frontend/app/faces/faces.component.css
Normal file
@ -0,0 +1,4 @@
|
|||||||
|
app-face {
|
||||||
|
margin: 2px;
|
||||||
|
display: inline-block;
|
||||||
|
}
|
7
frontend/app/faces/faces.component.html
Normal file
7
frontend/app/faces/faces.component.html
Normal 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>
|
21
frontend/app/faces/faces.component.ts
Normal file
21
frontend/app/faces/faces.component.ts
Normal 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);
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
}
|
||||||
|
|
20
frontend/app/faces/faces.service.ts
Normal file
20
frontend/app/faces/faces.service.ts
Normal 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)));
|
||||||
|
}
|
||||||
|
|
||||||
|
}
|
@ -22,10 +22,10 @@
|
|||||||
<a [routerLink]="['/search', item.text, {type: SearchTypes[item.type]}]">
|
<a [routerLink]="['/search', item.text, {type: SearchTypes[item.type]}]">
|
||||||
<span [ngSwitch]="item.type">
|
<span [ngSwitch]="item.type">
|
||||||
<span *ngSwitchCase="SearchTypes.photo" class="oi oi-image"></span>
|
<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.video" class="oi oi-video"></span>
|
||||||
<span *ngSwitchCase="SearchTypes.directory" class="oi oi-folder"></span>
|
<span *ngSwitchCase="SearchTypes.directory" class="oi oi-folder"></span>
|
||||||
<span *ngSwitchCase="SearchTypes.keyword" class="oi oi-tag"></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 *ngSwitchCase="SearchTypes.position" class="oi oi-map-marker"></span>
|
||||||
</span>
|
</span>
|
||||||
{{item.preText}}<strong>{{item.highLightText}}</strong>{{item.postText}}
|
{{item.preText}}<strong>{{item.highLightText}}</strong>{{item.postText}}
|
||||||
|
@ -35,6 +35,7 @@ export class SettingsService {
|
|||||||
Thumbnail: {
|
Thumbnail: {
|
||||||
concurrentThumbnailGenerations: null,
|
concurrentThumbnailGenerations: null,
|
||||||
iconSize: 30,
|
iconSize: 30,
|
||||||
|
personThumbnailSize: 200,
|
||||||
thumbnailSizes: []
|
thumbnailSizes: []
|
||||||
},
|
},
|
||||||
Sharing: {
|
Sharing: {
|
||||||
@ -92,6 +93,7 @@ export class SettingsService {
|
|||||||
port: 80,
|
port: 80,
|
||||||
host: '0.0.0.0',
|
host: '0.0.0.0',
|
||||||
thumbnail: {
|
thumbnail: {
|
||||||
|
personFaceMargin: 0.1,
|
||||||
folder: '',
|
folder: '',
|
||||||
qualityPriority: true,
|
qualityPriority: true,
|
||||||
processingLibrary: ThumbnailProcessingLib.sharp
|
processingLibrary: ThumbnailProcessingLib.sharp
|
||||||
|
@ -52,6 +52,9 @@ export class ThumbnailSettingsComponent
|
|||||||
if (v.value.toLowerCase() === 'sharp') {
|
if (v.value.toLowerCase() === 'sharp') {
|
||||||
v.value += ' ' + this.i18n('(recommended)');
|
v.value += ' ' + this.i18n('(recommended)');
|
||||||
}
|
}
|
||||||
|
if (v.value.toLowerCase() === 'gm') {
|
||||||
|
v.value += ' ' + this.i18n('(deprecated)');
|
||||||
|
}
|
||||||
return v;
|
return v;
|
||||||
});
|
});
|
||||||
this.ThumbnailProcessingLib = ThumbnailProcessingLib;
|
this.ThumbnailProcessingLib = ThumbnailProcessingLib;
|
||||||
|
@ -15,8 +15,8 @@
|
|||||||
"box": {
|
"box": {
|
||||||
"height": 2,
|
"height": 2,
|
||||||
"width": 2,
|
"width": 2,
|
||||||
"x": 8,
|
"x": 7,
|
||||||
"y": 4
|
"y": 3
|
||||||
},
|
},
|
||||||
"name": "squirrel"
|
"name": "squirrel"
|
||||||
},
|
},
|
||||||
@ -24,8 +24,8 @@
|
|||||||
"box": {
|
"box": {
|
||||||
"height": 3,
|
"height": 3,
|
||||||
"width": 2,
|
"width": 2,
|
||||||
"x": 5,
|
"x": 4,
|
||||||
"y": 5
|
"y": 3.5
|
||||||
},
|
},
|
||||||
"name": "special_chars űáéúőóüío?._:"
|
"name": "special_chars űáéúőóüío?._:"
|
||||||
}
|
}
|
||||||
|
@ -14,8 +14,8 @@ describe('PersonManager', () => {
|
|||||||
it('should upgrade keywords to person', async () => {
|
it('should upgrade keywords to person', async () => {
|
||||||
const pm = new PersonManager();
|
const pm = new PersonManager();
|
||||||
pm.loadAll = () => Promise.resolve();
|
pm.loadAll = () => Promise.resolve();
|
||||||
pm.persons = [{name: 'Han Solo', id: 0, faces: []},
|
pm.persons = [{name: 'Han Solo', id: 0, faces: [], count: 0},
|
||||||
{name: 'Anakin', id: 2, faces: []}];
|
{name: 'Anakin', id: 2, faces: [], count: 0}];
|
||||||
|
|
||||||
const p_noFaces = <PhotoDTO>{
|
const p_noFaces = <PhotoDTO>{
|
||||||
metadata: {
|
metadata: {
|
||||||
|
Loading…
x
Reference in New Issue
Block a user