mirror of
https://github.com/samsonjs/immich.git
synced 2026-04-27 15:07:45 +00:00
refactor(server): add isProgressive column (#25537)
* add isProgressive column * don't select isProgressive by default * linting * exclude sidecars
This commit is contained in:
parent
b5c3d87290
commit
9506398153
11 changed files with 398 additions and 152 deletions
|
|
@ -34,6 +34,8 @@ export interface MoveRequest {
|
||||||
|
|
||||||
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
export type ThumbnailPathEntity = { id: string; ownerId: string };
|
||||||
|
|
||||||
|
export type ImagePathOptions = { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean };
|
||||||
|
|
||||||
let instance: StorageCore | null;
|
let instance: StorageCore | null;
|
||||||
|
|
||||||
let mediaLocation: string | undefined;
|
let mediaLocation: string | undefined;
|
||||||
|
|
@ -110,14 +112,7 @@ export class StorageCore {
|
||||||
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
return StorageCore.getNestedPath(StorageFolder.Thumbnails, person.ownerId, `${person.id}.jpeg`);
|
||||||
}
|
}
|
||||||
|
|
||||||
static getImagePath(
|
static getImagePath(asset: ThumbnailPathEntity, { fileType, format, isEdited }: ImagePathOptions) {
|
||||||
asset: ThumbnailPathEntity,
|
|
||||||
{
|
|
||||||
fileType,
|
|
||||||
format,
|
|
||||||
isEdited,
|
|
||||||
}: { fileType: AssetFileType; format: ImageFormat | RawExtractedFormat; isEdited: boolean },
|
|
||||||
) {
|
|
||||||
return StorageCore.getNestedPath(
|
return StorageCore.getNestedPath(
|
||||||
StorageFolder.Thumbnails,
|
StorageFolder.Thumbnails,
|
||||||
asset.ownerId,
|
asset.ownerId,
|
||||||
|
|
|
||||||
|
|
@ -346,6 +346,13 @@ export const columns = {
|
||||||
'asset.height',
|
'asset.height',
|
||||||
],
|
],
|
||||||
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type', 'asset_file.isEdited'],
|
||||||
|
assetFilesForThumbnail: [
|
||||||
|
'asset_file.id',
|
||||||
|
'asset_file.path',
|
||||||
|
'asset_file.type',
|
||||||
|
'asset_file.isEdited',
|
||||||
|
'asset_file.isProgressive',
|
||||||
|
],
|
||||||
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
|
||||||
authApiKey: ['api_key.id', 'api_key.permissions'],
|
authApiKey: ['api_key.id', 'api_key.permissions'],
|
||||||
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
authSession: ['session.id', 'session.updatedAt', 'session.pinExpiresAt', 'session.appVersion'],
|
||||||
|
|
|
||||||
|
|
@ -165,11 +165,13 @@ select
|
||||||
"asset_file"."id",
|
"asset_file"."id",
|
||||||
"asset_file"."path",
|
"asset_file"."path",
|
||||||
"asset_file"."type",
|
"asset_file"."type",
|
||||||
"asset_file"."isEdited"
|
"asset_file"."isEdited",
|
||||||
|
"asset_file"."isProgressive"
|
||||||
from
|
from
|
||||||
"asset_file"
|
"asset_file"
|
||||||
where
|
where
|
||||||
"asset_file"."assetId" = "asset"."id"
|
"asset_file"."assetId" = "asset"."id"
|
||||||
|
and "asset_file"."type" in ($1, $2, $3)
|
||||||
) as agg
|
) as agg
|
||||||
) as "files",
|
) as "files",
|
||||||
(
|
(
|
||||||
|
|
@ -191,7 +193,7 @@ from
|
||||||
"asset"
|
"asset"
|
||||||
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
inner join "asset_exif" on "asset"."id" = "asset_exif"."assetId"
|
||||||
where
|
where
|
||||||
"asset"."id" = $1
|
"asset"."id" = $4
|
||||||
|
|
||||||
-- AssetJobRepository.getForMetadataExtraction
|
-- AssetJobRepository.getForMetadataExtraction
|
||||||
select
|
select
|
||||||
|
|
|
||||||
|
|
@ -1,5 +1,6 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { Kysely } from 'kysely';
|
import { Kysely } from 'kysely';
|
||||||
|
import { jsonArrayFrom } from 'kysely/helpers/postgres';
|
||||||
import { InjectKysely } from 'nestjs-kysely';
|
import { InjectKysely } from 'nestjs-kysely';
|
||||||
import { Asset, columns } from 'src/database';
|
import { Asset, columns } from 'src/database';
|
||||||
import { DummyValue, GenerateSql } from 'src/decorators';
|
import { DummyValue, GenerateSql } from 'src/decorators';
|
||||||
|
|
@ -104,7 +105,15 @@ export class AssetJobRepository {
|
||||||
'asset.thumbhash',
|
'asset.thumbhash',
|
||||||
'asset.type',
|
'asset.type',
|
||||||
])
|
])
|
||||||
.select(withFiles)
|
.select((eb) =>
|
||||||
|
jsonArrayFrom(
|
||||||
|
eb
|
||||||
|
.selectFrom('asset_file')
|
||||||
|
.select(columns.assetFilesForThumbnail)
|
||||||
|
.whereRef('asset_file.assetId', '=', 'asset.id')
|
||||||
|
.where('asset_file.type', 'in', [AssetFileType.Thumbnail, AssetFileType.Preview, AssetFileType.FullSize]),
|
||||||
|
).as('files'),
|
||||||
|
)
|
||||||
.select(withEdits)
|
.select(withEdits)
|
||||||
.$call(withExifInner)
|
.$call(withExifInner)
|
||||||
.where('asset.id', '=', id)
|
.where('asset.id', '=', id)
|
||||||
|
|
|
||||||
|
|
@ -904,11 +904,12 @@ export class AssetRepository {
|
||||||
.execute();
|
.execute();
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertFile(file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>): Promise<void> {
|
async upsertFile(
|
||||||
const value = { ...file, assetId: asUuid(file.assetId) };
|
file: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>,
|
||||||
|
): Promise<void> {
|
||||||
await this.db
|
await this.db
|
||||||
.insertInto('asset_file')
|
.insertInto('asset_file')
|
||||||
.values(value)
|
.values(file)
|
||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||||
path: eb.ref('excluded.path'),
|
path: eb.ref('excluded.path'),
|
||||||
|
|
@ -918,19 +919,19 @@ export class AssetRepository {
|
||||||
}
|
}
|
||||||
|
|
||||||
async upsertFiles(
|
async upsertFiles(
|
||||||
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited'>[],
|
files: Pick<Insertable<AssetFileTable>, 'assetId' | 'path' | 'type' | 'isEdited' | 'isProgressive'>[],
|
||||||
): Promise<void> {
|
): Promise<void> {
|
||||||
if (files.length === 0) {
|
if (files.length === 0) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const values = files.map((row) => ({ ...row, assetId: asUuid(row.assetId) }));
|
|
||||||
await this.db
|
await this.db
|
||||||
.insertInto('asset_file')
|
.insertInto('asset_file')
|
||||||
.values(values)
|
.values(files)
|
||||||
.onConflict((oc) =>
|
.onConflict((oc) =>
|
||||||
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
oc.columns(['assetId', 'type', 'isEdited']).doUpdateSet((eb) => ({
|
||||||
path: eb.ref('excluded.path'),
|
path: eb.ref('excluded.path'),
|
||||||
|
isProgressive: eb.ref('excluded.isProgressive'),
|
||||||
})),
|
})),
|
||||||
)
|
)
|
||||||
.execute();
|
.execute();
|
||||||
|
|
|
||||||
|
|
@ -0,0 +1,9 @@
|
||||||
|
import { Kysely, sql } from 'kysely';
|
||||||
|
|
||||||
|
export async function up(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "asset_file" ADD "isProgressive" boolean NOT NULL DEFAULT false;`.execute(db);
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function down(db: Kysely<any>): Promise<void> {
|
||||||
|
await sql`ALTER TABLE "asset_file" DROP COLUMN "isProgressive";`.execute(db);
|
||||||
|
}
|
||||||
|
|
@ -40,4 +40,7 @@ export class AssetFileTable {
|
||||||
|
|
||||||
@Column({ type: 'boolean', default: false })
|
@Column({ type: 'boolean', default: false })
|
||||||
isEdited!: Generated<boolean>;
|
isEdited!: Generated<boolean>;
|
||||||
|
|
||||||
|
@Column({ type: 'boolean', default: false })
|
||||||
|
isProgressive!: Generated<boolean>;
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -388,12 +388,14 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: expect.any(String),
|
path: expect.any(String),
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: expect.any(String),
|
path: expect.any(String),
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
|
expect(mocks.asset.update).toHaveBeenCalledWith({ id: 'asset-id', thumbhash: thumbhashBuffer });
|
||||||
|
|
@ -426,12 +428,14 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: expect.any(String),
|
path: expect.any(String),
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: expect.any(String),
|
path: expect.any(String),
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -463,12 +467,14 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: expect.any(String),
|
path: expect.any(String),
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: expect.any(String),
|
path: expect.any(String),
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
@ -673,6 +679,16 @@ describe(MediaService.name, () => {
|
||||||
}),
|
}),
|
||||||
expect.stringContaining('thumbnail.webp'),
|
expect.stringContaining('thumbnail.webp'),
|
||||||
);
|
);
|
||||||
|
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
isProgressive: true,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
isProgressive: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should generate progressive JPEG for thumbnail when enabled', async () => {
|
it('should generate progressive JPEG for thumbnail when enabled', async () => {
|
||||||
|
|
@ -699,6 +715,37 @@ describe(MediaService.name, () => {
|
||||||
}),
|
}),
|
||||||
expect.stringContaining('thumbnail.jpeg'),
|
expect.stringContaining('thumbnail.jpeg'),
|
||||||
);
|
);
|
||||||
|
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
isProgressive: false,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
isProgressive: true,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should never set isProgressive for videos', async () => {
|
||||||
|
mocks.media.probe.mockResolvedValue(probeStub.videoStreamHDR);
|
||||||
|
mocks.systemMetadata.get.mockResolvedValue({
|
||||||
|
image: { preview: { progressive: true }, thumbnail: { progressive: true } },
|
||||||
|
});
|
||||||
|
mocks.assetJob.getForGenerateThumbnailJob.mockResolvedValue(assetStub.video);
|
||||||
|
|
||||||
|
await sut.handleGenerateThumbnails({ id: assetStub.video.id });
|
||||||
|
|
||||||
|
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||||
|
expect.objectContaining({
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
isProgressive: false,
|
||||||
|
}),
|
||||||
|
expect.objectContaining({
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
isProgressive: false,
|
||||||
|
}),
|
||||||
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
it('should delete previous thumbnail if different path', async () => {
|
it('should delete previous thumbnail if different path', async () => {
|
||||||
|
|
@ -3353,14 +3400,38 @@ describe(MediaService.name, () => {
|
||||||
files: [],
|
files: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut['syncFiles'](asset, [
|
await sut['syncFiles'](asset.files, [
|
||||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
{
|
||||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
path: '/new/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
path: '/new/thumbnail.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
{
|
||||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
assetId: 'asset-id',
|
||||||
|
path: '/new/preview.jpg',
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assetId: 'asset-id',
|
||||||
|
path: '/new/thumbnail.jpg',
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||||
|
|
@ -3376,6 +3447,7 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: '/old/preview.jpg',
|
path: '/old/preview.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'file-2',
|
id: 'file-2',
|
||||||
|
|
@ -3383,18 +3455,43 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: '/old/thumbnail.jpg',
|
path: '/old/thumbnail.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut['syncFiles'](asset, [
|
await sut['syncFiles'](asset.files, [
|
||||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false },
|
{
|
||||||
{ type: AssetFileType.Thumbnail, newPath: '/new/thumbnail.jpg', isEdited: false },
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
path: '/new/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
path: '/new/thumbnail.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
{
|
||||||
{ assetId: 'asset-id', path: '/new/thumbnail.jpg', type: AssetFileType.Thumbnail, isEdited: false },
|
assetId: 'asset-id',
|
||||||
|
path: '/new/preview.jpg',
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assetId: 'asset-id',
|
||||||
|
path: '/new/thumbnail.jpg',
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
|
|
@ -3413,6 +3510,7 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: '/old/preview.jpg',
|
path: '/old/preview.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'file-2',
|
id: 'file-2',
|
||||||
|
|
@ -3420,24 +3518,30 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: '/old/thumbnail.jpg',
|
path: '/old/thumbnail.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut['syncFiles'](asset, [
|
await sut['syncFiles'](asset.files, []);
|
||||||
{ type: AssetFileType.Preview, isEdited: false },
|
|
||||||
{ type: AssetFileType.Thumbnail, isEdited: false },
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||||
{ id: 'file-1', assetId: 'asset-id', type: AssetFileType.Preview, path: '/old/preview.jpg', isEdited: false },
|
{
|
||||||
|
id: 'file-1',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
path: '/old/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
{
|
{
|
||||||
id: 'file-2',
|
id: 'file-2',
|
||||||
assetId: 'asset-id',
|
assetId: 'asset-id',
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: '/old/thumbnail.jpg',
|
path: '/old/thumbnail.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
|
|
@ -3456,6 +3560,7 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: '/same/preview.jpg',
|
path: '/same/preview.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'file-2',
|
id: 'file-2',
|
||||||
|
|
@ -3463,13 +3568,26 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: '/same/thumbnail.jpg',
|
path: '/same/thumbnail.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut['syncFiles'](asset, [
|
await sut['syncFiles'](asset.files, [
|
||||||
{ type: AssetFileType.Preview, newPath: '/same/preview.jpg', isEdited: false },
|
{
|
||||||
{ type: AssetFileType.Thumbnail, newPath: '/same/thumbnail.jpg', isEdited: false },
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
path: '/same/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
path: '/same/thumbnail.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||||
|
|
@ -3487,6 +3605,7 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: '/old/preview.jpg',
|
path: '/old/preview.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
id: 'file-2',
|
id: 'file-2',
|
||||||
|
|
@ -3494,19 +3613,43 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: '/old/thumbnail.jpg',
|
path: '/old/thumbnail.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut['syncFiles'](asset, [
|
await sut['syncFiles'](asset.files, [
|
||||||
{ type: AssetFileType.Preview, newPath: '/new/preview.jpg', isEdited: false }, // replace
|
{
|
||||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // delete
|
assetId: asset.id,
|
||||||
{ type: AssetFileType.FullSize, newPath: '/new/fullsize.jpg', isEdited: false }, // new
|
type: AssetFileType.Preview,
|
||||||
|
path: '/new/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
}, // replace
|
||||||
|
{
|
||||||
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.FullSize,
|
||||||
|
path: '/new/fullsize.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
}, // new
|
||||||
]);
|
]);
|
||||||
|
|
||||||
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||||
{ assetId: 'asset-id', path: '/new/preview.jpg', type: AssetFileType.Preview, isEdited: false },
|
{
|
||||||
{ assetId: 'asset-id', path: '/new/fullsize.jpg', type: AssetFileType.FullSize, isEdited: false },
|
assetId: 'asset-id',
|
||||||
|
path: '/new/preview.jpg',
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assetId: 'asset-id',
|
||||||
|
path: '/new/fullsize.jpg',
|
||||||
|
type: AssetFileType.FullSize,
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||||
{
|
{
|
||||||
|
|
@ -3515,6 +3658,7 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Thumbnail,
|
type: AssetFileType.Thumbnail,
|
||||||
path: '/old/thumbnail.jpg',
|
path: '/old/thumbnail.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
]);
|
]);
|
||||||
expect(mocks.job.queue).toHaveBeenCalledWith({
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
|
|
@ -3529,7 +3673,7 @@ describe(MediaService.name, () => {
|
||||||
files: [],
|
files: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut['syncFiles'](asset, []);
|
await sut['syncFiles'](asset.files, []);
|
||||||
|
|
||||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||||
|
|
@ -3546,15 +3690,79 @@ describe(MediaService.name, () => {
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: '/old/preview.jpg',
|
path: '/old/preview.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
};
|
};
|
||||||
|
|
||||||
await sut['syncFiles'](asset, [
|
await sut['syncFiles'](asset.files, []);
|
||||||
{ type: AssetFileType.Thumbnail, isEdited: false }, // file doesn't exist, newPath not provided
|
|
||||||
]);
|
|
||||||
|
|
||||||
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.upsertFiles).not.toHaveBeenCalled();
|
||||||
|
expect(mocks.asset.deleteFiles).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
id: 'file-1',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
path: '/old/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
expect(mocks.job.queue).toHaveBeenCalledWith({
|
||||||
|
name: JobName.FileDelete,
|
||||||
|
data: { files: ['/old/preview.jpg'] },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('should update database when isProgressive changes', async () => {
|
||||||
|
const asset = {
|
||||||
|
id: 'asset-id',
|
||||||
|
files: [
|
||||||
|
{
|
||||||
|
id: 'file-1',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
path: '/old/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
id: 'file-2',
|
||||||
|
assetId: 'asset-id',
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
path: '/old/thumbnail.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
};
|
||||||
|
|
||||||
|
await sut['syncFiles'](asset.files, [
|
||||||
|
{
|
||||||
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
path: '/old/preview.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: true,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
assetId: asset.id,
|
||||||
|
type: AssetFileType.Thumbnail,
|
||||||
|
path: '/old/thumbnail.jpg',
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
|
},
|
||||||
|
]);
|
||||||
|
|
||||||
|
expect(mocks.asset.upsertFiles).toHaveBeenCalledWith([
|
||||||
|
{
|
||||||
|
assetId: 'asset-id',
|
||||||
|
path: '/old/preview.jpg',
|
||||||
|
type: AssetFileType.Preview,
|
||||||
|
isEdited: false,
|
||||||
|
isProgressive: true,
|
||||||
|
},
|
||||||
|
]);
|
||||||
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
expect(mocks.asset.deleteFiles).not.toHaveBeenCalled();
|
||||||
expect(mocks.job.queue).not.toHaveBeenCalled();
|
expect(mocks.job.queue).not.toHaveBeenCalled();
|
||||||
});
|
});
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,7 @@
|
||||||
import { Injectable } from '@nestjs/common';
|
import { Injectable } from '@nestjs/common';
|
||||||
import { SystemConfig } from 'src/config';
|
import { SystemConfig } from 'src/config';
|
||||||
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
import { FACE_THUMBNAIL_SIZE, JOBS_ASSET_PAGINATION_SIZE } from 'src/constants';
|
||||||
import { StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
import { ImagePathOptions, StorageCore, ThumbnailPathEntity } from 'src/cores/storage.core';
|
||||||
import { AssetFile, Exif } from 'src/database';
|
import { AssetFile, Exif } from 'src/database';
|
||||||
import { OnEvent, OnJob } from 'src/decorators';
|
import { OnEvent, OnJob } from 'src/decorators';
|
||||||
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
import { AssetEditAction, CropParameters } from 'src/dtos/editing.dto';
|
||||||
|
|
@ -45,11 +45,13 @@ import { BaseConfig, ThumbnailConfig } from 'src/utils/media';
|
||||||
import { mimeTypes } from 'src/utils/mime-types';
|
import { mimeTypes } from 'src/utils/mime-types';
|
||||||
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
import { clamp, isFaceImportEnabled, isFacialRecognitionEnabled } from 'src/utils/misc';
|
||||||
import { getOutputDimensions } from 'src/utils/transform';
|
import { getOutputDimensions } from 'src/utils/transform';
|
||||||
|
|
||||||
interface UpsertFileOptions {
|
interface UpsertFileOptions {
|
||||||
assetId: string;
|
assetId: string;
|
||||||
type: AssetFileType;
|
type: AssetFileType;
|
||||||
path: string;
|
path: string;
|
||||||
isEdited: boolean;
|
isEdited: boolean;
|
||||||
|
isProgressive: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
type ThumbnailAsset = NonNullable<Awaited<ReturnType<AssetJobRepository['getForGenerateThumbnailJob']>>>;
|
||||||
|
|
@ -171,18 +173,22 @@ export class MediaService extends BaseService {
|
||||||
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
|
@OnJob({ name: JobName.AssetEditThumbnailGeneration, queue: QueueName.Editor })
|
||||||
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
|
async handleAssetEditThumbnailGeneration({ id }: JobOf<JobName.AssetEditThumbnailGeneration>): Promise<JobStatus> {
|
||||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||||
|
const config = await this.getConfig({ withCache: true });
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
||||||
return JobStatus.Failed;
|
return JobStatus.Failed;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generated = await this.generateEditedThumbnails(asset);
|
const generated = await this.generateEditedThumbnails(asset, config);
|
||||||
|
await this.syncFiles(
|
||||||
|
asset.files.filter((asset) => asset.isEdited),
|
||||||
|
generated?.files ?? [],
|
||||||
|
);
|
||||||
|
|
||||||
let thumbhash: Buffer | undefined = generated?.thumbhash;
|
let thumbhash: Buffer | undefined = generated?.thumbhash;
|
||||||
if (!thumbhash) {
|
if (!thumbhash) {
|
||||||
const { image } = await this.getConfig({ withCache: true });
|
const extractedImage = await this.extractOriginalImage(asset, config.image);
|
||||||
const extractedImage = await this.extractOriginalImage(asset, image);
|
|
||||||
const { info, data, colorspace } = extractedImage;
|
const { info, data, colorspace } = extractedImage;
|
||||||
|
|
||||||
thumbhash = await this.mediaRepository.generateThumbhash(data, {
|
thumbhash = await this.mediaRepository.generateThumbhash(data, {
|
||||||
|
|
@ -206,6 +212,7 @@ export class MediaService extends BaseService {
|
||||||
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
|
@OnJob({ name: JobName.AssetGenerateThumbnails, queue: QueueName.ThumbnailGeneration })
|
||||||
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
|
async handleGenerateThumbnails({ id }: JobOf<JobName.AssetGenerateThumbnails>): Promise<JobStatus> {
|
||||||
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
const asset = await this.assetJobRepository.getForGenerateThumbnailJob(id);
|
||||||
|
const config = await this.getConfig({ withCache: true });
|
||||||
|
|
||||||
if (!asset) {
|
if (!asset) {
|
||||||
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
this.logger.warn(`Thumbnail generation failed for asset ${id}: not found in database or missing metadata`);
|
||||||
|
|
@ -217,32 +224,25 @@ export class MediaService extends BaseService {
|
||||||
return JobStatus.Skipped;
|
return JobStatus.Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
let generated: {
|
let generated: Awaited<ReturnType<MediaService['generateImageThumbnails']>>;
|
||||||
previewPath: string;
|
|
||||||
thumbnailPath: string;
|
|
||||||
fullsizePath?: string;
|
|
||||||
thumbhash: Buffer;
|
|
||||||
fullsizeDimensions?: ImageDimensions;
|
|
||||||
};
|
|
||||||
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
if (asset.type === AssetType.Video || asset.originalFileName.toLowerCase().endsWith('.gif')) {
|
||||||
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
this.logger.verbose(`Thumbnail generation for video ${id} ${asset.originalPath}`);
|
||||||
generated = await this.generateVideoThumbnails(asset);
|
generated = await this.generateVideoThumbnails(asset, config);
|
||||||
} else if (asset.type === AssetType.Image) {
|
} else if (asset.type === AssetType.Image) {
|
||||||
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
this.logger.verbose(`Thumbnail generation for image ${id} ${asset.originalPath}`);
|
||||||
generated = await this.generateImageThumbnails(asset);
|
generated = await this.generateImageThumbnails(asset, config);
|
||||||
} else {
|
} else {
|
||||||
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
this.logger.warn(`Skipping thumbnail generation for asset ${id}: ${asset.type} is not an image or video`);
|
||||||
return JobStatus.Skipped;
|
return JobStatus.Skipped;
|
||||||
}
|
}
|
||||||
|
|
||||||
await this.syncFiles(asset, [
|
const editedGenerated = await this.generateEditedThumbnails(asset, config);
|
||||||
{ type: AssetFileType.Preview, newPath: generated.previewPath, isEdited: false },
|
if (editedGenerated) {
|
||||||
{ type: AssetFileType.Thumbnail, newPath: generated.thumbnailPath, isEdited: false },
|
generated.files.push(...editedGenerated.files);
|
||||||
{ type: AssetFileType.FullSize, newPath: generated.fullsizePath, isEdited: false },
|
}
|
||||||
]);
|
|
||||||
|
|
||||||
const editiedGenerated = await this.generateEditedThumbnails(asset);
|
await this.syncFiles(asset.files, generated.files);
|
||||||
const thumbhash = editiedGenerated?.thumbhash || generated.thumbhash;
|
const thumbhash = editedGenerated?.thumbhash || generated.thumbhash;
|
||||||
|
|
||||||
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
if (!asset.thumbhash || Buffer.compare(asset.thumbhash, thumbhash) !== 0) {
|
||||||
await this.assetRepository.update({ id: asset.id, thumbhash });
|
await this.assetRepository.update({ id: asset.id, thumbhash });
|
||||||
|
|
@ -274,11 +274,7 @@ export class MediaService extends BaseService {
|
||||||
return { info, data, colorspace };
|
return { info, data, colorspace };
|
||||||
}
|
}
|
||||||
|
|
||||||
private async extractOriginalImage(
|
private async extractOriginalImage(asset: ThumbnailAsset, image: SystemConfig['image'], useEdits = false) {
|
||||||
asset: NonNullable<ThumbnailAsset>,
|
|
||||||
image: SystemConfig['image'],
|
|
||||||
useEdits = false,
|
|
||||||
) {
|
|
||||||
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
const extractEmbedded = image.extractEmbedded && mimeTypes.isRaw(asset.originalFileName);
|
||||||
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
const extracted = extractEmbedded ? await this.extractImage(asset.originalPath, image.preview.size) : null;
|
||||||
const generateFullsize =
|
const generateFullsize =
|
||||||
|
|
@ -305,19 +301,21 @@ export class MediaService extends BaseService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateImageThumbnails(asset: ThumbnailAsset, useEdits: boolean = false) {
|
private async generateImageThumbnails(asset: ThumbnailAsset, { image }: SystemConfig, useEdits: boolean = false) {
|
||||||
const { image } = await this.getConfig({ withCache: true });
|
const previewFile = this.getImageFile(asset, {
|
||||||
const previewPath = StorageCore.getImagePath(asset, {
|
|
||||||
fileType: AssetFileType.Preview,
|
fileType: AssetFileType.Preview,
|
||||||
isEdited: useEdits,
|
|
||||||
format: image.preview.format,
|
format: image.preview.format,
|
||||||
});
|
|
||||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
|
||||||
fileType: AssetFileType.Thumbnail,
|
|
||||||
isEdited: useEdits,
|
isEdited: useEdits,
|
||||||
format: image.thumbnail.format,
|
isProgressive: !!image.preview.progressive && image.preview.format !== ImageFormat.Webp,
|
||||||
});
|
});
|
||||||
this.storageCore.ensureFolders(previewPath);
|
previewFile.isProgressive = !!image.preview.progressive && image.preview.format !== ImageFormat.Webp;
|
||||||
|
const thumbnailFile = this.getImageFile(asset, {
|
||||||
|
fileType: AssetFileType.Thumbnail,
|
||||||
|
format: image.thumbnail.format,
|
||||||
|
isEdited: useEdits,
|
||||||
|
isProgressive: !!image.thumbnail.progressive && image.thumbnail.format !== ImageFormat.Webp,
|
||||||
|
});
|
||||||
|
this.storageCore.ensureFolders(previewFile.path);
|
||||||
|
|
||||||
// Handle embedded preview extraction for RAW files
|
// Handle embedded preview extraction for RAW files
|
||||||
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
const extractedImage = await this.extractOriginalImage(asset, image, useEdits);
|
||||||
|
|
@ -327,26 +325,18 @@ export class MediaService extends BaseService {
|
||||||
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
const thumbnailOptions = { colorspace, processInvalidImages: false, raw: info, edits: useEdits ? asset.edits : [] };
|
||||||
const promises = [
|
const promises = [
|
||||||
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
this.mediaRepository.generateThumbhash(data, thumbnailOptions),
|
||||||
this.mediaRepository.generateThumbnail(
|
this.mediaRepository.generateThumbnail(data, { ...image.thumbnail, ...thumbnailOptions }, thumbnailFile.path),
|
||||||
data,
|
this.mediaRepository.generateThumbnail(data, { ...image.preview, ...thumbnailOptions }, previewFile.path),
|
||||||
{ ...image.thumbnail, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
|
||||||
thumbnailPath,
|
|
||||||
),
|
|
||||||
this.mediaRepository.generateThumbnail(
|
|
||||||
data,
|
|
||||||
{ ...image.preview, ...thumbnailOptions, edits: useEdits ? asset.edits : [] },
|
|
||||||
previewPath,
|
|
||||||
),
|
|
||||||
];
|
];
|
||||||
|
|
||||||
let fullsizePath: string | undefined;
|
let fullsizeFile: UpsertFileOptions | undefined;
|
||||||
|
|
||||||
if (convertFullsize) {
|
if (convertFullsize) {
|
||||||
// convert a new fullsize image from the same source as the thumbnail
|
// convert a new fullsize image from the same source as the thumbnail
|
||||||
fullsizePath = StorageCore.getImagePath(asset, {
|
fullsizeFile = this.getImageFile(asset, {
|
||||||
fileType: AssetFileType.FullSize,
|
fileType: AssetFileType.FullSize,
|
||||||
isEdited: useEdits,
|
|
||||||
format: image.fullsize.format,
|
format: image.fullsize.format,
|
||||||
|
isEdited: useEdits,
|
||||||
|
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||||
});
|
});
|
||||||
const fullsizeOptions = {
|
const fullsizeOptions = {
|
||||||
format: image.fullsize.format,
|
format: image.fullsize.format,
|
||||||
|
|
@ -354,23 +344,25 @@ export class MediaService extends BaseService {
|
||||||
progressive: image.fullsize.progressive,
|
progressive: image.fullsize.progressive,
|
||||||
...thumbnailOptions,
|
...thumbnailOptions,
|
||||||
};
|
};
|
||||||
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizePath));
|
promises.push(this.mediaRepository.generateThumbnail(data, fullsizeOptions, fullsizeFile.path));
|
||||||
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
} else if (generateFullsize && extracted && extracted.format === RawExtractedFormat.Jpeg) {
|
||||||
fullsizePath = StorageCore.getImagePath(asset, {
|
fullsizeFile = this.getImageFile(asset, {
|
||||||
fileType: AssetFileType.FullSize,
|
fileType: AssetFileType.FullSize,
|
||||||
format: extracted.format,
|
format: extracted.format,
|
||||||
isEdited: false,
|
isEdited: useEdits,
|
||||||
|
isProgressive: !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp,
|
||||||
});
|
});
|
||||||
this.storageCore.ensureFolders(fullsizePath);
|
fullsizeFile.isProgressive = !!image.fullsize.progressive && image.fullsize.format !== ImageFormat.Webp;
|
||||||
|
this.storageCore.ensureFolders(fullsizeFile.path);
|
||||||
|
|
||||||
// Write the buffer to disk with essential EXIF data
|
// Write the buffer to disk with essential EXIF data
|
||||||
await this.storageRepository.createOrOverwriteFile(fullsizePath, extracted.buffer);
|
await this.storageRepository.createOrOverwriteFile(fullsizeFile.path, extracted.buffer);
|
||||||
await this.mediaRepository.writeExif(
|
await this.mediaRepository.writeExif(
|
||||||
{
|
{
|
||||||
orientation: asset.exifInfo.orientation,
|
orientation: asset.exifInfo.orientation,
|
||||||
colorspace: asset.exifInfo.colorspace,
|
colorspace: asset.exifInfo.colorspace,
|
||||||
},
|
},
|
||||||
fullsizePath,
|
fullsizeFile.path,
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -378,9 +370,9 @@ export class MediaService extends BaseService {
|
||||||
|
|
||||||
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
if (asset.exifInfo.projectionType === 'EQUIRECTANGULAR') {
|
||||||
const promises = [
|
const promises = [
|
||||||
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewPath),
|
this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, previewFile.path),
|
||||||
fullsizePath
|
fullsizeFile
|
||||||
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizePath)
|
? this.mediaRepository.copyTagGroup('XMP-GPano', asset.originalPath, fullsizeFile.path)
|
||||||
: Promise.resolve(),
|
: Promise.resolve(),
|
||||||
];
|
];
|
||||||
await Promise.all(promises);
|
await Promise.all(promises);
|
||||||
|
|
@ -389,7 +381,11 @@ export class MediaService extends BaseService {
|
||||||
const decodedDimensions = { width: info.width, height: info.height };
|
const decodedDimensions = { width: info.width, height: info.height };
|
||||||
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
const fullsizeDimensions = useEdits ? getOutputDimensions(asset.edits, decodedDimensions) : decodedDimensions;
|
||||||
|
|
||||||
return { previewPath, thumbnailPath, fullsizePath, thumbhash: outputs[0] as Buffer, fullsizeDimensions };
|
return {
|
||||||
|
files: fullsizeFile ? [previewFile, thumbnailFile, fullsizeFile] : [previewFile, thumbnailFile],
|
||||||
|
thumbhash: outputs[0] as Buffer,
|
||||||
|
fullsizeDimensions,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
@OnJob({ name: JobName.PersonGenerateThumbnail, queue: QueueName.ThumbnailGeneration })
|
||||||
|
|
@ -493,19 +489,23 @@ export class MediaService extends BaseService {
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateVideoThumbnails(asset: ThumbnailPathEntity & { originalPath: string }) {
|
private async generateVideoThumbnails(
|
||||||
const { image, ffmpeg } = await this.getConfig({ withCache: true });
|
asset: ThumbnailPathEntity & { originalPath: string },
|
||||||
const previewPath = StorageCore.getImagePath(asset, {
|
{ ffmpeg, image }: SystemConfig,
|
||||||
|
) {
|
||||||
|
const previewFile = this.getImageFile(asset, {
|
||||||
fileType: AssetFileType.Preview,
|
fileType: AssetFileType.Preview,
|
||||||
format: image.preview.format,
|
format: image.preview.format,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
});
|
});
|
||||||
const thumbnailPath = StorageCore.getImagePath(asset, {
|
const thumbnailFile = this.getImageFile(asset, {
|
||||||
fileType: AssetFileType.Thumbnail,
|
fileType: AssetFileType.Thumbnail,
|
||||||
format: image.thumbnail.format,
|
format: image.thumbnail.format,
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
});
|
});
|
||||||
this.storageCore.ensureFolders(previewPath);
|
this.storageCore.ensureFolders(previewFile.path);
|
||||||
|
|
||||||
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
const { format, audioStreams, videoStreams } = await this.mediaRepository.probe(asset.originalPath);
|
||||||
const mainVideoStream = this.getMainStream(videoStreams);
|
const mainVideoStream = this.getMainStream(videoStreams);
|
||||||
|
|
@ -524,17 +524,16 @@ export class MediaService extends BaseService {
|
||||||
format,
|
format,
|
||||||
);
|
);
|
||||||
|
|
||||||
await this.mediaRepository.transcode(asset.originalPath, previewPath, previewOptions);
|
await this.mediaRepository.transcode(asset.originalPath, previewFile.path, previewOptions);
|
||||||
await this.mediaRepository.transcode(asset.originalPath, thumbnailPath, thumbnailOptions);
|
await this.mediaRepository.transcode(asset.originalPath, thumbnailFile.path, thumbnailOptions);
|
||||||
|
|
||||||
const thumbhash = await this.mediaRepository.generateThumbhash(previewPath, {
|
const thumbhash = await this.mediaRepository.generateThumbhash(previewFile.path, {
|
||||||
colorspace: image.colorspace,
|
colorspace: image.colorspace,
|
||||||
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
processInvalidImages: process.env.IMMICH_PROCESS_INVALID_IMAGES === 'true',
|
||||||
});
|
});
|
||||||
|
|
||||||
return {
|
return {
|
||||||
previewPath,
|
files: [previewFile, thumbnailFile],
|
||||||
thumbnailPath,
|
|
||||||
thumbhash,
|
thumbhash,
|
||||||
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
|
fullsizeDimensions: { width: mainVideoStream.width, height: mainVideoStream.height },
|
||||||
};
|
};
|
||||||
|
|
@ -791,34 +790,28 @@ export class MediaService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async syncFiles(
|
private async syncFiles(oldFiles: (AssetFile & { isProgressive: boolean })[], newFiles: UpsertFileOptions[]) {
|
||||||
asset: { id: string; files: AssetFile[] },
|
|
||||||
files: { type: AssetFileType; newPath?: string; isEdited: boolean }[],
|
|
||||||
) {
|
|
||||||
const toUpsert: UpsertFileOptions[] = [];
|
const toUpsert: UpsertFileOptions[] = [];
|
||||||
const pathsToDelete: string[] = [];
|
const pathsToDelete: string[] = [];
|
||||||
const toDelete: AssetFile[] = [];
|
const toDelete = new Set(oldFiles);
|
||||||
|
|
||||||
for (const { type, newPath, isEdited } of files) {
|
for (const newFile of newFiles) {
|
||||||
const existingFile = asset.files.find((file) => file.type === type && file.isEdited === isEdited);
|
const existingFile = oldFiles.find((file) => file.type === newFile.type && file.isEdited === newFile.isEdited);
|
||||||
|
if (existingFile) {
|
||||||
// upsert new file path
|
toDelete.delete(existingFile);
|
||||||
if (newPath && existingFile?.path !== newPath) {
|
|
||||||
toUpsert.push({ assetId: asset.id, path: newPath, type, isEdited });
|
|
||||||
|
|
||||||
// delete old file from disk
|
|
||||||
if (existingFile) {
|
|
||||||
this.logger.debug(`Deleting old ${type} image for asset ${asset.id} in favor of a replacement`);
|
|
||||||
pathsToDelete.push(existingFile.path);
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// delete old file from disk and database
|
// upsert new file path
|
||||||
if (!newPath && existingFile) {
|
if (existingFile?.path !== newFile.path || existingFile.isProgressive !== newFile.isProgressive) {
|
||||||
this.logger.debug(`Deleting old ${type} image for asset ${asset.id}`);
|
toUpsert.push(newFile);
|
||||||
|
|
||||||
pathsToDelete.push(existingFile.path);
|
// delete old file from disk
|
||||||
toDelete.push(existingFile);
|
if (existingFile && existingFile.path !== newFile.path) {
|
||||||
|
this.logger.debug(
|
||||||
|
`Deleting old ${newFile.type} image for asset ${newFile.assetId} in favor of a replacement`,
|
||||||
|
);
|
||||||
|
pathsToDelete.push(existingFile.path);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
@ -826,8 +819,12 @@ export class MediaService extends BaseService {
|
||||||
await this.assetRepository.upsertFiles(toUpsert);
|
await this.assetRepository.upsertFiles(toUpsert);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (toDelete.length > 0) {
|
if (toDelete.size > 0) {
|
||||||
await this.assetRepository.deleteFiles(toDelete);
|
const toDeleteArray = [...toDelete];
|
||||||
|
for (const file of toDeleteArray) {
|
||||||
|
pathsToDelete.push(file.path);
|
||||||
|
}
|
||||||
|
await this.assetRepository.deleteFiles(toDeleteArray);
|
||||||
}
|
}
|
||||||
|
|
||||||
if (pathsToDelete.length > 0) {
|
if (pathsToDelete.length > 0) {
|
||||||
|
|
@ -835,18 +832,12 @@ export class MediaService extends BaseService {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
private async generateEditedThumbnails(asset: ThumbnailAsset) {
|
private async generateEditedThumbnails(asset: ThumbnailAsset, config: SystemConfig) {
|
||||||
if (asset.type !== AssetType.Image) {
|
if (asset.type !== AssetType.Image || (asset.files.length === 0 && asset.edits.length === 0)) {
|
||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, true) : undefined;
|
const generated = asset.edits.length > 0 ? await this.generateImageThumbnails(asset, config, true) : undefined;
|
||||||
|
|
||||||
await this.syncFiles(asset, [
|
|
||||||
{ type: AssetFileType.Preview, newPath: generated?.previewPath, isEdited: true },
|
|
||||||
{ type: AssetFileType.Thumbnail, newPath: generated?.thumbnailPath, isEdited: true },
|
|
||||||
{ type: AssetFileType.FullSize, newPath: generated?.fullsizePath, isEdited: true },
|
|
||||||
]);
|
|
||||||
|
|
||||||
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
const crop = asset.edits.find((e) => e.action === AssetEditAction.Crop);
|
||||||
const cropBox = crop
|
const cropBox = crop
|
||||||
|
|
@ -870,4 +861,15 @@ export class MediaService extends BaseService {
|
||||||
|
|
||||||
return generated;
|
return generated;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
private getImageFile(asset: ThumbnailPathEntity, options: ImagePathOptions & { isProgressive: boolean }) {
|
||||||
|
const path = StorageCore.getImagePath(asset, options);
|
||||||
|
return {
|
||||||
|
assetId: asset.id,
|
||||||
|
type: options.fileType,
|
||||||
|
path,
|
||||||
|
isEdited: options.isEdited,
|
||||||
|
isProgressive: options.isProgressive,
|
||||||
|
};
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
|
||||||
19
server/test/fixtures/asset.stub.ts
vendored
19
server/test/fixtures/asset.stub.ts
vendored
|
|
@ -48,9 +48,9 @@ const editedFullsizeFile = factory.assetFile({
|
||||||
isEdited: true,
|
isEdited: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const files: AssetFile[] = [fullsizeFile, previewFile, thumbnailFile];
|
const files = [fullsizeFile, previewFile, thumbnailFile];
|
||||||
|
|
||||||
const editedFiles: AssetFile[] = [
|
const editedFiles = [
|
||||||
fullsizeFile,
|
fullsizeFile,
|
||||||
previewFile,
|
previewFile,
|
||||||
thumbnailFile,
|
thumbnailFile,
|
||||||
|
|
@ -624,14 +624,19 @@ export const assetStub = {
|
||||||
fileSizeInByte: 100_000,
|
fileSizeInByte: 100_000,
|
||||||
timeZone: `America/New_York`,
|
timeZone: `America/New_York`,
|
||||||
},
|
},
|
||||||
files: [] as AssetFile[],
|
files: [],
|
||||||
libraryId: null,
|
libraryId: null,
|
||||||
visibility: AssetVisibility.Hidden,
|
visibility: AssetVisibility.Hidden,
|
||||||
width: null,
|
width: null,
|
||||||
height: null,
|
height: null,
|
||||||
edits: [] as AssetEditActionItem[],
|
edits: [] as AssetEditActionItem[],
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; exifInfo: Exif; edits: AssetEditActionItem[] }),
|
} as unknown as MapAsset & {
|
||||||
|
faces: AssetFace[];
|
||||||
|
files: (AssetFile & { isProgressive: boolean })[];
|
||||||
|
exifInfo: Exif;
|
||||||
|
edits: AssetEditActionItem[];
|
||||||
|
}),
|
||||||
|
|
||||||
livePhotoStillAsset: Object.freeze({
|
livePhotoStillAsset: Object.freeze({
|
||||||
id: 'live-photo-still-asset',
|
id: 'live-photo-still-asset',
|
||||||
|
|
@ -653,7 +658,11 @@ export const assetStub = {
|
||||||
height: null,
|
height: null,
|
||||||
edits: [] as AssetEditActionItem[],
|
edits: [] as AssetEditActionItem[],
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
} as MapAsset & { faces: AssetFace[]; files: AssetFile[]; edits: AssetEditActionItem[] }),
|
} as unknown as MapAsset & {
|
||||||
|
faces: AssetFace[];
|
||||||
|
files: (AssetFile & { isProgressive: boolean })[];
|
||||||
|
edits: AssetEditActionItem[];
|
||||||
|
}),
|
||||||
|
|
||||||
livePhotoWithOriginalFileName: Object.freeze({
|
livePhotoWithOriginalFileName: Object.freeze({
|
||||||
id: 'live-photo-still-asset',
|
id: 'live-photo-still-asset',
|
||||||
|
|
|
||||||
|
|
@ -400,11 +400,12 @@ const assetOcrFactory = (
|
||||||
...ocr,
|
...ocr,
|
||||||
});
|
});
|
||||||
|
|
||||||
const assetFileFactory = (file: Partial<AssetFile> = {}): AssetFile => ({
|
const assetFileFactory = (file: Partial<AssetFile> = {}) => ({
|
||||||
id: newUuid(),
|
id: newUuid(),
|
||||||
type: AssetFileType.Preview,
|
type: AssetFileType.Preview,
|
||||||
path: '/uploads/user-id/thumbs/path.jpg',
|
path: '/uploads/user-id/thumbs/path.jpg',
|
||||||
isEdited: false,
|
isEdited: false,
|
||||||
|
isProgressive: false,
|
||||||
...file,
|
...file,
|
||||||
});
|
});
|
||||||
|
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue