1
0
mirror of https://github.com/immich-app/immich.git synced 2024-12-22 01:47:08 +02:00

feat(web,server): add thumbhash support (#2649)

* add thumbhash: server generation and web impl

* move logic to infra & use byta in db

* remove unnecesary logs

* update generated API and simplify thumbhash gen

* fix check errors

* removed unnecessary library and css tag

* style edits

* syntax mistake

* update server test, change thumbhash job name

* fix tests

* Update server/src/domain/asset/response-dto/asset-response.dto.ts

Co-authored-by: Thomas <9749173+uhthomas@users.noreply.github.com>

* add unit test, change migration date

* change to official thumbhash impl

* update call method to not use eval

* "generate missing" looks for thumbhash

* improve queue & improve syntax

* update syntax again

* update tests

* fix thumbhash generation

* consolidate queueing to avoid duplication

* cover all types of incorrect thumbnail cases

* split out jest tasks

* put back thumbnail duration loading for images without thumbhash

* Remove stray package.json

---------

Co-authored-by: Luke McCarthy <mail@lukehmcc.com>
Co-authored-by: Thomas <9749173+uhthomas@users.noreply.github.com>
Co-authored-by: Alex Tran <alex.tran1502@gmail.com>
This commit is contained in:
Covalent 2023-06-17 23:22:31 -04:00 committed by GitHub
parent 3512140148
commit 3e804f16df
No known key found for this signature in database
GPG Key ID: 4AEE18F83AFDEB23
29 changed files with 333 additions and 28 deletions

Binary file not shown.

Binary file not shown.

Binary file not shown.

View File

@ -4865,6 +4865,11 @@
"resized": { "resized": {
"type": "boolean" "type": "boolean"
}, },
"thumbhash": {
"type": "string",
"nullable": true,
"description": "base64 encoded thumbhash"
},
"fileCreatedAt": { "fileCreatedAt": {
"format": "date-time", "format": "date-time",
"type": "string" "type": "string"
@ -4926,6 +4931,7 @@
"originalPath", "originalPath",
"originalFileName", "originalFileName",
"resized", "resized",
"thumbhash",
"fileCreatedAt", "fileCreatedAt",
"fileModifiedAt", "fileModifiedAt",
"updatedAt", "updatedAt",

View File

@ -46,6 +46,7 @@
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sharp": "^0.31.3", "sharp": "^0.31.3",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.11", "typeorm": "^0.3.11",
"typesense": "^1.5.3", "typesense": "^1.5.3",
"ua-parser-js": "^1.0.35" "ua-parser-js": "^1.0.35"
@ -4234,9 +4235,9 @@
} }
}, },
"node_modules/bullmq": { "node_modules/bullmq": {
"version": "3.14.1", "version": "3.15.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.15.4.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==", "integrity": "sha512-jig63/PWODJEsAuswiCVUHaDWMv5fGpU36SjI0watAdXZMmy9K/iMKQAfPXmfZeK98bY/+co/efaDWVh3eVImw==",
"dependencies": { "dependencies": {
"cron-parser": "^4.6.0", "cron-parser": "^4.6.0",
"glob": "^8.0.3", "glob": "^8.0.3",
@ -10806,6 +10807,11 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
}, },
"node_modules/thumbhash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="
},
"node_modules/tmp": { "node_modules/tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",
@ -15241,9 +15247,9 @@
"integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ==" "integrity": "sha512-9q/rDEGSb/Qsvv2qvzIzdluL5k7AaJOTrw23z9reQthrbF7is4CtlT0DXyO1oei2DCp4uojjzQ7igaSHp1kAEQ=="
}, },
"bullmq": { "bullmq": {
"version": "3.14.1", "version": "3.15.4",
"resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.14.1.tgz", "resolved": "https://registry.npmjs.org/bullmq/-/bullmq-3.15.4.tgz",
"integrity": "sha512-Fom78UKljYsnJmwbROVPx3eFLuVfQjQbw9KCnVupLzT31RQHhFHV2xd/4J4oWl4u34bZ1JmEUfNnqNBz+IOJuA==", "integrity": "sha512-jig63/PWODJEsAuswiCVUHaDWMv5fGpU36SjI0watAdXZMmy9K/iMKQAfPXmfZeK98bY/+co/efaDWVh3eVImw==",
"requires": { "requires": {
"cron-parser": "^4.6.0", "cron-parser": "^4.6.0",
"glob": "^8.0.3", "glob": "^8.0.3",
@ -20185,6 +20191,11 @@
"resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz", "resolved": "https://registry.npmjs.org/through/-/through-2.3.8.tgz",
"integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg==" "integrity": "sha512-w89qg7PI8wAdvX60bMDP+bFoD5Dvhm9oLheFp5O4a2QF0cSBGsBX4qZmadPMvVqlLJBBci+WqGGOAPvcDeNSVg=="
}, },
"thumbhash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="
},
"tmp": { "tmp": {
"version": "0.0.33", "version": "0.0.33",
"resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz", "resolved": "https://registry.npmjs.org/tmp/-/tmp-0.0.33.tgz",

View File

@ -75,6 +75,7 @@
"rxjs": "^7.2.0", "rxjs": "^7.2.0",
"sanitize-filename": "^1.6.3", "sanitize-filename": "^1.6.3",
"sharp": "^0.31.3", "sharp": "^0.31.3",
"thumbhash": "^0.1.1",
"typeorm": "^0.3.11", "typeorm": "^0.3.11",
"typesense": "^1.5.3", "typesense": "^1.5.3",
"ua-parser-js": "^1.0.35" "ua-parser-js": "^1.0.35"

View File

@ -16,6 +16,8 @@ export class AssetResponseDto {
originalPath!: string; originalPath!: string;
originalFileName!: string; originalFileName!: string;
resized!: boolean; resized!: boolean;
/**base64 encoded thumbhash */
thumbhash!: string | null;
fileCreatedAt!: Date; fileCreatedAt!: Date;
fileModifiedAt!: Date; fileModifiedAt!: Date;
updatedAt!: Date; updatedAt!: Date;
@ -42,6 +44,7 @@ export function mapAsset(entity: AssetEntity): AssetResponseDto {
originalPath: entity.originalPath, originalPath: entity.originalPath,
originalFileName: entity.originalFileName, originalFileName: entity.originalFileName,
resized: !!entity.resizePath, resized: !!entity.resizePath,
thumbhash: entity.thumbhash?.toString('base64') ?? null,
fileCreatedAt: entity.fileCreatedAt, fileCreatedAt: entity.fileCreatedAt,
fileModifiedAt: entity.fileModifiedAt, fileModifiedAt: entity.fileModifiedAt,
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,
@ -68,6 +71,7 @@ export function mapAssetWithoutExif(entity: AssetEntity): AssetResponseDto {
originalPath: entity.originalPath, originalPath: entity.originalPath,
originalFileName: entity.originalFileName, originalFileName: entity.originalFileName,
resized: !!entity.resizePath, resized: !!entity.resizePath,
thumbhash: entity.thumbhash?.toString('base64') || null,
fileCreatedAt: entity.fileCreatedAt, fileCreatedAt: entity.fileCreatedAt,
fileModifiedAt: entity.fileModifiedAt, fileModifiedAt: entity.fileModifiedAt,
updatedAt: entity.updatedAt, updatedAt: entity.updatedAt,

View File

@ -27,6 +27,7 @@ export enum JobName {
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails', QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail', GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail', GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
GENERATE_THUMBHASH_THUMBNAIL = 'generate-thumbhash-thumbnail',
// metadata // metadata
QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction', QUEUE_METADATA_EXTRACTION = 'queue-metadata-extraction',
@ -92,6 +93,7 @@ export const JOBS_TO_QUEUE: Record<JobName, QueueName> = {
[JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION, [JobName.QUEUE_GENERATE_THUMBNAILS]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_JPEG_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_JPEG_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_WEBP_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION, [JobName.GENERATE_WEBP_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// metadata // metadata
[JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION, [JobName.QUEUE_METADATA_EXTRACTION]: QueueName.METADATA_EXTRACTION,

View File

@ -31,6 +31,7 @@ export type JobItem =
| { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob } | { name: JobName.QUEUE_GENERATE_THUMBNAILS; data: IBaseJob }
| { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IEntityJob } | { name: JobName.GENERATE_JPEG_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob } | { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_THUMBHASH_THUMBNAIL; data: IEntityJob }
// User Deletion // User Deletion
| { name: JobName.USER_DELETE_CHECK; data?: IBaseJob } | { name: JobName.USER_DELETE_CHECK; data?: IBaseJob }

View File

@ -261,7 +261,13 @@ describe(JobService.name, () => {
}, },
{ {
item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } }, item: { name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: 'asset-1' } },
jobs: [JobName.GENERATE_WEBP_THUMBNAIL, JobName.CLASSIFY_IMAGE, JobName.ENCODE_CLIP, JobName.RECOGNIZE_FACES], jobs: [
JobName.GENERATE_WEBP_THUMBNAIL,
JobName.CLASSIFY_IMAGE,
JobName.ENCODE_CLIP,
JobName.RECOGNIZE_FACES,
JobName.GENERATE_THUMBHASH_THUMBNAIL,
],
}, },
{ {
item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } }, item: { name: JobName.CLASSIFY_IMAGE, data: { id: 'asset-1' } },

View File

@ -160,6 +160,7 @@ export class JobService {
case JobName.GENERATE_JPEG_THUMBNAIL: { case JobName.GENERATE_JPEG_THUMBNAIL: {
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data }); await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: item.data });
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: item.data });
await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data }); await this.jobRepository.queue({ name: JobName.CLASSIFY_IMAGE, data: item.data });
await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data }); await this.jobRepository.queue({ name: JobName.ENCODE_CLIP, data: item.data });
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data }); await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });

