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:
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 {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';
|
||||
}
|
||||
}
|
||||
|
||||
|
@ -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>;
|
||||
}
|
||||
|
@ -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');
|
||||
}
|
||||
}
|
||||
|
@ -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 {
|
||||
|
@ -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)');
|
||||
}
|
||||
|
||||
}
|
||||
|
@ -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[];
|
||||
}
|
||||
|
@ -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 &&
|
||||
|
@ -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});
|
||||
}
|
||||
}
|
||||
|
@ -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, {
|
||||
|
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,
|
||||
setLocale,
|
||||
renderIndex
|
||||
|
@ -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);
|
||||
};
|
||||
|
||||
}
|
||||
|
||||
|
||||
|
@ -1 +1 @@
|
||||
export const DataStructureVersion = 10;
|
||||
export const DataStructureVersion = 11;
|
||||
|
@ -39,6 +39,7 @@ export interface ThumbnailConfig {
|
||||
folder: string;
|
||||
processingLibrary: ThumbnailProcessingLib;
|
||||
qualityPriority: boolean;
|
||||
personFaceMargin: number; // in ration [0-1]
|
||||
}
|
||||
|
||||
export interface SharingConfig {
|
||||
|
@ -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,
|
||||
|
@ -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,
|
||||
|
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 {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,
|
||||
|
@ -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
|
||||
|
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]}]">
|
||||
<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}}
|
||||
|
@ -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
|
||||
|
@ -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;
|
||||
|
@ -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?._:"
|
||||
}
|
||||
|
@ -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: {
|
||||
|
Loading…
x
Reference in New Issue
Block a user