diff --git a/e2e/src/ui/generators/timeline/rest-response.ts b/e2e/src/ui/generators/timeline/rest-response.ts index 3114e3676d95..8fc9ce331dac 100644 --- a/e2e/src/ui/generators/timeline/rest-response.ts +++ b/e2e/src/ui/generators/timeline/rest-response.ts @@ -332,7 +332,7 @@ export function toAssetResponseDto(asset: MockTimelineAsset, owner?: UserRespons isArchived: false, isTrashed: asset.isTrashed, visibility: asset.visibility, - duration: asset.duration || '0:00:00.00000', + duration: asset.duration, exifInfo, livePhotoVideoId: asset.livePhotoVideoId, tags: [], diff --git a/e2e/src/ui/mock-network/broken-asset-network.ts b/e2e/src/ui/mock-network/broken-asset-network.ts index 75d579e1ef40..ce66412e6148 100644 --- a/e2e/src/ui/mock-network/broken-asset-network.ts +++ b/e2e/src/ui/mock-network/broken-asset-network.ts @@ -40,7 +40,7 @@ export const createMockStackAsset = (ownerId: string): AssetResponseDto => { isArchived: false, isTrashed: false, visibility: AssetVisibility.Timeline, - duration: '0:00:00.00000', + duration: null, exifInfo: { make: null, model: null, diff --git a/mobile/lib/extensions/asset_extensions.dart b/mobile/lib/extensions/asset_extensions.dart index 73a8ec4d0565..6e8101bd04aa 100644 --- a/mobile/lib/extensions/asset_extensions.dart +++ b/mobile/lib/extensions/asset_extensions.dart @@ -14,7 +14,7 @@ extension DTOToAsset on api.AssetResponseDto { updatedAt: updatedAt, ownerId: ownerId, visibility: visibility.toAssetVisibility(), - durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + durationInSeconds: duration?.toDuration()?.inSeconds ?? 0, height: height?.toInt(), width: width?.toInt(), isFavorite: isFavorite, @@ -36,7 +36,7 @@ extension DTOToAsset on api.AssetResponseDto { updatedAt: updatedAt, ownerId: ownerId, visibility: visibility.toAssetVisibility(), - durationInSeconds: duration.toDuration()?.inSeconds ?? 0, + durationInSeconds: duration?.toDuration()?.inSeconds ?? 0, height: height?.toInt(), width: width?.toInt(), isFavorite: isFavorite, diff --git a/mobile/openapi/lib/model/asset_response_dto.dart b/mobile/openapi/lib/model/asset_response_dto.dart index a9d346b1553d..324d12fcbfc9 100644 --- a/mobile/openapi/lib/model/asset_response_dto.dart +++ b/mobile/openapi/lib/model/asset_response_dto.dart @@ -57,8 +57,8 @@ class AssetResponseDto { /// Duplicate group ID String? duplicateId; - /// Video duration (for videos) - String duration; + /// Video/gif duration in hh:mm:ss.SSS format (null for static images) + String? duration; /// /// Please note: This property should have been non-nullable! Since the specification file @@ -209,7 +209,7 @@ class AssetResponseDto { (checksum.hashCode) + (createdAt.hashCode) + (duplicateId == null ? 0 : duplicateId!.hashCode) + - (duration.hashCode) + + (duration == null ? 0 : duration!.hashCode) + (exifInfo == null ? 0 : exifInfo!.hashCode) + (fileCreatedAt.hashCode) + (fileModifiedAt.hashCode) + @@ -252,7 +252,11 @@ class AssetResponseDto { } else { // json[r'duplicateId'] = null; } + if (this.duration != null) { json[r'duration'] = this.duration; + } else { + // json[r'duration'] = null; + } if (this.exifInfo != null) { json[r'exifInfo'] = this.exifInfo; } else { @@ -337,7 +341,7 @@ class AssetResponseDto { checksum: mapValueOfType(json, r'checksum')!, createdAt: mapDateTime(json, r'createdAt', r'')!, duplicateId: mapValueOfType(json, r'duplicateId'), - duration: mapValueOfType(json, r'duration')!, + duration: mapValueOfType(json, r'duration'), exifInfo: ExifResponseDto.fromJson(json[r'exifInfo']), fileCreatedAt: mapDateTime(json, r'fileCreatedAt', r'')!, fileModifiedAt: mapDateTime(json, r'fileModifiedAt', r'')!, diff --git a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart index 720323cd147d..e2f9bec1ec5c 100644 --- a/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart +++ b/mobile/openapi/lib/model/time_bucket_asset_response_dto.dart @@ -39,7 +39,7 @@ class TimeBucketAssetResponseDto { /// Array of country names extracted from EXIF GPS data List country; - /// Array of video durations in HH:MM:SS format (null for images) + /// Array of video/gif durations in hh:mm:ss.SSS format (null for static images) List duration; /// Array of file creation timestamps in UTC diff --git a/open-api/immich-openapi-specs.json b/open-api/immich-openapi-specs.json index 175d609bd37c..68dc98e807d1 100644 --- a/open-api/immich-openapi-specs.json +++ b/open-api/immich-openapi-specs.json @@ -16640,7 +16640,8 @@ "type": "string" }, "duration": { - "description": "Video duration (for videos)", + "description": "Video/gif duration in hh:mm:ss.SSS format (null for static images)", + "nullable": true, "type": "string" }, "exifInfo": { @@ -24911,7 +24912,7 @@ "type": "array" }, "duration": { - "description": "Array of video durations in HH:MM:SS format (null for images)", + "description": "Array of video/gif durations in hh:mm:ss.SSS format (null for static images)", "items": { "nullable": true, "type": "string" diff --git a/open-api/typescript-sdk/src/fetch-client.ts b/open-api/typescript-sdk/src/fetch-client.ts index 2164b1acb419..538fb9f01a3e 100644 --- a/open-api/typescript-sdk/src/fetch-client.ts +++ b/open-api/typescript-sdk/src/fetch-client.ts @@ -856,8 +856,8 @@ export type AssetResponseDto = { createdAt: string; /** Duplicate group ID */ duplicateId?: string | null; - /** Video duration (for videos) */ - duration: string; + /** Video/gif duration in hh:mm:ss.SSS format (null for static images) */ + duration: string | null; exifInfo?: ExifResponseDto; /** 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. */ fileCreatedAt: string; @@ -2671,7 +2671,7 @@ export type TimeBucketAssetResponseDto = { city: (string | null)[]; /** Array of country names extracted from EXIF GPS data */ country: (string | null)[]; - /** Array of video durations in HH:MM:SS format (null for images) */ + /** Array of video/gif durations in hh:mm:ss.SSS format (null for static images) */ duration: (string | null)[]; /** Array of file creation timestamps in UTC */ fileCreatedAt: string[]; diff --git a/server/src/controllers/asset-media.controller.spec.ts b/server/src/controllers/asset-media.controller.spec.ts index 7ccd0d644d2f..6a328b1f6d60 100644 --- a/server/src/controllers/asset-media.controller.spec.ts +++ b/server/src/controllers/asset-media.controller.spec.ts @@ -12,7 +12,6 @@ const makeUploadDto = (options?: { omit: string }): Record => { fileCreatedAt: new Date().toISOString(), fileModifiedAt: new Date().toISOString(), isFavorite: 'false', - duration: '0:00:00.000000', }; const omit = options?.omit; diff --git a/server/src/dtos/asset-response.dto.ts b/server/src/dtos/asset-response.dto.ts index b4a398b5730c..faa1db4afbd7 100644 --- a/server/src/dtos/asset-response.dto.ts +++ b/server/src/dtos/asset-response.dto.ts @@ -47,7 +47,7 @@ const SanitizedAssetResponseSchema = z .describe( '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.', ), - duration: z.string().describe('Video duration (for videos)'), + duration: z.string().nullable().describe('Video/gif duration in hh:mm:ss.SSS format (null for static images)'), livePhotoVideoId: z.string().nullish().describe('Live photo video ID'), hasMetadata: z.boolean().describe('Whether asset has metadata'), width: z.number().min(0).nullable().describe('Asset width'), @@ -221,7 +221,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt originalMimeType: mimeTypes.lookup(entity.originalFileName), thumbhash: entity.thumbhash ? hexOrBufferToBase64(entity.thumbhash) : null, localDateTime: asDateString(entity.localDateTime), - duration: entity.duration ?? '0:00:00.00000', + duration: entity.duration, livePhotoVideoId: entity.livePhotoVideoId, hasMetadata: false, width: entity.width, @@ -251,7 +251,7 @@ export function mapAsset(entity: MaybeDehydrated, options: AssetMapOpt isArchived: entity.visibility === AssetVisibility.Archive, isTrashed: !!entity.deletedAt, visibility: entity.visibility, - duration: entity.duration ?? '0:00:00.00000', + duration: entity.duration, exifInfo: entity.exifInfo ? mapExif(entity.exifInfo) : undefined, livePhotoVideoId: entity.livePhotoVideoId, tags: entity.tags?.map((tag) => mapTag(tag)), diff --git a/server/src/dtos/time-bucket.dto.ts b/server/src/dtos/time-bucket.dto.ts index af820e6868c9..0b4be5cba1d0 100644 --- a/server/src/dtos/time-bucket.dto.ts +++ b/server/src/dtos/time-bucket.dto.ts @@ -88,7 +88,9 @@ const TimeBucketAssetResponseSchema = z .describe( "Array of UTC offset hours at the time each photo was taken. Positive values are east of UTC, negative values are west of UTC. Values may be fractional (e.g., 5.5 for +05:30, -9.75 for -09:45). Applying this offset to 'fileCreatedAt' will give you the time the photo was taken from the photographer's perspective.", ), - duration: z.array(z.string().nullable()).describe('Array of video durations in HH:MM:SS format (null for images)'), + duration: z + .array(z.string().nullable()) + .describe('Array of video/gif durations in hh:mm:ss.SSS format (null for static images)'), stack: z .array(stackTupleSchema) .optional() diff --git a/server/src/services/asset-media.service.spec.ts b/server/src/services/asset-media.service.spec.ts index ce8ecac72cb6..6571a5ac2282 100644 --- a/server/src/services/asset-media.service.spec.ts +++ b/server/src/services/asset-media.service.spec.ts @@ -148,7 +148,6 @@ const createDto = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), fileModifiedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - duration: '0:00:00.000000', }) as AssetMediaCreateDto; const assetEntity = Object.freeze({ @@ -160,7 +159,7 @@ const assetEntity = Object.freeze({ fileCreatedAt: new Date('2022-06-19T23:41:36.910Z'), updatedAt: new Date('2022-06-19T23:41:36.910Z'), isFavorite: false, - duration: '0:00:00.000000', + duration: null, files: [] as AssetFile[], exifInfo: { latitude: 49.533_547, diff --git a/web/src/lib/components/assets/thumbnail/thumbnail.svelte b/web/src/lib/components/assets/thumbnail/thumbnail.svelte index 7aaa46107d43..b7001e217f61 100644 --- a/web/src/lib/components/assets/thumbnail/thumbnail.svelte +++ b/web/src/lib/components/assets/thumbnail/thumbnail.svelte @@ -291,7 +291,7 @@ playbackOnIconHover={!$playVideoThumbnailOnHover} /> - {:else if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000') && mouseOver} + {:else if asset.isImage && asset.duration && mouseOver}
{/if} - {#if asset.isImage && asset.duration && !asset.duration.includes('0:00:00.000')} + {#if asset.isImage && asset.duration}
diff --git a/web/src/lib/utils.ts b/web/src/lib/utils.ts index 5204835d116e..dc53e4f513c5 100644 --- a/web/src/lib/utils.ts +++ b/web/src/lib/utils.ts @@ -219,7 +219,7 @@ export function getAssetUrls(asset: AssetResponseDto, sharedLink?: SharedLinkRes } const forceUseOriginal = (asset: AssetResponseDto) => { - return asset.type === AssetTypeEnum.Image && asset.duration && !asset.duration.includes('0:00:00.000'); + return asset.type === AssetTypeEnum.Image && asset.duration; }; export const targetImageSize = (asset: AssetResponseDto, forceOriginal: boolean) => { diff --git a/web/src/lib/utils/file-uploader.ts b/web/src/lib/utils/file-uploader.ts index b8f6d6fbba65..b50a468b00cb 100644 --- a/web/src/lib/utils/file-uploader.ts +++ b/web/src/lib/utils/file-uploader.ts @@ -155,7 +155,6 @@ async function fileUploader({ fileCreatedAt, fileModifiedAt: new Date(assetFile.lastModified).toISOString(), isFavorite: 'false', - duration: '0:00:00.000000', assetData: new File([assetFile], assetFile.name), })) { formData.append(key, value); diff --git a/web/src/test-data/factories/asset-factory.ts b/web/src/test-data/factories/asset-factory.ts index 10800bfb8f1d..d15a8a3e8414 100644 --- a/web/src/test-data/factories/asset-factory.ts +++ b/web/src/test-data/factories/asset-factory.ts @@ -21,7 +21,7 @@ export const assetFactory = Sync.makeFactory({ isFavorite: Sync.each(() => faker.datatype.boolean()), isArchived: false, isTrashed: false, - duration: '0:00:00.00000', + duration: null, checksum: Sync.each(() => faker.string.alphanumeric(28)), isOffline: Sync.each(() => faker.datatype.boolean()), hasMetadata: Sync.each(() => faker.datatype.boolean()), @@ -44,7 +44,7 @@ export const timelineAssetFactory = Sync.makeFactory({ isTrashed: false, isImage: true, isVideo: false, - duration: '0:00:00.00000', + duration: null, stack: null, projectionType: null, livePhotoVideoId: Sync.each(() => faker.string.uuid()),