View File

@ -47,6 +47,7 @@ export interface IMediaRepository {
// image // image
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>; resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
crop(input: string, options: CropOptions): Promise<Buffer>; crop(input: string, options: CropOptions): Promise<Buffer>;
generateThumbhash(imagePath: string): Promise<Buffer>;
// video // video
extractVideoThumbnail(input: string, output: string, size: number): Promise<void>; extractVideoThumbnail(input: string, output: string, size: number): Promise<void>;

View File

@ -54,9 +54,9 @@ describe(MediaService.name, () => {
}); });
}); });
it('should queue all assets with missing thumbnails', async () => { it('should queue all assets with missing resize path', async () => {
assetMock.getWithout.mockResolvedValue({ assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.image], items: [assetEntityStub.noResizePath],
hasNextPage: false, hasNextPage: false,
}); });
@ -69,6 +69,38 @@ describe(MediaService.name, () => {
data: { id: assetEntityStub.image.id }, data: { id: assetEntityStub.image.id },
}); });
}); });
it('should queue all assets with missing webp path', async () => {
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.noWebpPath],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_WEBP_THUMBNAIL,
data: { id: assetEntityStub.image.id },
});
});
it('should queue all assets with missing thumbhash', async () => {
assetMock.getWithout.mockResolvedValue({
items: [assetEntityStub.noThumbhash],
hasNextPage: false,
});
await sut.handleQueueGenerateThumbnails({ force: false });
expect(assetMock.getAll).not.toHaveBeenCalled();
expect(assetMock.getWithout).toHaveBeenCalledWith({ skip: 0, take: 1000 }, WithoutProperty.THUMBNAIL);
expect(jobMock.queue).toHaveBeenCalledWith({
name: JobName.GENERATE_THUMBHASH_THUMBNAIL,
data: { id: assetEntityStub.image.id },
});
});
}); });
describe('handleGenerateJpegThumbnail', () => { describe('handleGenerateJpegThumbnail', () => {
@ -129,6 +161,25 @@ describe(MediaService.name, () => {
}); });
}); });
describe('handleGenerateThumbhashThumbnail', () => {
it('should skip thumbhash generation if resize path is missing', async () => {
assetMock.getByIds.mockResolvedValue([assetEntityStub.noResizePath]);
await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.noResizePath.id });
expect(mediaMock.generateThumbhash).not.toHaveBeenCalled();
});
it('should generate a thumbhash', async () => {
const thumbhashBuffer = Buffer.from('a thumbhash', 'utf8');
assetMock.getByIds.mockResolvedValue([assetEntityStub.image]);
mediaMock.generateThumbhash.mockResolvedValue(thumbhashBuffer);
await sut.handleGenerateThumbhashThumbnail({ id: assetEntityStub.image.id });
expect(mediaMock.generateThumbhash).toHaveBeenCalledWith('/uploads/user-id/thumbs/path.ext');
expect(assetMock.save).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
});
});
describe('handleQueueVideoConversion', () => { describe('handleQueueVideoConversion', () => {
it('should queue all video assets', async () => { it('should queue all video assets', async () => {
assetMock.getAll.mockResolvedValue({ assetMock.getAll.mockResolvedValue({

View File

@ -37,7 +37,16 @@ export class MediaService {
for await (const assets of assetPagination) { for await (const assets of assetPagination) {
for (const asset of assets) { for (const asset of assets) {
if (!asset.resizePath || force) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } }); await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
continue;
}
if (!asset.webpPath) {
await this.jobRepository.queue({ name: JobName.GENERATE_WEBP_THUMBNAIL, data: { id: asset.id } });
}
if (!asset.thumbhash) {
await this.jobRepository.queue({ name: JobName.GENERATE_THUMBHASH_THUMBNAIL, data: { id: asset.id } });
}
} }
} }
@ -87,6 +96,18 @@ export class MediaService {
return true; return true;
} }
async handleGenerateThumbhashThumbnail({ id }: IEntityJob): Promise<boolean> {
const [asset] = await this.assetRepository.getByIds([id]);
if (!asset?.resizePath) {
return false;
}
const thumbhash = await this.mediaRepository.generateThumbhash(asset.resizePath);
await this.assetRepository.save({ id: asset.id, thumbhash });
return true;
}
async handleQueueVideoConversion(job: IBaseJob) { async handleQueueVideoConversion(job: IBaseJob) {
const { force } = job; const { force } = job;

View File

@ -35,6 +35,7 @@ export class AssetCore {
livePhotoVideo: livePhotoAssetId != null ? ({ id: livePhotoAssetId } as AssetEntity) : null, livePhotoVideo: livePhotoAssetId != null ? ({ id: livePhotoAssetId } as AssetEntity) : null,
resizePath: null, resizePath: null,
webpPath: null, webpPath: null,
thumbhash: null,
encodedVideoPath: null, encodedVideoPath: null,
tags: [], tags: [],
sharedLinks: [], sharedLinks: [],

View File

@ -51,6 +51,9 @@ export class AssetEntity {
@Column({ type: 'varchar', nullable: true, default: '' }) @Column({ type: 'varchar', nullable: true, default: '' })
webpPath!: string | null; webpPath!: string | null;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'varchar', nullable: true, default: '' }) @Column({ type: 'varchar', nullable: true, default: '' })
encodedVideoPath!: string | null; encodedVideoPath!: string | null;

View File

@ -0,0 +1,13 @@
import { MigrationInterface, QueryRunner } from 'typeorm';
export class AddThumbhashColumn1685546571785 implements MigrationInterface {
name = 'AddThumbhashColumn1686762895180';
public async up(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" ADD "thumbhash" bytea NULL`);
}
public async down(queryRunner: QueryRunner): Promise<void> {
await queryRunner.query(`ALTER TABLE "assets" DROP COLUMN "thumbhash"`);
}
}

View File

@ -135,6 +135,7 @@ export class AssetRepository implements IAssetRepository {
{ resizePath: '', isVisible: true }, { resizePath: '', isVisible: true },
{ webpPath: IsNull(), isVisible: true }, { webpPath: IsNull(), isVisible: true },
{ webpPath: '', isVisible: true }, { webpPath: '', isVisible: true },
{ thumbhash: IsNull(), isVisible: true },
]; ];
break; break;

View File

@ -119,4 +119,17 @@ export class MediaRepository implements IMediaRepository {
.run(); .run();
}); });
} }
async generateThumbhash(imagePath: string): Promise<Buffer> {
const maxSize = 100;
const { data, info } = await sharp(imagePath)
.resize(maxSize, maxSize, { fit: 'inside', withoutEnlargement: true })
.raw()
.ensureAlpha()
.toBuffer({ resolveWithObject: true });
const thumbhash = await import('thumbhash');
return Buffer.from(thumbhash.rgbaToThumbHash(info.width, info.height, data));
}
} }

View File

@ -61,6 +61,7 @@ export class AppService {
[JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data), [JobName.QUEUE_GENERATE_THUMBNAILS]: (data) => this.mediaService.handleQueueGenerateThumbnails(data),
[JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data), [JobName.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(data),
[JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data), [JobName.GENERATE_WEBP_THUMBNAIL]: (data) => this.mediaService.handleGenerateWepbThumbnail(data),
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: (data) => this.mediaService.handleGenerateThumbhashThumbnail(data),
[JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data), [JobName.QUEUE_VIDEO_CONVERSION]: (data) => this.mediaService.handleQueueVideoConversion(data),
[JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data), [JobName.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data), [JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),

View File

@ -196,7 +196,8 @@ export const assetEntityStub = {
resizePath: null, resizePath: null,
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE, type: AssetType.IMAGE,
webpPath: null, webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null, encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'), createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -212,7 +213,7 @@ export const assetEntityStub = {
faces: [], faces: [],
sidecarPath: null, sidecarPath: null,
}), }),
image: Object.freeze<AssetEntity>({ noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id', id: 'asset-id',
deviceAssetId: 'device-asset-id', deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'), fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -225,6 +226,67 @@ export const assetEntityStub = {
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE, type: AssetType.IMAGE,
webpPath: null, webpPath: null,
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
mimeType: null,
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
}),
noThumbhash: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userEntityStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: null,
encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'),
mimeType: null,
isFavorite: true,
isArchived: false,
duration: null,
isVisible: true,
livePhotoVideo: null,
livePhotoVideoId: null,
tags: [],
sharedLinks: [],
originalFileName: 'asset-id.ext',
faces: [],
sidecarPath: null,
}),
image: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
fileCreatedAt: new Date('2023-02-23T05:06:29.716Z'),
owner: userEntityStub.user1,
ownerId: 'user-id',
deviceId: 'device-id',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: '/uploads/user-id/webp/path.ext',
thumbhash: Buffer.from('blablabla', 'base64'),
encodedVideoPath: null, encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'), createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -255,6 +317,7 @@ export const assetEntityStub = {
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.VIDEO, type: AssetType.VIDEO,
webpPath: null, webpPath: null,
thumbhash: null,
encodedVideoPath: null, encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'), createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -305,6 +368,7 @@ export const assetEntityStub = {
sidecarPath: null, sidecarPath: null,
type: AssetType.IMAGE, type: AssetType.IMAGE,
webpPath: null, webpPath: null,
thumbhash: null,
encodedVideoPath: null, encodedVideoPath: null,
createdAt: new Date('2023-02-23T05:06:29.716Z'), createdAt: new Date('2023-02-23T05:06:29.716Z'),
updatedAt: new Date('2023-02-23T05:06:29.716Z'), updatedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -334,6 +398,7 @@ export const assetEntityStub = {
deviceId: 'device-id', deviceId: 'device-id',
originalPath: '/original/path.ext', originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext', resizePath: '/uploads/user-id/thumbs/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'), checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE, type: AssetType.IMAGE,
webpPath: null, webpPath: null,
@ -507,6 +572,7 @@ const assetResponse: AssetResponseDto = {
originalPath: 'fake_path/jpeg', originalPath: 'fake_path/jpeg',
originalFileName: 'asset_1.jpeg', originalFileName: 'asset_1.jpeg',
resized: false, resized: false,
thumbhash: null,
fileModifiedAt: today, fileModifiedAt: today,
fileCreatedAt: today, fileCreatedAt: today,
updatedAt: today, updatedAt: today,
@ -787,6 +853,7 @@ export const sharedLinkStub = {
clipEmbedding: [0.12, 0.13, 0.14], clipEmbedding: [0.12, 0.13, 0.14],
}, },
webpPath: '', webpPath: '',
thumbhash: null,
encodedVideoPath: '', encodedVideoPath: '',
duration: null, duration: null,
isVisible: true, isVisible: true,

View File

@ -3,6 +3,7 @@ import { IMediaRepository } from '@app/domain';
export const newMediaRepositoryMock = (): jest.Mocked<IMediaRepository> => { export const newMediaRepositoryMock = (): jest.Mocked<IMediaRepository> => {
return { return {
extractVideoThumbnail: jest.fn(), extractVideoThumbnail: jest.fn(),
generateThumbhash: jest.fn(),
resize: jest.fn(), resize: jest.fn(),
crop: jest.fn(), crop: jest.fn(),
probe: jest.fn(), probe: jest.fn(),

View File

@ -9,6 +9,7 @@
"allowSyntheticDefaultImports": true, "allowSyntheticDefaultImports": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"target": "es2017", "target": "es2017",
"moduleResolution": "node16",
"sourceMap": true, "sourceMap": true,
"outDir": "./dist", "outDir": "./dist",
"incremental": true, "incremental": true,

61
web/package-lock.json generated
View File

@ -20,7 +20,8 @@
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"svelte-local-storage-store": "^0.5.0", "svelte-local-storage-store": "^0.5.0",
"svelte-material-icons": "^3.0.4" "svelte-material-icons": "^3.0.4",
"unlazy": "^0.8.9"
}, },
"devDependencies": { "devDependencies": {
"@babel/preset-env": "^7.20.2", "@babel/preset-env": "^7.20.2",
@ -4134,6 +4135,15 @@
"url": "https://opencollective.com/typescript-eslint" "url": "https://opencollective.com/typescript-eslint"
} }
}, },
"node_modules/@unlazy/core": {
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/@unlazy/core/-/core-0.8.9.tgz",
"integrity": "sha512-DQ4WB/cuEWTknU/59uRwpSipvMAJzBDmRyaHDUc1RcXi0Z7/Vcl0EE7BpROxEynqd1EI+2oMWQaDLyXffUdUiA==",
"dependencies": {
"fast-blurhash": "^1.1.2",
"thumbhash": "^0.1.1"
}
},
"node_modules/@zoom-image/core": { "node_modules/@zoom-image/core": {
"version": "0.18.2", "version": "0.18.2",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.18.2.tgz", "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.18.2.tgz",
@ -5945,6 +5955,11 @@
"node": ">= 14" "node": ">= 14"
} }
}, },
"node_modules/fast-blurhash": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-blurhash/-/fast-blurhash-1.1.2.tgz",
"integrity": "sha512-lJVOgYSlahqkRhrKumNx/SGB2F/qS0D1z7xjGYjb5EZJRtlzySGMniZjkQ9h9Rv8sPmM/V9orEgRiMwazDNH6A=="
},
"node_modules/fast-deep-equal": { "node_modules/fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -11217,6 +11232,11 @@
"node": ">=0.8" "node": ">=0.8"
} }
}, },
"node_modules/thumbhash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="
},
"node_modules/tiny-glob": { "node_modules/tiny-glob": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@ -11441,6 +11461,18 @@
"node": ">= 4.0.0" "node": ">= 4.0.0"
} }
}, },
"node_modules/unlazy": {
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/unlazy/-/unlazy-0.8.9.tgz",
"integrity": "sha512-lRCuXN20N1esqSQqtSVBLAw9GJz0lcBuOBs3UGGw7cFWHQlWJVZZ3OviwOl42f1CnVHjAON1rs2hIdJWgMAUyg==",
"dependencies": {
"@unlazy/core": "0.8.9"
},
"peerDependencies": {
"fast-blurhash": "^1.1.2",
"thumbhash": "^0.1.1"
}
},
"node_modules/update-browserslist-db": { "node_modules/update-browserslist-db": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",
@ -14739,6 +14771,15 @@
"eslint-visitor-keys": "^3.3.0" "eslint-visitor-keys": "^3.3.0"
} }
}, },
"@unlazy/core": {
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/@unlazy/core/-/core-0.8.9.tgz",
"integrity": "sha512-DQ4WB/cuEWTknU/59uRwpSipvMAJzBDmRyaHDUc1RcXi0Z7/Vcl0EE7BpROxEynqd1EI+2oMWQaDLyXffUdUiA==",
"requires": {
"fast-blurhash": "^1.1.2",
"thumbhash": "^0.1.1"
}
},
"@zoom-image/core": { "@zoom-image/core": {
"version": "0.18.2", "version": "0.18.2",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.18.2.tgz", "resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.18.2.tgz",
@ -16053,6 +16094,11 @@
"source-map-support": "^0.5.21" "source-map-support": "^0.5.21"
} }
}, },
"fast-blurhash": {
"version": "1.1.2",
"resolved": "https://registry.npmjs.org/fast-blurhash/-/fast-blurhash-1.1.2.tgz",
"integrity": "sha512-lJVOgYSlahqkRhrKumNx/SGB2F/qS0D1z7xjGYjb5EZJRtlzySGMniZjkQ9h9Rv8sPmM/V9orEgRiMwazDNH6A=="
},
"fast-deep-equal": { "fast-deep-equal": {
"version": "3.1.3", "version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz", "resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -19861,6 +19907,11 @@
"thenify": ">= 3.1.0 < 4" "thenify": ">= 3.1.0 < 4"
} }
}, },
"thumbhash": {
"version": "0.1.1",
"resolved": "https://registry.npmjs.org/thumbhash/-/thumbhash-0.1.1.tgz",
"integrity": "sha512-kH5pKeIIBPQXAOni2AiY/Cu/NKdkFREdpH+TLdM0g6WA7RriCv0kPLgP731ady67MhTAqrVG/4mnEeibVuCJcg=="
},
"tiny-glob": { "tiny-glob": {
"version": "0.2.9", "version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz", "resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@ -20023,6 +20074,14 @@
"integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==", "integrity": "sha512-CJ1QgKmNg3CwvAv/kOFmtnEN05f0D/cn9QntgNOQlQF9dgvVTHj3t+8JPdjqawCHk7V/KA+fbUqzZ9XWhcqPUg==",
"dev": true "dev": true
}, },
"unlazy": {
"version": "0.8.9",
"resolved": "https://registry.npmjs.org/unlazy/-/unlazy-0.8.9.tgz",
"integrity": "sha512-lRCuXN20N1esqSQqtSVBLAw9GJz0lcBuOBs3UGGw7cFWHQlWJVZZ3OviwOl42f1CnVHjAON1rs2hIdJWgMAUyg==",
"requires": {
"@unlazy/core": "0.8.9"
}
},
"update-browserslist-db": { "update-browserslist-db": {
"version": "1.0.10", "version": "1.0.10",
"resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz", "resolved": "https://registry.npmjs.org/update-browserslist-db/-/update-browserslist-db-1.0.10.tgz",

View File

@ -70,6 +70,7 @@
"rxjs": "^7.8.0", "rxjs": "^7.8.0",
"socket.io-client": "^4.6.1", "socket.io-client": "^4.6.1",
"svelte-local-storage-store": "^0.5.0", "svelte-local-storage-store": "^0.5.0",
"svelte-material-icons": "^3.0.4" "svelte-material-icons": "^3.0.4",
"unlazy": "^0.8.9"
} }
} }

