feat: asset dimensions in asset table

This commit is contained in:
bwees
2025-11-21 22:50:41 -06:00
parent f0b069adb9
commit 81ed88fb99
28 changed files with 485 additions and 143 deletions

View File

@@ -340,6 +340,8 @@ export const columns = {
'asset.originalPath',
'asset.ownerId',
'asset.type',
'asset.width',
'asset.height',
],
assetFiles: ['asset_file.id', 'asset_file.path', 'asset_file.type'],
authUser: ['user.id', 'user.name', 'user.email', 'user.isAdmin', 'user.quotaUsageInBytes', 'user.quotaSizeInBytes'],
@@ -390,6 +392,8 @@ export const columns = {
'asset.livePhotoVideoId',
'asset.stackId',
'asset.libraryId',
'asset.width',
'asset.height',
],
syncAlbumUser: ['album_user.albumId as albumId', 'album_user.userId as userId', 'album_user.role'],
syncStack: ['stack.id', 'stack.createdAt', 'stack.updatedAt', 'stack.primaryAssetId', 'stack.ownerId'],

View File

@@ -34,6 +34,8 @@ export class SanitizedAssetResponseDto {
duration!: string;
livePhotoVideoId?: string | null;
hasMetadata!: boolean;
width!: number | null;
height!: number | null;
}
export class AssetResponseDto extends SanitizedAssetResponseDto {
@@ -129,6 +131,8 @@ export type MapAsset = {
tags?: Tag[];
thumbhash: Buffer<ArrayBufferLike> | null;
type: AssetType;
width: number | null;
height: number | null;
};
export class AssetStackResponseDto {
@@ -190,6 +194,8 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
duration: entity.duration ?? '0:00:00.00000',
livePhotoVideoId: entity.livePhotoVideoId,
hasMetadata: false,
width: entity.width,
height: entity.height,
};
return sanitizedAssetResponse as AssetResponseDto;
}
@@ -227,5 +233,7 @@ export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): Asset
hasMetadata: true,
duplicateId: entity.duplicateId,
resized: true,
width: entity.width,
height: entity.height,
};
}

View File

@@ -118,6 +118,10 @@ export class SyncAssetV1 {
livePhotoVideoId!: string | null;
stackId!: string | null;
libraryId!: string | null;
@ApiProperty({ type: 'integer' })
width!: number | null;
@ApiProperty({ type: 'integer' })
height!: number | null;
}
@ExtraModel()

View File

