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": {
"type": "boolean"
},
"thumbhash": {
"type": "string",
"nullable": true,
"description": "base64 encoded thumbhash"
},
"fileCreatedAt": {
"format": "date-time",
"type": "string"
@ -4926,6 +4931,7 @@
"originalPath",
"originalFileName",
"resized",
"thumbhash",
"fileCreatedAt",
"fileModifiedAt",
"updatedAt",

View File

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

View File

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

View File

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

View File

@ -27,6 +27,7 @@ export enum JobName {
QUEUE_GENERATE_THUMBNAILS = 'queue-generate-thumbnails',
GENERATE_JPEG_THUMBNAIL = 'generate-jpeg-thumbnail',
GENERATE_WEBP_THUMBNAIL = 'generate-webp-thumbnail',
GENERATE_THUMBHASH_THUMBNAIL = 'generate-thumbhash-thumbnail',
// metadata
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.GENERATE_JPEG_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_WEBP_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
[JobName.GENERATE_THUMBHASH_THUMBNAIL]: QueueName.THUMBNAIL_GENERATION,
// metadata
[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.GENERATE_JPEG_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_WEBP_THUMBNAIL; data: IEntityJob }
| { name: JobName.GENERATE_THUMBHASH_THUMBNAIL; data: IEntityJob }
// User Deletion
| { 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' } },
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' } },

View File

@ -160,6 +160,7 @@ export class JobService {
case JobName.GENERATE_JPEG_THUMBNAIL: {
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.ENCODE_CLIP, data: item.data });
await this.jobRepository.queue({ name: JobName.RECOGNIZE_FACES, data: item.data });

View File

@ -47,6 +47,7 @@ export interface IMediaRepository {
// image
resize(input: string | Buffer, output: string, options: ResizeOptions): Promise<void>;
crop(input: string, options: CropOptions): Promise<Buffer>;
generateThumbhash(imagePath: string): Promise<Buffer>;
// video
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({
items: [assetEntityStub.image],
items: [assetEntityStub.noResizePath],
hasNextPage: false,
});
@ -69,6 +69,38 @@ describe(MediaService.name, () => {
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', () => {
@ -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', () => {
it('should queue all video assets', async () => {
assetMock.getAll.mockResolvedValue({

View File

@ -37,7 +37,16 @@ export class MediaService {
for await (const assets of assetPagination) {
for (const asset of assets) {
await this.jobRepository.queue({ name: JobName.GENERATE_JPEG_THUMBNAIL, data: { id: asset.id } });
if (!asset.resizePath || force) {
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;
}
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) {
const { force } = job;

View File

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

View File

@ -51,6 +51,9 @@ export class AssetEntity {
@Column({ type: 'varchar', nullable: true, default: '' })
webpPath!: string | null;
@Column({ type: 'bytea', nullable: true })
thumbhash!: Buffer | null;
@Column({ type: 'varchar', nullable: true, default: '' })
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 },
{ webpPath: IsNull(), isVisible: true },
{ webpPath: '', isVisible: true },
{ thumbhash: IsNull(), isVisible: true },
];
break;

View File

@ -119,4 +119,17 @@ export class MediaRepository implements IMediaRepository {
.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.GENERATE_JPEG_THUMBNAIL]: (data) => this.mediaService.handleGenerateJpegThumbnail(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.VIDEO_CONVERSION]: (data) => this.mediaService.handleVideoConversion(data),
[JobName.QUEUE_METADATA_EXTRACTION]: (data) => this.metadataProcessor.handleQueueMetadataExtraction(data),

View File

@ -196,7 +196,8 @@ export const assetEntityStub = {
resizePath: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: null,
webpPath: '/uploads/user-id/webp/path.ext',
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'),
@ -212,7 +213,7 @@ export const assetEntityStub = {
faces: [],
sidecarPath: null,
}),
image: Object.freeze<AssetEntity>({
noWebpPath: Object.freeze<AssetEntity>({
id: 'asset-id',
deviceAssetId: 'device-asset-id',
fileModifiedAt: new Date('2023-02-23T05:06:29.716Z'),
@ -225,6 +226,67 @@ export const assetEntityStub = {
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
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,
createdAt: 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'),
type: AssetType.VIDEO,
webpPath: null,
thumbhash: null,
encodedVideoPath: null,
createdAt: 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,
type: AssetType.IMAGE,
webpPath: null,
thumbhash: null,
encodedVideoPath: null,
createdAt: 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',
originalPath: '/original/path.ext',
resizePath: '/uploads/user-id/thumbs/path.ext',
thumbhash: null,
checksum: Buffer.from('file hash', 'utf8'),
type: AssetType.IMAGE,
webpPath: null,
@ -507,6 +572,7 @@ const assetResponse: AssetResponseDto = {
originalPath: 'fake_path/jpeg',
originalFileName: 'asset_1.jpeg',
resized: false,
thumbhash: null,
fileModifiedAt: today,
fileCreatedAt: today,
updatedAt: today,
@ -787,6 +853,7 @@ export const sharedLinkStub = {
clipEmbedding: [0.12, 0.13, 0.14],
},
webpPath: '',
thumbhash: null,
encodedVideoPath: '',
duration: null,
isVisible: true,

View File

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

View File

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

61
web/package-lock.json generated
View File

@ -20,7 +20,8 @@
"rxjs": "^7.8.0",
"socket.io-client": "^4.6.1",
"svelte-local-storage-store": "^0.5.0",
"svelte-material-icons": "^3.0.4"
"svelte-material-icons": "^3.0.4",
"unlazy": "^0.8.9"
},
"devDependencies": {
"@babel/preset-env": "^7.20.2",
@ -4134,6 +4135,15 @@
"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": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.18.2.tgz",
@ -5945,6 +5955,11 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -11217,6 +11232,11 @@
"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": {
"version": "0.2.9",
"resolved": "https://registry.npmjs.org/tiny-glob/-/tiny-glob-0.2.9.tgz",
@ -11441,6 +11461,18 @@
"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": {
"version": "1.0.10",
"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"
}
},
"@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": {
"version": "0.18.2",
"resolved": "https://registry.npmjs.org/@zoom-image/core/-/core-0.18.2.tgz",
@ -16053,6 +16094,11 @@
"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": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/fast-deep-equal/-/fast-deep-equal-3.1.3.tgz",
@ -19861,6 +19907,11 @@
"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": {
"version": "0.2.9",
"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==",
"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": {
"version": "1.0.10",
"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",
"socket.io-client": "^4.6.1",
"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
*/
'resized': boolean;
/**
* base64 encoded thumbhash
* @type {string}
* @memberof AssetResponseDto
*/
'thumbhash': string | null;
/**
*
* @type {string}

View File

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

View File

@ -1,27 +1,58 @@
<script lang="ts">
import { onMount } from 'svelte';
import { lazyLoad } from 'unlazy';
import { imageLoad } from '$lib/utils/image-load';
export let url: string;
export let altText: string;
export let heightStyle: string | undefined = undefined;
export let widthStyle: string;
export let thumbhash: string | null = null;
export let curve = false;
export let shadow = false;
export let circle = false;
let loading = true;
let imageElement: HTMLImageElement;
onMount(() => {
if (thumbhash) {
lazyLoad(imageElement, {
hash: thumbhash,
hashType: 'thumbhash'
});
}
});
</script>
<img
style:width={widthStyle}
style:height={heightStyle}
src={url}
alt={altText}
class="object-cover transition-opacity duration-300"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:opacity-0={loading}
draggable="false"
use:imageLoad
on:image-load|once={() => (loading = false)}
/>
{#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:height={heightStyle}
src={url}
alt={altText}
class="object-cover transition-opacity duration-300"
class:rounded-lg={curve}
class:shadow-lg={shadow}
class:rounded-full={circle}
class:opacity-0={loading}
draggable="false"
use:imageLoad
on:image-load|once={() => (loading = false)}
/>
{/if}

View File

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