This commit is contained in:
Neeraj Gupta
2025-06-27 15:17:08 +05:30
parent 5c78de5355
commit d0931d1d0e
12 changed files with 146 additions and 159 deletions

View File

@@ -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 =

View File

@@ -1,9 +0,0 @@
import "package:photos/utils/standalone/task_queue.dart";
class LocalThumbnailService {
final thumbnailQueue = TaskQueue<String>(
maxConcurrentTasks: 15,
taskTimeout: const Duration(minutes: 1),
maxQueueSize: 100, // Limit the queue to 50 pending tasks
);
}

View File

@@ -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<Uint8List?> 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);
}
}

View File

@@ -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<AssetEntity> 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<File> 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;
}
}

View File

@@ -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<DroidMetadata?> 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<AssetEntity> 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<File> 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;
}
}

View File

@@ -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,
),
),
),

View File

@@ -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<ThumbnailWidget> {
!_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;

View File

@@ -258,7 +258,7 @@ class _ZoomableImageState extends State<ZoomableImage> {
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<ZoomableImage> {
!_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<ZoomableImage> {
!_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);

View File

@@ -52,8 +52,8 @@ class GalleryFileWidget extends StatelessWidget {
shouldShowLivePhotoOverlay: true,
key: Key(heroTag),
thumbnailSize: photoGridSize < photoGridSizeDefault
? thumbnailLargeSize
: thumbnailSmallSize,
? thumbnailLarge512
: thumbnailSmall256,
shouldShowOwnerAvatar: !isFileSelected,
shouldShowVideoDuration: true,
);

View File

@@ -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<MediaUploadData> _getMediaUploadDataFromAssetFile(
Map<String, IfdTag>? 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<MediaUploadData> _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<MediaUploadData> _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<int?> motionVideoIndex(Map<String, dynamic> args) async {
return (await MotionPhotos(path).getMotionVideoIndex())?.start;
}
Future<void> _computeZip(Map<String, dynamic> 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<void> zip({
required String zipPath,
required String imagePath,
required String videoPath,
}) {
return Computer.shared().compute(
_computeZip,
(Map<String, dynamic> 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<void> zip({
);
}
Future<Uint8List?> _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<Uint8List?> 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<Uint8List?> 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);

View File

@@ -342,8 +342,8 @@ String getExtension(String nameOrPath) {
Future<Uint8List> compressThumbnail(Uint8List thumbnail) {
return FlutterImageCompress.compressWithList(
thumbnail,
minHeight: compressedThumbnailResolution,
minWidth: compressedThumbnailResolution,
minHeight: compressThumb1080,
minWidth: compressThumb1080,
quality: 25,
);
}

View File

@@ -39,7 +39,7 @@ Future<Uint8List?> getThumbnail(EnteFile file) async {
} else {
return getThumbnailFromLocal(
file,
size: thumbnailLargeSize,
size: thumbnailLarge512,
);
}
}
@@ -69,7 +69,7 @@ Future<Uint8List> 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<Uint8List> getThumbnailFromServer(EnteFile file) async {
Future<Uint8List?> getThumbnailFromLocal(
EnteFile file, {
int size = thumbnailSmallSize,
int size = thumbnailSmall256,
int quality = thumbnailQuality,
}) async {
final lruCachedThumbnail = enteImageCache.getThumb(file, size);
@@ -116,7 +116,8 @@ Future<Uint8List?> 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<void> _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();