@@ -345,14 +345,10 @@ with
"asset_exif"."projectionType",
coalesce(
case
when asset_exif."exifImageHeight" = 0
or asset_exif."exifImageWidth" = 0 then 1
when "asset_exif"."orientation" in ('5', '6', '7', '8', '-90', '90') then round(
asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric,
3
)
when asset."height" = 0
or asset."width" = 0 then 1
else round(
asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric,
asset."width"::numeric / asset."height"::numeric,
3
)
end,

View File

@@ -69,6 +69,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"album_asset"."updateId"
from
"album_asset" as "album_asset"
@@ -99,6 +101,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -134,7 +138,9 @@ select
"asset"."duration",
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId"
"asset"."libraryId",
"asset"."width",
"asset"."height"
from
"album_asset" as "album_asset"
inner join "asset" on "asset"."id" = "album_asset"."assetId"
@@ -448,6 +454,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -740,6 +748,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"
@@ -789,6 +799,8 @@ select
"asset"."livePhotoVideoId",
"asset"."stackId",
"asset"."libraryId",
"asset"."width",
"asset"."height",
"asset"."updateId"
from
"asset" as "asset"

View File

@@ -632,11 +632,9 @@ export class AssetRepository {
.coalesce(
eb
.case()
.when(sql`asset_exif."exifImageHeight" = 0 or asset_exif."exifImageWidth" = 0`)
.when(sql`asset."height" = 0 or asset."width" = 0`)
.then(eb.lit(1))
.when('asset_exif.orientation', 'in', sql<string>`('5', '6', '7', '8', '-90', '90')`)
.then(sql`round(asset_exif."exifImageHeight"::numeric / asset_exif."exifImageWidth"::numeric, 3)`)
.else(sql`round(asset_exif."exifImageWidth"::numeric / asset_exif."exifImageHeight"::numeric, 3)`)
.else(sql`round(asset."width"::numeric / asset."height"::numeric, 3)`)
.end(),
eb.lit(1),
)

View File

@@ -0,0 +1,28 @@
import { Kysely, sql } from 'kysely';
export async function up(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" ADD COLUMN "width" integer;`.execute(db);
await sql`ALTER TABLE "asset" ADD COLUMN "height" integer;`.execute(db);
// Populate width and height from exif data with orientation-aware swapping
await sql`
UPDATE "asset"
SET
"width" = CASE
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageHeight"
ELSE "asset_exif"."exifImageWidth"
END,
"height" = CASE
WHEN "asset_exif"."orientation" IN ('5', '6', '7', '8', '-90', '90') THEN "asset_exif"."exifImageWidth"
ELSE "asset_exif"."exifImageHeight"
END
FROM "asset_exif"
WHERE "asset"."id" = "asset_exif"."assetId"
AND ("asset_exif"."exifImageWidth" IS NOT NULL OR "asset_exif"."exifImageHeight" IS NOT NULL)
`.execute(db);
}
export async function down(db: Kysely<any>): Promise<void> {
await sql`ALTER TABLE "asset" DROP COLUMN "width";`.execute(db);
await sql`ALTER TABLE "asset" DROP COLUMN "height";`.execute(db);
}

View File

@@ -137,4 +137,10 @@ export class AssetTable {
@Column({ enum: asset_visibility_enum, default: AssetVisibility.Timeline })
visibility!: Generated<AssetVisibility>;
@Column({ type: 'integer', nullable: true })
width!: number | null;
@Column({ type: 'integer', nullable: true })
height!: number | null;
}

View File

@@ -141,6 +141,8 @@ export class JobService extends BaseService {
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
exif: {
assetId: exif.assetId,

View File

@@ -221,6 +221,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: fileModifiedAt,
fileModifiedAt,
localDateTime: fileModifiedAt,
width: null,
height: null,
});
});
@@ -245,6 +247,8 @@ describe(MetadataService.name, () => {
fileCreatedAt,
fileModifiedAt,
localDateTime: fileCreatedAt,
width: null,
height: null,
});
});
@@ -288,6 +292,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.image.fileCreatedAt,
fileModifiedAt: assetStub.image.fileCreatedAt,
localDateTime: assetStub.image.fileCreatedAt,
width: null,
height: null,
});
});
@@ -317,6 +323,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
width: null,
height: null,
});
});
@@ -346,6 +354,8 @@ describe(MetadataService.name, () => {
fileCreatedAt: assetStub.withLocation.fileCreatedAt,
fileModifiedAt: assetStub.withLocation.fileModifiedAt,
localDateTime: new Date('2023-02-22T05:06:29.716Z'),
width: null,
height: null,
});
});
@@ -1531,6 +1541,49 @@ describe(MetadataService.name, () => {
}),
);
});
it('should properly set width/height for normal images', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: 1000,
height: 2000,
}),
);
});
it('should properly swap asset width/height for rotated images', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.image);
mockReadTags({ ImageWidth: 1000, ImageHeight: 2000, Orientation: 6 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.update).toHaveBeenCalledWith(
expect.objectContaining({
width: 2000,
height: 1000,
}),
);
});
it('should not overwrite existing width/height if they already exist', async () => {
mocks.assetJob.getForMetadataExtraction.mockResolvedValue({
...assetStub.image,
width: 1920,
height: 1080,
});
mockReadTags({ ImageWidth: 1280, ImageHeight: 720 });
await sut.handleMetadataExtraction({ id: assetStub.image.id });
expect(mocks.asset.update).not.toHaveBeenCalledWith(
expect.objectContaining({
width: 1280,
height: 720,
}),
);
});
});
describe('handleQueueSidecar', () => {

View File

@@ -196,6 +196,15 @@ export class MetadataService extends BaseService {
await this.eventRepository.emit('AssetHide', { assetId: motionAsset.id, userId: motionAsset.ownerId });
}
private isOrientationSidewards(orientation: ExifOrientation | number): boolean {
return [
ExifOrientation.MirrorHorizontalRotate270CW,
ExifOrientation.Rotate90CW,
ExifOrientation.MirrorHorizontalRotate90CW,
ExifOrientation.Rotate270CW,
].includes(orientation);
}
@OnJob({ name: JobName.AssetExtractMetadataQueueAll, queue: QueueName.MetadataExtraction })
async handleQueueMetadataExtraction(job: JobOf<JobName.AssetExtractMetadataQueueAll>): Promise<JobStatus> {
const { force } = job;
@@ -289,6 +298,10 @@ export class MetadataService extends BaseService {
autoStackId: this.getAutoStackId(exifTags),
};
const isSidewards = exifTags.Orientation && this.isOrientationSidewards(exifTags.Orientation);
const assetWidth = isSidewards ? validate(height) : validate(width);
const assetHeight = isSidewards ? validate(width) : validate(height);
const promises: Promise<unknown>[] = [
this.assetRepository.upsertExif(exifData),
this.assetRepository.update({
@@ -297,6 +310,11 @@ export class MetadataService extends BaseService {
localDateTime: dates.localDateTime,
fileCreatedAt: dates.dateTimeOriginal ?? undefined,
fileModifiedAt: stats.mtime,
// only update the dimensions if they don't already exist
// we don't want to overwrite width/height that are modified by edits
width: asset.width == null ? assetWidth : undefined,
height: asset.height == null ? assetHeight : undefined,
}),
this.applyTagList(asset, exifTags),
];
@@ -700,12 +718,7 @@ export class MetadataService extends BaseService {
return regionInfo;
}
const isSidewards = [
ExifOrientation.MirrorHorizontalRotate270CW,
ExifOrientation.Rotate90CW,
ExifOrientation.MirrorHorizontalRotate90CW,
ExifOrientation.Rotate270CW,
].includes(orientation);
const isSidewards = this.isOrientationSidewards(orientation);
// swap image dimensions in AppliedToDimensions if orientation is sidewards
const adjustedAppliedToDimensions = isSidewards
@@ -951,9 +964,17 @@ export class MetadataService extends BaseService {
private async getVideoTags(originalPath: string) {
const { videoStreams, format } = await this.mediaRepository.probe(originalPath);
const tags: Pick<ImmichTags, 'Duration' | 'Orientation'> = {};
const tags: Pick<ImmichTags, 'Duration' | 'Orientation' | 'ImageWidth' | 'ImageHeight'> = {};
if (videoStreams[0]) {
// Set video dimensions
if (videoStreams[0].width) {
tags.ImageWidth = videoStreams[0].width;
}
if (videoStreams[0].height) {
tags.ImageHeight = videoStreams[0].height;
}
switch (videoStreams[0].rotation) {
case -90: {
tags.Orientation = ExifOrientation.Rotate90CW;

View File

@@ -261,8 +261,8 @@ export const assetStub = {
stack: null,
orientation: '',
projectionType: null,
height: 3840,
width: 2160,
height: null,
width: null,
visibility: AssetVisibility.Timeline,
}),

View File

@@ -72,6 +72,8 @@ const assetResponse: AssetResponseDto = {
libraryId: 'library-id',
hasMetadata: true,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
};
const assetResponseWithoutMetadata = {
@@ -83,6 +85,8 @@ const assetResponseWithoutMetadata = {
duration: '0:00:00.00000',
livePhotoVideoId: null,
hasMetadata: false,
width: 500,
height: 500,
} as AssetResponseDto;
const albumResponse: AlbumResponseDto = {
@@ -257,6 +261,8 @@ export const sharedLinkStub = {
libraryId: null,
stackId: null,
visibility: AssetVisibility.Timeline,
width: 500,
height: 500,
},
],
},

View File

@@ -52,6 +52,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: null,
stackId: null,
libraryId: null,
width: 1920,
height: 1080,
});
const { album } = await ctx.newAlbum({ ownerId: user2.id });
await ctx.newAlbumAsset({ albumId: album.id, assetId: asset.id });
@@ -79,6 +81,8 @@ describe(SyncRequestType.AlbumAssetsV1, () => {
livePhotoVideoId: asset.livePhotoVideoId,
stackId: asset.stackId,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: SyncEntityType.AlbumAssetCreateV1,
},

View File

@@ -37,6 +37,8 @@ describe(SyncEntityType.AssetV1, () => {
deletedAt: null,
duration: '0:10:00.00000',
libraryId: null,
width: 1920,
height: 1080,
});
const response = await ctx.syncStream(auth, [SyncRequestType.AssetsV1]);
@@ -60,6 +62,8 @@ describe(SyncEntityType.AssetV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: asset.width,
height: asset.height,
},
type: 'AssetV1',
},

View File

@@ -66,6 +66,8 @@ describe(SyncRequestType.PartnerAssetsV1, () => {
stackId: null,
livePhotoVideoId: null,
libraryId: asset.libraryId,
width: null,
height: null,
},
type: SyncEntityType.PartnerAssetV1,
},

View File

@@ -249,6 +249,8 @@ const assetFactory = (asset: Partial<MapAsset> = {}) => ({
thumbhash: null,
type: AssetType.Image,
visibility: AssetVisibility.Timeline,
width: null,
height: null,
...asset,
});