diff --git a/mobile/lib/core/constants.dart b/mobile/lib/core/constants.dart index 6215ebc7d7..eab89e5157 100644 --- a/mobile/lib/core/constants.dart +++ b/mobile/lib/core/constants.dart @@ -1,10 +1,12 @@ import "package:flutter/foundation.dart"; -const int thumbnailSmallSize = 256; const int thumbnailQuality = 50; -const int thumbnailLargeSize = 512; -const int compressedThumbnailResolution = 1080; -const int thumbnailDataLimit = 100 * 1024; +// thumbnailSmallSize Thumbnail sizes in pixels 256px +const int thumbnailSmall256 = 256; +// thumbnailMediumSize Thumbnail sizes in pixels 512px +const int thumbnailLarge512 = 512; // 512px +const int compressThumb1080 = 1080; +const int thumbnailDataMaxSize = 100 * 1024; const String sentryDSN = "https://2235e5c99219488ea93da34b9ac1cb68@sentry.ente.io/4"; const String sentryDebugDSN = diff --git a/mobile/lib/image/thumnail/local_thumb_service.dart b/mobile/lib/image/thumnail/local_thumb_service.dart deleted file mode 100644 index acd362b401..0000000000 --- a/mobile/lib/image/thumnail/local_thumb_service.dart +++ /dev/null @@ -1,9 +0,0 @@ -import "package:photos/utils/standalone/task_queue.dart"; - -class LocalThumbnailService { - final thumbnailQueue = TaskQueue( - maxConcurrentTasks: 15, - taskTimeout: const Duration(minutes: 1), - maxQueueSize: 100, // Limit the queue to 50 pending tasks - ); -} diff --git a/mobile/lib/image/thumnail/upload_thumb.dart b/mobile/lib/image/thumnail/upload_thumb.dart new file mode 100644 index 0000000000..f478838f33 --- /dev/null +++ b/mobile/lib/image/thumnail/upload_thumb.dart @@ -0,0 +1,49 @@ +import "dart:typed_data"; + +import "package:logging/logging.dart"; +import "package:photo_manager/photo_manager.dart"; +import "package:photos/core/constants.dart"; +import "package:photos/core/errors.dart"; +import "package:photos/utils/file_util.dart"; + +Logger _logger = Logger("UploadThumbnailService"); +const kMaximumThumbnailCompressionAttempts = 2; +Future getThumbnailForUpload( + AssetEntity asset, +) async { + try { + Uint8List? thumbnailData = await asset.thumbnailDataWithSize( + const ThumbnailSize(thumbnailLarge512, thumbnailLarge512), + quality: thumbnailQuality, + ); + if (thumbnailData == null) { + // allow videos to be uploaded without thumbnails + if (asset.type == AssetType.video) { + return null; + } + throw InvalidFileError( + "no thumbnail : ${asset.type.name} ${asset.id}", + InvalidReason.thumbnailMissing, + ); + } + int compressionAttempts = 0; + while (thumbnailData!.length > thumbnailDataMaxSize && + compressionAttempts < kMaximumThumbnailCompressionAttempts) { + _logger.info("Thumbnail size " + thumbnailData.length.toString()); + thumbnailData = await compressThumbnail(thumbnailData); + _logger + .info("Compressed thumbnail size " + thumbnailData.length.toString()); + compressionAttempts++; + } + return thumbnailData; + } catch (e) { + final String errMessage = + "thumbErr id: ${asset.id} type: ${asset.type.name}, name: ${await asset.titleAsync}"; + _logger.warning(errMessage, e); + // allow videos to be uploaded without thumbnails + if (asset.type == AssetType.video) { + return null; + } + throw InvalidFileError(errMessage, InvalidReason.thumbnailMissing); + } +} diff --git a/mobile/lib/services/local/asset_entity.service.dart b/mobile/lib/services/local/asset_entity.service.dart new file mode 100644 index 0000000000..ce5101902d --- /dev/null +++ b/mobile/lib/services/local/asset_entity.service.dart @@ -0,0 +1,47 @@ +import "dart:async"; +import "dart:io"; + +import "package:logging/logging.dart"; +import "package:photo_manager/photo_manager.dart"; +import "package:photos/core/errors.dart"; + +class AssetEntityService { + static final Logger _logger = Logger("AssetEntityService"); + static Future fromIDWithRetry(String localID) async { + final asset = await AssetEntity.fromId(localID) + .timeout(const Duration(seconds: 3)) + .catchError((e) async { + if (e is TimeoutException) { + _logger.info("Asset fetch timed out for id $localID "); + return await AssetEntity.fromId(localID); + } else { + throw e; + } + }); + + if (asset == null) { + throw InvalidFileError("asset null", InvalidReason.assetDeleted); + } + return asset; + } + + static Future sourceFromAsset(AssetEntity asset) async { + final sourceFile = await asset.originFile + .timeout(const Duration(seconds: 15)) + .catchError((e) async { + if (e is TimeoutException) { + _logger.info("Origin file fetch timed out for ${asset.id}"); + return await asset.originFile; + } else { + throw e; + } + }); + if (sourceFile == null || !sourceFile.existsSync()) { + throw InvalidFileError( + "id: ${asset.id}", + InvalidReason.sourceFileMissing, + ); + } + return sourceFile; + } +} diff --git a/mobile/lib/services/local/metadata/metadata.service.dart b/mobile/lib/services/local/metadata/metadata.service.dart index b19ab8fae1..913b8d1f9f 100644 --- a/mobile/lib/services/local/metadata/metadata.service.dart +++ b/mobile/lib/services/local/metadata/metadata.service.dart @@ -6,12 +6,12 @@ import "package:ente_crypto/ente_crypto.dart"; import "package:exif_reader/exif_reader.dart"; import "package:logging/logging.dart"; import "package:motion_photos/motion_photos.dart"; -import "package:photos/core/errors.dart"; import "package:photos/extensions/stop_watch.dart"; import "package:photos/models/ffmpeg/ffprobe_props.dart"; import "package:photos/models/file/file.dart"; import "package:photos/models/local/local_metadata.dart"; import "package:photos/models/location/location.dart"; +import "package:photos/services/local/asset_entity.service.dart"; import "package:photos/utils/exif_util.dart"; import "package:wechat_assets_picker/wechat_assets_picker.dart"; @@ -21,8 +21,8 @@ class LocalMetadataService { static Future getMetadata(String id) async { try { final TimeLogger t = TimeLogger(context: "getDroidMetadata"); - final AssetEntity asset = await fromIDWithRetry(id); - final sourceFile = await sourceFromAsset(asset); + final AssetEntity asset = await AssetEntityService.fromIDWithRetry(id); + final sourceFile = await AssetEntityService.sourceFromAsset(asset); final latLng = await asset.latlngAsync(); Location location = Location(latitude: latLng.latitude, longitude: latLng.longitude); @@ -89,41 +89,4 @@ class LocalMetadataService { taskName: "motionVideoIndex", ); } - - static Future fromIDWithRetry(String localID) async { - final asset = await AssetEntity.fromId(localID) - .timeout(const Duration(seconds: 3)) - .catchError((e) async { - if (e is TimeoutException) { - _logger.info("Asset fetch timed out for id $localID "); - return await AssetEntity.fromId(localID); - } else { - throw e; - } - }); - if (asset == null) { - throw InvalidFileError("", InvalidReason.assetDeleted); - } - return asset; - } - - static Future sourceFromAsset(AssetEntity asset) async { - final sourceFile = await asset.originFile - .timeout(const Duration(seconds: 15)) - .catchError((e) async { - if (e is TimeoutException) { - _logger.info("Origin file fetch timed out for ${asset.id}"); - return await asset.originFile; - } else { - throw e; - } - }); - if (sourceFile == null || !sourceFile.existsSync()) { - throw InvalidFileError( - "id: ${asset.id}", - InvalidReason.sourceFileMissing, - ); - } - return sourceFile; - } } diff --git a/mobile/lib/ui/sharing/show_images_prevew.dart b/mobile/lib/ui/sharing/show_images_prevew.dart index eb04e49a6c..fc0d8c8201 100644 --- a/mobile/lib/ui/sharing/show_images_prevew.dart +++ b/mobile/lib/ui/sharing/show_images_prevew.dart @@ -319,7 +319,7 @@ class _BackDrop extends StatelessWidget { backDropImage, shouldShowSyncStatus: false, shouldShowFavoriteIcon: false, - thumbnailSize: thumbnailLargeSize, + thumbnailSize: thumbnailLarge512, ), BackdropFilter( filter: ImageFilter.blur(sigmaX: 12, sigmaY: 12), @@ -388,7 +388,7 @@ class _CustomImage extends StatelessWidget { file, shouldShowSyncStatus: false, shouldShowFavoriteIcon: false, - thumbnailSize: thumbnailLargeSize, + thumbnailSize: thumbnailLarge512, ), ), ), diff --git a/mobile/lib/ui/viewer/file/thumbnail_widget.dart b/mobile/lib/ui/viewer/file/thumbnail_widget.dart index 376fa131b8..e24800a9b5 100644 --- a/mobile/lib/ui/viewer/file/thumbnail_widget.dart +++ b/mobile/lib/ui/viewer/file/thumbnail_widget.dart @@ -53,7 +53,7 @@ class ThumbnailWidget extends StatefulWidget { this.shouldShowOwnerAvatar = false, this.diskLoadDeferDuration, this.serverLoadDeferDuration, - this.thumbnailSize = thumbnailSmallSize, + this.thumbnailSize = thumbnailSmall256, this.shouldShowFavoriteIcon = true, this.shouldShowVideoDuration = false, this.shouldShowVideoOverlayIcon = true, @@ -250,7 +250,7 @@ class _ThumbnailWidgetState extends State { !_isLoadingRemoteThumbnail) { _isLoadingRemoteThumbnail = true; final cachedThumbnail = - enteImageCache.getThumb(widget.file, thumbnailLargeSize); + enteImageCache.getThumb(widget.file, thumbnailLarge512); if (cachedThumbnail != null) { _imageProvider = Image.memory(cachedThumbnail).image; _hasLoadedThumbnail = true; diff --git a/mobile/lib/ui/viewer/file/zoomable_image.dart b/mobile/lib/ui/viewer/file/zoomable_image.dart index 982c9579a0..bfaf9a827a 100644 --- a/mobile/lib/ui/viewer/file/zoomable_image.dart +++ b/mobile/lib/ui/viewer/file/zoomable_image.dart @@ -258,7 +258,7 @@ class _ZoomableImageState extends State { void _loadNetworkImage() { if (!_loadedSmallThumbnail && !_loadedFinalImage) { final cachedThumbnail = - enteImageCache.getThumb(_photo, thumbnailLargeSize); + enteImageCache.getThumb(_photo, thumbnailLarge512); if (cachedThumbnail != null) { _imageProvider = Image.memory(cachedThumbnail).image; _loadedSmallThumbnail = true; @@ -300,7 +300,7 @@ class _ZoomableImageState extends State { !_loadedLargeThumbnail && !_loadedFinalImage) { final cachedThumbnail = - enteImageCache.getThumb(_photo, thumbnailSmallSize); + enteImageCache.getThumb(_photo, thumbnailSmall256); if (cachedThumbnail != null) { _imageProvider = Image.memory(cachedThumbnail).image; _loadedSmallThumbnail = true; @@ -311,7 +311,7 @@ class _ZoomableImageState extends State { !_loadedLargeThumbnail && !_loadedFinalImage) { _loadingLargeThumbnail = true; - getThumbnailFromLocal(_photo, size: thumbnailLargeSize, quality: 100) + getThumbnailFromLocal(_photo, size: thumbnailLarge512, quality: 100) .then((cachedThumbnail) { if (cachedThumbnail != null) { _onLargeThumbnailLoaded(Image.memory(cachedThumbnail).image, context); diff --git a/mobile/lib/ui/viewer/gallery/component/gallery_file_widget.dart b/mobile/lib/ui/viewer/gallery/component/gallery_file_widget.dart index 1462dbf8ae..eb7b567d85 100644 --- a/mobile/lib/ui/viewer/gallery/component/gallery_file_widget.dart +++ b/mobile/lib/ui/viewer/gallery/component/gallery_file_widget.dart @@ -52,8 +52,8 @@ class GalleryFileWidget extends StatelessWidget { shouldShowLivePhotoOverlay: true, key: Key(heroTag), thumbnailSize: photoGridSize < photoGridSizeDefault - ? thumbnailLargeSize - : thumbnailSmallSize, + ? thumbnailLarge512 + : thumbnailSmall256, shouldShowOwnerAvatar: !isFileSelected, shouldShowVideoDuration: true, ); diff --git a/mobile/lib/utils/file_uploader_util.dart b/mobile/lib/utils/file_uploader_util.dart index 1ad87d8c5b..8927455f2d 100644 --- a/mobile/lib/utils/file_uploader_util.dart +++ b/mobile/lib/utils/file_uploader_util.dart @@ -17,12 +17,14 @@ import 'package:photo_manager/photo_manager.dart'; import 'package:photos/core/configuration.dart'; import 'package:photos/core/constants.dart'; import 'package:photos/core/errors.dart'; +import "package:photos/image/thumnail/upload_thumb.dart"; import "package:photos/models/api/metadata.dart"; import "package:photos/models/ffmpeg/ffprobe_props.dart"; import "package:photos/models/file/extensions/file_props.dart"; import 'package:photos/models/file/file.dart'; import 'package:photos/models/file/file_type.dart'; import "package:photos/models/location/location.dart"; +import "package:photos/services/local/asset_entity.service.dart"; import "package:photos/services/local/local_import.dart"; import "package:photos/utils/exif_util.dart"; import 'package:photos/utils/file_util.dart'; @@ -30,7 +32,6 @@ import "package:uuid/uuid.dart"; import 'package:video_thumbnail/video_thumbnail.dart'; final _logger = Logger("FileUtil"); -const kMaximumThumbnailCompressionAttempts = 2; class MediaUploadData { final File? sourceFile; @@ -95,39 +96,12 @@ Future _getMediaUploadDataFromAssetFile( Map? exifData; // The timeouts are to safeguard against https://github.com/CaiJingLong/flutter_photo_manager/issues/467 - final asset = await file.getAsset - .timeout(const Duration(seconds: 3)) - .catchError((e) async { - if (e is TimeoutException) { - _logger.info("Asset fetch timed out for " + file.toString()); - return await file.getAsset; - } else { - throw e; - } - }); - if (asset == null) { - throw InvalidFileError("", InvalidReason.assetDeleted); - } + final asset = await AssetEntityService.fromIDWithRetry(file.lAsset!.id); _assertFileType(asset, file); if (Platform.isIOS) { - trackOriginFetchForUploadOrML.put(file.localID!, true); - } - sourceFile = await asset.originFile - .timeout(const Duration(seconds: 15)) - .catchError((e) async { - if (e is TimeoutException) { - _logger.info("Origin file fetch timed out for " + file.tag); - return await asset.originFile; - } else { - throw e; - } - }); - if (sourceFile == null || !sourceFile.existsSync()) { - throw InvalidFileError( - "id: ${file.localID}", - InvalidReason.sourceFileMissing, - ); + trackOriginFetchForUploadOrML.put(file.lAsset!.id, true); } + sourceFile = await AssetEntityService.sourceFromAsset(asset); if (parseExif) { exifData = await tryExifFromFile(sourceFile); } @@ -143,10 +117,10 @@ Future _getMediaUploadDataFromAssetFile( _logger.severe(errMsg); throw InvalidFileError(errMsg, InvalidReason.livePhotoVideoMissing); } - final String livePhotoVideoHash = + final String videoHash = CryptoUtil.bin2base64(await CryptoUtil.getHash(videoUrl)); // imgHash:vidHash - fileHash = '$fileHash$kLivePhotoHashSeparator$livePhotoVideoHash'; + fileHash = '$fileHash$kLivePhotoHashSeparator$videoHash'; final tempPath = Configuration.instance.getTempDirectory(); // .elp -> ente live photo final uniqueId = const Uuid().v4().toString(); @@ -166,7 +140,7 @@ Future _getMediaUploadDataFromAssetFile( zipHash = CryptoUtil.bin2base64(await CryptoUtil.getHash(sourceFile)); } - thumbnailData = await _getThumbnailForUpload(asset, file); + thumbnailData = await getThumbnailForUpload(asset); isDeleted = !(await asset.exists); int? h, w; if (asset.width != 0 && asset.height != 0) { @@ -202,24 +176,25 @@ Future motionVideoIndex(Map args) async { return (await MotionPhotos(path).getMotionVideoIndex())?.start; } -Future _computeZip(Map args) async { - final String zipPath = args['zipPath']; - final String imagePath = args['imagePath']; - final String videoPath = args['videoPath']; - final encoder = ZipFileEncoder(); - encoder.create(zipPath); - await encoder.addFile(File(imagePath), "image" + extension(imagePath)); - await encoder.addFile(File(videoPath), "video" + extension(videoPath)); - await encoder.close(); -} - Future zip({ required String zipPath, required String imagePath, required String videoPath, }) { return Computer.shared().compute( - _computeZip, + (Map args) async { + final encoder = ZipFileEncoder(); + encoder.create(args['zipPath']); + await encoder.addFile( + File(args['imagePath']), + "image${extension(args['imagePath'])}", + ); + await encoder.addFile( + File(args['videoPath']), + "video${extension(args['videoPath'])}", + ); + await encoder.close(); + }, param: { 'zipPath': zipPath, 'imagePath': imagePath, @@ -229,47 +204,6 @@ Future zip({ ); } -Future _getThumbnailForUpload( - AssetEntity asset, - EnteFile file, -) async { - try { - Uint8List? thumbnailData = await asset.thumbnailDataWithSize( - const ThumbnailSize(thumbnailLargeSize, thumbnailLargeSize), - quality: thumbnailQuality, - ); - if (thumbnailData == null) { - // allow videos to be uploaded without thumbnails - if (asset.type == AssetType.video) { - return null; - } - throw InvalidFileError( - "no thumbnail : ${file.fileType} ${file.tag}", - InvalidReason.thumbnailMissing, - ); - } - int compressionAttempts = 0; - while (thumbnailData!.length > thumbnailDataLimit && - compressionAttempts < kMaximumThumbnailCompressionAttempts) { - _logger.info("Thumbnail size " + thumbnailData.length.toString()); - thumbnailData = await compressThumbnail(thumbnailData); - _logger - .info("Compressed thumbnail size " + thumbnailData.length.toString()); - compressionAttempts++; - } - return thumbnailData; - } catch (e) { - final String errMessage = - "thumbErr for ${file.fileType}, ${extension(file.displayName)} ${file.tag}"; - _logger.warning(errMessage, e); - // allow videos to be uploaded without thumbnails - if (asset.type == AssetType.video) { - return null; - } - throw InvalidFileError(errMessage, InvalidReason.thumbnailMissing); - } -} - // check if the assetType is still the same. This can happen for livePhotos // if the user turns off the video using native photos app void _assertFileType(AssetEntity asset, EnteFile file) { @@ -443,7 +377,7 @@ Future getThumbnailFromInAppCacheFile(EnteFile file) async { video: localFile.path, imageFormat: ImageFormat.JPEG, thumbnailPath: (await getTemporaryDirectory()).path, - maxWidth: thumbnailLargeSize, + maxWidth: thumbnailLarge512, quality: 80, ); localFile = File(thumbnailFilePath!); @@ -454,7 +388,7 @@ Future getThumbnailFromInAppCacheFile(EnteFile file) async { } var thumbnailData = await localFile.readAsBytes(); int compressionAttempts = 0; - while (thumbnailData.length > thumbnailDataLimit && + while (thumbnailData.length > thumbnailDataMaxSize && compressionAttempts < kMaximumThumbnailCompressionAttempts) { _logger.info("Thumbnail size " + thumbnailData.length.toString()); thumbnailData = await compressThumbnail(thumbnailData); diff --git a/mobile/lib/utils/file_util.dart b/mobile/lib/utils/file_util.dart index 4611800981..140e89af65 100644 --- a/mobile/lib/utils/file_util.dart +++ b/mobile/lib/utils/file_util.dart @@ -342,8 +342,8 @@ String getExtension(String nameOrPath) { Future compressThumbnail(Uint8List thumbnail) { return FlutterImageCompress.compressWithList( thumbnail, - minHeight: compressedThumbnailResolution, - minWidth: compressedThumbnailResolution, + minHeight: compressThumb1080, + minWidth: compressThumb1080, quality: 25, ); } diff --git a/mobile/lib/utils/thumbnail_util.dart b/mobile/lib/utils/thumbnail_util.dart index 5ebc5fda55..21e1093816 100644 --- a/mobile/lib/utils/thumbnail_util.dart +++ b/mobile/lib/utils/thumbnail_util.dart @@ -39,7 +39,7 @@ Future getThumbnail(EnteFile file) async { } else { return getThumbnailFromLocal( file, - size: thumbnailLargeSize, + size: thumbnailLarge512, ); } } @@ -69,7 +69,7 @@ Future getThumbnailFromServer(EnteFile file) async { final cachedThumbnail = cachedThumbnailPath(file); if (await cachedThumbnail.exists()) { final data = await cachedThumbnail.readAsBytes(); - enteImageCache.putThumb(file, data, thumbnailLargeSize); + enteImageCache.putThumb(file, data, thumbnailLarge512); return data; } // Check if there's already in flight request for fetching thumbnail from the @@ -95,7 +95,7 @@ Future getThumbnailFromServer(EnteFile file) async { Future getThumbnailFromLocal( EnteFile file, { - int size = thumbnailSmallSize, + int size = thumbnailSmall256, int quality = thumbnailQuality, }) async { final lruCachedThumbnail = enteImageCache.getThumb(file, size); @@ -116,7 +116,8 @@ Future getThumbnailFromLocal( return null; } return asset - .thumbnailDataWithSize(ThumbnailSize(size, size), quality: quality, format: ThumbnailFormat.jpeg) + .thumbnailDataWithSize(ThumbnailSize(size, size), + quality: quality, format: ThumbnailFormat.jpeg) .then((data) { enteImageCache.putThumb(file, data, size); return data; @@ -207,10 +208,10 @@ Future _downloadAndDecryptThumbnail(FileDownloadItem item) async { return; } final thumbnailSize = data.length; - if (thumbnailSize > thumbnailDataLimit) { + if (thumbnailSize > thumbnailDataMaxSize) { data = await compressThumbnail(data); } - enteImageCache.putThumb(item.file, data, thumbnailLargeSize); + enteImageCache.putThumb(item.file, data, thumbnailLarge512); final cachedThumbnail = cachedThumbnailPath(item.file); if (await cachedThumbnail.exists()) { await cachedThumbnail.delete();