diff --git a/server/src/repositories/media.repository.ts b/server/src/repositories/media.repository.ts index 699c31ba5b..51a738ee59 100644 --- a/server/src/repositories/media.repository.ts +++ b/server/src/repositories/media.repository.ts @@ -258,6 +258,7 @@ export class MediaRepository { colorPrimaries: stream.color_primaries, colorSpace: stream.color_space, colorTransfer: stream.color_transfer, + displayAspectRatio: stream.display_aspect_ratio, })), audioStreams: results.streams .filter((stream) => stream.codec_type === 'audio') diff --git a/server/src/services/metadata.service.spec.ts b/server/src/services/metadata.service.spec.ts index b10325998e..4fa45e663a 100644 --- a/server/src/services/metadata.service.spec.ts +++ b/server/src/services/metadata.service.spec.ts @@ -599,6 +599,21 @@ describe(MetadataService.name, () => { ); }); + it('should apply Display Aspect Ratio (DAR) for anamorphic video', async () => { + mocks.assetJob.getForMetadataExtraction.mockResolvedValue(assetStub.video); + mocks.media.probe.mockResolvedValue(probeStub.videoStreamAnamorphic); + mockReadTags({}); + + await sut.handleMetadataExtraction({ id: assetStub.video.id }); + + expect(mocks.assetJob.getForMetadataExtraction).toHaveBeenCalledWith(assetStub.video.id); + // Anamorphic video: 1440x1080 with DAR 16:9 should display as 1920x1080 + expect(mocks.asset.upsertExif).toHaveBeenCalledWith( + expect.objectContaining({ exifImageWidth: 1920, exifImageHeight: 1080 }), + { lockedPropertiesBehavior: 'skip' }, + ); + }); + it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => { mocks.assetJob.getForMetadataExtraction.mockResolvedValue({ ...assetStub.livePhotoWithOriginalFileName, diff --git a/server/src/services/metadata.service.ts b/server/src/services/metadata.service.ts index e6cc15bc77..328121710a 100644 --- a/server/src/services/metadata.service.ts +++ b/server/src/services/metadata.service.ts @@ -989,12 +989,15 @@ export class MetadataService extends BaseService { const tags: Pick = {}; if (videoStreams[0]) { - // Set video dimensions - if (videoStreams[0].width) { - tags.ImageWidth = videoStreams[0].width; + // Set video dimensions, considering Display Aspect Ratio (DAR) for anamorphic videos + const { width, height, displayAspectRatio } = videoStreams[0]; + const displayDimensions = this.applyDisplayAspectRatio(width, height, displayAspectRatio); + + if (displayDimensions.width) { + tags.ImageWidth = displayDimensions.width; } - if (videoStreams[0].height) { - tags.ImageHeight = videoStreams[0].height; + if (displayDimensions.height) { + tags.ImageHeight = displayDimensions.height; } switch (videoStreams[0].rotation) { @@ -1023,4 +1026,44 @@ export class MetadataService extends BaseService { return tags; } + + /** + * Calculates the display dimensions of a video based on its Display Aspect Ratio (DAR). + * DAR accounts for anamorphic videos where the stored pixel dimensions differ from the intended display dimensions. + * For example, a 1440x1080 video with DAR 16:9 should display as 1920x1080 (1440 * 16/9 / (1440/1080) = 1920). + */ + private applyDisplayAspectRatio( + width: number | undefined, + height: number | undefined, + displayAspectRatio: string | undefined, + ): { width?: number; height?: number } { + if (!width || !height || !displayAspectRatio) { + return { width, height }; + } + + // Parse DAR string (e.g., "16:9" or "4:3") + const darMatch = displayAspectRatio.match(/^(\d+):(\d+)$/); + if (!darMatch) { + return { width, height }; + } + + const darWidth = Number.parseInt(darMatch[1], 10); + const darHeight = Number.parseInt(darMatch[2], 10); + if (!darWidth || !darHeight) { + return { width, height }; + } + + const dar = darWidth / darHeight; + const storedAspectRatio = width / height; + + // If DAR is effectively the same as stored aspect ratio (within a small tolerance), no adjustment needed + if (Math.abs(dar - storedAspectRatio) < 0.01) { + return { width, height }; + } + + // Apply DAR by adjusting width while keeping height constant + // This matches how video players typically handle anamorphic content + const displayWidth = Math.round(height * dar); + return { width: displayWidth, height }; + } } diff --git a/server/src/types.ts b/server/src/types.ts index 3984087301..849c9666a7 100644 --- a/server/src/types.ts +++ b/server/src/types.ts @@ -85,6 +85,7 @@ export interface VideoStreamInfo { colorPrimaries?: string; colorSpace?: string; colorTransfer?: string; + displayAspectRatio?: string; } export interface AudioStreamInfo { diff --git a/server/test/fixtures/media.stub.ts b/server/test/fixtures/media.stub.ts index 727f5ae7cf..119577f31a 100644 --- a/server/test/fixtures/media.stub.ts +++ b/server/test/fixtures/media.stub.ts @@ -272,4 +272,21 @@ export const probeStub = { }, ], }), + videoStreamAnamorphic: Object.freeze({ + ...probeStubDefault, + videoStreams: [ + { + index: 0, + height: 1080, + width: 1440, + codecName: 'h264', + frameCount: 100, + rotation: 0, + isHDR: false, + bitrate: 0, + pixelFormat: 'yuv420p', + displayAspectRatio: '16:9', + }, + ], + }), };