Skip to content

Commit a972dd4

Browse files
kaysondjrasm91
andauthored
fix(server): extraction of Samsung Motionphoto videos (#6337)
* Fix extraction of samsung motionphoto videos * Refactor binary tag extraction to the repository to consolidate exiftool usage * format * fix linting and swap argument orders * Fix tag name and conditional order * Add unit test * Update server test assets submodule * Remove old motion photo video assets when a new one is extracted * delete first, then write * Include motion photo asset uuid's in the filename If the filenames are not uniquified, then we can't delete old/corrupt ones * Fix formatting and fix/add tests * chore: only use new uuid --------- Co-authored-by: Jason Rasmussen <[email protected]>
1 parent 7b314f9 commit a972dd4

File tree

10 files changed

+176
-46
lines changed

10 files changed

+176
-46
lines changed

Diff for: server/e2e/jobs/specs/metadata.e2e-spec.ts

+23-1
Original file line numberDiff line numberDiff line change
@@ -36,7 +36,7 @@ describe(`${AssetController.name} (e2e)`, () => {
3636
await restoreTempFolder();
3737
});
3838

39-
describe.only('should strip metadata of', () => {
39+
describe('should strip metadata of', () => {
4040
let assetWithLocation: AssetResponseDto;
4141

4242
beforeEach(async () => {
@@ -84,4 +84,26 @@ describe(`${AssetController.name} (e2e)`, () => {
8484
expect(exifData).not.toHaveProperty('GPSLatitude');
8585
});
8686
});
87+
88+
describe.each([
89+
// These hashes were created by copying the image files to a Samsung phone,
90+
// exporting the video from Samsung's stock Gallery app, and hashing them locally.
91+
// This ensures that immich+exiftool are extracting the videos the same way Samsung does.
92+
// DO NOT assume immich+exiftool are doing things correctly and just copy whatever hash it gives
93+
// into the test here.
94+
['Samsung One UI 5.jpg', 'fr14niqCq6N20HB8rJYEvpsUVtI='],
95+
['Samsung One UI 6.jpg', 'lT9Uviw/FFJYCjfIxAGPTjzAmmw='],
96+
['Samsung One UI 6.heic', '/ejgzywvgvzvVhUYVfvkLzFBAF0='],
97+
])('should extract motionphoto video', (file, checksum) => {
98+
itif(runAllTests)(`with checksum ${checksum} from ${file}`, async () => {
99+
const fileContent = await fs.promises.readFile(`${IMMICH_TEST_ASSET_PATH}/formats/motionphoto/${file}`);
100+
101+
const response = await api.assetApi.upload(server, admin.accessToken, 'test-asset-id', { content: fileContent });
102+
const asset = await api.assetApi.get(server, admin.accessToken, response.id);
103+
expect(asset).toHaveProperty('livePhotoVideoId');
104+
const video = await api.assetApi.get(server, admin.accessToken, asset.livePhotoVideoId as string);
105+
106+
expect(video.checksum).toStrictEqual(checksum);
107+
});
108+
});
87109
});

Diff for: server/src/domain/metadata/metadata.service.spec.ts

+95-27
Original file line numberDiff line numberDiff line change
@@ -1,6 +1,7 @@
11
import { AssetType, ExifEntity, SystemConfigKey } from '@app/infra/entities';
22
import {
33
assetStub,
4+
fileStub,
45
newAlbumRepositoryMock,
56
newAssetRepositoryMock,
67
newCommunicationRepositoryMock,
@@ -16,6 +17,7 @@ import {
1617
probeStub,
1718
} from '@test';
1819
import { randomBytes } from 'crypto';
20+
import { BinaryField } from 'exiftool-vendored';
1921
import { Stats } from 'fs';
2022
import { constants } from 'fs/promises';
2123
import { when } from 'jest-when';
@@ -343,61 +345,127 @@ describe(MetadataService.name, () => {
343345
);
344346
});
345347

346-
it('should apply motion photos', async () => {
348+
it('should extract the MotionPhotoVideo tag from Samsung HEIC motion photos', async () => {
347349
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
348350
metadataMock.readTags.mockResolvedValue({
349351
Directory: 'foo/bar/',
350-
MotionPhoto: 1,
351-
MicroVideo: 1,
352-
MicroVideoOffset: 1,
352+
MotionPhotoVideo: new BinaryField(0, ''),
353+
// The below two are included to ensure that the MotionPhotoVideo tag is extracted
354+
// instead of the EmbeddedVideoFile, since HEIC MotionPhotos include both
355+
EmbeddedVideoFile: new BinaryField(0, ''),
356+
EmbeddedVideoType: 'MotionPhoto_Data',
353357
});
354-
storageMock.readFile.mockResolvedValue(randomBytes(512));
355358
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
356-
assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
359+
assetMock.getByChecksum.mockResolvedValue(null);
360+
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
361+
cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
362+
const video = randomBytes(512);
363+
metadataMock.extractBinaryTag.mockResolvedValue(video);
357364

358365
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
366+
expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith(
367+
assetStub.livePhotoStillAsset.originalPath,
368+
'MotionPhotoVideo',
369+
);
359370
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
360-
expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
361-
expect(assetMock.save).toHaveBeenCalledWith({
371+
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
372+
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
373+
expect(assetMock.save).toHaveBeenNthCalledWith(1, {
362374
id: assetStub.livePhotoStillAsset.id,
363-
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
375+
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
364376
});
365377
});
366378

367-
it('should create new motion asset if not found and link it with the photo', async () => {
379+
it('should extract the EmbeddedVideo tag from Samsung JPEG motion photos', async () => {
380+
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
381+
metadataMock.readTags.mockResolvedValue({
382+
Directory: 'foo/bar/',
383+
EmbeddedVideoFile: new BinaryField(0, ''),
384+
EmbeddedVideoType: 'MotionPhoto_Data',
385+
});
386+
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
387+
assetMock.getByChecksum.mockResolvedValue(null);
388+
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
389+
cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
390+
const video = randomBytes(512);
391+
metadataMock.extractBinaryTag.mockResolvedValue(video);
392+
393+
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
394+
expect(metadataMock.extractBinaryTag).toHaveBeenCalledWith(
395+
assetStub.livePhotoStillAsset.originalPath,
396+
'EmbeddedVideoFile',
397+
);
398+
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
399+
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
400+
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
401+
expect(assetMock.save).toHaveBeenNthCalledWith(1, {
402+
id: assetStub.livePhotoStillAsset.id,
403+
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
404+
});
405+
});
406+
407+
it('should extract the motion photo video from the XMP directory entry ', async () => {
368408
assetMock.getByIds.mockResolvedValue([{ ...assetStub.livePhotoStillAsset, livePhotoVideoId: null }]);
369409
metadataMock.readTags.mockResolvedValue({
370410
Directory: 'foo/bar/',
371411
MotionPhoto: 1,
372412
MicroVideo: 1,
373413
MicroVideoOffset: 1,
374414
});
415+
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
416+
assetMock.getByChecksum.mockResolvedValue(null);
417+
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
418+
cryptoRepository.randomUUID.mockReturnValue(fileStub.livePhotoMotion.uuid);
375419
const video = randomBytes(512);
376420
storageMock.readFile.mockResolvedValue(video);
377-
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
378-
assetMock.create.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
379-
assetMock.save.mockResolvedValueOnce(assetStub.livePhotoMotionAsset);
380421

381422
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
382423
expect(assetMock.getByIds).toHaveBeenCalledWith([assetStub.livePhotoStillAsset.id]);
383424
expect(storageMock.readFile).toHaveBeenCalledWith(assetStub.livePhotoStillAsset.originalPath, expect.any(Object));
384-
expect(assetMock.create).toHaveBeenCalledWith(
385-
expect.objectContaining({
386-
type: AssetType.VIDEO,
387-
originalFileName: assetStub.livePhotoStillAsset.originalFileName,
388-
isVisible: false,
389-
isReadOnly: false,
390-
}),
391-
);
392-
expect(assetMock.save).toHaveBeenCalledWith({
425+
expect(assetMock.create).toHaveBeenCalled(); // This could have arguments added
426+
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
427+
expect(assetMock.save).toHaveBeenNthCalledWith(1, {
393428
id: assetStub.livePhotoStillAsset.id,
394-
livePhotoVideoId: assetStub.livePhotoMotionAsset.id,
429+
livePhotoVideoId: fileStub.livePhotoMotion.uuid,
395430
});
396-
expect(storageMock.writeFile).toHaveBeenCalledWith(assetStub.livePhotoMotionAsset.originalPath, video);
397-
expect(jobMock.queue).toHaveBeenCalledWith({
398-
name: JobName.METADATA_EXTRACTION,
399-
data: { id: assetStub.livePhotoMotionAsset.id },
431+
});
432+
433+
it('should delete old motion photo video assets if they do not match what is extracted', async () => {
434+
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
435+
metadataMock.readTags.mockResolvedValue({
436+
Directory: 'foo/bar/',
437+
MotionPhoto: 1,
438+
MicroVideo: 1,
439+
MicroVideoOffset: 1,
440+
});
441+
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
442+
assetMock.getByChecksum.mockResolvedValue(null);
443+
assetMock.create.mockResolvedValue(assetStub.livePhotoMotionAsset);
444+
445+
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
446+
expect(jobMock.queue).toHaveBeenNthCalledWith(2, {
447+
name: JobName.ASSET_DELETION,
448+
data: { id: assetStub.livePhotoStillAsset.livePhotoVideoId },
449+
});
450+
});
451+
452+
it('should not create a new motionphoto video asset if the of the extracted video matches an existing asset', async () => {
453+
assetMock.getByIds.mockResolvedValue([assetStub.livePhotoStillAsset]);
454+
metadataMock.readTags.mockResolvedValue({
455+
Directory: 'foo/bar/',
456+
MotionPhoto: 1,
457+
MicroVideo: 1,
458+
MicroVideoOffset: 1,
400459
});
460+
cryptoRepository.hashSha1.mockReturnValue(randomBytes(512));
461+
assetMock.getByChecksum.mockResolvedValue(assetStub.livePhotoMotionAsset);
462+
463+
await sut.handleMetadataExtraction({ id: assetStub.livePhotoStillAsset.id });
464+
expect(assetMock.create).toHaveBeenCalledTimes(0);
465+
expect(storageMock.writeFile).toHaveBeenCalledTimes(0);
466+
// The still asset gets saved by handleMetadataExtraction, but not the video
467+
expect(assetMock.save).toHaveBeenCalledTimes(1);
468+
expect(jobMock.queue).toHaveBeenCalledTimes(0);
401469
});
402470

403471
it('should save all metadata', async () => {

Diff for: server/src/domain/metadata/metadata.service.ts

+43-12
Original file line numberDiff line numberDiff line change
@@ -354,14 +354,16 @@ export class MetadataService {
354354
}
355355

356356
private async applyMotionPhotos(asset: AssetEntity, tags: ImmichTags) {
357-
if (asset.type !== AssetType.IMAGE || asset.livePhotoVideoId) {
357+
if (asset.type !== AssetType.IMAGE) {
358358
return;
359359
}
360360

361361
const rawDirectory = tags.Directory;
362362
const isMotionPhoto = tags.MotionPhoto;
363363
const isMicroVideo = tags.MicroVideo;
364364
const videoOffset = tags.MicroVideoOffset;
365+
const hasMotionPhotoVideo = tags.MotionPhotoVideo;
366+
const hasEmbeddedVideoFile = tags.EmbeddedVideoType === 'MotionPhoto_Data' && tags.EmbeddedVideoFile;
365367
const directory = Array.isArray(rawDirectory) ? (rawDirectory as DirectoryEntry[]) : null;
366368

367369
let length = 0;
@@ -381,7 +383,7 @@ export class MetadataService {
381383
length = videoOffset;
382384
}
383385

384-
if (!length) {
386+
if (!length && !hasEmbeddedVideoFile && !hasMotionPhotoVideo) {
385387
return;
386388
}
387389

@@ -390,20 +392,35 @@ export class MetadataService {
390392
try {
391393
const stat = await this.storageRepository.stat(asset.originalPath);
392394
const position = stat.size - length - padding;
393-
const video = await this.storageRepository.readFile(asset.originalPath, {
394-
buffer: Buffer.alloc(length),
395-
position,
396-
length,
397-
});
395+
let video: Buffer;
396+
// Samsung MotionPhoto video extraction
397+
// HEIC-encoded
398+
if (hasMotionPhotoVideo) {
399+
video = await this.repository.extractBinaryTag(asset.originalPath, 'MotionPhotoVideo');
400+
}
401+
// JPEG-encoded; HEIC also contains these tags, so this conditional must come second
402+
else if (hasEmbeddedVideoFile) {
403+
video = await this.repository.extractBinaryTag(asset.originalPath, 'EmbeddedVideoFile');
404+
}
405+
// Default video extraction
406+
else {
407+
video = await this.storageRepository.readFile(asset.originalPath, {
408+
buffer: Buffer.alloc(length),
409+
position,
410+
length,
411+
});
412+
}
398413
const checksum = this.cryptoRepository.hashSha1(video);
399414

400-
const motionPath = StorageCore.getAndroidMotionPath(asset);
401-
this.storageCore.ensureFolders(motionPath);
402-
403415
let motionAsset = await this.assetRepository.getByChecksum(asset.ownerId, checksum);
404416
if (!motionAsset) {
417+
// We create a UUID in advance so that each extracted video can have a unique filename
418+
// (allowing us to delete old ones if necessary)
419+
const motionAssetId = this.cryptoRepository.randomUUID();
420+
const motionPath = StorageCore.getAndroidMotionPath(asset, motionAssetId);
405421
const createdAt = asset.fileCreatedAt ?? asset.createdAt;
406422
motionAsset = await this.assetRepository.create({
423+
id: motionAssetId,
407424
libraryId: asset.libraryId,
408425
type: AssetType.VIDEO,
409426
fileCreatedAt: createdAt,
@@ -419,12 +436,26 @@ export class MetadataService {
419436
deviceId: 'NONE',
420437
});
421438

439+
this.storageCore.ensureFolders(motionPath);
422440
await this.storageRepository.writeFile(motionAsset.originalPath, video);
423441
await this.jobRepository.queue({ name: JobName.METADATA_EXTRACTION, data: { id: motionAsset.id } });
442+
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
443+
444+
// If the asset already had an associated livePhotoVideo, delete it, because
445+
// its checksum doesn't match the checksum of the motionAsset we just extracted
446+
// (if it did, getByChecksum() would've returned non-null)
447+
if (asset.livePhotoVideoId) {
448+
await this.jobRepository.queue({ name: JobName.ASSET_DELETION, data: { id: asset.livePhotoVideoId } });
449+
this.logger.log(`Removed old motion photo video asset (${asset.livePhotoVideoId})`);
450+
}
451+
} else {
452+
this.logger.debug(
453+
`Asset ${asset.id}'s motion photo video with checksum ${checksum.toString(
454+
'base64',
455+
)} already exists in the repository`,
456+
);
424457
}
425458

426-
await this.assetRepository.save({ id: asset.id, livePhotoVideoId: motionAsset.id });
427-
428459
this.logger.debug(`Finished motion photo video extraction (${asset.id})`);
429460
} catch (error: Error | any) {
430461
this.logger.error(`Failed to extract live photo ${asset.originalPath}: ${error}`, error?.stack);

Diff for: server/src/domain/repositories/metadata.repository.ts

+5-1
Original file line numberDiff line numberDiff line change
@@ -1,4 +1,4 @@
1-
import { Tags } from 'exiftool-vendored';
1+
import { BinaryField, Tags } from 'exiftool-vendored';
22

33
export const IMetadataRepository = 'IMetadataRepository';
44

@@ -27,6 +27,9 @@ export interface ImmichTags extends Omit<Tags, 'FocalLength' | 'Duration'> {
2727
ImagePixelDepth?: string;
2828
FocalLength?: number;
2929
Duration?: number | ExifDuration;
30+
EmbeddedVideoType?: string;
31+
EmbeddedVideoFile?: BinaryField;
32+
MotionPhotoVideo?: BinaryField;
3033
}
3134

3235
export interface IMetadataRepository {
@@ -35,4 +38,5 @@ export interface IMetadataRepository {
3538
reverseGeocode(point: GeoPoint): Promise<ReverseGeocodeResult | null>;
3639
readTags(path: string): Promise<ImmichTags | null>;
3740
writeTags(path: string, tags: Partial<Tags>): Promise<void>;
41+
extractBinaryTag(tagName: string, path: string): Promise<Buffer>;
3842
}

Diff for: server/src/domain/storage/storage.core.ts

+2-2
Original file line numberDiff line numberDiff line change
@@ -103,8 +103,8 @@ export class StorageCore {
103103
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}.mp4`);
104104
}
105105

106-
static getAndroidMotionPath(asset: AssetEntity) {
107-
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${asset.id}-MP.mp4`);
106+
static getAndroidMotionPath(asset: AssetEntity, uuid: string) {
107+
return StorageCore.getNestedPath(StorageFolder.ENCODED_VIDEO, asset.ownerId, `${uuid}-MP.mp4`);
108108
}
109109

110110
static isAndroidMotionPath(originalPath: string) {

Diff for: server/src/infra/repositories/metadata.repository.ts

+4
Original file line numberDiff line numberDiff line change
@@ -201,6 +201,10 @@ export class MetadataRepository implements IMetadataRepository {
201201
}) as Promise<ImmichTags | null>;
202202
}
203203

204+
extractBinaryTag(path: string, tagName: string): Promise<Buffer> {
205+
return exiftool.extractBinaryTagToBuffer(tagName, path);
206+
}
207+
204208
async writeTags(path: string, tags: Partial<Tags>): Promise<void> {
205209
try {
206210
await exiftool.write(path, tags, ['-overwrite_original']);

Diff for: server/test/fixtures/asset.stub.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -401,7 +401,7 @@ export const assetStub = {
401401
}),
402402

403403
livePhotoMotionAsset: Object.freeze({
404-
id: 'live-photo-motion-asset',
404+
id: fileStub.livePhotoMotion.uuid,
405405
originalPath: fileStub.livePhotoMotion.originalPath,
406406
ownerId: authStub.user1.user.id,
407407
type: AssetType.VIDEO,

Diff for: server/test/fixtures/file.stub.ts

+1-1
Original file line numberDiff line numberDiff line change
@@ -7,7 +7,7 @@ export const fileStub = {
77
size: 42,
88
}),
99
livePhotoMotion: Object.freeze({
10-
uuid: 'random-uuid',
10+
uuid: 'live-photo-motion-asset',
1111
originalPath: 'fake_path/asset_1.mp4',
1212
checksum: Buffer.from('live photo file hash', 'utf8'),
1313
originalName: 'asset_1.mp4',

Diff for: server/test/repositories/metadata.repository.mock.ts

+1
Original file line numberDiff line numberDiff line change
@@ -7,5 +7,6 @@ export const newMetadataRepositoryMock = (): jest.Mocked<IMetadataRepository> =>
77
reverseGeocode: jest.fn(),
88
readTags: jest.fn(),
99
writeTags: jest.fn(),
10+
extractBinaryTag: jest.fn(),
1011
};
1112
};

0 commit comments

Comments
 (0)