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:
parent
3512140148
commit
3e804f16df
BIN
mobile/openapi/doc/AssetResponseDto.md
generated
BIN
mobile/openapi/doc/AssetResponseDto.md
generated
Binary file not shown.
BIN
mobile/openapi/lib/model/asset_response_dto.dart
generated
BIN
mobile/openapi/lib/model/asset_response_dto.dart
generated
Binary file not shown.
BIN
mobile/openapi/test/asset_response_dto_test.dart
generated
BIN
mobile/openapi/test/asset_response_dto_test.dart
generated
Binary file not shown.
@ -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",
|
||||||
|
23
server/package-lock.json
generated
23
server/package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
|
@ -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,
|
||||||
|
@ -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,
|
||||||
|
@ -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 }
|
||||||
|
@ -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' } },
|
||||||
|
@ -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 });
|
||||||
|
@ -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>;
|
||||||
|
@ -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({
|
||||||
|
@ -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) {
|
||||||
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;
|
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;
|
||||||
|
|
||||||
|
@ -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: [],
|
||||||
|
@ -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;
|
||||||
|
|
||||||
|
@ -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"`);
|
||||||
|
}
|
||||||
|
}
|
@ -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;
|
||||||
|
|
||||||
|
@ -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));
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
@ -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),
|
||||||
|
@ -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,
|
||||||
|
@ -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(),
|
||||||
|
@ -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
61
web/package-lock.json
generated
@ -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",
|
||||||
|
@ -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"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
6
web/src/api/open-api/api.ts
generated
6
web/src/api/open-api/api.ts
generated
@ -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}
|
||||||
|
@ -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>
|
||||||
|
@ -1,27 +1,58 @@
|
|||||||
<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}
|
||||||
style:width={widthStyle}
|
<img
|
||||||
style:height={heightStyle}
|
style:width={widthStyle}
|
||||||
src={url}
|
style:height={heightStyle}
|
||||||
alt={altText}
|
data-src={url}
|
||||||
class="object-cover transition-opacity duration-300"
|
alt={altText}
|
||||||
class:rounded-lg={curve}
|
class="object-cover"
|
||||||
class:shadow-lg={shadow}
|
class:rounded-lg={curve}
|
||||||
class:rounded-full={circle}
|
class:shadow-lg={shadow}
|
||||||
class:opacity-0={loading}
|
class:rounded-full={circle}
|
||||||
draggable="false"
|
draggable="false"
|
||||||
use:imageLoad
|
bind:this={imageElement}
|
||||||
on:image-load|once={() => (loading = false)}
|
/>
|
||||||
/>
|
|
||||||
|
<!-- 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}
|
||||||
|
@ -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">
|
||||||
|
Loading…
Reference in New Issue
Block a user