mirror of
https://github.com/immich-app/immich.git
synced 2025-12-21 09:15:44 +03:00
feat: db insertions for edits feat: get asset edits endpoint feat: wip apply edits feat: finish asset files changes feat: wip feat: wip fix: openapi fix: tests the failing tests were so scuffed. Simply solved by adding [] to the param list feat: more wip feat: more wip feat: some more tests and fixes chore: fix default for getting thumbnail and add todo for tests feat: LRTB validation chore: code cleanup chore: more test checks for cleanup feat: show edit pane fix: state issues chore: restructure web editor feat: restructure edit manager feat: refactor cropManager chore: combine all editing chore: web editing improvements fix: handling when no crops fix: openapi enum chore: more edit refactoring fix: make image decoding more efficient chore: more refactoring fix: getCrop LRTB algorithm fix: missing await chore: use relative coordinates for edit chore: update sql fix: use resize observer instead of svelte:doc resize hook chore: simplify quad box generation fix: light mode styling chore: refactor to not be a recursive job call this simplifies the logic and the job only completes once thumbhash and others are properly updated chore: more refactoring feat: use affine transforms for most operations feat: bounding box edit transformation feat: tests chore: sql and openapi sync fix: medium tests fix: rotated OCR chore: cleanup transform test fix: remove rebase issue fix(server): block edits for live photos, gifs, panoramic photos fix: openapi enum validation chore: rename edit endpoint chore: remove public modifiers feat: delete endpoint chore: use === and !== explicitly fix: require 1 edit for the editAsset endpoint fix: remove thumbnail edit notification and use on_upload_success instead fix: primary key on asset edit table chore: refactor to isPanorama chore: rename editRepository to assetEditRepository fix: missing toLowerCase fix: db migrations chore: update sql files
250 lines
8.3 KiB
TypeScript
250 lines
8.3 KiB
TypeScript
import { ApiProperty } from '@nestjs/swagger';
|
|
import { Selectable } from 'kysely';
|
|
import { AssetFace, AssetFile, Exif, Stack, Tag, User } from 'src/database';
|
|
import { HistoryBuilder, Property } from 'src/decorators';
|
|
import { AuthDto } from 'src/dtos/auth.dto';
|
|
import { EditActionItem } from 'src/dtos/editing.dto';
|
|
import { ExifResponseDto, mapExif } from 'src/dtos/exif.dto';
|
|
import {
|
|
AssetFaceWithoutPersonResponseDto,
|
|
PersonWithFacesResponseDto,
|
|
mapFacesWithoutPerson,
|
|
mapPerson,
|
|
} from 'src/dtos/person.dto';
|
|
import { TagResponseDto, mapTag } from 'src/dtos/tag.dto';
|
|
import { UserResponseDto, mapUser } from 'src/dtos/user.dto';
|
|
import { AssetStatus, AssetType, AssetVisibility } from 'src/enum';
|
|
import { ImageDimensions } from 'src/types';
|
|
import { getDimensions } from 'src/utils/asset.util';
|
|
import { hexOrBufferToBase64 } from 'src/utils/bytes';
|
|
import { mimeTypes } from 'src/utils/mime-types';
|
|
import { ValidateEnum } from 'src/validation';
|
|
|
|
export class SanitizedAssetResponseDto {
|
|
id!: string;
|
|
@ValidateEnum({ enum: AssetType, name: 'AssetTypeEnum' })
|
|
type!: AssetType;
|
|
thumbhash!: string | null;
|
|
originalMimeType?: string;
|
|
@ApiProperty({
|
|
type: 'string',
|
|
format: 'date-time',
|
|
description:
|
|
'The local date and time when the photo/video was taken, derived from EXIF metadata. This represents the photographer\'s local time regardless of timezone, stored as a timezone-agnostic timestamp. Used for timeline grouping by "local" days and months.',
|
|
example: '2024-01-15T14:30:00.000Z',
|
|
})
|
|
localDateTime!: Date;
|
|
duration!: string;
|
|
livePhotoVideoId?: string | null;
|
|
hasMetadata!: boolean;
|
|
width!: number | null;
|
|
height!: number | null;
|
|
}
|
|
|
|
export class AssetResponseDto extends SanitizedAssetResponseDto {
|
|
@ApiProperty({
|
|
type: 'string',
|
|
format: 'date-time',
|
|
description: 'The UTC timestamp when the asset was originally uploaded to Immich.',
|
|
example: '2024-01-15T20:30:00.000Z',
|
|
})
|
|
createdAt!: Date;
|
|
deviceAssetId!: string;
|
|
deviceId!: string;
|
|
ownerId!: string;
|
|
owner?: UserResponseDto;
|
|
@Property({ history: new HistoryBuilder().added('v1').deprecated('v1') })
|
|
libraryId?: string | null;
|
|
originalPath!: string;
|
|
originalFileName!: string;
|
|
@ApiProperty({
|
|
type: 'string',
|
|
format: 'date-time',
|
|
description:
|
|
'The actual UTC timestamp when the file was created/captured, preserving timezone information. This is the authoritative timestamp for chronological sorting within timeline groups. Combined with timezone data, this can be used to determine the exact moment the photo was taken.',
|
|
example: '2024-01-15T19:30:00.000Z',
|
|
})
|
|
fileCreatedAt!: Date;
|
|
@ApiProperty({
|
|
type: 'string',
|
|
format: 'date-time',
|
|
description:
|
|
'The UTC timestamp when the file was last modified on the filesystem. This reflects the last time the physical file was changed, which may be different from when the photo was originally taken.',
|
|
example: '2024-01-16T10:15:00.000Z',
|
|
})
|
|
fileModifiedAt!: Date;
|
|
@ApiProperty({
|
|
type: 'string',
|
|
format: 'date-time',
|
|
description:
|
|
'The UTC timestamp when the asset record was last updated in the database. This is automatically maintained by the database and reflects when any field in the asset was last modified.',
|
|
example: '2024-01-16T12:45:30.000Z',
|
|
})
|
|
updatedAt!: Date;
|
|
isFavorite!: boolean;
|
|
isArchived!: boolean;
|
|
isTrashed!: boolean;
|
|
isOffline!: boolean;
|
|
@ValidateEnum({ enum: AssetVisibility, name: 'AssetVisibility' })
|
|
visibility!: AssetVisibility;
|
|
exifInfo?: ExifResponseDto;
|
|
tags?: TagResponseDto[];
|
|
people?: PersonWithFacesResponseDto[];
|
|
unassignedFaces?: AssetFaceWithoutPersonResponseDto[];
|
|
/**base64 encoded sha1 hash */
|
|
checksum!: string;
|
|
stack?: AssetStackResponseDto | null;
|
|
duplicateId?: string | null;
|
|
|
|
@Property({ history: new HistoryBuilder().added('v1').deprecated('v1.113.0') })
|
|
resized?: boolean;
|
|
}
|
|
|
|
export type MapAsset = {
|
|
createdAt: Date;
|
|
updatedAt: Date;
|
|
deletedAt: Date | null;
|
|
id: string;
|
|
updateId: string;
|
|
status: AssetStatus;
|
|
checksum: Buffer<ArrayBufferLike>;
|
|
deviceAssetId: string;
|
|
deviceId: string;
|
|
duplicateId: string | null;
|
|
duration: string | null;
|
|
edits?: EditActionItem[];
|
|
encodedVideoPath: string | null;
|
|
exifInfo?: Selectable<Exif> | null;
|
|
faces?: AssetFace[];
|
|
fileCreatedAt: Date;
|
|
fileModifiedAt: Date;
|
|
files?: AssetFile[];
|
|
isExternal: boolean;
|
|
isFavorite: boolean;
|
|
isOffline: boolean;
|
|
visibility: AssetVisibility;
|
|
libraryId: string | null;
|
|
livePhotoVideoId: string | null;
|
|
localDateTime: Date;
|
|
originalFileName: string;
|
|
originalPath: string;
|
|
owner?: User | null;
|
|
ownerId: string;
|
|
stack?: Stack | null;
|
|
stackId: string | null;
|
|
tags?: Tag[];
|
|
thumbhash: Buffer<ArrayBufferLike> | null;
|
|
type: AssetType;
|
|
width: number | null;
|
|
height: number | null;
|
|
};
|
|
|
|
export class AssetStackResponseDto {
|
|
id!: string;
|
|
|
|
primaryAssetId!: string;
|
|
|
|
@ApiProperty({ type: 'integer' })
|
|
assetCount!: number;
|
|
}
|
|
|
|
export type AssetMapOptions = {
|
|
stripMetadata?: boolean;
|
|
withStack?: boolean;
|
|
auth?: AuthDto;
|
|
};
|
|
|
|
// TODO: this is inefficient
|
|
const peopleWithFaces = (
|
|
faces?: AssetFace[],
|
|
edits?: EditActionItem[],
|
|
assetDimensions?: ImageDimensions,
|
|
): PersonWithFacesResponseDto[] => {
|
|
const result: PersonWithFacesResponseDto[] = [];
|
|
if (faces && edits && assetDimensions) {
|
|
for (const face of faces) {
|
|
if (face.person) {
|
|
const existingPersonEntry = result.find((item) => item.id === face.person!.id);
|
|
if (existingPersonEntry) {
|
|
existingPersonEntry.faces.push(face);
|
|
} else {
|
|
result.push({ ...mapPerson(face.person!), faces: [mapFacesWithoutPerson(face, edits, assetDimensions)] });
|
|
}
|
|
}
|
|
}
|
|
}
|
|
|
|
return result;
|
|
};
|
|
|
|
const mapStack = (entity: { stack?: Stack | null }) => {
|
|
if (!entity.stack) {
|
|
return null;
|
|
}
|
|
|
|
return {
|
|
id: entity.stack.id,
|
|
primaryAssetId: entity.stack.primaryAssetId,
|
|
assetCount: entity.stack.assetCount ?? entity.stack.assets.length + 1,
|
|
};
|
|
};
|
|
|
|
export function mapAsset(entity: MapAsset, options: AssetMapOptions = {}): AssetResponseDto {
|
|
const { stripMetadata = false, withStack = false } = options;
|
|
|
|
if (stripMetadata) {
|
|
const sanitizedAssetResponse: SanitizedAssetResponseDto = {
|
|
id: entity.id,
|
|
type: entity.type,
|
|
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
|
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
|
localDateTime: entity.localDateTime,
|
|
duration: entity.duration ?? '0:00:00.00000',
|
|
livePhotoVideoId: entity.livePhotoVideoId,
|
|
hasMetadata: false,
|
|
width: entity.width,
|
|
height: entity.height,
|
|
};
|
|
return sanitizedAssetResponse as AssetResponseDto;
|
|
}
|
|
|
|
const assetDimensions = entity.exifInfo ? getDimensions(entity.exifInfo) : undefined;
|
|
|
|
return {
|
|
id: entity.id,
|
|
createdAt: entity.createdAt,
|
|
deviceAssetId: entity.deviceAssetId,
|
|
ownerId: entity.ownerId,
|
|
owner: entity.owner ? mapUser(entity.owner) : undefined,
|
|
deviceId: entity.deviceId,
|
|
libraryId: entity.libraryId,
|
|
type: entity.type,
|
|
originalPath: entity.originalPath,
|
|
originalFileName: entity.originalFileName,
|
|
originalMimeType: mimeTypes.lookup(entity.originalFileName),
|
|
thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null,
|
|
fileCreatedAt: entity.fileCreatedAt,
|
|
fileModifiedAt: entity.fileModifiedAt,
|
|
localDateTime: entity.localDateTime,
|
|
updatedAt: entity.updatedAt,
|
|
isFavorite: options.auth?.user.id === entity.ownerId && entity.isFavorite,
|
|
isArchived: entity.visibility === AssetVisibility.Archive,
|
|
isTrashed: !!entity.deletedAt,
|
|
visibility: entity.visibility,
|
|
duration: entity.duration ?? '0:00:00.00000',
|
|
exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined,
|
|
livePhotoVideoId: entity.livePhotoVideoId,
|
|
tags: entity.tags?.map((tag) => mapTag(tag)),
|
|
people: peopleWithFaces(entity.faces, entity.edits, assetDimensions),
|
|
unassignedFaces: entity.faces?.filter((face) => !face.person).map((a) => mapFacesWithoutPerson(a)),
|
|
checksum: hexOrBufferToBase64(entity.checksum)!,
|
|
stack: withStack ? mapStack(entity) : undefined,
|
|
isOffline: entity.isOffline,
|
|
hasMetadata: true,
|
|
duplicateId: entity.duplicateId,
|
|
resized: true,
|
|
width: entity.width,
|
|
height: entity.height,
|
|
};
|
|
}
|