View File

@ -637,6 +637,12 @@ export interface AssetResponseDto {
* @memberof AssetResponseDto * @memberof AssetResponseDto
*/ */
'resized': boolean; 'resized': boolean;
/**
* base64 encoded thumbhash
* @type {string}
* @memberof AssetResponseDto
*/
'thumbhash': string | null;
/** /**
* *
* @type {string} * @type {string}

View File

@ -120,6 +120,7 @@
altText={person.name} altText={person.name}
widthStyle="90px" widthStyle="90px"
heightStyle="90px" heightStyle="90px"
thumbhash={null}
/> />
<p class="font-medium mt-1 truncate">{person.name}</p> <p class="font-medium mt-1 truncate">{person.name}</p>
</a> </a>

View File

@ -1,17 +1,47 @@
<script lang="ts"> <script lang="ts">
import { onMount } from 'svelte';
import { lazyLoad } from 'unlazy';
import { imageLoad } from '$lib/utils/image-load'; import { imageLoad } from '$lib/utils/image-load';
export let url: string; export let url: string;
export let altText: string; export let altText: string;
export let heightStyle: string | undefined = undefined; export let heightStyle: string | undefined = undefined;
export let widthStyle: string; export let widthStyle: string;
export let thumbhash: string | null = null;
export let curve = false; export let curve = false;
export let shadow = false; export let shadow = false;
export let circle = false; export let circle = false;
let loading = true; let loading = true;
let imageElement: HTMLImageElement;
onMount(() => {
if (thumbhash) {
lazyLoad(imageElement, {
hash: thumbhash,
hashType: 'thumbhash'
});
}
});
</script> </script>
<img {#if thumbhash}
<img
style:width={widthStyle}
style:height={heightStyle}
data-src={url}
alt={altText}
class="object-cover"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
draggable="false"
bind:this={imageElement}
/>
<!-- not everthing yet has thumbhash support so the old method is kept -->
{:else}
<img
style:width={widthStyle} style:width={widthStyle}
style:height={heightStyle} style:height={heightStyle}
src={url} src={url}
@ -24,4 +54,5 @@
draggable="false" draggable="false"
use:imageLoad use:imageLoad
on:image-load|once={() => (loading = false)} on:image-load|once={() => (loading = false)}
/> />
{/if}

View File

@ -129,6 +129,7 @@
altText={asset.originalFileName} altText={asset.originalFileName}
widthStyle="{width}px" widthStyle="{width}px"
heightStyle="{height}px" heightStyle="{height}px"
thumbhash={asset.thumbhash}
/> />
{:else} {:else}
<div class="w-full h-full p-4 flex items-center justify-center"> <div class="w-full h-full p-4 flex items-center justify-center">