thumbnail decoding: use raw image descriptor in Flutter on decoded bytes from Android

This commit is contained in:
Thibault Deckers 2025-03-02 18:05:44 +01:00
parent 1f95506abe
commit d4791df333
6 changed files with 57 additions and 46 deletions

View file

@ -4,7 +4,6 @@ import android.content.Context
import android.graphics.Rect import android.graphics.Rect
import androidx.core.net.toUri import androidx.core.net.toUri
import deckers.thibault.aves.channel.calls.Coresult.Companion.safe import deckers.thibault.aves.channel.calls.Coresult.Companion.safe
import deckers.thibault.aves.channel.calls.Coresult.Companion.safeSuspend
import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher import deckers.thibault.aves.channel.calls.fetchers.RegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher import deckers.thibault.aves.channel.calls.fetchers.SvgRegionFetcher
import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher import deckers.thibault.aves.channel.calls.fetchers.ThumbnailFetcher
@ -29,13 +28,13 @@ class MediaFetchBytesHandler(private val context: Context) : MethodCallHandler {
override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) { override fun onMethodCall(call: MethodCall, result: MethodChannel.Result) {
when (call.method) { when (call.method) {
"getThumbnail" -> ioScope.launch { safeSuspend(call, result, ::getThumbnail) } "getThumbnail" -> ioScope.launch { safe(call, result, ::getThumbnail) }
"getRegion" -> ioScope.launch { safe(call, result, ::getRegion) } "getRegion" -> ioScope.launch { safe(call, result, ::getRegion) }
else -> result.notImplemented() else -> result.notImplemented()
} }
} }
private suspend fun getThumbnail(call: MethodCall, result: MethodChannel.Result) { private fun getThumbnail(call: MethodCall, result: MethodChannel.Result) {
val uri = call.argument<String>(EntryFields.URI) val uri = call.argument<String>(EntryFields.URI)
val mimeType = call.argument<String>(EntryFields.MIME_TYPE) val mimeType = call.argument<String>(EntryFields.MIME_TYPE)
val dateModifiedMillis = call.argument<Number>(EntryFields.DATE_MODIFIED_MILLIS)?.toLong() val dateModifiedMillis = call.argument<Number>(EntryFields.DATE_MODIFIED_MILLIS)?.toLong()

View file

@ -15,9 +15,8 @@ import com.bumptech.glide.request.RequestOptions
import com.bumptech.glide.signature.ObjectKey import com.bumptech.glide.signature.ObjectKey
import deckers.thibault.aves.decoder.AvesAppGlideModule import deckers.thibault.aves.decoder.AvesAppGlideModule
import deckers.thibault.aves.decoder.MultiPageImage import deckers.thibault.aves.decoder.MultiPageImage
import deckers.thibault.aves.utils.BitmapUtils
import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation import deckers.thibault.aves.utils.BitmapUtils.applyExifOrientation
import deckers.thibault.aves.utils.BitmapUtils.getEncodedBytes import deckers.thibault.aves.utils.BitmapUtils.getDecodedBytes
import deckers.thibault.aves.utils.MimeTypes import deckers.thibault.aves.utils.MimeTypes
import deckers.thibault.aves.utils.MimeTypes.SVG import deckers.thibault.aves.utils.MimeTypes.SVG
import deckers.thibault.aves.utils.MimeTypes.isVideo import deckers.thibault.aves.utils.MimeTypes.isVideo
@ -49,7 +48,7 @@ class ThumbnailFetcher internal constructor(
private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType) private val multiPageFetch = pageId != null && MultiPageImage.isSupported(mimeType)
private val customFetch = svgFetch || tiffFetch || multiPageFetch private val customFetch = svgFetch || tiffFetch || multiPageFetch
suspend fun fetch() { fun fetch() {
var bitmap: Bitmap? = null var bitmap: Bitmap? = null
var exception: Exception? = null var exception: Exception? = null
@ -78,13 +77,8 @@ class ThumbnailFetcher internal constructor(
} }
} }
if (bitmap != null) { val bytes = bitmap?.getDecodedBytes(recycle = false)
val canHaveAlpha = MimeTypes.canHaveAlpha(mimeType) if (bytes != null) {
val recycle = false
var bytes = bitmap.getEncodedBytes(canHaveAlpha, quality, recycle)
if (bytes != null && bytes.isEmpty()) {
bytes = BitmapUtils.tryPixelFormatConversion(bitmap)?.getEncodedBytes(canHaveAlpha, quality, recycle)
}
result.success(bytes) result.success(bytes)
} else { } else {
var errorDetails: String? = exception?.message var errorDetails: String? = exception?.message

View file

@ -3,6 +3,7 @@ import 'dart:math';
import 'dart:ui' as ui; import 'dart:ui' as ui;
import 'package:aves/services/common/services.dart'; import 'package:aves/services/common/services.dart';
import 'package:aves_report/aves_report.dart';
import 'package:equatable/equatable.dart'; import 'package:equatable/equatable.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/widgets.dart'; import 'package:flutter/widgets.dart';
@ -33,7 +34,7 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await mediaFetchService.getRegion( final descriptor = await mediaFetchService.getRegion(
uri, uri,
mimeType, mimeType,
key.rotationDegrees, key.rotationDegrees,
@ -45,28 +46,14 @@ class RegionProvider extends ImageProvider<RegionProviderKey> {
sizeBytes: key.sizeBytes, sizeBytes: key.sizeBytes,
taskKey: key, taskKey: key,
); );
if (bytes.isEmpty) { if (descriptor == null) {
throw StateError('$uri ($mimeType) region loading failed'); throw UnreportedStateError('$uri ($mimeType) region loading failed');
} }
final trailerOffset = bytes.length - 4 * 2;
final trailer = ByteData.sublistView(bytes, trailerOffset);
final bitmapWidth = trailer.getUint32(0);
final bitmapHeight = trailer.getUint32(4);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
final descriptor = ui.ImageDescriptor.raw(
buffer,
width: bitmapWidth,
height: bitmapHeight,
pixelFormat: ui.PixelFormat.rgba8888,
);
return descriptor.instantiateCodec(); return descriptor.instantiateCodec();
} catch (error) { } catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw StateError('$mimeType region decoding failed (page $pageId)'); throw UnreportedStateError('$mimeType region decoding failed (page $pageId)');
} }
} }

View file

@ -36,7 +36,7 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
final mimeType = key.mimeType; final mimeType = key.mimeType;
final pageId = key.pageId; final pageId = key.pageId;
try { try {
final bytes = await mediaFetchService.getThumbnail( final descriptor = await mediaFetchService.getThumbnail(
uri: uri, uri: uri,
mimeType: mimeType, mimeType: mimeType,
pageId: pageId, pageId: pageId,
@ -46,15 +46,14 @@ class ThumbnailProvider extends ImageProvider<ThumbnailProviderKey> {
extent: key.extent, extent: key.extent,
taskKey: key, taskKey: key,
); );
if (bytes.isEmpty) { if (descriptor == null) {
throw UnreportedStateError('$uri ($mimeType) loading failed'); throw UnreportedStateError('$uri ($mimeType) thumbnail loading failed');
} }
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes); return descriptor.instantiateCodec();
return await decode(buffer);
} catch (error) { } catch (error) {
// loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF) // loading may fail if the provided MIME type is incorrect (e.g. the Media Store may report a JPEG as a TIFF)
debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error'); debugPrint('$runtimeType _loadAsync failed with mimeType=$mimeType, uri=$uri, error=$error');
throw UnreportedStateError('$mimeType decoding failed (page $pageId)'); throw UnreportedStateError('$mimeType thumbnail decoding failed (page $pageId)');
} }
} }

View file

@ -1,4 +1,5 @@
import 'dart:async'; import 'dart:async';
import 'dart:ui';
import 'package:aves/geo/uri.dart'; import 'package:aves/geo/uri.dart';
import 'package:aves/model/app_inventory.dart'; import 'package:aves/model/app_inventory.dart';
@ -219,7 +220,7 @@ class PlatformAppService implements AppService {
Uint8List? iconBytes; Uint8List? iconBytes;
if (coverEntry != null) { if (coverEntry != null) {
final size = coverEntry.isVideo ? 0.0 : 256.0; final size = coverEntry.isVideo ? 0.0 : 256.0;
iconBytes = await mediaFetchService.getThumbnail( final iconDescriptor = await mediaFetchService.getThumbnail(
uri: coverEntry.uri, uri: coverEntry.uri,
mimeType: coverEntry.mimeType, mimeType: coverEntry.mimeType,
pageId: coverEntry.pageId, pageId: coverEntry.pageId,
@ -228,6 +229,12 @@ class PlatformAppService implements AppService {
dateModifiedMillis: coverEntry.dateModifiedMillis, dateModifiedMillis: coverEntry.dateModifiedMillis,
extent: size, extent: size,
); );
if (iconDescriptor != null) {
final codec = await iconDescriptor.instantiateCodec();
final frameInfo = await codec.getNextFrame();
final byteData = await frameInfo.image.toByteData(format: ImageByteFormat.png);
iconBytes = byteData?.buffer.asUint8List();
}
} }
try { try {
await _platform.invokeMethod('pinShortcut', <String, dynamic>{ await _platform.invokeMethod('pinShortcut', <String, dynamic>{

View file

@ -11,6 +11,7 @@ import 'package:aves/services/media/byte_receiving_codec.dart';
import 'package:flutter/foundation.dart'; import 'package:flutter/foundation.dart';
import 'package:flutter/services.dart'; import 'package:flutter/services.dart';
import 'package:streams_channel/streams_channel.dart'; import 'package:streams_channel/streams_channel.dart';
import 'dart:ui' as ui;
abstract class MediaFetchService { abstract class MediaFetchService {
Future<AvesEntry?> getEntry(String uri, String? mimeType, {bool allowUnsized = false}); Future<AvesEntry?> getEntry(String uri, String? mimeType, {bool allowUnsized = false});
@ -33,7 +34,7 @@ abstract class MediaFetchService {
}); });
// `rect`: region to decode, with coordinates in reference to `imageSize` // `rect`: region to decode, with coordinates in reference to `imageSize`
Future<Uint8List> getRegion( Future<ui.ImageDescriptor?> getRegion(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
@ -47,7 +48,7 @@ abstract class MediaFetchService {
int? priority, int? priority,
}); });
Future<Uint8List> getThumbnail({ Future<ui.ImageDescriptor?> getThumbnail({
required String uri, required String uri,
required String mimeType, required String mimeType,
required int rotationDegrees, required int rotationDegrees,
@ -162,7 +163,7 @@ class PlatformMediaFetchService implements MediaFetchService {
} }
@override @override
Future<Uint8List> getRegion( Future<ui.ImageDescriptor?> getRegion(
String uri, String uri,
String mimeType, String mimeType,
int rotationDegrees, int rotationDegrees,
@ -191,13 +192,16 @@ class PlatformMediaFetchService implements MediaFetchService {
'imageWidth': imageSize.width.toInt(), 'imageWidth': imageSize.width.toInt(),
'imageHeight': imageSize.height.toInt(), 'imageHeight': imageSize.height.toInt(),
}); });
if (result != null) return result as Uint8List; if (result != null) {
final bytes = result as Uint8List;
return _bytesToCodec(bytes);
}
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (_isUnknownVisual(mimeType)) { if (_isUnknownVisual(mimeType)) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
} }
return Uint8List(0); return null;
}, },
priority: priority ?? ServiceCallPriority.getRegion, priority: priority ?? ServiceCallPriority.getRegion,
key: taskKey, key: taskKey,
@ -205,7 +209,7 @@ class PlatformMediaFetchService implements MediaFetchService {
} }
@override @override
Future<Uint8List> getThumbnail({ Future<ui.ImageDescriptor?> getThumbnail({
required String uri, required String uri,
required String mimeType, required String mimeType,
required int rotationDegrees, required int rotationDegrees,
@ -231,13 +235,16 @@ class PlatformMediaFetchService implements MediaFetchService {
'defaultSizeDip': _thumbnailDefaultSize, 'defaultSizeDip': _thumbnailDefaultSize,
'quality': 100, 'quality': 100,
}); });
if (result != null) return result as Uint8List; if (result != null) {
final bytes = result as Uint8List;
return _bytesToCodec(bytes);
}
} on PlatformException catch (e, stack) { } on PlatformException catch (e, stack) {
if (_isUnknownVisual(mimeType)) { if (_isUnknownVisual(mimeType)) {
await reportService.recordError(e, stack); await reportService.recordError(e, stack);
} }
} }
return Uint8List(0); return null;
}, },
priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail), priority: priority ?? (extent == 0 ? ServiceCallPriority.getFastThumbnail : ServiceCallPriority.getSizedThumbnail),
key: taskKey, key: taskKey,
@ -264,6 +271,24 @@ class PlatformMediaFetchService implements MediaFetchService {
// convenience methods // convenience methods
Future<ui.ImageDescriptor?> _bytesToCodec(Uint8List bytes) async {
const trailerLength = 4 * 2;
if (bytes.length < trailerLength) return null;
final trailerOffset = bytes.length - trailerLength;
final trailer = ByteData.sublistView(bytes, trailerOffset);
final bitmapWidth = trailer.getUint32(0);
final bitmapHeight = trailer.getUint32(4);
final buffer = await ui.ImmutableBuffer.fromUint8List(bytes);
return ui.ImageDescriptor.raw(
buffer,
width: bitmapWidth,
height: bitmapHeight,
pixelFormat: ui.PixelFormat.rgba8888,
);
}
bool _isUnknownVisual(String mimeType) => !_knownMediaTypes.contains(mimeType) && MimeTypes.isVisual(mimeType); bool _isUnknownVisual(String mimeType) => !_knownMediaTypes.contains(mimeType) && MimeTypes.isVisual(mimeType);
static const Set<String> _knownOpaqueImages = { static const Set<String> _knownOpaqueImages = {